openvibers/openvibe.live Commits

Live: VOD and clip pages move to OpenVibe.Video (merge on or after 2026-10-19) (#143)

OP OpenVibers committed 25 days ago · 9a09571003 · 46 files, +273 −3390

* Live: recordings and clips are watched on OpenVibe.Video; /vod/<id> and /clip/<id> 301 there

OpenVibe.Video has done everything Live's VOD and clip pages did since 2026-10-12 (player, AI chapters and captions,
chat replay, comments, clipping, the owner's visibility and delete). server/web/video-handover.js answers the old URLs
with a 301 (mounted before SEO, like the paste handover; ?t= goes along as seconds); every watch link Live renders
points there (videoHref in the SPA from the shell's ov-video-base meta, videoUrl on the server: channel and home
lists, the content feed, recaps, arena, context cards, chat clip cards, the dashboard, AI moments). handleLinkClick
and navigate let off-site URLs go to the browser. The VOD and clip pages go: app-media.js, media.css, the two
fragments and features, the SEO detail pages and the context card parts only they used (3,000 lines). /vods and /clips
stay as Live's Content feed filters, linking out.

* Live CSS: the old player's rules go; two stray braces fixed (one silently dropped the next rule)

With the VOD and clip pages gone, 30 style.css rules that only they used are removed (PostCSS, selector by selector:
.vod-player-*, the clip creator modal and timeline, the chat replay, .vp-live-*), and their three critical
selectors. style.css also held an orphaned keyframe step ('50% {…} }' left from a deleted @keyframes), whose stray
brace made the browser drop the next rule; home.css ended with an extra brace. test/css-syntax.test.js parses every
stylesheet Live serves. The critical-CSS script no longer visits /vod/ and /clip/ pages.

* The Rundown's watch and chapter links open the recording on OpenVibe.Video (they named Live's /vod/<id>, which now redirects); the discovery list says /vod/<id> redirects

Changes

ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex b3eb9786..ddaa2f2e 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -42,7 +42,7 @@ The SPA is plain JavaScript with no build step. What loads is decided per route:
public/index.html shell: navbar, home page, empty <section id="page-*"> shells, core scripts
public/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetch
routes: URL pattern → features
--public/fragments/*.html page markup for channel, VOD/clip players, dashboard, broadcast, chat, documentation, content, moments
++public/fragments/*.html page markup for channel, dashboard, broadcast, chat, documentation, content, moments (VOD and clip pages are OpenVibe.Video's)
public/css/features/*.css rules used only by one feature (split out of style.css / broadcast.css / i18n-star.css)
public/js/ov-loader.js configures /shared/web-runtime.js (openvibe-shared): ov.load(feature), ov.route(path), ov.prefetch(),
route generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks()
ddiff --git a/docs/performance-audit.md b/docs/performance-audit.md
iindex 57cb6d40..237a048e 100644
---- a/docs/performance-audit.md
++++ b/docs/performance-audit.md
@@@ -166,7 +166,7 @@ placeholder markup, then the data, and then several parts that load on their own
("Channel", "0 followers") are outlines; until the first render and, offline, the "Most watched" block and the discover
board (`ch-ready`, at most 2.5 s after the data) the tabs are out of the layout and the page keeps a full viewport, so
the footer never shows and climbs back down.
--- **VOD and clip** (`_mediaReady` in `app-media.js`, `mp-data`/`mp-ready`): the same outlines; the AI timeline, the
++- **VOD and clip** (until 2026-10, then OpenVibe.Video's pages; `_mediaReady` in the removed `app-media.js`): the same outlines; the AI timeline, the
activity strip, the source card, the clips and the comments appear together. `.vod-player-container` keeps a 16:9 box
(`max-height: 70vh`, like the video) before the video knows its size; a taller video still grows it.
- **Home hero:** the countdown line ships as `&nbsp;` instead of `hidden`; the "shipped" line is no longer `hidden`
ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 7c4d5380..c94179aa 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -73,14 +73,17 @@ From the dashboard:
(`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once
- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load
--## The canonical page is on OpenVibe.Video
--
--A public, finished recording's and a person's public clip's canonical page is on OpenVibe.Video
--(`https://openvibe.video/v/<id>`, `/c/<id>`; `server/web/video-url.js`, `VIDEO_PUBLIC_URL` overrides the origin). Live's
--own `/vod/<id>` and `/clip/<id>` pages keep working and keep their own `og:url`, but their `<link rel="canonical">`
--names the Video page, Live's sitemap leaves recordings and clips out (Video's sitemap lists them), the documents Live
--sends to OpenVibe.Search link there, and an AI clip's canonical moment is the Video page at that second. Unlisted
--items, recordings in progress and private items keep their Live canonical (they are not indexed anywhere).
++## Recordings and clips are watched on OpenVibe.Video
++
++Every recording and clip has its page on OpenVibe.Video (`https://openvibe.video/v/<id>`, `/c/<id>`;
++`server/web/video-url.js`, `VIDEO_PUBLIC_URL` overrides the origin): the player, AI chapters and captions, the chat
++replay, comments, clipping, and the owner's visibility and delete, for private items too (to their owner). Live's old
++`/vod/<id>` and `/clip/<id>` URLs answer a 301 there (`server/web/video-handover.js`, mounted before the SEO renderer
++like the paste handover; a `?t=` start time goes along as seconds). Every watch link Live renders points there: the
++SPA through `videoHref()` (the shell's `ov-video-base` meta names the origin), the server through `videoUrl()`. Live's
++sitemap leaves recordings and clips out (Video's lists them), and the documents Live sends to OpenVibe.Search link
++there. `/vods` and `/clips` stay on Live as the Content feed with a filter, linking out. Live keeps the recording
++pipeline, the API (`/api/vods`, `/api/clips`) that OpenVibe.Video reads, and the owner's manager on the dashboard.
## Playback URLs
ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex e16a76ad..ee965a90 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -422,9 +422,6 @@
".vod-ai-timeline-hint i",
".vod-ai-timeline-title i",
".vod-extra-details .detail-chip i",
-- ".vod-player-container",
-- ".vod-player-info h2",
-- ".vod-player-main",
".vod-source-info i",
".volume-slider",
".volume-slider::-webkit-slider-runnable-track",
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 28f853a7..243cffab 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -1165,4 +1165,3 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.cta-btn { width: 100%; }
.cta-checks { gap: 4px 12px; font-size: 0.8rem; }
}
--}
ddiff --git a/public/css/features/media.css b/public/css/features/media.css
ddeleted file mode 100644
iindex 4a412ab3..00000000
---- a/public/css/features/media.css
++++ /dev/null
@@@ -1,419 +0,0 @@
--/* media — rules used only by this feature, split out of the global stylesheets so other routes do not
-- download them. Loaded with the feature (public/features.json), directly after style.css in the cascade.
-- Split by class/id ownership with cascade-order checks; edit freely. */
--/* ── from style.css ── */
--.vod-group-card:hover .vod-group-count { border-color: color-mix(in srgb, var(--accent) 60%, transparent); }
--.vod-group-hint { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; font-size: 0.72rem; font-weight: 700;
-- color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 12%, transparent);
-- border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); border-radius: 8px; padding: 5px 11px; cursor: pointer;
-- transition: background .15s, border-color .15s, transform .12s; font-family: inherit; }
--.vod-group-hint:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); transform: translateY(-1px); }
--.vod-group-chev { transition: transform .22s ease; font-size: .68rem; }
--.vod-group-card.expanded .vod-group-chev { transform: rotate(180deg); }
--.vod-group-parts::before { content: ''; display: block; grid-column: 1 / -1; height: 1px; background: var(--border); margin: 2px 0 6px; }
--.badge-unlisted {
-- display: inline-block;
-- background: var(--text-muted); color: #fff;
-- padding: 2px 8px; border-radius: var(--radius-sm);
-- font-size: 0.75rem; font-weight: 600; margin-bottom: 6px;
--}
--.vc-progress-wrap:hover .vc-progress-bar {
-- height: 6px;
--}
--.vc-progress-wrap:hover .vc-progress-fill::after,
--.vc-progress-wrap:active .vc-progress-fill::after {
-- transform: translateY(-50%) scale(1.28);
--}
--.vc-speed-btn {
-- font-size: 0.7rem; padding: 2px 6px;
-- background: rgba(255,255,255,0.1);
-- border-radius: var(--radius-sm);
-- color: rgba(255,255,255,0.85);
-- font-family: var(--font-mono);
-- border: none; cursor: pointer;
-- transition: all 0.2s;
--}
--.vc-speed-btn:hover { background: rgba(255,255,255,0.2); color: var(--accent-light); }
--.vod-player-container video,
--.vod-player-container .vod-video {
-- width: 100%; max-height: 70vh;
-- display: block; object-fit: contain;
--}
--.vod-player-container .video-overlay {
-- position: absolute; bottom: 0; left: 0; right: 0;
-- background: linear-gradient(transparent, rgba(0,0,0,0.85));
-- padding: 0; opacity: 0;
-- transition: opacity 0.3s; z-index: 5;
--}
--.vp-live-badge {
-- background: #e53e3e; color: #fff;
-- padding: 4px 10px; border-radius: 4px;
-- font-size: 12px; font-weight: 700;
-- letter-spacing: 0.5px;
-- display: flex; align-items: center; gap: 5px;
--}
--.vp-jump-live-btn {
-- background: rgba(255,255,255,0.15); color: #fff;
-- border: 1px solid rgba(255,255,255,0.3);
-- padding: 4px 10px; border-radius: 4px;
-- font-size: 11px; cursor: pointer;
-- backdrop-filter: blur(4px);
-- transition: background 0.2s;
--}
--.vp-live-mode .vc-progress-fill { background: #e53e3e; }
--.vp-live-mode .vc-progress-fill::after { background: #e53e3e; }
--.vod-player-container:fullscreen video { max-height: 100vh; }
--.vod-player-container:fullscreen .video-overlay { opacity: 0; }
--.vod-player-container:fullscreen:hover .video-overlay { opacity: 1; }
--@media (max-width: 900px) {
-- .vod-player-row { flex-direction: column; }
-- .chat-replay-sidebar {
-- width: 100%; max-height: 280px;
-- border-left: none; border-top: 1px solid var(--border);
-- }
--}
--@media (max-width: 768px) {
-- .vod-player-info,
-- .comments-section { padding: 16px; border-radius: 16px; }
-- .vod-player-row {
-- flex-direction: column;
-- max-height: none; /* remove desktop height constraint */
-- border-radius: 12px;
-- }
-- .chat-replay-sidebar {
-- width: 100%;
-- max-height: 250px;
-- border-left: none;
-- border-top: 1px solid var(--border);
-- }
-- .vod-player-layout { gap: 12px; }
-- .vod-player-meta { gap: 8px; font-size: 0.82rem; }
-- .vod-extra-details { flex-wrap: wrap; }
-- .vod-extra-details .detail-chip { font-size: 0.72rem; }
--}
--
--@media (max-width: 480px) {
-- .vod-player-info { padding: 12px; border-radius: 12px; }
-- .comments-section { padding: 12px; border-radius: 12px; }
-- .chat-replay-sidebar { max-height: 200px; }
--}
--.vod-player-layout { max-width: 1200px; margin: 0 auto; }
--.vod-player-layout {
-- display: flex;
-- flex-direction: column;
-- gap: 18px;
--}
--.vod-video {
-- width: 100%; max-height: 70vh; background: #000;
-- border-radius: 0; display: block;
--}
--.vod-player-info { margin-bottom: 24px; }
--/* Before a VOD's or clip's data (app-media.js _mediaReady): outlines for the placeholder words, and the parts that
-- arrive on their own (AI timeline, activity strip, clips, comments) stay out of the layout until they are all in.
-- Only with scripting: without it the data never comes, and the page shows what the server sent, all of it. */
--@media (scripting: enabled) {
-- :is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #vp-streamer, #vp-duration, #vp-views, #clp-title, #clp-streamer, #clp-duration, #clp-views) {
-- color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-radius: 6px;
-- }
-- :is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #clp-title) { width: fit-content; }
-- :is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-avatar, #clp-avatar, .vod-player-meta i) { visibility: hidden; }
-- #page-vod-player:not(.mp-ready) :is(#vp-ai-timeline, #vp-context, .vod-player-layout > .section-header, #vp-clips-grid, #vp-comments) { display: none; }
-- #page-clip-player:not(.mp-ready) #clp-comments { display: none; }
--}
--.vod-player-info {
-- background: rgba(22,22,26,0.74);
-- border: 1px solid rgba(255,255,255,0.05);
-- border-radius: 18px;
-- padding: 20px;
-- box-shadow: var(--shadow-soft);
--}
--.vod-player-meta {
-- display: flex; flex-wrap: wrap; gap: 16px; align-items: center;
-- margin-bottom: 12px; font-size: 0.9rem;
--}
--.vod-player-streamer {
-- display: flex; align-items: center; gap: 8px;
-- font-weight: 600;
--}
--.vod-player-description {
-- color: var(--text-secondary); font-size: 0.9rem;
-- line-height: 1.5; white-space: pre-wrap;
--}
--.vod-source-info {
-- margin-top: 6px; font-size: 0.85rem;
-- color: var(--text-muted); display: flex; align-items: center; gap: 6px;
--}
--.vod-private-notice {
-- display: flex; flex-direction: column; align-items: center;
-- justify-content: center; height: 300px; background: var(--bg-secondary);
-- border-radius: var(--radius); border: 1px solid var(--border);
--}
--.vod-extra-details {
-- display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0 4px;
-- font-size: 0.8rem; color: var(--text-muted);
--}
--.vod-extra-details .detail-chip {
-- display: inline-flex; align-items: center; gap: 5px;
-- background: var(--bg-secondary); padding: 3px 10px;
-- border-radius: 12px; border: 1px solid var(--border);
--}
--.clip-modal {
-- background: var(--bg-card, #1e1e24); border: 1px solid rgba(255,255,255,0.1);
-- border-radius: 18px; width: 100%; max-width: 720px;
-- box-shadow: 0 24px 64px rgba(0,0,0,0.6); overflow: hidden;
-- animation: modalSlideUp 0.25s ease;
--}
--.clip-modal-header {
-- display: flex; justify-content: space-between; align-items: center;
-- padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.07);
--}
--.clip-modal-close {
-- background: none; border: none; color: var(--text-secondary); cursor: pointer;
-- padding: 6px 8px; border-radius: 8px; font-size: 1.1rem; transition: all 0.15s;
--}
--.clip-modal-close:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); }
--.clip-modal-body { padding: 16px 20px 12px; }
--.clip-modal-footer {
-- display: flex; justify-content: flex-end; gap: 10px;
-- padding: 12px 20px 16px; border-top: 1px solid rgba(255,255,255,0.07);
--}
--.clip-preview-video { width: 100%; height: 100%; object-fit: contain; display: block; }
--.clip-preview-wrap:hover .clip-preview-play-btn { opacity: 1; }
--.clip-preview-play-btn:hover { background: rgba(0,0,0,0.5); }
--.clip-timeline-section { margin-bottom: 16px; }
--.clip-timeline-label {
-- font-size: 0.78rem; color: var(--text-secondary); margin-bottom: 8px;
--}
--.clip-handle-grip {
-- width: 12px; height: 22px; border-radius: 4px;
-- background: var(--accent, var(--accent)); border: 2px solid rgba(255,255,255,0.35);
-- box-shadow: 0 1px 6px rgba(0,0,0,0.4);
-- transition: transform 0.15s, box-shadow 0.15s;
--}
--.clip-handle:hover .clip-handle-grip,
--.clip-handle:active .clip-handle-grip {
-- transform: scaleY(1.15); box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 50%, transparent);
--}
--.clip-handle-start .clip-handle-grip { border-radius: 4px 2px 2px 4px; }
--.clip-handle-end .clip-handle-grip { border-radius: 2px 4px 4px 2px; }
--.clip-time-controls {
-- display: flex; align-items: flex-start; gap: 12px; margin-bottom: 14px;
--}
--.clip-time-field {
-- flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px;
--}
--.clip-time-input-group {
-- display: flex; align-items: center; gap: 4px;
--}
--.clip-nudge-btn {
-- background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
-- color: var(--text-secondary); cursor: pointer; border-radius: 6px;
-- padding: 4px 7px; font-size: 0.75rem; transition: all 0.15s;
-- display: flex; align-items: center; justify-content: center;
--}
--.clip-nudge-btn:hover { background: rgba(255,255,255,0.12); color: var(--text-primary); }
--.clip-nudge-btn.clip-nudge-sm { font-weight: 700; min-width: 26px; }
--.clip-set-btn {
-- background: none; border: none; color: var(--accent, var(--accent)); cursor: pointer;
-- font-size: 0.72rem; padding: 2px 0; transition: opacity 0.15s; opacity: 0.75;
--}
--.clip-set-btn:hover { opacity: 1; text-decoration: underline; }
--.clip-duration-number {
-- font-size: 2rem; font-weight: 800; line-height: 1;
-- font-variant-numeric: tabular-nums; color: var(--accent, var(--accent));
-- transition: color 0.2s;
--}
--.clip-duration-number.clip-duration-over { color: #e53e3e; }
--.clip-duration-number.clip-duration-zero { color: var(--text-muted); }
--.clip-duration-label { font-size: 0.7rem; color: var(--text-secondary); }
--.clip-duration-bar-wrap {
-- width: 100%; height: 3px; border-radius: 2px;
-- background: rgba(255,255,255,0.08); margin-top: 4px; overflow: hidden;
--}
--.clip-duration-bar {
-- height: 100%; border-radius: 2px; transition: width 0.1s;
-- background: var(--accent, var(--accent));
--}
--.clip-title-section { margin-bottom: 4px; }
--.clip-title-section .form-input {
-- width: 100%; box-sizing: border-box;
--}
--@media (max-width: 600px) {
-- .clip-modal { max-width: 100%; border-radius: 14px; }
-- .clip-time-controls { flex-direction: column; align-items: stretch; gap: 8px; }
-- .clip-duration-number { font-size: 1.3rem; }
-- .clip-time-field { flex-direction: column; }
-- .clip-handle { width: 28px; height: 36px; }
-- .clip-handle-grip { width: 16px; height: 28px; }
--}
--.vod-player-row {
-- display: flex; gap: 0; margin-bottom: 16px;
-- border-radius: 20px; overflow: hidden;
-- border: 1px solid rgba(255,255,255,0.06);
-- background: #000;
-- box-shadow: var(--shadow-soft);
-- max-height: 75vh; /* constrain so chat replay doesn't push off screen */
--}
--.chat-replay-sidebar {
-- width: 340px; flex-shrink: 0;
-- background: var(--bg-secondary);
-- border-left: 1px solid var(--border);
-- display: flex; flex-direction: column;
-- min-height: 0; /* allow flex children to shrink */
-- overflow: hidden;
--}
--.chat-replay-header {
-- display: flex; justify-content: space-between; align-items: center;
-- padding: 10px 14px; border-bottom: 1px solid var(--border);
-- font-size: 0.85rem; flex-shrink: 0;
--}
--.chat-replay-messages {
-- flex: 1; overflow-y: auto; padding: 8px 12px;
-- font-size: 0.82rem; line-height: 1.45;
-- min-height: 0;
--}
--.chat-replay-msg {
-- padding: 3px 0; animation: fadeIn 0.3s ease;
--}
--.chat-replay-msg .cr-time {
-- color: var(--text-muted); font-size: 0.72rem; margin-right: 6px;
-- font-variant-numeric: tabular-nums;
--}
--.chat-replay-msg .cr-user {
-- font-weight: 600; margin-right: 4px;
--}
--.chat-replay-msg .cr-text { color: var(--text-secondary); word-break: break-word; }
--.chat-replay-empty {
-- display: flex; flex-direction: column; align-items: center;
-- justify-content: center; height: 100%; color: var(--text-muted);
-- text-align: center; gap: 8px; padding: 20px;
-- font-size: 0.82rem;
--}
--.chat-replay-sidebar.no-data .chat-replay-header h4::after {
-- content: '(no chat data)'; margin-left: 6px;
-- font-weight: 400; font-size: 0.75rem; color: var(--text-muted);
--}
--.comments-section {
-- margin-top: 0;
-- padding: 20px;
-- border: 1px solid rgba(255,255,255,0.05);
-- border-radius: 18px;
-- background: rgba(22,22,26,0.72);
-- box-shadow: var(--shadow-soft);
--}
--.comments-count {
-- font-size: 0.85rem; color: var(--text-muted); font-weight: 400;
--}
--.comment-form {
-- margin-bottom: 20px;
--}
--.comment-form-actions {
-- display: flex; justify-content: flex-end; margin-top: 8px; gap: 8px;
--}
--.comments-list { display: flex; flex-direction: column; gap: 0; }
--.stream-card-date {
-- margin-left: auto; font-size: 0.75rem;
-- color: var(--text-muted); white-space: nowrap;
--}
--@media (max-width: 900px) {
-- .chat-replay-sidebar { width: 100% !important; max-height: min(60vh, 520px) !important; }
-- .vod-player-row { max-height: none !important; }
--}
--
--.vod-ai-timeline {
-- margin-top: 12px; display: flex; flex-direction: column; gap: 4px;
-- max-height: 320px; overflow-y: auto;
-- border: 1px solid var(--border, rgba(255,255,255,0.1)); border-radius: 10px; padding: 10px 12px;
-- background: rgba(255,255,255,0.02);
--}
--.vod-ai-timeline-title { font-weight: 700; font-size: 0.85rem; margin-bottom: 4px; color: var(--accent, var(--accent)); display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
--.vod-ai-timeline-hint {
-- font-weight: 500; font-size: 0.72rem; color: var(--text-muted, #999);
-- background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
-- padding: 1px 8px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px;
--}
--.vod-ai-memory {
-- display: flex; align-items: flex-start; gap: 10px; text-align: left; width: 100%;
-- background: none; border: 1px solid transparent; color: inherit; cursor: pointer; padding: 6px 8px; border-radius: 7px;
-- transition: background 0.15s, border-color 0.15s, transform 0.1s;
--}
--.vod-ai-memory:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); border-color: color-mix(in srgb, var(--accent) 28%, transparent); }
--.vod-ai-memory:active { transform: scale(0.99); }
--.vod-ai-memory-play { font-size: 0.62rem; opacity: 0.55; transition: opacity 0.15s, transform 0.15s; }
--.vod-ai-memory:hover .vod-ai-memory-play { opacity: 1; transform: scale(1.15); }
--.vod-ai-memory-d { font-size: 0.82rem; color: var(--text-secondary, #bbb); line-height: 1.35; }
--.vp-seek-flash { animation: vpSeekFlash 0.7s ease-out; }
--.vp-visibility-control {
-- display: inline-flex; align-items: center; gap: 7px;
-- padding: 3px 6px 3px 10px; border-radius: 8px;
-- border: 1px solid var(--border, rgba(255,255,255,0.12)); background: rgba(255,255,255,0.03);
--}
--.vp-visibility-icon { color: var(--accent, var(--accent)); font-size: 0.85rem; }
--.vp-visibility-control select { border: none; background: transparent; padding: 4px 6px; }
--.media-ai-overview {
-- margin: 8px 0 10px; padding: 10px 12px;
-- background: color-mix(in srgb, var(--accent) 8%, transparent);
-- border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
-- border-radius: 8px; font-size: 0.9rem; line-height: 1.5;
--}
--.media-ai-label { color: var(--accent); font-weight: 700; margin-right: 6px; }
--.media-ai-text { color: var(--text-secondary); }
--.media-transcript {
-- margin: 10px 0 12px;
-- border: 1px solid var(--border);
-- border-radius: 10px;
-- background: var(--bg-card);
-- overflow: hidden;
--}
--.media-transcript-head {
-- display: flex; align-items: center; gap: 8px;
-- padding: 8px 10px;
--}
--.media-transcript-toggle {
-- flex: 1; display: flex; align-items: center; gap: 8px;
-- background: transparent; border: none; cursor: pointer;
-- color: var(--text-primary); font-weight: 600; font-size: 0.9rem; text-align: left;
-- padding: 2px 0;
--}
--.media-transcript-meta {
-- font-size: 0.72rem; font-weight: 500; color: var(--text-muted);
--}
--.media-transcript-caret {
-- margin-left: auto; font-size: 0.75rem; color: var(--text-secondary);
-- transition: transform 0.2s ease;
--}
--.media-transcript.open .media-transcript-caret { transform: rotate(180deg); }
--.media-transcript-dl { flex-shrink: 0; }
--.media-transcript-body {
-- max-height: 0; overflow: hidden;
-- transition: max-height 0.3s ease;
-- border-top: 1px solid transparent;
--}
--.media-transcript.open .media-transcript-body {
-- max-height: 440px; overflow-y: auto;
-- border-top-color: var(--border);
--}
--.media-transcript-text {
-- white-space: pre-wrap; word-break: break-word;
-- line-height: 1.6; font-size: 0.85rem; color: var(--text-secondary);
-- padding: 12px; margin: 0;
--}
--.media-transcript-segs { display: flex; flex-direction: column; padding: 6px; gap: 1px; }
--.ts-seg:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text-primary); }
--.ts-text { flex: 1; }
--
--/* Comments are OpenVibe.Community threads: the link to the same thread there, deleted tombstones */
--.comments-elsewhere { margin: 10px 0 0; font-size: .85rem; color: var(--text-muted); }
--.comments-elsewhere:empty { display: none; }
--.comment-item.comment-deleted .comment-text { font-style: italic; }
--
--/* AI clip attribution on the clip page ("AI clip · from <streamer>'s stream", roadmap 33.4). */
--.ai-badge {
-- display: inline-block; padding: 1px 8px; border-radius: 999px;
-- font-size: 0.72rem; font-weight: 700; letter-spacing: .02em; color: #fff;
-- background: linear-gradient(135deg, #6d28d9, #0e7490); /* white text at 4.5:1 or better */
--}
--.clp-ai-attribution a { color: var(--accent); font-weight: 600; text-decoration: none; }
--.clp-ai-attribution a:hover { text-decoration: underline; }
--.clp-ai-how { font-size: 0.8rem; margin-top: 2px; }
--.detail-chip .cat-inferred { opacity: .78; }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 7f717d9d..383c811a 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -949,10 +949,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
.brand-text { color: var(--text-primary); }
.brand-text strong { color: var(--accent); }
--
-- 50% { background-position: 100% 50%; box-shadow: 0 0 14px rgba(238, 90, 36, 0.7); }
--}
--
/* Brand hover tooltip */
.nav-brand[data-brand-tooltip]::after {
content: attr(data-brand-tooltip);
@@@ -1549,16 +1545,7 @@ body.ov-nav-overlay .navbar.ovnav-top {
.ch-paste-meta { font-size: 0.75rem; display: flex; gap: 6px; }
/* ── Condensed VOD session group (many restart-split VODs → one expandable card) ── */
--.vod-group-card { position: relative; cursor: pointer; box-shadow: 3px 3px 0 -1px var(--border), 6px 6px 0 -2px color-mix(in srgb, var(--border) 70%, transparent); }
--.vod-group-card:hover { box-shadow: 3px 3px 0 -1px var(--accent), 6px 6px 0 -2px color-mix(in srgb, var(--accent) 55%, transparent); }
--.vod-group-count { position: absolute; top: 8px; left: 8px; z-index: 2; display: inline-flex; align-items: center; gap: 5px;
-- background: rgba(12,14,20,.72); color: #fff; font-size: 0.7rem; font-weight: 800; letter-spacing: .01em;
-- padding: 3px 9px 3px 7px; border-radius: 999px; border: 1px solid rgba(255,255,255,.16); backdrop-filter: blur(5px);
-- box-shadow: 0 2px 8px rgba(0,0,0,.4); }
--.vod-group-count i { color: var(--accent-light, var(--accent)); font-size: .72rem; }
--.vod-group-hint b { font-weight: 800; }
.vods-truncated-note { grid-column: 1 / -1; text-align: center; padding: 14px 0 4px; font-size: .82rem; opacity: .6; }
--.vod-group-parts { grid-column: 1 / -1; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; padding: 2px 0 12px; }
.stream-card {
display: block;
@@@ -2966,29 +2953,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
}
/* ── VOD/Clip Custom Player ─────────────────────────────────── */
--.vod-player-container {
-- position: relative; width: 100%;
-- background: #000; border-radius: 0;
-- overflow: hidden;
-- /* The usual shape before the video knows its own: a taller video still grows the box (aspect-ratio is a minimum
-- for content), and nothing below jumps when a 16:9 one arrives. */
-- aspect-ratio: 16 / 9; max-height: 70vh;
--}
--.vod-player-container:hover .video-overlay { opacity: 1; }
--.vod-player-container.paused .video-overlay { opacity: 1; }
/* Live VOD indicator */
--.vp-live-indicator {
-- position: absolute; top: 12px; right: 12px;
-- display: flex; align-items: center; gap: 8px;
-- z-index: 10; opacity: 1; pointer-events: auto;
--}
--.vp-live-badge i { font-size: 8px; animation: livePulse 1.5s infinite; }
@keyframes livePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
--.vp-jump-live-btn:hover { background: rgba(255,255,255,0.3); }
/* Live progress bar: red tint for the fill */
@@@ -4980,7 +4950,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.tab-bar .tab-btn { flex-shrink: 0; }
/* ─── VOD/Clip Player Mobile ───────────────────────────── */
-- .vod-player-info h2 { font-size: 1.1rem; }
/* ─── Stream Grid (VODs/Clips list pages) ─────────────── */
.stream-grid {
@@@ -5040,7 +5009,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.section-header h2 { font-size: 1.1rem; }
/* VOD player tighter on small phones */
-- .vod-player-info h2 { font-size: 1rem; }
/* Pastes page header buttons stack */
.page-header { flex-direction: column; align-items: flex-start; }
@@@ -5569,28 +5537,16 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
/* ═══════════════════════════════════════════════════════════════
VOD / Clip Player Pages
═══════════════════════════════════════════════════════════════ */
--.vod-player-info h2 { margin: 0 0 8px; }
--.vod-source-info i { color: var(--accent); }
/* Private VOD notice ─────────────────────────────────────── */
/* Enhanced details row ───────────────────────────────────── */
--.vod-extra-details .detail-chip i { color: var(--accent); font-size: 0.75rem; }
/* VOD Clip Creator Modal ────────────────────────────────────── */
--.clip-modal-overlay {
-- position: fixed; inset: 0; z-index: 9999;
-- background: rgba(0,0,0,0.75); backdrop-filter: blur(6px);
-- display: flex; justify-content: center; align-items: center;
-- padding: 16px; animation: fadeIn 0.2s ease;
--}
@keyframes modalSlideUp {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
--.clip-modal-header h3 {
-- margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px;
--}
/* Preview player */
.clip-preview-wrap {
@@@ -5610,20 +5566,6 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
}
/* Timeline section */
--.clip-timeline-wrap {
-- position: relative; padding: 12px 8px 20px; cursor: pointer;
-- user-select: none; -webkit-user-select: none;
--}
--.clip-timeline-bar {
-- position: relative; height: 8px; border-radius: 4px;
-- background: rgba(255,255,255,0.1);
--}
--.clip-timeline-fill {
-- position: absolute; top: 0; height: 100%; border-radius: 4px;
-- background: linear-gradient(90deg, var(--accent, var(--accent)), var(--accent-light));
-- opacity: 0.5; transition: left 0.05s, width 0.05s;
-- pointer-events: none;
--}
.clip-handle {
position: absolute; top: 50%; transform: translate(-50%, -50%);
width: 20px; height: 28px; cursor: ew-resize; z-index: 3;
@@@ -5636,9 +5578,6 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
transform: translateX(-1px); display: none; pointer-events: none;
box-shadow: 0 0 4px rgba(255,255,255,0.5);
}
--.clip-timeline-ticks {
-- position: relative; height: 14px; margin-top: 4px;
--}
.clip-tick {
position: absolute; font-size: 0.65rem; color: var(--text-muted, #666);
transform: translateX(-50%); white-space: nowrap;
@@@ -5670,16 +5609,6 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
}
/* Chat Replay Sidebar ─────────────────────────────────────── */
--.vod-player-main { flex: 1; min-width: 0; position: relative; }
--.chat-replay-header h4 {
-- margin: 0; font-size: 0.85rem; display: flex; align-items: center; gap: 6px;
-- color: var(--text-secondary);
--}
--.chat-replay-msg.cr-highlight {
-- background: rgba(var(--accent-rgb, 192, 150, 92), 0.08);
-- border-radius: 4px; padding: 3px 6px; margin: 0 -6px;
--}
--.chat-replay-empty p { margin: 0; }
/* Comments Section ────────────────────────────────────────── */
.comments-header h3 {
@@@ -8501,7 +8430,6 @@ body.theme-light .btn-outline:hover {
/* Ensure smooth scrolling on mobile for all scroll containers */
.chat-messages,
--.chat-replay-messages,
.global-chat-messages,
.emote-picker-grid,
.pastes-grid,
@@@ -9551,12 +9479,6 @@ body.theme-light .btn-outline:hover {
line-height: 1.3;
}
.stream-card-ai i { opacity: 0.8; margin-right: 3px; }
--.vod-ai-timeline-title i { margin-right: 4px; }
--.vod-ai-timeline-hint i { margin: 0; }
--.vod-ai-memory-t {
-- flex-shrink: 0; font-variant-numeric: tabular-nums; color: var(--accent, var(--accent));
-- font-weight: 700; font-size: 0.78rem; min-width: 58px; display: inline-flex; align-items: center; gap: 5px;
--}
/* Flash the player briefly when a timeline/transcript jump lands. */
@keyframes vpSeekFlash {
ddiff --git a/public/features.json b/public/features.json
iindex 0f861722..95fddff3 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -22,14 +22,6 @@
"channel"
]
},
-- "appMedia": {
-- "css": [
-- "/css/features/media.css"
-- ],
-- "js": [
-- "/js/app-media.js"
-- ]
-- },
"feeds": {
"css": [
"/css/features/content.css"
@@@ -115,20 +107,6 @@
"toggleChannelChat"
]
},
-- "vodPage": {
-- "fragment": "vod-player",
-- "deps": [
-- "context",
-- "appMedia"
-- ]
-- },
-- "clipPage": {
-- "fragment": "clip-player",
-- "deps": [
-- "context",
-- "appMedia"
-- ]
-- },
"voice": {
"js": [
"/js/call.js",
@@@ -325,18 +303,6 @@
"search"
]
},
-- {
-- "path": "^/vod/[^/]+",
-- "features": [
-- "vodPage"
-- ]
-- },
-- {
-- "path": "^/clip/[^/]+",
-- "features": [
-- "clipPage"
-- ]
-- },
{
"path": "^/dashboard",
"features": [
ddiff --git a/public/fragments/clip-player.html b/public/fragments/clip-player.html
ddeleted file mode 100644
iindex cfdafa4b..00000000
---- a/public/fragments/clip-player.html
++++ /dev/null
@@@ -1,79 +0,0 @@
--<!-- #page-clip-player markup, loaded on first visit to the route (public/features.json). The server inlines it for direct visits. -->
-- <div class="container">
-- <div class="vod-player-layout">
-- <div class="vod-player-row">
-- <div class="vod-player-main">
-- <div class="vod-player-container" id="clp-container">
-- <video id="clp-video" class="vod-video" style="display:none" playsinline></video>
-- <div class="video-overlay">
-- <div class="vc-progress-wrap" id="clp-progress-wrap">
-- <div class="vc-progress-bar">
-- <div class="vc-progress-buffer" id="clp-progress-buffer"></div>
-- <div class="vc-progress-fill" id="clp-progress-fill"></div>
-- </div>
-- </div>
-- <div class="video-controls">
-- <button class="vc-btn" id="clp-btn-play" title="Play/Pause"><i class="fa-solid fa-play"></i></button>
-- <div class="vc-volume-group">
-- <button class="vc-btn" id="clp-btn-vol" title="Mute"><i class="fa-solid fa-volume-high"></i></button>
-- <input type="range" id="clp-vol-slider" min="0" max="100" value="100" class="volume-slider" aria-label="Volume">
-- </div>
-- <span class="vc-time-display" id="clp-time">0:00 / 0:00</span>
-- <div class="vc-spacer"></div>
-- <button class="vc-speed-btn" id="clp-btn-speed" title="Playback Speed">1x</button>
-- <button class="vc-btn" id="clp-btn-fullscreen" title="Fullscreen"><i class="fa-solid fa-expand"></i></button>
-- </div>
-- </div>
-- </div>
-- </div>
-- <!-- Chat Replay Sidebar (Clip) -->
-- <div class="chat-replay-sidebar" id="clp-chat-replay">
-- <div class="chat-replay-header">
-- <h4><i class="fa-solid fa-clock-rotate-left"></i> Chat Replay</h4>
-- </div>
-- <div class="chat-replay-messages" id="clp-chat-replay-messages">
-- <div class="chat-replay-empty" id="clp-chat-replay-empty">
-- <i class="fa-solid fa-comments" style="font-size:1.5rem"></i>
-- <p>Chat messages will appear here as the clip plays</p>
-- </div>
-- </div>
-- </div>
-- </div>
-- <div class="vod-player-info">
-- <h2 id="clp-title">Clip Title</h2>
-- <span id="clp-unlisted-badge" class="badge-unlisted" style="display:none"><i class="fa-solid fa-eye-slash"></i> Unlisted</span>
-- <div class="vod-player-meta">
-- <a href="#" class="vod-player-streamer" id="clp-streamer-link" style="cursor:pointer;text-decoration:none;color:inherit">
-- <span class="stream-card-avatar" id="clp-avatar">?</span>
-- <span id="clp-streamer">Streamer</span>
-- </a>
-- <span class="muted" id="clp-date"></span>
-- <span class="muted"><i class="fa-solid fa-clock"></i> <span id="clp-duration">0:00</span></span>
-- <span class="muted"><i class="fa-solid fa-eye"></i> <span id="clp-views">0</span></span>
-- <span id="clp-protocol"></span>
-- <a id="clp-video-link" class="muted vod-video-link" href="#" style="display:none" title="This clip on OpenVibe.Video"><i class="fa-solid fa-film"></i> OpenVibe.Video</a>
-- </div>
-- <!-- Who made it, right under the title: "Clipped by <clipper>", or "AI clip · from <streamer>'s stream". -->
-- <div id="clp-clipped-by" class="muted" style="margin-top:4px"></div>
-- <div id="clp-extra-details" class="vod-extra-details" style="display:none"></div>
-- <p id="clp-description" class="vod-player-description"></p>
-- <div id="clp-stream-source" class="vod-source-info muted" style="display:none"></div>
-- <div id="clp-actions" style="margin-top:10px;display:none"></div>
-- </div>
--
-- <!-- Comments Section (Clip) -->
-- <div class="comments-section" id="clp-comments">
-- <div class="comments-header">
-- <h3><i class="fa-solid fa-comments"></i> Comments <span class="comments-count" id="clp-comment-count"></span></h3>
-- </div>
-- <div class="comment-form" id="clp-comment-form" style="display:none">
-- <textarea id="clp-comment-input" placeholder="Add a comment..." rows="2" maxlength="2000"></textarea>
-- <div class="comment-form-actions">
-- <button class="btn btn-primary btn-small" onclick="postComment('clip', window._clpClipId)">Comment</button>
-- </div>
-- </div>
-- <div class="comments-list" id="clp-comments-list"></div>
-- </div>
-- </div>
-- </div>
--
ddiff --git a/public/fragments/vod-player.html b/public/fragments/vod-player.html
ddeleted file mode 100644
iindex 9e5af082..00000000
---- a/public/fragments/vod-player.html
++++ /dev/null
@@@ -1,177 +0,0 @@
--<!-- #page-vod-player markup, loaded on first visit to the route (public/features.json). The server inlines it for direct visits. -->
-- <div class="container">
-- <div class="vod-player-layout">
-- <div class="vod-player-row">
-- <div class="vod-player-main">
-- <div class="vod-player-container" id="vp-container">
-- <video id="vp-video" class="vod-video" style="display:none" playsinline></video>
-- <div id="vp-private-notice" class="vod-private-notice" style="display:none">
-- <i class="fa-solid fa-lock" style="font-size:2.5rem;margin-bottom:12px;color:var(--text-muted)"></i>
-- <p style="font-size:1.1rem;margin:0 0 4px">This video is private</p>
-- <p class="muted" style="font-size:0.85rem">Only the uploader can watch this video</p>
-- </div>
-- <div class="vp-live-indicator" id="vp-live-indicator" style="display:none">
-- <span class="vp-live-badge"><i class="fa-solid fa-circle"></i> LIVE</span>
-- <button class="vp-jump-live-btn" id="vp-jump-live" title="Jump to live"><i class="fa-solid fa-forward"></i> Jump to Live</button>
-- </div>
-- <div class="video-overlay">
-- <div class="vc-progress-wrap" id="vp-progress-wrap">
-- <div class="vc-progress-bar">
-- <div class="vc-progress-buffer" id="vp-progress-buffer"></div>
-- <div class="vc-progress-fill" id="vp-progress-fill"></div>
-- </div>
-- </div>
-- <div class="video-controls">
-- <button class="vc-btn" id="vp-btn-play" title="Play/Pause"><i class="fa-solid fa-play"></i></button>
-- <div class="vc-volume-group">
-- <button class="vc-btn" id="vp-btn-vol" title="Mute"><i class="fa-solid fa-volume-high"></i></button>
-- <input type="range" id="vp-vol-slider" min="0" max="100" value="100" class="volume-slider" aria-label="Volume">
-- </div>
-- <span class="vc-time-display" id="vp-time">0:00 / 0:00</span>
-- <div class="vc-spacer"></div>
-- <button class="vc-btn" id="vp-btn-clip" title="Create Clip" onclick="toggleVodClipPanel()"><i class="fa-solid fa-scissors"></i></button>
-- <button class="vc-speed-btn" id="vp-btn-speed" title="Playback Speed">1x</button>
-- <button class="vc-btn" id="vp-btn-fullscreen" title="Fullscreen"><i class="fa-solid fa-expand"></i></button>
-- </div>
-- </div>
-- </div>
-- </div>
-- <!-- Chat Replay Sidebar (VOD) -->
-- <div class="chat-replay-sidebar" id="vp-chat-replay">
-- <div class="chat-replay-header">
-- <h4><i class="fa-solid fa-clock-rotate-left"></i> Chat Replay</h4>
-- </div>
-- <div class="chat-replay-messages" id="vp-chat-replay-messages">
-- <div class="chat-replay-empty" id="vp-chat-replay-empty">
-- <i class="fa-solid fa-comments" style="font-size:1.5rem"></i>
-- <p>Chat messages will appear here as the video plays</p>
-- </div>
-- </div>
-- </div>
-- </div>
-- <div class="vod-player-info">
-- <h2 id="vp-title">Video</h2>
-- <div class="vod-player-meta">
-- <a href="#" class="vod-player-streamer" id="vp-streamer-link" style="cursor:pointer;text-decoration:none;color:inherit">
-- <span class="stream-card-avatar" id="vp-avatar">?</span>
-- <span id="vp-streamer">Streamer</span>
-- </a>
-- <span class="muted" id="vp-date"></span>
-- <span class="muted"><i class="fa-solid fa-clock"></i> <span id="vp-duration">0:00</span></span>
-- <span class="muted"><i class="fa-solid fa-eye"></i> <span id="vp-views">0</span></span>
-- <span id="vp-protocol"></span>
-- <a id="vp-video-link" class="muted vod-video-link" href="#" style="display:none" title="The same recording on OpenVibe.Video, with AI chapters and captions"><i class="fa-solid fa-film"></i> OpenVibe.Video</a>
-- </div>
-- <div id="vp-extra-details" class="vod-extra-details" style="display:none"></div>
-- <p id="vp-description" class="vod-player-description"></p>
-- <div id="vp-stream-source" class="vod-source-info muted" style="display:none"></div>
-- <div id="vp-ai-timeline" class="vod-ai-timeline" style="display:none"></div>
-- <div id="vp-actions" style="margin-top:10px;display:none"></div>
--
-- <!-- VOD Clip Creator Modal (opened by clip button in video controls) -->
-- <div id="vp-clip-modal" class="clip-modal-overlay" style="display:none" onclick="if(event.target===this)closeClipCreator()">
-- <div class="clip-modal">
-- <div class="clip-modal-header">
-- <h3><i class="fa-solid fa-scissors"></i> Create Clip</h3>
-- <button class="clip-modal-close" onclick="closeClipCreator()" title="Close"><i class="fa-solid fa-xmark"></i></button>
-- </div>
--
-- <div class="clip-modal-body">
-- <!-- Mini preview player -->
-- <div class="clip-preview-wrap">
-- <video id="clip-preview-video" class="clip-preview-video" playsinline muted></video>
-- <div class="clip-preview-badge" id="clip-preview-badge">Preview</div>
-- <button class="clip-preview-play-btn" id="clip-preview-play-btn" onclick="toggleClipPreview()">
-- <i class="fa-solid fa-play"></i>
-- </button>
-- </div>
--
-- <!-- Timeline range selector -->
-- <div class="clip-timeline-section">
-- <div class="clip-timeline-label">
-- <span>Drag the handles to select your clip range (max 60s)</span>
-- </div>
-- <div class="clip-timeline-wrap" id="clip-timeline-wrap">
-- <div class="clip-timeline-bar">
-- <div class="clip-timeline-fill" id="clip-timeline-fill"></div>
-- <div class="clip-handle clip-handle-start" id="clip-handle-start" title="Drag to set clip start">
-- <div class="clip-handle-grip"></div>
-- </div>
-- <div class="clip-handle clip-handle-end" id="clip-handle-end" title="Drag to set clip end">
-- <div class="clip-handle-grip"></div>
-- </div>
-- <div class="clip-playhead" id="clip-playhead"></div>
-- </div>
-- <div class="clip-timeline-ticks" id="clip-timeline-ticks"></div>
-- </div>
-- </div>
--
-- <!-- Time controls row -->
-- <div class="clip-time-controls">
-- <div class="clip-time-field">
-- <label>Start</label>
-- <div class="clip-time-input-group">
-- <button class="clip-nudge-btn" onclick="nudgeClipMark('start',-5)" title="-5s"><i class="fa-solid fa-backward"></i></button>
-- <button class="clip-nudge-btn clip-nudge-sm" onclick="nudgeClipMark('start',-1)" title="-1s">-1</button>
-- <span class="clip-time-display" id="clip-start-display">0:00</span>
-- <button class="clip-nudge-btn clip-nudge-sm" onclick="nudgeClipMark('start',1)" title="+1s">+1</button>
-- <button class="clip-nudge-btn" onclick="nudgeClipMark('start',5)" title="+5s"><i class="fa-solid fa-forward"></i></button>
-- </div>
-- <button class="clip-set-btn" onclick="setClipMarkToCurrent('start')"><i class="fa-solid fa-location-dot"></i> Use current time</button>
-- </div>
-- <div class="clip-duration-display">
-- <div class="clip-duration-number" id="clip-duration-number">0</div>
-- <div class="clip-duration-label">seconds</div>
-- <div class="clip-duration-bar-wrap">
-- <div class="clip-duration-bar" id="clip-duration-bar"></div>
-- </div>
-- </div>
-- <div class="clip-time-field">
-- <label>End</label>
-- <div class="clip-time-input-group">
-- <button class="clip-nudge-btn" onclick="nudgeClipMark('end',-5)" title="-5s"><i class="fa-solid fa-backward"></i></button>
-- <button class="clip-nudge-btn clip-nudge-sm" onclick="nudgeClipMark('end',-1)" title="-1s">-1</button>
-- <span class="clip-time-display" id="clip-end-display">0:00</span>
-- <button class="clip-nudge-btn clip-nudge-sm" onclick="nudgeClipMark('end',1)" title="+1s">+1</button>
-- <button class="clip-nudge-btn" onclick="nudgeClipMark('end',5)" title="+5s"><i class="fa-solid fa-forward"></i></button>
-- </div>
-- <button class="clip-set-btn" onclick="setClipMarkToCurrent('end')"><i class="fa-solid fa-location-dot"></i> Use current time</button>
-- </div>
-- </div>
--
-- <!-- Title input -->
-- <div class="clip-title-section">
-- <input type="text" id="clip-title-input" class="form-input" placeholder="Give your clip a title (optional)" maxlength="200">
-- </div>
-- </div>
--
-- <div class="clip-modal-footer">
-- <button class="btn btn-outline" onclick="closeClipCreator()">Cancel</button>
-- <button class="btn btn-primary" id="clip-create-btn" onclick="createVodClip()">
-- <i class="fa-solid fa-scissors"></i> Create Clip
-- </button>
-- </div>
-- </div>
-- </div>
-- </div>
-- <div class="section-header">
-- <h3><i class="fa-solid fa-scissors"></i> Clips from this stream</h3>
-- </div>
-- <div class="stream-grid" id="vp-clips-grid"></div>
--
-- <!-- Comments Section (VOD) -->
-- <div class="comments-section" id="vp-comments">
-- <div class="comments-header">
-- <h3><i class="fa-solid fa-comments"></i> Comments <span class="comments-count" id="vp-comment-count"></span></h3>
-- </div>
-- <div class="comment-form" id="vp-comment-form" style="display:none">
-- <textarea id="vp-comment-input" placeholder="Add a comment..." rows="2" maxlength="2000"></textarea>
-- <div class="comment-form-actions">
-- <button class="btn btn-primary btn-small" onclick="postComment('vod', window._vpVodId)">Comment</button>
-- </div>
-- </div>
-- <div class="comments-list" id="vp-comments-list"></div>
-- </div>
-- </div>
-- </div>
--
ddiff --git a/public/index.html b/public/index.html
iindex a9d7764b..2e348344 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -692,10 +692,8 @@
<section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- ── VOD PLAYER ────────────────────────────────────── -->
-- <section id="page-vod-player" class="page" data-fragment="vod-player"><!--ov:fragment:vod-player--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- ── CLIP PLAYER ───────────────────────────────────── -->
-- <section id="page-clip-player" class="page" data-fragment="clip-player"><!--ov:fragment:clip-player--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- ── DASHBOARD ─────────────────────────────────────── -->
<section id="page-dashboard" class="page" data-fragment="dashboard"><!--ov:fragment:dashboard--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 77852419..4b3e3306 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -114,7 +114,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
if (liveVod && liveVod.vod) {
const v = liveVod.vod;
liveVodHtml += `
-- <a class="stream-card" href="/vod/${v.id}" onclick="return handleLinkClick(event, '/vod/${v.id}')" style="border:2px solid var(--accent);position:relative">
++ <a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')" style="border:2px solid var(--accent);position:relative">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
<span class="stream-card-nsfw" style="background:#e53e3e;animation:pulse 2s infinite">● RECORDING</span>
@@@ -134,7 +134,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => _reloadChannelContent(username));
vodsGrid.innerHTML = liveVodHtml + vods.map(v => _selWrap('vod', v.id, `
-- <a class="stream-card" href="/vod/${v.id}" onclick="return handleLinkClick(event, '/vod/${v.id}')">
++ <a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${_visBadge(v.visibility, v.is_public, canManage)}
@@@ -210,7 +210,7 @@ function renderChannelClipsSection(username, clips, meta = {}) {
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => _reloadChannelContent(username));
clipsGrid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
-- <a class="stream-card" href="/clip/${cl.id}" onclick="return handleLinkClick(event, '/clip/${cl.id}')">
++ <a class="stream-card" href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
@@@ -291,7 +291,7 @@ function _renderClipsTakenGrid(username, data) {
return;
}
grid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
-- <a class="stream-card" href="/clip/${cl.id}" onclick="return handleLinkClick(event, '/clip/${cl.id}')">
++ <a class="stream-card" href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
@@@ -333,7 +333,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
if (clips.length) {
grid.innerHTML = clips.map(cl => `
-- <a class="stream-card" href="/clip/${cl.id}" onclick="return handleLinkClick(event, '/clip/${cl.id}')">
++ <a class="stream-card" href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${cl.stream_protocol ? protocolBadge(cl.stream_protocol) : ''}
@@@ -367,7 +367,7 @@ function renderChannelAiClipsSection(username, clips, meta = {}) {
grid.textContent = '';
if (!total) { renderVodsPagination('ch-ai-clips-pagination', 1, 0, pageSize, 'setChannelAiClipsPage', 'AI clips'); return; }
for (const cl of clips) {
-- const href = `/clip/${Number(cl.id)}`;
++ const href = `${videoHref('clip', Number(cl.id))}`;
const card = document.createElement('a');
card.className = 'stream-card';
card.href = href;
@@@ -1906,7 +1906,7 @@ function _aiTimelineMomentHTML(mom, vodId) {
? `<img class="ai-tl-moment-thumb" src="${esc(thumbUrl)}" alt="" loading="lazy" onerror="this.remove()">`
: '';
const jump = vodId
-- ? `<a class="ai-tl-stamp" href="/vod/${vodId}?t=${off}" onclick="return handleLinkClick(event, '/vod/${vodId}?t=${off}')" title="Watch this moment"><i class="fa-solid fa-play"></i> ${stamp}</a>`
++ ? `<a class="ai-tl-stamp" href="${videoHref('vod', vodId, off)}" onclick="return handleLinkClick(event, '${videoHref('vod', vodId, off)}')" title="Watch this moment"><i class="fa-solid fa-play"></i> ${stamp}</a>`
: `<span class="ai-tl-stamp ai-tl-stamp--novod" title="No VOD available for this moment"><i class="fa-solid fa-clock"></i> ${stamp}</span>`;
return `<div class="ai-tl-moment">${thumb}<div class="ai-tl-moment-body">${jump}<div class="ai-tl-moment-desc">${desc}</div>${tagHTML ? `<div class="ai-tl-tags">${tagHTML}</div>` : ''}</div></div>`;
}
@@@ -1942,7 +1942,7 @@ function _aiTimelineSessionHTML(s) {
const dateStr = when ? new Date((String(when).includes('T') ? when : when.replace(' ', 'T') + 'Z')).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }) : '';
const ov = (s.ai_overview || s.ai_overview_short || '').trim();
const memCount = s.memory_count != null ? s.memory_count : (Array.isArray(s.memories) ? s.memories.length : 0);
-- const _vodHref = s.vod_id ? `/vod/${s.vod_id}` : null;
++ const _vodHref = s.vod_id ? `${videoHref('vod', s.vod_id)}` : null;
const _asLink = (label) => _vodHref ? `<a href="${_vodHref}" onclick="return handleLinkClick(event, '${_vodHref}')">${label}</a>` : label;
const aiTitle = _aiSessionTitle(s);
const mainTitle = aiTitle ? _asLink(esc(aiTitle)) : _asLink(esc(s.title || 'Untitled stream'));
@@@ -2054,7 +2054,7 @@ async function _aiTlToggleTranscript(btn) {
}
const nSpeech = blocks.filter(b => b.kind === 'speech').length, nSound = blocks.filter(b => b.kind === 'sounds').length;
const cov = data.coverageSec ? `${_aiTimeFmt(data.coverageSec)} of speech transcribed` : '';
-- const jumpFor = (t, cls = 'ai-tl-ts') => vod ? `<a class="${cls}" href="/vod/${vod}?t=${Math.floor(t)}" onclick="return handleLinkClick(event, '/vod/${vod}?t=${Math.floor(t)}')" title="Watch from here">${_aiTimeFmt(t)}</a>` : `<span class="${cls}">${_aiTimeFmt(t)}</span>`;
++ const jumpFor = (t, cls = 'ai-tl-ts') => vod ? `<a class="${cls}" href="${videoHref('vod', vod, Math.floor(t))}" onclick="return handleLinkClick(event, '${videoHref('vod', vod, Math.floor(t))}')" title="Watch from here">${_aiTimeFmt(t)}</a>` : `<span class="${cls}">${_aiTimeFmt(t)}</span>`;
const toolbar = `<div class="ai-tl-tr-bar">
<div class="ai-tl-tr-filter" role="tablist">
<button type="button" class="active" data-f="all">All</button>
@@@ -2379,7 +2379,7 @@ function _aboutAiOverviewHTML() {
let _aboutRecent = { username: null, vods: [], clips: [] };
function _aboutRowCard(kind, it) {
const isVod = kind === 'vod';
-- const href = isVod ? `/vod/${it.id}` : `/clip/${it.id}`;
++ const href = isVod ? `${videoHref('vod', it.id)}` : `${videoHref('clip', it.id)}`;
const gen = isVod ? `/api/thumbnails/generate/vod/${it.id}` : `/api/thumbnails/generate/clip/${it.id}`;
const views = Number(it.view_count || 0);
return `<a class="ch-about-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
@@@ -3162,7 +3162,7 @@ function _offlineCyclerRender() {
}
function _offlineTopCard(kind, item) {
const isVod = kind === 'vod';
-- const href = isVod ? `/vod/${item.id}` : `/clip/${item.id}`;
++ const href = isVod ? `${videoHref('vod', item.id)}` : `${videoHref('clip', item.id)}`;
const icon = isVod ? 'fa-video' : 'fa-scissors';
const label = isVod ? 'VOD' : 'Clip';
const thumbGen = isVod ? `/api/thumbnails/generate/vod/${item.id}` : `/api/thumbnails/generate/clip/${item.id}`;
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 8520f77c..bc75f97a 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1475,7 +1475,7 @@ async function loadHomeRecentVods(opts) {
header.style.display = '';
const _put = (html) => ovPutCards(grid, html, append);
_put(vods.map(v => {
-- const href = `/vod/${v.id}`;
++ const href = `${videoHref('vod', v.id)}`;
return `
<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
<div class="stream-card-thumb">
@@@ -1514,7 +1514,7 @@ async function loadHomeClips(opts) {
if (header) header.style.display = '';
const _put = (html) => ovPutCards(grid, html, append);
_put(clips.map(c => `
-- <a class="stream-card" href="/clip/${c.id}" onclick="return handleLinkClick(event, '/clip/${c.id}')">
++ <a class="stream-card" href="${videoHref('clip', c.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', c.id)}')">
<div class="stream-card-thumb">
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
${ovViewsBadge(c.view_count)}
@@@ -1588,7 +1588,7 @@ function streamCardHTML(s, isLive) {
const msRef = s.managed_stream_slug || s.managed_stream_id || null;
navUrl = channelPath(s.username, msRef);
} else if (!isLive && s.vod_id && s.vod_is_public) {
-- navUrl = `/vod/${s.vod_id}`;
++ navUrl = `${videoHref('vod', s.vod_id)}`;
} else {
navUrl = channelPath(s.username);
}
ddiff --git a/public/js/app-media.js b/public/js/app-media.js
ddeleted file mode 100644
iindex 9c8fad07..00000000
---- a/public/js/app-media.js
++++ /dev/null
@@@ -1,1895 +0,0 @@
--/* ═══════════════════════════════════════════════════════════════
-- OpenVibe.Live — VOD and clip lists and players: pagination and filters, VOD player, clip creator, clip player, chat replay, comments, watch progress.
--
-- Split out of app.js, which every page used to download and parse. This file loads with its
-- route (public/features.json); it runs after app.js and relies on app.js globals.
-- ═══════════════════════════════════════════════════════════════ */
--// Render an AI overview block just above a VOD/clip description element.
--function _renderMediaAiOverview(descElId, overview) {
-- const desc = document.getElementById(descElId);
-- if (!desc || !desc.parentNode) return;
-- let box = desc.parentNode.querySelector('.media-ai-overview');
-- const txt = (overview || '').trim();
-- if (!txt) { if (box) box.remove(); return; }
-- if (!box) {
-- box = document.createElement('div');
-- box.className = 'media-ai-overview';
-- desc.parentNode.insertBefore(box, desc);
-- }
-- box.innerHTML = `<span class="media-ai-label"><i class="fa-solid fa-wand-magic-sparkles"></i> AI overview</span> <span class="media-ai-text">${esc(txt)}</span>`;
--}
--
--// Render a collapsible, downloadable transcript block after a VOD/clip description.
--// `item` carries {id, title, ai_transcript}. The ' ' sentinel = "no speech" → hidden.
--function _fmtTs(sec) {
-- sec = Math.max(0, Math.floor(Number(sec) || 0));
-- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
-- return (h ? h + ':' + String(m).padStart(2, '0') : String(m)) + ':' + String(s).padStart(2, '0');
--}
--// Seek a VOD/clip <video> to a transcript timestamp.
--function seekMediaTo(videoId, sec) {
-- const v = document.getElementById(videoId);
-- if (!v) return;
-- try { v.currentTime = Math.max(0, Number(sec) || 0); v.play().catch(() => {}); } catch { /* */ }
-- v.scrollIntoView({ behavior: 'smooth', block: 'center' });
--}
--
--function _renderMediaTranscript(descElId, kind, item) {
-- const desc = document.getElementById(descElId);
-- if (!desc || !desc.parentNode) return;
-- const prev = desc.parentNode.querySelector('.media-transcript');
-- if (prev) prev.remove();
-- const t = ((item && item.ai_transcript) || '').trim();
-- let segments = null;
-- try { segments = (item && item.ai_transcript_json) ? JSON.parse(item.ai_transcript_json) : null; } catch { segments = null; }
-- const hasSegs = Array.isArray(segments) && segments.length > 0;
-- if (!t && !hasSegs) {
-- // No transcript (yet): say why, instead of silently showing nothing. 'empty' and
-- // unknown states stay quiet — there is nothing useful to tell the viewer.
-- const st = item && item.transcript_status;
-- const msg = (st === 'pending' || st === 'retry' || st === 'processing' || st === null || st === undefined) && item && item.id
-- ? (st === 'processing' ? 'Transcript in progress…' : (st ? 'Transcript queued — check back soon' : null))
-- : (st === 'failed' ? 'Transcript unavailable for this recording' : null);
-- if (!msg) return;
-- const note = document.createElement('div');
-- note.className = 'media-transcript media-transcript-pending';
-- note.innerHTML = `<div class="media-transcript-head"><span class="media-transcript-toggle" style="cursor:default;opacity:.7"><i class="fa-solid ${st === 'failed' ? 'fa-file-circle-xmark' : 'fa-file-lines'}"></i> <span>${esc(msg)}</span></span></div>`;
-- desc.parentNode.insertBefore(note, desc.nextSibling);
-- return;
-- }
-- const videoId = kind === 'vod' ? 'vp-video' : 'clp-video';
-- const words = (t || segments.map(s => s.text).join(' ')).split(/\s+/).filter(Boolean).length;
--
-- const bodyHtml = hasSegs
-- ? `<div class="media-transcript-segs">${segments.map(s =>
-- `<button type="button" class="ts-seg" onclick="seekMediaTo('${videoId}', ${Number(s.start) || 0})"><span class="ts-time">${_fmtTs(s.start)}</span><span class="ts-text">${esc(s.text)}</span></button>`
-- ).join('')}</div>`
-- : `<p class="media-transcript-text">${esc(t)}</p>`;
--
-- const box = document.createElement('div');
-- box.className = 'media-transcript';
-- box.dataset.filename = `${kind}-${item.id}-transcript`;
-- box.dataset.plain = hasSegs ? segments.map(s => `[${_fmtTs(s.start)}] ${s.text}`).join('\n') : t;
-- box.innerHTML = `
-- <div class="media-transcript-head">
-- <button type="button" class="media-transcript-toggle" onclick="toggleMediaTranscript(this)">
-- <i class="fa-solid fa-file-lines"></i> <span>Transcript</span>
-- <span class="media-transcript-meta">${words} words · ${hasSegs ? 'timestamped · ' : ''}local AI</span>
-- <i class="fa-solid fa-chevron-down media-transcript-caret"></i>
-- </button>
-- <button type="button" class="btn btn-small btn-outline media-transcript-dl" onclick="downloadMediaTranscript(this)" title="Download as .txt">
-- <i class="fa-solid fa-download"></i> .txt
-- </button>
-- </div>
-- <div class="media-transcript-body">${bodyHtml}</div>`;
-- desc.parentNode.insertBefore(box, desc.nextSibling);
--}
--
--function toggleMediaTranscript(btn) {
-- const box = btn.closest('.media-transcript');
-- if (box) box.classList.toggle('open');
--}
--
--function downloadMediaTranscript(btn) {
-- const box = btn.closest('.media-transcript');
-- if (!box) return;
-- const text = box.dataset.plain || box.querySelector('.media-transcript-text')?.textContent || '';
-- const name = (box.dataset.filename || 'transcript') + '.txt';
-- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
-- const url = URL.createObjectURL(blob);
-- const a = document.createElement('a');
-- a.href = url; a.download = name;
-- document.body.appendChild(a); a.click();
-- setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
--} // streamer's user id — the stable chat room key
--// The site-wide VOD and clip lists are the Content feed now (public/js/content-feed.js).
--// AI "memory" timeline on the VOD player — clickable timestamps that seek the video.
--// OpenVibe.Video (openvibe.video) serves public watch pages for recordings and clips that are public or unlisted
--// and finished; the link appears only for those. Private items and recordings in progress keep Live's page alone.
--function _showVideoLink(elId, kind, item) {
-- const a = document.getElementById(elId);
-- if (!a) return;
-- const vis = item.visibility || (item.is_public ? 'public' : 'private');
-- const ok = (vis === 'public' || vis === 'unlisted') && !item.is_recording && Number.isSafeInteger(Number(item.id));
-- a.style.display = ok ? '' : 'none';
-- if (ok) a.href = `https://openvibe.video/${kind === 'clip' ? 'c' : 'v'}/${Number(item.id)}`;
--}
--
--async function loadVodAiTimeline(vodId) {
-- const el = document.getElementById('vp-ai-timeline');
-- if (!el) return;
-- el.style.display = 'none'; el.innerHTML = '';
-- try {
-- const data = await api(`/vods/${vodId}/memories`);
-- const mems = data.memories || [];
-- if (!mems.length) return;
-- el.innerHTML = `<div class="vod-ai-timeline-title"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Timeline <span class="vod-ai-timeline-hint"><i class="fa-solid fa-hand-pointer"></i> click a moment to jump the video</span></div>` +
-- mems.map(m => {
-- const t = formatDuration(m.offset_seconds || 0);
-- return `<button class="vod-ai-memory" title="Jump to ${t}" onclick="seekVodTo(${Number(m.offset_seconds) || 0})">
-- <span class="vod-ai-memory-t"><i class="fa-solid fa-play vod-ai-memory-play"></i> ${t}</span>
-- <span class="vod-ai-memory-d">${esc(m.description || '')}</span></button>`;
-- }).join('');
-- el.style.display = '';
-- } catch { /* silent */ }
--}
--function seekVodTo(seconds) {
-- const v = document.getElementById('vp-video');
-- if (v && Number.isFinite(seconds)) {
-- v.currentTime = Math.max(0, seconds);
-- if (v.play) v.play().catch(() => {});
-- // Bring the viewer back up to the player so the jump is visible.
-- try { v.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* */ }
-- // Briefly flash the player to signal the jump landed.
-- const container = document.getElementById('vp-container') || v.closest('.video-container');
-- if (container) { container.classList.remove('vp-seek-flash'); void container.offsetWidth; container.classList.add('vp-seek-flash'); setTimeout(() => container.classList.remove('vp-seek-flash'), 700); }
-- }
--}
--
--/* ── VOD watch progress (localStorage, per-vod) ───────────────── */
--const VOD_PROGRESS_KEY = 'vodProgress';
--const VOD_PROGRESS_MAX = 200; // cap stored entries (LRU-ish by write time)
--
--function _readVodProgressMap() {
-- try { return JSON.parse(localStorage.getItem(VOD_PROGRESS_KEY)) || {}; } catch { return {}; }
--}
--function _getVodProgress(vodId) {
-- const m = _readVodProgressMap();
-- const e = m[String(vodId)];
-- return e && Number.isFinite(e.t) ? e.t : null;
--}
--function _saveVodProgress(vodId, seconds) {
-- const m = _readVodProgressMap();
-- m[String(vodId)] = { t: Math.max(0, Math.floor(seconds)), at: Date.now() };
-- // Trim oldest if we exceed the cap.
-- const keys = Object.keys(m);
-- if (keys.length > VOD_PROGRESS_MAX) {
-- keys.sort((a, b) => (m[a].at || 0) - (m[b].at || 0));
-- for (const k of keys.slice(0, keys.length - VOD_PROGRESS_MAX)) delete m[k];
-- }
-- try { localStorage.setItem(VOD_PROGRESS_KEY, JSON.stringify(m)); } catch { /* quota */ }
--}
--function _clearVodProgress(vodId) {
-- const m = _readVodProgressMap();
-- if (m[String(vodId)]) { delete m[String(vodId)]; try { localStorage.setItem(VOD_PROGRESS_KEY, JSON.stringify(m)); } catch { /* */ } }
--}
--// Save currentTime periodically while watching; clear it once effectively finished.
--function _attachVodProgressTracking(video, vodId) {
-- if (!video || video._progressTracked === vodId) return;
-- video._progressTracked = vodId;
-- let last = 0;
-- video.addEventListener('timeupdate', () => {
-- const now = Date.now();
-- if (now - last < 4000) return; // throttle writes to ~every 4s
-- last = now;
-- const dur = isFinite(video.duration) ? video.duration : 0;
-- const t = video.currentTime || 0;
-- if (dur > 0 && t > dur - 10) _clearVodProgress(vodId); // near the end → don't resume next time
-- else if (t > 3) _saveVodProgress(vodId, t);
-- });
-- video.addEventListener('ended', () => _clearVodProgress(vodId));
-- // Best-effort flush on navigate away / tab close.
-- window.addEventListener('pagehide', () => {
-- const dur = isFinite(video.duration) ? video.duration : 0;
-- const t = video.currentTime || 0;
-- if (t > 3 && !(dur > 0 && t > dur - 10)) _saveVodProgress(vodId, t);
-- }, { once: true });
--}
--/* ── VOD Player ───────────────────────────────────────────────── */
--// ── Playback source: Media's timeline playlist when there is one, the file otherwise ──────────────────────
--// A finished VOD or clip with a Media timeline (hls_url) plays its HLS playlist, so a seek reads only the segments
--// it needs (packed chunks included). A recording, a VOD without a timeline, or a browser that cannot play HLS keeps
--// the file URL, and a playlist that fails falls back to the file once.
--let _vodHls = null;
--let _vodHlsScript = null;
--function vodLoadHlsScript() {
-- if (typeof Hls !== 'undefined') return Promise.resolve();
-- if (!_vodHlsScript) {
-- _vodHlsScript = new Promise((resolve, reject) => {
-- const s = document.createElement('script');
-- s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js';
-- s.async = true;
-- s.onload = () => resolve();
-- s.onerror = () => { _vodHlsScript = null; reject(new Error('hls.js did not load')); };
-- document.head.appendChild(s);
-- });
-- }
-- return _vodHlsScript;
--}
--function vodDropHls() {
-- if (_vodHls) { try { _vodHls.destroy(); } catch { /* already gone */ } _vodHls = null; }
--}
--/**
-- * Point `video` at the playlist (or the file); remembers the file URL for the fallback and the clip preview.
-- * `routeGen` is the router generation the caller started under: a load that finishes after the person left the
-- * page attaches nothing (no hls.js instance outlives the route).
-- */
--async function attachVodSource(video, hlsUrl, fileUrl, routeGen = null) {
-- const stale = () => routeGen != null && typeof ov !== 'undefined' && ov && typeof ov.isCurrent === 'function' && !ov.isCurrent(routeGen);
-- if (stale()) return 'stale';
-- vodDropHls();
-- video.dataset.fileUrl = fileUrl;
-- video.dataset.source = 'file';
-- if (!hlsUrl) { video.src = fileUrl; return 'file'; }
-- if (video.canPlayType('application/vnd.apple.mpegurl')) {
-- video.dataset.source = 'hls';
-- video.src = hlsUrl;
-- return 'hls';
-- }
-- try { await vodLoadHlsScript(); } catch { if (!stale()) video.src = fileUrl; return 'file'; }
-- if (stale()) return 'stale';
-- if (typeof Hls === 'undefined' || !Hls.isSupported()) { video.src = fileUrl; return 'file'; }
-- const hls = new Hls({ enableWorker: true, backBufferLength: 60 });
-- _vodHls = hls;
-- video.dataset.source = 'hls';
-- hls.on(Hls.Events.ERROR, (_event, data) => {
-- if (data && data.fatal && _vodHls === hls) vodFallBackToFile(video);
-- });
-- hls.loadSource(hlsUrl);
-- hls.attachMedia(video);
-- return 'hls';
--}
--/** A playlist that failed: play the file instead, once. Returns true when it switched (callers then ignore the error). */
--function vodFallBackToFile(video) {
-- if (video.dataset.source !== 'hls' || !video.dataset.fileUrl) return false;
-- const at = video.currentTime || 0;
-- vodDropHls();
-- video.dataset.source = 'file';
-- video.src = video.dataset.fileUrl;
-- if (at > 0) video.addEventListener('loadedmetadata', function _resume() { video.removeEventListener('loadedmetadata', _resume); try { video.currentTime = at; } catch { /* */ } });
-- return true;
--}
--
--// A direct visit paints a VOD's or clip's markup before its data. Until the data (mp-data) media.css outlines the info
--// card's placeholder words; until the parts that load on their own are in (mp-ready: the AI timeline, the activity
--// strip, the source card) it keeps them, the clips and the comments out of the layout, so they arrive together and
--// push nothing on screen down. Never longer than 2.5 s after the data.
--const _mediaReadyTimers = {};
--function _mediaReady(pageId, ready = true) {
-- const page = document.getElementById(pageId);
-- if (page) { page.classList.toggle('mp-ready', ready); if (!ready) page.classList.remove('mp-data'); }
-- clearTimeout(_mediaReadyTimers[pageId]);
--}
--function _mediaHasData(pageId) {
-- const page = document.getElementById(pageId);
-- if (page) page.classList.add('mp-data');
-- clearTimeout(_mediaReadyTimers[pageId]);
-- _mediaReadyTimers[pageId] = setTimeout(() => _mediaReady(pageId), 2500);
--}
--
--async function loadVodPlayer(vodId, seekTo) {
-- const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
-- const blocks = [];
-- _mediaReady('page-vod-player', false);
-- try {
-- // Clean up any previous live VOD poll
-- if (window._liveVodPollTimer) {
-- clearInterval(window._liveVodPollTimer);
-- window._liveVodPollTimer = null;
-- }
-- // Reset live-DVR globals so a previously-viewed live stream's inflated
-- // duration can't leak into a completed VOD's timeline (which would let the
-- // scrubber seek past the real footage → permanent black screen).
-- window._liveVodIsLive = false;
-- window._liveVodDuration = 0;
-- // Clean up chat replay
-- if (window._chatReplayTimer) {
-- cancelAnimationFrame(window._chatReplayTimer);
-- window._chatReplayTimer = null;
-- }
-- window._vpChatReplay = null;
-- const vpMsgs = document.getElementById('vp-chat-replay-messages');
-- if (vpMsgs) vpMsgs.innerHTML = '<div class="chat-replay-empty" id="vp-chat-replay-empty"><i class="fa-solid fa-comments" style="font-size:1.5rem"></i><p>Chat messages will appear here as the video plays</p></div>';
-- const vpSidebar = document.getElementById('vp-chat-replay');
-- if (vpSidebar) vpSidebar.classList.remove('no-data');
--
-- const data = await api(`/vods/${vodId}`);
-- const v = data.vod;
-- const clips = data.clips || [];
-- _mediaHasData('page-vod-player');
--
-- // Store for comments
-- window._vpVodId = v.id;
--
-- document.getElementById('vp-title').textContent = v.title || 'Video';
-- setPageTitle(v.title || 'Video');
-- blocks.push(loadVodAiTimeline(v.id));
-- document.getElementById('vp-streamer').textContent = v.display_name || v.username || 'Unknown';
-- { const _a = document.getElementById('vp-avatar'); if (_a) _a.innerHTML = _avatarInner(v.avatar_url, v.username); }
-- document.getElementById('vp-date').textContent = formatDateTime(v.created_at);
-- document.getElementById('vp-duration').textContent = formatDuration(v.duration_seconds || v.duration);
-- document.getElementById('vp-views').textContent = `${v.view_count || 0} view${(v.view_count || 0) === 1 ? '' : 's'}${v.unique_views != null ? ` · ${v.unique_views} unique` : ''}`;
-- _showVideoLink('vp-video-link', 'vod', v);
-- document.getElementById('vp-description').textContent = v.description || '';
-- _renderMediaAiOverview('vp-description', v.ai_overview);
-- _renderMediaTranscript('vp-description', 'vod', v);
-- // Show this streamer's OpenCoins in the navbar while viewing their VOD.
-- if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(v.user_id);
--
-- // Protocol badge
-- const vpProto = document.getElementById('vp-protocol');
-- if (vpProto) vpProto.innerHTML = v.stream_protocol ? protocolBadge(v.stream_protocol) : '';
--
-- // Enhanced details
-- const extraDetails = document.getElementById('vp-extra-details');
-- if (extraDetails) {
-- let chips = '';
-- if (v.stream_category) chips += `<span class="detail-chip"${v.stream_category_inferred ? ' title="Category inferred by OpenVibe&#39;s AI from the stream"' : ''}><i class="fa-solid fa-tag"></i> ${esc(_capTag(v.stream_category))}${v.stream_category_inferred ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>`;
-- if (v.stream_peak_viewers) chips += `<span class="detail-chip"><i class="fa-solid fa-users"></i> Peak: ${v.stream_peak_viewers}</span>`;
-- if (v.stream_started_at) {
-- const streamDate = new Date(v.stream_started_at + 'Z');
-- chips += `<span class="detail-chip"><i class="fa-solid fa-calendar"></i> ${streamDate.toLocaleDateString()}</span>`;
-- }
-- if (chips) { extraDetails.innerHTML = chips; extraDetails.style.display = ''; }
-- else extraDetails.style.display = 'none';
-- }
--
-- // Handle private VODs
-- const video = document.getElementById('vp-video');
-- const container = document.getElementById('vp-container');
-- const privateNotice = document.getElementById('vp-private-notice');
-- const liveIndicator = document.getElementById('vp-live-indicator');
-- const jumpLiveBtn = document.getElementById('vp-jump-live');
--
-- if (v.is_private) {
-- // Private VOD — show notice instead of video
-- video.style.display = 'none';
-- if (privateNotice) privateNotice.style.display = '';
-- container.querySelector('.video-overlay').style.display = 'none';
-- } else if (v.file_path) {
-- if (privateNotice) privateNotice.style.display = 'none';
-- container.querySelector('.video-overlay').style.display = '';
--
-- // Stream source info
-- const vpStream = document.getElementById('vp-stream-source');
-- if (vpStream) {
-- if (v.is_recording) {
-- vpStream.innerHTML = `<span style="color:#e53e3e;animation:pulse 2s infinite"><i class="fa-solid fa-circle"></i> Recording in progress</span> — VOD is being recorded live`;
-- vpStream.style.display = '';
-- } else if (v.stream_title) {
-- vpStream.innerHTML = `<i class="fa-solid fa-tower-broadcast"></i> From stream: <strong>${esc(v.stream_title)}</strong>`;
-- vpStream.style.display = '';
-- } else {
-- vpStream.style.display = 'none';
-- }
-- // Activity strip: chat lines, chatters, viewers, mic moments, follows, report.
-- if (typeof renderVodContextStrip === 'function') blocks.push(renderVodContextStrip(v.id));
-- }
--
-- const fileUrl = `/api/vods/${Number(v.id)}/file`;
-- // Record the server-probed duration (ffprobe truth). The player clamps
-- // to this when the browser mis-reports the WebM container duration.
-- const serverDur = Number(v.duration_seconds || v.duration || 0);
-- video.dataset.serverDuration = (serverDur > 0 && !v.is_recording) ? String(serverDur) : '';
-- // A recording keeps the growing file; a finished VOD with a timeline plays its playlist.
-- if (!video.dataset.hlsFallbackBound) {
-- video.dataset.hlsFallbackBound = '1';
-- video.addEventListener('error', () => { vodFallBackToFile(video); });
-- }
-- attachVodSource(video, v.is_recording ? null : v.hls_url, `${fileUrl}?t=${Date.now()}`, routeGen);
-- video.style.display = 'block';
--
-- if (v.is_recording) {
-- // Live VOD mode
-- container.classList.add('vp-live-mode');
-- if (liveIndicator) liveIndicator.style.display = '';
--
-- window._liveVodDuration = v.duration_seconds || 0;
-- window._liveVodId = v.id;
-- window._liveVodIsLive = true;
--
-- if (jumpLiveBtn) {
-- jumpLiveBtn.onclick = () => {
-- video.src = `${fileUrl}?t=${Date.now()}`;
-- video.addEventListener('loadedmetadata', function _jumpOnce() {
-- video.removeEventListener('loadedmetadata', _jumpOnce);
-- const dur = isFinite(video.duration) ? video.duration : window._liveVodDuration;
-- if (dur > 2) video.currentTime = dur - 1;
-- video.play().catch(() => {});
-- });
-- };
-- }
--
-- window._liveVodSeekableLoaded = false;
-- window._liveVodLastSeekableRefresh = 0;
--
-- window._liveVodPollTimer = setInterval(async () => {
-- try {
-- const info = await api(`/vods/${v.id}/live-info`);
-- if (!info.isRecording) {
-- clearInterval(window._liveVodPollTimer);
-- window._liveVodPollTimer = null;
-- window._liveVodIsLive = false;
-- container.classList.remove('vp-live-mode');
-- if (liveIndicator) liveIndicator.style.display = 'none';
-- loadVodPlayer(v.id);
-- return;
-- }
-- window._liveVodDuration = info.duration || 0;
--
-- if (info.seekable) {
-- const now = Date.now();
-- const shouldRefresh = !window._liveVodSeekableLoaded ||
-- (now - window._liveVodLastSeekableRefresh > 60000);
--
-- if (shouldRefresh) {
-- window._liveVodSeekableLoaded = true;
-- window._liveVodLastSeekableRefresh = now;
-- const currentTime = video.currentTime;
-- const wasPaused = video.paused;
-- video.src = `${fileUrl}?t=${now}`;
-- video.addEventListener('loadedmetadata', function _restore() {
-- video.removeEventListener('loadedmetadata', _restore);
-- const dur = isFinite(video.duration) ? video.duration : window._liveVodDuration;
-- video.currentTime = Math.min(currentTime, dur);
-- if (!wasPaused) video.play().catch(() => {});
-- });
-- }
-- }
--
-- document.getElementById('vp-duration').textContent = formatDuration(info.duration);
-- } catch (e) { /* silent */ }
-- }, 15000);
-- } else {
-- // Normal completed VOD
-- container.classList.remove('vp-live-mode');
-- if (liveIndicator) liveIndicator.style.display = 'none';
-- window._liveVodIsLive = false;
-- }
--
-- setupCustomVideoControls('vp');
--
-- // Resume position: an explicit deep-link timestamp (?t=) wins; otherwise
-- // restore the viewer's last saved watch position for THIS vod. This also
-- // fixes VODs that would otherwise load parked at the very end (a WebM whose
-- // container reports the full duration as the initial currentTime).
-- if (!v.is_recording) {
-- const saved = _getVodProgress(v.id);
-- const _dur0 = () => (isFinite(video.duration) && video.duration > 0 ? video.duration : (serverDur || 0));
-- let target = null;
-- if (seekTo && seekTo > 0) {
-- target = seekTo; // deep link (from a clip / timeline share)
-- } else if (saved && saved > 3) {
-- target = saved; // resume where they left off
-- } else {
-- target = 0; // start from the beginning
-- }
-- const _applyStart = function () {
-- video.removeEventListener('loadedmetadata', _applyStart);
-- const dur = _dur0();
-- // Never resume within the last ~10s (counts as "finished" → restart).
-- let t = target;
-- if (dur > 0 && t > dur - 10) t = (seekTo && seekTo > 0) ? Math.max(0, dur - 0.5) : 0;
-- try { video.currentTime = Math.max(0, t); } catch { /* */ }
-- if (seekTo && seekTo > 0) video.play().catch(() => {});
-- };
-- if (video.readyState >= 1) _applyStart();
-- else video.addEventListener('loadedmetadata', _applyStart);
--
-- // Persist progress as they watch (throttled) and clear it when finished.
-- _attachVodProgressTracking(video, v.id);
-- }
--
-- // Load chat replay data for this VOD
-- if (v.stream_id && v.stream_started_at) {
-- loadChatReplayData('vp', v.stream_id, v.stream_started_at, v.stream_ended_at);
-- }
-- }
--
-- // Navigate to streamer on click
-- const streamerLink = document.getElementById('vp-streamer-link');
-- if (streamerLink && v.username) {
-- const targetUrl = channelPath(v.username);
-- streamerLink.href = targetUrl;
-- streamerLink.onclick = (event) => handleLinkClick(event, targetUrl);
-- }
--
-- // Owner/admin controls: change visibility (public/unlisted/private) + delete.
-- const vpActions = document.getElementById('vp-actions');
-- if (vpActions && currentUser) {
-- let canManage = (v.user_id === currentUser.id) || currentUser.capabilities?.moderate_global;
-- if (canManage && !v.is_recording) {
-- const vis = v.visibility || (v.is_public ? 'public' : 'private');
-- vpActions.style.display = '';
-- vpActions.innerHTML = `
-- <div class="vp-visibility-control" title="Who can see this video">
-- <i class="fa-solid ${vis === 'public' ? 'fa-globe' : vis === 'unlisted' ? 'fa-link' : 'fa-lock'} vp-visibility-icon" id="vp-visibility-icon"></i>
-- <select id="vp-visibility-select" class="form-input form-input-sm" onchange="setVodVisibilityFromPlayer(${v.id}, this.value)">
-- <option value="public"${vis === 'public' ? ' selected' : ''}>Public</option>
-- <option value="unlisted"${vis === 'unlisted' ? ' selected' : ''}>Unlisted</option>
-- <option value="private"${vis === 'private' ? ' selected' : ''}>Private</option>
-- </select>
-- </div>
-- <button class="btn btn-danger btn-small" onclick="deleteVodFromPlayer(${v.id})"><i class="fa-solid fa-trash"></i> Delete</button>`;
-- } else if (canManage) {
-- // Still recording — only allow delete once finished; show nothing intrusive.
-- vpActions.style.display = '';
-- vpActions.innerHTML = `<button class="btn btn-danger btn-small" onclick="deleteVodFromPlayer(${v.id})"><i class="fa-solid fa-trash"></i> Delete</button>`;
-- } else {
-- vpActions.style.display = 'none';
-- }
-- }
--
-- // Clips for this VOD
-- const clipsGrid = document.getElementById('vp-clips-grid');
-- if (clips.length) {
-- clipsGrid.innerHTML = clips.map(cl => `
-- <a class="stream-card" href="/clip/${cl.id}" onclick="return handleLinkClick(event, '/clip/${cl.id}')">
-- <div class="stream-card-thumb">
-- ${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
-- <span class="stream-card-viewers">${formatDuration(cl.duration_seconds)}</span>
-- </div>
-- <div class="stream-card-info">
-- <div class="stream-card-title">${esc(cl.title || 'Clip')}</div>
-- </div>
-- </a>
-- `).join('');
-- } else {
-- clipsGrid.innerHTML = '<p class="muted">No clips from this stream</p>';
-- }
--
-- // Load comments
-- loadComments('vod', v.id, 'vp');
-- Promise.allSettled(blocks).then(() => _mediaReady('page-vod-player'));
-- } catch (e) {
-- _mediaReady('page-vod-player');
-- // Gone or private (the server answered this address 404 too): say so here, not a toast and the VOD list.
-- if (e && e.status === 404) return showNotFound({ title: 'This video is not here', message: 'It was deleted, made private, or the link is mistyped.' });
-- console.error('Failed to load VOD player', e);
-- toast('Failed to load video: ' + (e.message || 'not found'), 'error');
-- navigate('/vods');
-- }
--}
--
--/* ── VOD Clip Creator ─────────────────────────────────────────── */
--let _vpClipStart = 0;
--let _vpClipEnd = 0;
--let _clipDragging = null; // 'start' | 'end' | null
--let _clipPreviewRAF = null;
--let _clipVideoDuration = 0;
--const CLIP_MAX_DURATION = 60;
--
--function openClipCreator() {
-- if (!currentUser) { toast('Login required to create clips', 'info'); return; }
-- const modal = document.getElementById('vp-clip-modal');
-- const video = document.getElementById('vp-video');
-- if (!modal || !video) return;
--
-- _clipVideoDuration = video.duration || 0;
-- if (!_clipVideoDuration || !isFinite(_clipVideoDuration)) {
-- toast('Video not loaded yet', 'error');
-- return;
-- }
--
-- // Pause the main video
-- video.pause();
--
-- // Initialize clip range: center on current position, 30s default
-- const cur = video.currentTime;
-- const halfDur = 15;
-- _vpClipStart = Math.max(0, cur - halfDur);
-- _vpClipEnd = Math.min(_clipVideoDuration, _vpClipStart + 30);
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipEnd = _vpClipStart + CLIP_MAX_DURATION;
--
-- // Setup preview video
-- const preview = document.getElementById('clip-preview-video');
-- if (preview) {
-- // The main player may be on the HLS playlist (a blob: URL): the preview cuts from the file.
-- preview.src = video.dataset.fileUrl || video.src;
-- preview.currentTime = _vpClipStart;
-- preview.muted = true;
-- }
--
-- // Build timeline ticks
-- _buildClipTimelineTicks();
--
-- modal.style.display = '';
-- document.body.style.overflow = 'hidden';
--
-- _updateClipCreatorUI();
-- _setupClipDragHandlers();
-- document.addEventListener('keydown', _clipModalKeyHandler);
--}
--
--/**
-- * Legacy alias — the HTML button still calls toggleVodClipPanel()
-- */
--function toggleVodClipPanel() {
-- const modal = document.getElementById('vp-clip-modal');
-- if (modal && modal.style.display !== 'none') {
-- closeClipCreator();
-- } else {
-- openClipCreator();
-- }
--}
--
--function closeClipCreator() {
-- const modal = document.getElementById('vp-clip-modal');
-- if (modal) modal.style.display = 'none';
-- document.body.style.overflow = '';
--
-- // Stop preview playback
-- const preview = document.getElementById('clip-preview-video');
-- if (preview) { preview.pause(); preview.removeAttribute('src'); preview.load(); }
-- if (_clipPreviewRAF) { cancelAnimationFrame(_clipPreviewRAF); _clipPreviewRAF = null; }
--
-- _teardownClipDragHandlers();
-- document.removeEventListener('keydown', _clipModalKeyHandler);
--}
--
--function _clipModalKeyHandler(e) {
-- if (e.key === 'Escape') { closeClipCreator(); e.stopPropagation(); }
--}
--
--function _buildClipTimelineTicks() {
-- const ticksEl = document.getElementById('clip-timeline-ticks');
-- if (!ticksEl || !_clipVideoDuration) return;
-- ticksEl.innerHTML = '';
--
-- // Determine tick interval based on duration
-- let interval;
-- if (_clipVideoDuration <= 60) interval = 10;
-- else if (_clipVideoDuration <= 300) interval = 30;
-- else if (_clipVideoDuration <= 1800) interval = 120;
-- else if (_clipVideoDuration <= 7200) interval = 300;
-- else interval = 600;
--
-- for (let t = 0; t <= _clipVideoDuration; t += interval) {
-- const pct = (t / _clipVideoDuration) * 100;
-- const tick = document.createElement('span');
-- tick.className = 'clip-tick';
-- tick.style.left = pct + '%';
-- tick.textContent = formatDuration(t);
-- ticksEl.appendChild(tick);
-- }
--}
--
--function _updateClipCreatorUI() {
-- if (!_clipVideoDuration) return;
-- const startPct = (_vpClipStart / _clipVideoDuration) * 100;
-- const endPct = (_vpClipEnd / _clipVideoDuration) * 100;
-- const duration = Math.max(0, _vpClipEnd - _vpClipStart);
--
-- // Timeline handles & fill
-- const handleStart = document.getElementById('clip-handle-start');
-- const handleEnd = document.getElementById('clip-handle-end');
-- const fill = document.getElementById('clip-timeline-fill');
-- if (handleStart) handleStart.style.left = startPct + '%';
-- if (handleEnd) handleEnd.style.left = endPct + '%';
-- if (fill) { fill.style.left = startPct + '%'; fill.style.width = (endPct - startPct) + '%'; }
--
-- // Time displays
-- const startDisp = document.getElementById('clip-start-display');
-- const endDisp = document.getElementById('clip-end-display');
-- if (startDisp) startDisp.textContent = formatDuration(Math.floor(_vpClipStart));
-- if (endDisp) endDisp.textContent = formatDuration(Math.floor(_vpClipEnd));
--
-- // Duration display
-- const durNum = document.getElementById('clip-duration-number');
-- const durBar = document.getElementById('clip-duration-bar');
-- const durSec = Math.floor(duration);
-- if (durNum) {
-- durNum.textContent = durSec;
-- durNum.classList.toggle('clip-duration-over', durSec > CLIP_MAX_DURATION);
-- durNum.classList.toggle('clip-duration-zero', durSec <= 0);
-- }
-- if (durBar) durBar.style.width = Math.min(100, (durSec / CLIP_MAX_DURATION) * 100) + '%';
--
-- // Create button state
-- const btn = document.getElementById('clip-create-btn');
-- if (btn) btn.disabled = durSec <= 0 || durSec > CLIP_MAX_DURATION;
--}
--
--function setClipMarkToCurrent(which) {
-- const video = document.getElementById('vp-video');
-- if (!video) return;
-- const cur = video.currentTime;
-- if (which === 'start') {
-- _vpClipStart = Math.max(0, cur);
-- if (_vpClipEnd <= _vpClipStart) _vpClipEnd = Math.min(_vpClipStart + 30, _clipVideoDuration);
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipEnd = _vpClipStart + CLIP_MAX_DURATION;
-- } else {
-- _vpClipEnd = Math.min(cur, _clipVideoDuration);
-- if (_vpClipStart >= _vpClipEnd) _vpClipStart = Math.max(0, _vpClipEnd - 30);
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipStart = _vpClipEnd - CLIP_MAX_DURATION;
-- }
-- _updateClipCreatorUI();
-- _seekClipPreview(_vpClipStart);
--}
--
--function nudgeClipMark(which, delta) {
-- if (which === 'start') {
-- _vpClipStart = Math.max(0, Math.min(_vpClipStart + delta, _clipVideoDuration));
-- if (_vpClipStart >= _vpClipEnd) _vpClipEnd = Math.min(_vpClipStart + 1, _clipVideoDuration);
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipEnd = _vpClipStart + CLIP_MAX_DURATION;
-- } else {
-- _vpClipEnd = Math.max(0, Math.min(_vpClipEnd + delta, _clipVideoDuration));
-- if (_vpClipEnd <= _vpClipStart) _vpClipStart = Math.max(0, _vpClipEnd - 1);
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipStart = _vpClipEnd - CLIP_MAX_DURATION;
-- }
-- _updateClipCreatorUI();
-- _seekClipPreview(which === 'start' ? _vpClipStart : _vpClipEnd - 1);
--}
--
--function _seekClipPreview(time) {
-- const preview = document.getElementById('clip-preview-video');
-- if (preview && preview.readyState >= 1) {
-- preview.currentTime = Math.max(0, time);
-- }
--}
--
--function toggleClipPreview() {
-- const preview = document.getElementById('clip-preview-video');
-- const btn = document.getElementById('clip-preview-play-btn');
-- if (!preview) return;
--
-- if (preview.paused) {
-- preview.currentTime = _vpClipStart;
-- preview.play().catch(() => {});
-- if (btn) btn.innerHTML = '<i class="fa-solid fa-pause"></i>';
-- _clipPreviewLoop();
-- } else {
-- preview.pause();
-- if (btn) btn.innerHTML = '<i class="fa-solid fa-play"></i>';
-- if (_clipPreviewRAF) { cancelAnimationFrame(_clipPreviewRAF); _clipPreviewRAF = null; }
-- }
--}
--
--function _clipPreviewLoop() {
-- const preview = document.getElementById('clip-preview-video');
-- if (!preview || preview.paused) return;
--
-- // Update playhead position
-- const playhead = document.getElementById('clip-playhead');
-- if (playhead && _clipVideoDuration) {
-- const pct = (preview.currentTime / _clipVideoDuration) * 100;
-- playhead.style.left = pct + '%';
-- playhead.style.display = '';
-- }
--
-- // Stop at clip end
-- if (preview.currentTime >= _vpClipEnd) {
-- preview.pause();
-- preview.currentTime = _vpClipStart;
-- const btn = document.getElementById('clip-preview-play-btn');
-- if (btn) btn.innerHTML = '<i class="fa-solid fa-play"></i>';
-- if (playhead) playhead.style.display = 'none';
-- _clipPreviewRAF = null;
-- return;
-- }
--
-- _clipPreviewRAF = requestAnimationFrame(_clipPreviewLoop);
--}
--
--/* -- Clip timeline drag handlers -- */
--let _clipDragBound = {};
--
--function _setupClipDragHandlers() {
-- const wrap = document.getElementById('clip-timeline-wrap');
-- if (!wrap) return;
--
-- const onMouseDown = (e) => {
-- const handle = e.target.closest('.clip-handle-start, .clip-handle-end');
-- if (!handle) {
-- // Click on timeline bar itself → move nearest handle
-- const rect = wrap.getBoundingClientRect();
-- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-- const time = pct * _clipVideoDuration;
-- // Move whichever handle is closer
-- const distStart = Math.abs(time - _vpClipStart);
-- const distEnd = Math.abs(time - _vpClipEnd);
-- if (distStart <= distEnd) {
-- _vpClipStart = Math.max(0, time);
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipEnd = _vpClipStart + CLIP_MAX_DURATION;
-- } else {
-- _vpClipEnd = Math.min(_clipVideoDuration, time);
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipStart = _vpClipEnd - CLIP_MAX_DURATION;
-- }
-- _updateClipCreatorUI();
-- _seekClipPreview(_vpClipStart);
-- return;
-- }
-- _clipDragging = handle.classList.contains('clip-handle-start') ? 'start' : 'end';
-- e.preventDefault();
-- };
--
-- const onMouseMove = (e) => {
-- if (!_clipDragging) return;
-- const rect = wrap.getBoundingClientRect();
-- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-- const time = pct * _clipVideoDuration;
--
-- if (_clipDragging === 'start') {
-- _vpClipStart = Math.max(0, Math.min(time, _vpClipEnd - 1));
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipEnd = Math.min(_vpClipStart + CLIP_MAX_DURATION, _clipVideoDuration);
-- } else {
-- _vpClipEnd = Math.min(_clipVideoDuration, Math.max(time, _vpClipStart + 1));
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipStart = Math.max(0, _vpClipEnd - CLIP_MAX_DURATION);
-- }
-- _updateClipCreatorUI();
-- // Live-scrub: show the frame of whichever handle you're dragging (esp. the end).
-- _seekClipPreview(_clipDragging === 'end' ? _vpClipEnd : _vpClipStart);
-- };
--
-- const onMouseUp = () => {
-- if (_clipDragging) {
-- _seekClipPreview(_clipDragging === 'end' ? _vpClipEnd : _vpClipStart);
-- _clipDragging = null;
-- }
-- };
--
-- // Touch support
-- const onTouchStart = (e) => {
-- const handle = e.target.closest('.clip-handle-start, .clip-handle-end');
-- if (!handle) return;
-- _clipDragging = handle.classList.contains('clip-handle-start') ? 'start' : 'end';
-- e.preventDefault();
-- };
--
-- const onTouchMove = (e) => {
-- if (!_clipDragging) return;
-- const touch = e.touches[0];
-- const rect = wrap.getBoundingClientRect();
-- const pct = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
-- const time = pct * _clipVideoDuration;
--
-- if (_clipDragging === 'start') {
-- _vpClipStart = Math.max(0, Math.min(time, _vpClipEnd - 1));
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipEnd = Math.min(_vpClipStart + CLIP_MAX_DURATION, _clipVideoDuration);
-- } else {
-- _vpClipEnd = Math.min(_clipVideoDuration, Math.max(time, _vpClipStart + 1));
-- if (_vpClipEnd - _vpClipStart > CLIP_MAX_DURATION) _vpClipStart = Math.max(0, _vpClipEnd - CLIP_MAX_DURATION);
-- }
-- _updateClipCreatorUI();
-- _seekClipPreview(_clipDragging === 'end' ? _vpClipEnd : _vpClipStart);
-- e.preventDefault();
-- };
--
-- const onTouchEnd = () => {
-- if (_clipDragging) {
-- _seekClipPreview(_clipDragging === 'end' ? _vpClipEnd : _vpClipStart);
-- _clipDragging = null;
-- }
-- };
--
-- wrap.addEventListener('mousedown', onMouseDown);
-- document.addEventListener('mousemove', onMouseMove);
-- document.addEventListener('mouseup', onMouseUp);
-- wrap.addEventListener('touchstart', onTouchStart, { passive: false });
-- document.addEventListener('touchmove', onTouchMove, { passive: false });
-- document.addEventListener('touchend', onTouchEnd);
--
-- _clipDragBound = { wrap, onMouseDown, onMouseMove, onMouseUp, onTouchStart, onTouchMove, onTouchEnd };
--}
--
--function _teardownClipDragHandlers() {
-- const b = _clipDragBound;
-- if (b.wrap) {
-- b.wrap.removeEventListener('mousedown', b.onMouseDown);
-- b.wrap.removeEventListener('touchstart', b.onTouchStart);
-- }
-- document.removeEventListener('mousemove', b.onMouseMove);
-- document.removeEventListener('mouseup', b.onMouseUp);
-- document.removeEventListener('touchmove', b.onTouchMove);
-- document.removeEventListener('touchend', b.onTouchEnd);
-- _clipDragBound = {};
-- _clipDragging = null;
--}
--
--let _clipCreating = false;
--let _clipCooldownUntil = 0;
--let _clipCooldownTimer = null;
--const CLIP_CLIENT_COOLDOWN_MS = 10000;
--
--async function createVodClip() {
-- if (!currentUser) { toast('Login required to create clips', 'info'); return; }
--
-- // Debounce: prevent double-clicks while request is in-flight
-- if (_clipCreating) return;
--
-- // Cooldown: enforce client-side wait between clips
-- const now = Date.now();
-- if (now < _clipCooldownUntil) {
-- const secs = Math.ceil((_clipCooldownUntil - now) / 1000);
-- toast(`Please wait ${secs}s before creating another clip`, 'info');
-- return;
-- }
--
-- const vodId = window._vpVodId;
-- if (!vodId) { toast('No VOD loaded', 'error'); return; }
--
-- const duration = _vpClipEnd - _vpClipStart;
-- if (duration <= 0) { toast('End time must be after start time', 'error'); return; }
-- if (duration > CLIP_MAX_DURATION) { toast('Clips are limited to 60 seconds', 'error'); return; }
--
-- const title = document.getElementById('clip-title-input')?.value?.trim() || 'Untitled Clip';
-- const btn = document.getElementById('clip-create-btn');
--
-- _clipCreating = true;
-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating…'; }
--
-- try {
-- const result = await api('/vods/clips', {
-- method: 'POST',
-- body: {
-- vod_id: vodId,
-- start_time: _vpClipStart,
-- end_time: _vpClipEnd,
-- title,
-- }
-- });
-- toast(result.deduplicated ? 'Clip already exists — opening it' : 'Clip created!', 'success');
--
-- // Start client-side cooldown
-- _clipCooldownUntil = Date.now() + CLIP_CLIENT_COOLDOWN_MS;
-- _startClipCooldownUI(btn);
--
-- closeClipCreator();
-- if (result.clip?.id) {
-- navigate(`/clip/${result.clip.id}`);
-- } else {
-- loadVodPlayer(vodId);
-- }
-- } catch (err) {
-- toast(err.message || 'Failed to create clip', 'error');
-- if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-scissors"></i> Create Clip'; }
-- } finally {
-- _clipCreating = false;
-- }
--}
--
--function _startClipCooldownUI(btn) {
-- if (!btn) return;
-- if (_clipCooldownTimer) clearInterval(_clipCooldownTimer);
-- const update = () => {
-- const left = Math.ceil((_clipCooldownUntil - Date.now()) / 1000);
-- if (left <= 0) {
-- clearInterval(_clipCooldownTimer);
-- _clipCooldownTimer = null;
-- btn.disabled = false;
-- btn.innerHTML = '<i class="fa-solid fa-scissors"></i> Create Clip';
-- } else {
-- btn.disabled = true;
-- btn.innerHTML = `<i class="fa-solid fa-clock"></i> Wait ${left}s`;
-- }
-- };
-- update();
-- _clipCooldownTimer = setInterval(update, 1000);
--}
--
--/* ── Clip Player ──────────────────────────────────────────────── */
--/** Ask the server to re-render a clip whose cut failed, then resume polling. */
--async function recutClip(clipId) {
-- const note = document.getElementById('clp-processing-note');
-- if (note) note.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="font-size:2rem;color:var(--accent)"></i><p class="muted">Re-cutting…</p>';
-- try {
-- await api(`/clips/${clipId}/recut`, { method: 'POST' });
-- toast('Re-cutting the clip — this page will update when it is ready', 'info');
-- setTimeout(() => { if (window._clpClipId === clipId) loadClipPlayer(clipId); }, 3000);
-- } catch (e) {
-- toast(e.message, 'error');
-- if (window._clpClipId === clipId) loadClipPlayer(clipId);
-- }
--}
--
--/**
-- * The clip page's attribution line, from the clip's own origin (Media's auto_generated):
-- * a person's clip → "Clipped by <clipper>"
-- * an auto-clip → "AI clip · from <streamer>'s stream" and what made it; never "Clipped by"
-- * the streamer the job filed it under.
-- */
--function _renderClipAttribution(el, cl) {
-- el.textContent = '';
-- el.classList.toggle('clp-ai-attribution', !!cl.auto_generated);
-- const icon = (cls) => { const i = document.createElement('i'); i.className = cls; i.setAttribute('aria-hidden', 'true'); return i; };
-- if (!cl.auto_generated) {
-- const who = document.createElement('strong');
-- who.textContent = cl.display_name || cl.username || 'Unknown';
-- el.append(icon('fa-solid fa-scissors'), ' Clipped by ', who);
-- return;
-- }
-- const streamerName = cl.source_streamer_display_name || cl.streamer_display_name || cl.source_streamer_username || cl.streamer_username || cl.display_name || cl.username;
-- const streamerUser = cl.source_streamer_username || cl.streamer_username || cl.username;
-- const badge = document.createElement('span');
-- badge.className = 'ai-badge';
-- badge.textContent = 'AI clip';
-- el.append(icon('fa-solid fa-wand-magic-sparkles'), ' ', badge, ' · from ');
-- if (streamerName && streamerUser) {
-- const a = document.createElement('a');
-- const href = channelPath(streamerUser);
-- a.href = href;
-- a.textContent = streamerName;
-- a.addEventListener('click', (event) => handleLinkClick(event, href));
-- el.append(a, "'s stream");
-- } else {
-- el.append('a live stream');
-- }
-- const how = document.createElement('div');
-- how.className = 'muted clp-ai-how';
-- how.textContent = 'Cut automatically by OpenVibe\'s auto-clip workflow when chat reacted. No one clipped it.';
-- el.append(how);
--}
--
--async function loadClipPlayer(clipId) {
-- const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
-- const blocks = [];
-- _mediaReady('page-clip-player', false);
-- try {
-- // Clean up chat replay
-- if (window._chatReplayTimer) {
-- cancelAnimationFrame(window._chatReplayTimer);
-- window._chatReplayTimer = null;
-- }
-- window._clpChatReplay = null;
-- const clpMsgs = document.getElementById('clp-chat-replay-messages');
-- if (clpMsgs) clpMsgs.innerHTML = '<div class="chat-replay-empty" id="clp-chat-replay-empty"><i class="fa-solid fa-comments" style="font-size:1.5rem"></i><p>Chat messages will appear here as the clip plays</p></div>';
-- const clpSidebar = document.getElementById('clp-chat-replay');
-- if (clpSidebar) clpSidebar.classList.remove('no-data');
--
-- const data = await api(`/clips/${clipId}`);
-- const cl = data.clip;
-- _mediaHasData('page-clip-player');
--
-- // Store for comments
-- window._clpClipId = cl.id;
--
-- document.getElementById('clp-title').textContent = cl.title || 'Clip';
-- setPageTitle(cl.title || 'Clip');
-- // Reset unlisted badge
-- const unlistedBadge = document.getElementById('clp-unlisted-badge');
-- if (unlistedBadge) unlistedBadge.style.display = 'none';
-- document.getElementById('clp-streamer').textContent = cl.display_name || cl.username || 'Unknown';
-- { const _a = document.getElementById('clp-avatar'); if (_a) _a.innerHTML = _avatarInner(cl.avatar_url, cl.username); }
-- document.getElementById('clp-date').textContent = formatDateTime(cl.created_at);
-- document.getElementById('clp-duration').textContent = formatDuration(cl.duration_seconds);
-- document.getElementById('clp-description').textContent = cl.description || '';
-- _renderMediaAiOverview('clp-description', cl.ai_overview);
-- _renderMediaTranscript('clp-description', 'clip', cl);
-- // Show this streamer's OpenCoins in the navbar while viewing their clip.
-- if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(cl.user_id);
--
-- // View count
-- const clpViews = document.getElementById('clp-views');
-- if (clpViews) clpViews.textContent = `${cl.view_count || 0} view${(cl.view_count || 0) === 1 ? '' : 's'}${cl.unique_views != null ? ` · ${cl.unique_views} unique` : ''}`;
-- _showVideoLink('clp-video-link', 'clip', cl);
--
-- // Protocol badge
-- const clpProto = document.getElementById('clp-protocol');
-- if (clpProto) clpProto.innerHTML = cl.stream_protocol ? protocolBadge(cl.stream_protocol) : '';
--
-- // Enhanced details
-- const extraDetails = document.getElementById('clp-extra-details');
-- if (extraDetails) {
-- let chips = '';
-- if (cl.stream_category) chips += `<span class="detail-chip"${cl.stream_category_inferred ? ' title="Category inferred by OpenVibe&#39;s AI from the stream"' : ''}><i class="fa-solid fa-tag"></i> ${esc(_capTag(cl.stream_category))}${cl.stream_category_inferred ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>`;
-- if (cl.stream_peak_viewers) chips += `<span class="detail-chip"><i class="fa-solid fa-users"></i> Peak: ${cl.stream_peak_viewers}</span>`;
-- if (cl.stream_started_at) {
-- const streamDate = new Date(cl.stream_started_at + 'Z');
-- chips += `<span class="detail-chip"><i class="fa-solid fa-calendar"></i> ${streamDate.toLocaleDateString()}</span>`;
-- }
-- if (chips) { extraDetails.innerHTML = chips; extraDetails.style.display = ''; }
-- else extraDetails.style.display = 'none';
-- }
--
-- // Stream source + timestamp info
-- const clpSource = document.getElementById('clp-stream-source');
-- if (clpSource) {
-- let sourceHtml = '';
-- // Deep-link into the source VOD at the exact moment this clip starts,
-- // but only when that VOD is still up and public/unlisted.
-- const seekT = Math.max(0, Math.floor(cl.start_time || 0));
-- const vodJumpUrl = (cl.vod_id && cl.vod_available) ? `/vod/${cl.vod_id}?t=${seekT}` : null;
-- if (cl.stream_title) {
-- const titleText = esc(cl.stream_title);
-- if (vodJumpUrl) {
-- sourceHtml += `<i class="fa-solid fa-tower-broadcast"></i> From stream: <a href="${vodJumpUrl}" onclick="return handleLinkClick(event, '${vodJumpUrl}')" style="color:var(--accent);text-decoration:none;font-weight:600">${titleText}</a>`;
-- } else {
-- sourceHtml += `<i class="fa-solid fa-tower-broadcast"></i> From stream: <strong>${titleText}</strong>`;
-- }
-- if (cl.start_time > 0) {
-- sourceHtml += ` at <strong>${formatDuration(cl.start_time)}</strong>`;
-- }
-- } else if (cl.start_time > 0) {
-- sourceHtml += `<i class="fa-solid fa-clock"></i> Clipped at <strong>${formatDuration(cl.start_time)}</strong> into the stream`;
-- }
-- if (vodJumpUrl) {
-- sourceHtml += ` <a href="${vodJumpUrl}" onclick="return handleLinkClick(event, '${vodJumpUrl}')" class="clip-vod-jump" title="Watch this moment in the full VOD" style="display:inline-flex;align-items:center;gap:5px;margin-left:8px;padding:3px 10px;border-radius:999px;background:var(--accent);color:#fff;font-size:0.8rem;font-weight:600;text-decoration:none"><i class="fa-solid fa-forward"></i> Watch in full VOD</a>`;
-- }
-- if (sourceHtml) {
-- clpSource.innerHTML = sourceHtml;
-- clpSource.style.display = '';
-- } else {
-- clpSource.style.display = 'none';
-- }
-- // Upgrade the plain line into the rich "from this stream" card when the VOD context loads.
-- if (cl.vod_id && typeof renderClipSourceCard === 'function') blocks.push(renderClipSourceCard(cl));
-- }
--
-- const video = document.getElementById('clp-video');
-- clearTimeout(window._clpProcessingPoll);
-- { const _n = document.getElementById('clp-processing-note'); if (_n) _n.remove(); }
-- if (!cl.file_path && (cl.status || 'processing') !== 'ready') {
-- // A clip with no file is either still being cut or the cut FAILED. These used
-- // to render identically — a failed clip showed "the server is cutting your
-- // clip" and polled forever, which is why hours-old broken clips still claimed
-- // to be in progress. Tell the truth, and offer a retry.
-- const failed = String(cl.status || '') === 'failed';
-- video.style.display = 'none';
-- const container = document.getElementById('clp-container');
-- if (container) {
-- const note = document.createElement('div');
-- note.id = 'clp-processing-note';
-- note.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;height:300px;gap:12px;color:var(--text-muted,#999)';
-- note.innerHTML = failed ? `
-- <i class="fa-solid fa-triangle-exclamation" style="font-size:2.5rem;color:var(--danger,#e74c3c)"></i>
-- <p style="font-size:1.05rem;font-weight:600">${cl.will_retry ? 'This clip is being re-cut' : 'This clip failed to render'}</p>
-- ${cl.will_retry ? `<p class="muted" style="font-size:0.82rem;margin:-4px 0 8px"><i class="fa-solid fa-rotate fa-spin"></i> The server retries automatically${cl.cut_next_at ? ' · next try ' + esc(timeAgo(cl.cut_next_at)) : ''}${cl.cut_attempts ? ' · attempt ' + cl.cut_attempts : ''}</p>` : ''}
-- ${cl.cut_error ? `<p class="muted" style="font-size:0.74rem;max-width:520px;margin:0 auto 8px;opacity:0.75">${esc(String(cl.cut_error).split(' | ')[0].slice(0, 160))}</p>` : ''}
-- <p class="muted" style="font-size:0.85rem">The server couldn't cut it from the recording.</p>
-- <button class="btn btn-sm btn-outline" onclick="recutClip(${cl.id})"><i class="fa-solid fa-rotate-right"></i> Try again</button>` : `
-- <i class="fa-solid fa-scissors fa-bounce" style="font-size:2.5rem;color:var(--accent)"></i>
-- <p style="font-size:1.05rem;font-weight:600">Clip is processing…</p>
-- <p class="muted" style="font-size:0.85rem">The server is cutting your clip — this page will update automatically.</p>`;
-- container.appendChild(note);
-- }
-- // Only poll while it is genuinely in progress; polling a failed clip forever
-- // was pure noise.
-- if (!failed) {
-- window._clpProcessingPoll = setTimeout(() => {
-- if (window._clpClipId === cl.id) loadClipPlayer(cl.id);
-- }, 3000);
-- }
-- }
-- if (cl.file_path) {
-- // Handle video load errors (corrupt files, codec issues)
-- video.onerror = () => {
-- if (vodFallBackToFile(video)) return; // the playlist failed: the file gets its chance first
-- const container = document.getElementById('clp-container');
-- if (container) {
-- container.innerHTML = `
-- <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:300px;color:var(--text-muted,#999)">
-- <i class="fa-solid fa-triangle-exclamation" style="font-size:3rem;margin-bottom:12px;color:#ef4444"></i>
-- <p style="font-size:1.1rem">This clip could not be played</p>
-- <p class="muted" style="font-size:0.85rem">The recording may be corrupt or in an unsupported format</p>
-- </div>`;
-- }
-- };
-- attachVodSource(video, cl.hls_url, `/api/clips/${Number(cl.id)}/file`, routeGen);
-- video.style.display = 'block';
-- setupCustomVideoControls('clp');
--
-- // Load chat replay data for this clip
-- if (cl.stream_id && cl.stream_started_at) {
-- loadChatReplayData('clp', cl.stream_id, cl.stream_started_at, cl.stream_ended_at, cl.start_time, cl.end_time);
-- }
-- }
--
-- const streamerLink = document.getElementById('clp-streamer-link');
-- if (streamerLink && cl.username) {
-- const targetUrl = channelPath(cl.username);
-- streamerLink.href = targetUrl;
-- streamerLink.onclick = (event) => handleLinkClick(event, targetUrl);
-- }
--
-- // Who made it. An auto-clip was cut by OpenVibe's AI when chat reacted, not by the
-- // streamer it is filed under (roadmap 33.4): "AI clip · from <streamer>'s stream".
-- const clippedByEl = document.getElementById('clp-clipped-by');
-- if (clippedByEl) _renderClipAttribution(clippedByEl, cl);
--
-- // Show delete button per the server-authoritative can_delete flag (streamer /
-- // channel mod / staff, or the creator only if the channel opted in).
-- const clpActions = document.getElementById('clp-actions');
-- if (clpActions && currentUser) {
-- let canDelete = !!cl.can_delete;
-- let isStreamOwner = false;
-- // Check if current user owns the stream this clip is from (gates publish toggle)
-- if (cl.stream_id) {
-- try {
-- const sData = await api(`/streams/${cl.stream_id}`);
-- if (sData.stream && sData.stream.user_id === currentUser.id) {
-- isStreamOwner = true;
-- }
-- } catch {}
-- }
--
-- let actionsHtml = '';
-- // Edit title — clip creator or admin
-- if (cl.user_id === currentUser.id || currentUser.capabilities?.moderate_global) {
-- actionsHtml += `<button class="btn btn-small" onclick="editClipTitle(${cl.id})"><i class="fa-solid fa-pen"></i> Edit Title</button> `;
-- }
-- // Publish/unpublish toggle — only for stream owner or admin
-- if (isStreamOwner || currentUser.capabilities?.moderate_global) {
-- if (cl.is_public) {
-- actionsHtml += `<button class="btn btn-small" onclick="toggleClipVisibility(${cl.id}, false)"><i class="fa-solid fa-eye-slash"></i> Make Unlisted</button>`;
-- } else {
-- actionsHtml += `<button class="btn btn-primary btn-small" onclick="toggleClipVisibility(${cl.id}, true)"><i class="fa-solid fa-eye"></i> Make Public</button>`;
-- }
-- }
-- // Unlisted badge for non-public clips
-- if (!cl.is_public) {
-- const badge = document.getElementById('clp-unlisted-badge');
-- if (badge) badge.style.display = '';
-- }
-- if (canDelete) {
-- actionsHtml += ` <button class="btn btn-danger btn-small" onclick="deleteClipFromPlayer(${cl.id})"><i class="fa-solid fa-trash"></i> Delete Clip</button>`;
-- }
-- if (actionsHtml) {
-- clpActions.style.display = '';
-- clpActions.innerHTML = actionsHtml;
-- }
-- }
--
-- // Load comments
-- loadComments('clip', cl.id, 'clp');
-- Promise.allSettled(blocks).then(() => _mediaReady('page-clip-player'));
-- } catch (e) {
-- _mediaReady('page-clip-player');
-- if (e && e.status === 404) return showNotFound({ title: 'This clip is not here', message: 'It was deleted, made private, or the link is mistyped.' });
-- toast('Clip not found', 'error');
-- navigate('/clips');
-- }
--}
--
--/* ═══════════════════════════════════════════════════════════════
-- Chat Replay System
-- Syncs stored chat messages with VOD/clip video playback
-- Sidebar always visible — shows empty state or synced messages
-- ═══════════════════════════════════════════════════════════════ */
--
--/**
-- * Load chat messages for replay and set up sync with video.
-- * @param {string} prefix - 'vp' or 'clp'
-- * @param {number} streamId - stream the messages belong to
-- * @param {string} streamStartedAt - ISO timestamp of stream start
-- * @param {string} streamEndedAt - ISO timestamp of stream end (optional)
-- * @param {number} clipStartOffset - for clips, seconds into the stream the clip starts
-- * @param {number} clipEndOffset - for clips, seconds into the stream the clip ends
-- */
--async function loadChatReplayData(prefix, streamId, streamStartedAt, streamEndedAt, clipStartOffset, clipEndOffset) {
-- const sidebar = document.getElementById(`${prefix}-chat-replay`);
-- const emptyEl = document.getElementById(`${prefix}-chat-replay-empty`);
-- const container = document.getElementById(`${prefix}-chat-replay-messages`);
--
-- try {
-- // For clips, narrow the fetch window to just the clip's time range (+ small buffer)
-- const params = new URLSearchParams();
-- const streamStartMs = new Date(streamStartedAt + (streamStartedAt.endsWith('Z') ? '' : 'Z')).getTime();
-- if (clipStartOffset && streamStartMs) {
-- // Fetch from 5s before clip start to clip end
-- const clipFromMs = streamStartMs + Math.max(0, (clipStartOffset - 5)) * 1000;
-- const clipToMs = streamStartMs + (clipEndOffset || clipStartOffset + 300) * 1000;
-- // Format as 'YYYY-MM-DD HH:MM:SS' to match SQLite CURRENT_TIMESTAMP format
-- const toSqlite = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
-- params.set('from', toSqlite(clipFromMs));
-- params.set('to', toSqlite(clipToMs));
-- } else {
-- if (streamStartedAt) params.set('from', streamStartedAt);
-- if (streamEndedAt) params.set('to', streamEndedAt);
-- }
--
-- const data = await api(`/chat/${streamId}/replay?${params.toString()}`);
-- const messages = data.messages || [];
--
-- if (!messages.length) {
-- // No chat data — show empty state with "no data" indicator
-- if (sidebar) sidebar.classList.add('no-data');
-- if (emptyEl) emptyEl.innerHTML = '<i class="fa-solid fa-comment-slash" style="font-size:1.5rem"></i><p>No chat messages were recorded for this stream</p>';
-- return;
-- }
--
-- // Has data — clear empty state, prep for sync
-- if (sidebar) sidebar.classList.remove('no-data');
-- if (emptyEl) emptyEl.remove();
--
-- // Store replay data on window for sync
-- const streamStart = new Date(streamStartedAt + (streamStartedAt.endsWith('Z') ? '' : 'Z')).getTime();
-- window[`_${prefix}ChatReplay`] = {
-- messages,
-- streamStart,
-- clipStartOffset: clipStartOffset || 0,
-- lastIndex: 0,
-- };
--
-- // Start sync loop
-- startChatReplaySync(prefix);
-- } catch (e) {
-- console.warn('Failed to load chat replay:', e.message);
-- if (sidebar) sidebar.classList.add('no-data');
-- if (emptyEl) emptyEl.innerHTML = '<i class="fa-solid fa-circle-exclamation" style="font-size:1.5rem"></i><p>Failed to load chat replay</p>';
-- }
--}
--
--function startChatReplaySync(prefix) {
-- const video = document.getElementById(`${prefix}-video`);
-- const container = document.getElementById(`${prefix}-chat-replay-messages`);
-- if (!video || !container) return;
--
-- function syncFrame() {
-- const replay = window[`_${prefix}ChatReplay`];
-- if (!replay) return;
--
-- const currentTime = video.currentTime; // seconds into the video
-- // For clips, add the clip's start offset to get stream-relative time
-- const streamRelativeSeconds = currentTime + replay.clipStartOffset;
-- const currentMs = replay.streamStart + (streamRelativeSeconds * 1000);
--
-- // Find messages up to current time
-- let newMessages = false;
-- while (replay.lastIndex < replay.messages.length) {
-- const msg = replay.messages[replay.lastIndex];
-- const msgTime = new Date(msg.timestamp + (msg.timestamp.endsWith('Z') ? '' : 'Z')).getTime();
--
-- if (msgTime <= currentMs) {
-- appendChatReplayMessage(container, msg, streamRelativeSeconds, replay.streamStart, replay.clipStartOffset);
-- replay.lastIndex++;
-- newMessages = true;
-- } else {
-- break;
-- }
-- }
--
-- // If user seeked backward, reset and re-render up to current time
-- if (replay.lastIndex > 0) {
-- const lastMsg = replay.messages[replay.lastIndex - 1];
-- const lastMsgTime = new Date(lastMsg.timestamp + (lastMsg.timestamp.endsWith('Z') ? '' : 'Z')).getTime();
-- if (currentMs < lastMsgTime - 2000) {
-- replay.lastIndex = 0;
-- container.innerHTML = '';
-- }
-- }
--
-- if (newMessages) {
-- container.scrollTop = container.scrollHeight;
-- }
--
-- window._chatReplayTimer = requestAnimationFrame(syncFrame);
-- }
--
-- // Clear previous
-- if (window._chatReplayTimer) cancelAnimationFrame(window._chatReplayTimer);
-- window._chatReplayTimer = requestAnimationFrame(syncFrame);
--}
--
--function appendChatReplayMessage(container, msg, streamSeconds, streamStart, clipStartOffset) {
-- const div = document.createElement('div');
-- div.className = 'chat-replay-msg';
--
-- // Calculate relative time — clip-relative if viewing a clip, stream-relative otherwise
-- const msgTime = new Date(msg.timestamp + (msg.timestamp.endsWith('Z') ? '' : 'Z')).getTime();
-- const streamRelSecs = (msgTime - streamStart) / 1000;
-- const relSecs = Math.max(0, Math.floor(clipStartOffset ? streamRelSecs - clipStartOffset : streamRelSecs));
-- const timeStr = formatDuration(relSecs);
--
-- const color = msg.profile_color || '#8b5cf6';
-- const name = msg.display_name || msg.username || msg.anon_id || 'Anonymous';
--
-- div.innerHTML = `<span class="cr-time">${timeStr}</span><span class="cr-user" style="color:${esc(color)}">${esc(name)}</span><span class="cr-text">${esc(msg.message)}</span>`;
-- container.appendChild(div);
--
-- // Keep max 300 messages in DOM for performance
-- while (container.children.length > 300) {
-- container.removeChild(container.firstChild);
-- }
--}
--
--/* ═══════════════════════════════════════════════════════════════
-- Comments System (YouTube-style)
-- ═══════════════════════════════════════════════════════════════ */
--
--async function loadComments(contentType, contentId, prefix) {
-- const countEl = document.getElementById(`${prefix}-comment-count`);
-- const listEl = document.getElementById(`${prefix}-comments-list`);
-- const formEl = document.getElementById(`${prefix}-comment-form`);
-- if (!listEl) return;
--
-- // Show comment form if logged in
-- if (formEl) formEl.style.display = currentUser ? '' : 'none';
-- // The same thread on OpenVibe.Community (comments are Community threads; the link is offered for items anyone may see).
-- let elsewhere = document.getElementById(`${prefix}-comments-elsewhere`);
-- if (!elsewhere) {
-- elsewhere = document.createElement('p');
-- elsewhere.id = `${prefix}-comments-elsewhere`;
-- elsewhere.className = 'comments-elsewhere';
-- listEl.after(elsewhere);
-- }
-- elsewhere.replaceChildren();
--
-- try {
-- const data = await api(`/comments/${contentType}/${contentId}`);
-- const comments = data.comments || [];
-- const total = data.total || 0;
--
-- if (countEl) countEl.textContent = total > 0 ? `(${total})` : '';
-- if (data.thread && data.thread.url) {
-- const a = document.createElement('a');
-- a.href = data.thread.url;
-- a.target = '_blank';
-- a.rel = 'noopener';
-- a.textContent = 'View this thread on OpenVibe.Community';
-- elsewhere.append(a);
-- }
--
-- if (!comments.length) {
-- listEl.innerHTML = '<div class="comments-empty"><i class="fa-solid fa-comment-dots" style="font-size:1.5rem;margin-bottom:8px"></i><p>No comments yet. Be the first!</p></div>';
-- return;
-- }
--
-- listEl.innerHTML = comments.map(c => renderComment(c, contentType, contentId)).join('');
-- } catch (e) {
-- if (countEl) countEl.textContent = '';
-- // Never an empty list that looks real: say what happened.
-- if (formEl && e && e.status === 503) formEl.style.display = 'none';
-- listEl.innerHTML = e && e.status === 503
-- ? '<div class="comments-empty comments-unavailable"><i class="fa-solid fa-plug-circle-exclamation" style="font-size:1.5rem;margin-bottom:8px"></i><p>Comments are unavailable right now. Try again in a moment.</p></div>'
-- : `<p class="muted">${esc((e && e.message) || 'Failed to load comments')}</p>`;
-- }
--}
--
--function renderComment(c, contentType, contentId) {
-- if (c.deleted) {
-- const replies = c.replies && c.replies.length ? `<div class="comment-replies">${c.replies.map(r => renderComment(r, contentType, contentId)).join('')}</div>` : '';
-- return `
-- <div class="comment-item comment-deleted" id="comment-${c.id}">
-- <div class="comment-avatar" style="background:var(--bg-tertiary, #444)">?</div>
-- <div class="comment-body">
-- <div class="comment-text muted">This comment was deleted.</div>
-- ${replies}
-- </div>
-- </div>`;
-- }
-- const name = c.display_name || c.username || 'Unknown';
-- const initial = (c.username || name || '?')[0].toUpperCase();
-- const color = c.profile_color || '#8b5cf6';
-- const canEdit = c.can_edit ?? (currentUser && c.user_id === currentUser.id);
-- const canDelete = c.can_delete ?? (canEdit || (currentUser && currentUser.capabilities?.moderate_global));
-- const edited = !!c.edited_at || (c.updated_at && c.updated_at !== c.created_at);
--
-- let actionsHtml = '';
-- if (currentUser) {
-- actionsHtml += `<button onclick="showReplyForm(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-reply"></i> Reply</button>`;
-- }
-- if (canEdit) {
-- actionsHtml += `<button onclick="editComment(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-pen"></i> Edit</button>`;
-- }
-- if (canDelete) {
-- actionsHtml += `<button onclick="deleteCommentAction(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-trash"></i> Delete</button>`;
-- }
--
-- let repliesHtml = '';
-- if (c.replies && c.replies.length) {
-- repliesHtml = `<div class="comment-replies">${c.replies.map(r => renderComment(r, contentType, contentId)).join('')}</div>`;
-- }
--
-- return `
-- <div class="comment-item" id="comment-${c.id}">
-- <div class="comment-avatar" style="background:${esc(color)}">${esc(initial)}</div>
-- <div class="comment-body">
-- <div class="comment-meta">
-- <span class="comment-author" style="color:${esc(color)}">${esc(name)}</span>
-- <span class="comment-date">${timeAgo(c.created_at)}${edited ? ' (edited)' : ''}</span>
-- ${c.role === 'admin' ? '<span class="badge" style="font-size:0.7rem;padding:1px 5px">ADMIN</span>' : ''}
-- </div>
-- <div class="comment-text">${esc(c.message)}</div>
-- <div class="comment-actions">${actionsHtml}</div>
-- <div id="reply-form-${c.id}"></div>
-- ${repliesHtml}
-- </div>
-- </div>`;
--}
--
--async function postComment(contentType, contentId) {
-- const prefix = contentType === 'vod' ? 'vp' : 'clp';
-- const input = document.getElementById(`${prefix}-comment-input`);
-- if (!input) return;
--
-- const message = input.value.trim();
-- if (!message) return toast('Write a comment first', 'error');
--
-- try {
-- await api(`/comments/${contentType}/${contentId}`, {
-- method: 'POST',
-- body: { message },
-- });
-- input.value = '';
-- toast('Comment posted', 'success');
-- loadComments(contentType, contentId, prefix);
-- } catch (e) {
-- toast(e.message || 'Failed to post comment', 'error');
-- }
--}
--
--function showReplyForm(parentId, contentType, contentId) {
-- const existing = document.getElementById(`reply-form-${parentId}`);
-- if (!existing) return;
--
-- // Toggle off if already visible
-- if (existing.innerHTML) {
-- existing.innerHTML = '';
-- return;
-- }
--
-- existing.innerHTML = `
-- <div class="reply-form">
-- <input type="text" id="reply-input-${parentId}" placeholder="Write a reply..." maxlength="2000"
-- onkeydown="if(event.key==='Enter')postReply(${parentId}, '${contentType}', ${contentId})">
-- <button class="btn btn-small btn-primary" onclick="postReply(${parentId}, '${contentType}', ${contentId})">Reply</button>
-- </div>`;
-- document.getElementById(`reply-input-${parentId}`)?.focus();
--}
--
--async function postReply(parentId, contentType, contentId) {
-- const input = document.getElementById(`reply-input-${parentId}`);
-- if (!input) return;
--
-- const message = input.value.trim();
-- if (!message) return;
--
-- try {
-- await api(`/comments/${contentType}/${contentId}`, {
-- method: 'POST',
-- body: { message, parent_id: parentId },
-- });
-- toast('Reply posted', 'success');
-- const prefix = contentType === 'vod' ? 'vp' : 'clp';
-- loadComments(contentType, contentId, prefix);
-- } catch (e) {
-- toast(e.message || 'Failed to post reply', 'error');
-- }
--}
--
--async function editComment(commentId, contentType, contentId) {
-- const commentEl = document.getElementById(`comment-${commentId}`);
-- if (!commentEl) return;
-- const textEl = commentEl.querySelector('.comment-text');
-- if (!textEl) return;
--
-- const currentText = textEl.textContent;
-- const newText = prompt('Edit comment:', currentText);
-- if (newText === null || newText.trim() === currentText) return;
--
-- try {
-- await api(`/comments/${commentId}`, {
-- method: 'PUT',
-- body: { message: newText.trim() },
-- });
-- toast('Comment updated', 'success');
-- const prefix = contentType === 'vod' ? 'vp' : 'clp';
-- loadComments(contentType, contentId, prefix);
-- } catch (e) {
-- toast(e.message || 'Failed to update comment', 'error');
-- }
--}
--
--async function deleteCommentAction(commentId, contentType, contentId) {
-- if (!confirm('Delete this comment?')) return;
--
-- try {
-- await api(`/comments/${commentId}`, { method: 'DELETE' });
-- toast('Comment deleted', 'success');
-- const prefix = contentType === 'vod' ? 'vp' : 'clp';
-- loadComments(contentType, contentId, prefix);
-- } catch (e) {
-- toast(e.message || 'Failed to delete comment', 'error');
-- }
--}
--
--/* ── Delete VOD / Clip from player pages ──────────────────────── */
--async function deleteVodFromPlayer(vodId) {
-- if (!confirm('Delete this video permanently?')) return;
-- try {
-- await api(`/vods/${vodId}`, { method: 'DELETE' });
-- toast('Video deleted', 'success');
-- navigate('/vods');
-- } catch (e) { toast(e.message || 'Delete failed', 'error'); }
--}
--
--// Change a VOD's visibility (public/unlisted/private) from its player page.
--async function setVodVisibilityFromPlayer(vodId, visibility) {
-- const sel = document.getElementById('vp-visibility-select');
-- const icon = document.getElementById('vp-visibility-icon');
-- if (sel) sel.disabled = true;
-- try {
-- await api(`/vods/${vodId}`, { method: 'PUT', body: { visibility } });
-- if (icon) icon.className = `fa-solid ${visibility === 'public' ? 'fa-globe' : visibility === 'unlisted' ? 'fa-link' : 'fa-lock'} vp-visibility-icon`;
-- const label = visibility === 'public' ? 'Public — anyone can find it'
-- : visibility === 'unlisted' ? 'Unlisted — only people with the link'
-- : 'Private — only you can see it';
-- toast(`Video is now ${label}`, 'success');
-- } catch (e) {
-- toast(e.message || 'Failed to update visibility', 'error');
-- } finally {
-- if (sel) sel.disabled = false;
-- }
--}
--
--async function editClipTitle(clipId) {
-- const newTitle = prompt('Enter new clip title:');
-- if (newTitle === null) return; // cancelled
-- if (!newTitle.trim()) { toast('Title cannot be empty', 'error'); return; }
-- try {
-- await api(`/clips/${clipId}/title`, {
-- method: 'PUT',
-- body: { title: newTitle.trim() }
-- });
-- toast('Title updated', 'success');
-- loadClipPlayer(clipId); // refresh the page
-- } catch (e) { toast(e.message || 'Failed to update title', 'error'); }
--}
--
--async function deleteClipFromPlayer(clipId) {
-- if (!confirm('Delete this clip permanently?')) return;
-- try {
-- await api(`/clips/${clipId}`, { method: 'DELETE' });
-- toast('Clip deleted', 'success');
-- navigate('/clips');
-- } catch (e) { toast(e.message || 'Delete failed', 'error'); }
--}
--
--async function toggleClipVisibility(clipId, makePublic) {
-- try {
-- const data = await api(`/clips/${clipId}/visibility`, {
-- method: 'PUT',
-- body: { is_public: makePublic }
-- });
-- toast(data.message || (makePublic ? 'Clip is now public' : 'Clip is now unlisted'), 'success');
-- loadClipPlayer(clipId); // refresh the page
-- } catch (e) { toast(e.message || 'Failed to update visibility', 'error'); }
--}
--
--/* ── Init ─────────────────────────────────────────────────────── */
--/* ── Custom VOD / Clip Player Controls ────────────────────────── */
--/**
-- * Set up themed custom controls for a <video> element.
-- * @param {string} prefix - Element ID prefix ('vp' for VOD, 'clp' for clip)
-- */
--function setupCustomVideoControls(prefix) {
-- const video = document.getElementById(`${prefix}-video`);
-- const container = document.getElementById(`${prefix}-container`);
-- const btnPlay = document.getElementById(`${prefix}-btn-play`);
-- const btnVol = document.getElementById(`${prefix}-btn-vol`);
-- const volSlider = document.getElementById(`${prefix}-vol-slider`);
-- const timeDisplay = document.getElementById(`${prefix}-time`);
-- const btnSpeed = document.getElementById(`${prefix}-btn-speed`);
-- const btnFullscreen = document.getElementById(`${prefix}-btn-fullscreen`);
-- const progressWrap = document.getElementById(`${prefix}-progress-wrap`);
-- const progressFill = document.getElementById(`${prefix}-progress-fill`);
-- const progressBuffer = document.getElementById(`${prefix}-progress-buffer`);
--
-- if (!video || !container) return;
--
-- const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
-- let speedIdx = 3; // 1x
-- let _rafId = null;
--
-- function fmtTime(s) {
-- if (!s || isNaN(s) || !isFinite(s)) return '0:00';
-- const h = Math.floor(s / 3600);
-- const m = Math.floor((s % 3600) / 60);
-- const sec = Math.floor(s % 60);
-- return h > 0
-- ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
-- : `${m}:${String(sec).padStart(2, '0')}`;
-- }
--
-- /**
-- * Get effective duration.
-- * - Live VODs: use the largest known duration (video vs. server live-info).
-- * - Completed VODs: trust the browser's container duration UNLESS it is missing
-- * or absurdly larger than the server's ffprobe'd duration. Some WHIP recordings
-- * carry a bogus inflated container duration; trusting it lets the scrubber seek
-- * into a region that has no frames, leaving a permanent black screen. In that
-- * case we clamp to the ffprobe'd truth so the timeline matches the real footage.
-- */
-- function getEffectiveDuration() {
-- const vd = (video.duration && isFinite(video.duration) && video.duration > 0) ? video.duration : 0;
-- if (window._liveVodIsLive && window._liveVodDuration > 0) return Math.max(vd, window._liveVodDuration);
-- const sd = parseFloat(video.dataset.serverDuration || '') || 0;
-- if (vd > 0 && sd > 0) return (vd > sd * 1.5) ? sd : vd;
-- return vd || sd || 0;
-- }
--
-- function updateProgress() {
-- const dur = getEffectiveDuration();
-- if (dur > 0) {
-- const pct = (video.currentTime / dur) * 100;
-- progressFill.style.width = Math.min(pct, 100) + '%';
-- if (window._liveVodIsLive) {
-- timeDisplay.textContent = `${fmtTime(video.currentTime)} / ${fmtTime(dur)} [LIVE]`;
-- } else {
-- timeDisplay.textContent = `${fmtTime(video.currentTime)} / ${fmtTime(dur)}`;
-- }
-- }
-- // Update buffer bar
-- if (video.buffered.length > 0) {
-- const dur2 = getEffectiveDuration();
-- if (dur2 > 0) {
-- const buffEnd = video.buffered.end(video.buffered.length - 1);
-- progressBuffer.style.width = (buffEnd / dur2) * 100 + '%';
-- }
-- }
-- if (!video.paused) _rafId = requestAnimationFrame(updateProgress);
-- }
--
-- // Play / Pause
-- btnPlay.onclick = () => {
-- if (video.paused) { video.play().catch(() => {}); } else { video.pause(); }
-- };
-- video.addEventListener('play', () => {
-- btnPlay.innerHTML = '<i class="fa-solid fa-pause"></i>';
-- container.classList.remove('paused');
-- _rafId = requestAnimationFrame(updateProgress);
-- });
-- video.addEventListener('pause', () => {
-- btnPlay.innerHTML = '<i class="fa-solid fa-play"></i>';
-- container.classList.add('paused');
-- if (_rafId) cancelAnimationFrame(_rafId);
-- });
-- video.addEventListener('ended', () => {
-- btnPlay.innerHTML = '<i class="fa-solid fa-rotate-right"></i>';
-- container.classList.add('paused');
-- });
-- // When the container over-reports its duration, the browser never fires 'ended'
-- // at the real end — it keeps "playing" black past the last frame. Detect the
-- // clamped case and stop at the true end so the VOD doesn't appear frozen/black.
-- video.addEventListener('timeupdate', () => {
-- if (window._liveVodIsLive) return;
-- const eff = getEffectiveDuration();
-- if (eff > 0 && isFinite(video.duration) && video.duration > eff * 1.5 && video.currentTime >= eff - 0.25) {
-- video.pause();
-- btnPlay.innerHTML = '<i class="fa-solid fa-rotate-right"></i>';
-- container.classList.add('paused');
-- }
-- });
--
-- // Click on video to toggle play
-- video.addEventListener('click', () => {
-- if (video.paused) { video.play().catch(() => {}); } else { video.pause(); }
-- });
--
-- // Double-click for fullscreen
-- video.addEventListener('dblclick', () => {
-- if (document.fullscreenElement) { document.exitFullscreen(); }
-- else { container.requestFullscreen().catch(() => {}); }
-- });
--
-- // Volume
-- btnVol.onclick = () => {
-- video.muted = !video.muted;
-- btnVol.innerHTML = video.muted
-- ? '<i class="fa-solid fa-volume-xmark"></i>'
-- : '<i class="fa-solid fa-volume-high"></i>';
-- volSlider.value = video.muted ? 0 : video.volume * 100;
-- };
-- volSlider.oninput = () => {
-- const v = volSlider.value / 100;
-- video.volume = v;
-- video.muted = v === 0;
-- btnVol.innerHTML = v === 0
-- ? '<i class="fa-solid fa-volume-xmark"></i>'
-- : v < 0.5 ? '<i class="fa-solid fa-volume-low"></i>'
-- : '<i class="fa-solid fa-volume-high"></i>';
-- };
--
-- // Progress seek
-- progressWrap.onclick = (e) => {
-- const rect = progressWrap.getBoundingClientRect();
-- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-- const dur = getEffectiveDuration();
-- if (dur > 0) video.currentTime = pct * dur;
-- };
-- // Drag seek
-- let _seeking = false;
-- progressWrap.addEventListener('mousedown', (e) => {
-- _seeking = true;
-- const rect = progressWrap.getBoundingClientRect();
-- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-- const dur = getEffectiveDuration();
-- if (dur > 0) video.currentTime = pct * dur;
-- });
-- document.addEventListener('mousemove', (e) => {
-- if (!_seeking) return;
-- const rect = progressWrap.getBoundingClientRect();
-- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-- const dur = getEffectiveDuration();
-- if (dur > 0) {
-- video.currentTime = pct * dur;
-- progressFill.style.width = pct * 100 + '%';
-- }
-- });
-- document.addEventListener('mouseup', () => { _seeking = false; });
-- // Touch seek — scoped to the bar (touch events keep firing on the origin element).
-- const _touchSeek = (clientX) => {
-- const rect = progressWrap.getBoundingClientRect();
-- const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
-- const dur = getEffectiveDuration();
-- if (dur > 0) { video.currentTime = pct * dur; if (progressFill) progressFill.style.width = pct * 100 + '%'; }
-- };
-- progressWrap.addEventListener('touchstart', (e) => { _seeking = true; if (e.touches[0]) _touchSeek(e.touches[0].clientX); e.preventDefault(); }, { passive: false });
-- progressWrap.addEventListener('touchmove', (e) => { if (_seeking && e.touches[0]) { _touchSeek(e.touches[0].clientX); e.preventDefault(); } }, { passive: false });
-- progressWrap.addEventListener('touchend', () => { _seeking = false; });
--
-- // Speed
-- btnSpeed.onclick = () => {
-- speedIdx = (speedIdx + 1) % speeds.length;
-- video.playbackRate = speeds[speedIdx];
-- btnSpeed.textContent = speeds[speedIdx] + 'x';
-- };
--
-- // Fullscreen
-- btnFullscreen.onclick = () => {
-- if (document.fullscreenElement) {
-- document.exitFullscreen();
-- } else {
-- container.requestFullscreen().catch(() => {});
-- }
-- };
-- document.addEventListener('fullscreenchange', () => {
-- if (document.fullscreenElement === container) {
-- btnFullscreen.innerHTML = '<i class="fa-solid fa-compress"></i>';
-- } else {
-- btnFullscreen.innerHTML = '<i class="fa-solid fa-expand"></i>';
-- }
-- });
--
-- // Keyboard shortcuts when container/video focused
-- container.tabIndex = 0;
-- container.addEventListener('keydown', (e) => {
-- switch (e.key) {
-- case ' ':
-- case 'k':
-- e.preventDefault();
-- if (video.paused) video.play().catch(() => {}); else video.pause();
-- break;
-- case 'ArrowLeft':
-- e.preventDefault(); video.currentTime = Math.max(0, video.currentTime - 5); break;
-- case 'ArrowRight':
-- e.preventDefault(); video.currentTime = Math.min(getEffectiveDuration() || 0, video.currentTime + 5); break;
-- case 'ArrowUp':
-- e.preventDefault(); video.volume = Math.min(1, video.volume + 0.1);
-- volSlider.value = video.volume * 100; break;
-- case 'ArrowDown':
-- e.preventDefault(); video.volume = Math.max(0, video.volume - 0.1);
-- volSlider.value = video.volume * 100; break;
-- case 'f':
-- e.preventDefault();
-- if (document.fullscreenElement) document.exitFullscreen();
-- else container.requestFullscreen().catch(() => {});
-- break;
-- case 'm':
-- e.preventDefault();
-- video.muted = !video.muted;
-- btnVol.innerHTML = video.muted
-- ? '<i class="fa-solid fa-volume-xmark"></i>'
-- : '<i class="fa-solid fa-volume-high"></i>';
-- volSlider.value = video.muted ? 0 : video.volume * 100;
-- break;
-- }
-- });
--
-- // Metadata loaded — update time
-- video.addEventListener('loadedmetadata', () => {
-- const dur = getEffectiveDuration();
-- timeDisplay.textContent = `0:00 / ${fmtTime(dur)}`;
-- });
-- video.addEventListener('timeupdate', updateProgress);
--}
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 89eca3f6..01a19813 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -581,7 +581,7 @@ function _ssoHint() {
// What the signed-in account watches/opens here shows up in its network-wide History and the
// "Recently used" rows of the shared navbar on every other site. Recorded after the route
// rendered and its title settled; never for guests, and only for the pages worth remembering.
--const _HISTORY_PAGES = { channel: 'stream', 'vod-player': 'vod', 'clip-player': 'clip', 'paste-viewer': 'paste', pastes: null, rundown: 'page' };
++const _HISTORY_PAGES = { channel: 'stream', 'paste-viewer': 'paste', pastes: null, rundown: 'page' };
let _historyTimer = null;
function _recordHistory(pageId) {
const type = _HISTORY_PAGES[pageId];
@@@ -848,6 +848,7 @@ function ovViewsBadge(n) {
function handleLinkClick(event, urlPath, replace = false) {
if (isModifiedLinkClick(event)) return true;
++ if (isOffSite(urlPath)) return true; // the link's own href leaves Live
event?.preventDefault?.();
navigate(urlPath, replace);
return false;
@@@ -910,20 +911,13 @@ function teardownRoute(nextPath) {
// Loops other modules own. Each is optional — a module that isn't loaded simply isn't stopped.
const stop = (fn) => { try { if (typeof window[fn] === 'function') window[fn](); } catch { /* */ } };
-- // A VOD or clip playing Media's playlist: its hls.js instance, worker and loaders end with the page.
-- stop('vodDropHls');
try { currentStreamId = null; } catch { /* */ }
-- // The VOD/clip chat replay re-arms itself every animation frame; nothing stopped it.
-- if (window._chatReplayTimer) { cancelAnimationFrame(window._chatReplayTimer); window._chatReplayTimer = null; }
-- window._vpChatReplay = null; window._clpChatReplay = null;
// Channel strays that kept fetching or cycling off-route.
stop('stopGoalWidget');
stop('_stopOfflineCycler');
stop('_stopCaptions');
if (typeof _channelPastesTimer !== 'undefined' && _channelPastesTimer) { clearInterval(_channelPastesTimer); _channelPastesTimer = null; }
if (typeof _goalCycleTimer !== 'undefined' && _goalCycleTimer) { clearInterval(_goalCycleTimer); _goalCycleTimer = null; }
-- // A Back press with the clip creator open left its key handler, drag handlers and preview loop.
-- try { const cm = document.getElementById('vp-clip-modal'); if (cm && cm.style.display && cm.style.display !== 'none') stop('closeClipCreator'); } catch { /* */ }
stop('stopAiViewerActivity');
stop('stopPlayerStatsPoll');
// The broadcast desk's polls only matter while you are on it. Leaving them running elsewhere
@@@ -937,7 +931,11 @@ function teardownRoute(nextPath) {
}
}
++/** A URL that leaves Live (an OpenVibe.Video watch page, another OpenVibe site): the browser goes there, not the SPA. */
++const isOffSite = (url) => /^https?:\/\//i.test(String(url || '')) && !String(url).startsWith(`${location.origin}/`);
++
function navigate(urlPath, replace = false) {
++ if (isOffSite(urlPath)) { if (replace) location.replace(urlPath); else location.href = urlPath; return; }
teardownRoute(urlPath);
// Normalize path
@@@ -1023,6 +1021,17 @@ function whenRouteReady(pageId, render) {
});
}
++/**
++ * A recording's or clip's page on openvibe.video (the shell's ov-video-base meta names the origin): videoHref('vod', 12),
++ * videoHref('clip', 7), videoHref('vod', 12, 95) for a moment. Plain links: they leave Live, so no SPA navigation.
++ */
++function videoHref(kind, id, t = null) {
++ const meta = document.querySelector('meta[name="ov-video-base"]');
++ const base = (meta && meta.content) || 'https://openvibe.video';
++ const at = t != null && Number(t) > 0 ? `?t=${Math.floor(Number(t))}` : '';
++ return `${base}/${kind === 'clip' ? 'c' : 'v'}/${encodeURIComponent(String(id))}${at}`;
++}
++
/** A paste's page on openvibe.community (the shell's ov-pastes-base meta names it). */
function pasteHref(slug) {
const meta = document.querySelector('meta[name="ov-pastes-base"]');
@@@ -1074,15 +1083,11 @@ function routeFromURL() {
// Channels, VODs and clips through OpenVibe.Search (public/js/app-search.js).
showPage('search');
whenRouteReady('search', () => loadSearchPage());
-- } else if (segments[0] === 'vod' && segments[1]) {
-- // VOD player: /vod/:id (optional ?t=<seconds> to auto-seek, e.g. from a clip link)
-- showPage('vod-player');
++ } else if ((segments[0] === 'vod' || segments[0] === 'clip') && segments[1]) {
++ // Recordings and clips are watched on openvibe.video (the server 301s these paths too).
const _t = parseFloat(new URLSearchParams(window.location.search).get('t'));
-- whenRouteReady('vod-player', () => loadVodPlayer(segments[1], Number.isFinite(_t) && _t > 0 ? _t : null));
-- } else if (segments[0] === 'clip' && segments[1]) {
-- // Clip player: /clip/:id
-- showPage('clip-player');
-- whenRouteReady('clip-player', () => loadClipPlayer(segments[1]));
++ location.replace(videoHref(segments[0], segments[1], Number.isFinite(_t) && _t > 0 ? _t : null));
++ return;
} else if (segments[0] === 'dashboard') {
showPage('dashboard');
whenRouteReady('dashboard', () => { if (typeof loadDashboard === 'function') loadDashboard(); });
@@@ -1267,7 +1272,7 @@ function ovWakeBrandMark() {
}, BRAND_AWAKE_MS);
}
--const _PAGE_LABELS = { 'vod-player': 'video', 'clip-player': 'clip', broadcast: 'Go Live', recap: 'recap' };
++const _PAGE_LABELS = { broadcast: 'Go Live', recap: 'recap' };
function _pageLabel(page) { return _PAGE_LABELS[page] || String(page).replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
function showPage(page) {
ddiff --git a/public/js/arena-alert.js b/public/js/arena-alert.js
iindex 483c8466..339f033f 100644
---- a/public/js/arena-alert.js
++++ b/public/js/arena-alert.js
@@@ -29,7 +29,7 @@ function _aaAvatar(u, size = 40) {
}
function _aaSirens(n) { return `<span class="aa-sirens" aria-label="alert level ${n} of 5">${'🚨'.repeat(Math.max(1, Math.min(5, n)))}</span>`; }
function _aaWatch(s) {
-- if (s.vod_id) return _aA(`/vod/${s.vod_id}?t=${Math.max(0, Math.floor(s.sec || 0))}`, '<i class="fa-solid fa-play"></i> Watch it happen', 'aa-watch');
++ if (s.vod_id) return _aA(videoHref('vod', s.vod_id, Math.max(0, Math.floor(s.sec || 0))), '<i class="fa-solid fa-play"></i> Watch it happen', 'aa-watch');
if (s.user && s.kind !== 'fell_off') return _aA(_aChannelLink(s.user), '<i class="fa-solid fa-tower-broadcast"></i> Their channel', 'aa-watch');
return '';
}
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex 4808319e..403c0fa8 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -130,8 +130,9 @@ function _aChannelLink(u, slot = null) { return typeof channelPath === 'function
function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }
function _aConsoleLink(u) { return `/arena/live/${encodeURIComponent(u.username)}`; }
function _aBeefLink(b) { return `/arena/beef/${b.id}`; }
--function _aVodLink(vodId, sec) { return vodId ? `/vod/${vodId}?t=${Math.max(0, Math.floor(sec || 0))}` : null; }
--function _aA(href, inner, cls = '', title = '') { return `<a class="${cls}" href="${_aEsc(href)}" ${title ? `title="${_aEsc(title)}"` : ''} onclick="return handleLinkClick(event, '${_aEsc(href)}')">${inner}</a>`; }
++function _aVodLink(vodId, sec) { return vodId ? videoHref('vod', vodId, Math.max(0, Math.floor(sec || 0))) : null; }
++// A link inside Live navigates the SPA; one that leaves (an OpenVibe.Video moment) is a plain link.
++function _aA(href, inner, cls = '', title = '') { const out = /^https?:\/\//.test(href); return `<a class="${cls}" href="${_aEsc(href)}" ${title ? `title="${_aEsc(title)}"` : ''}${out ? '' : ` onclick="return handleLinkClick(event, '${_aEsc(href)}')"`}>${inner}</a>`; }
function _aPlay(vodId, sec, label = '') { const h = _aVodLink(vodId, sec); return h ? _aA(h, `<i class="fa-solid fa-play"></i>${label ? ` ${_aEsc(label)}` : ''}`, 'arena-play', 'Hear them say it (jumps to the VOD)') : ''; }
function _aLevelPill(level) { return `<span class="arena-lvl" title="Trash Level — XP from what you say on mic, nothing else">TL ${_aEsc(level ?? 1)}</span>`; }
function _aBriefChip(f, extra = '') {
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex eff44769..d32217da 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3708,7 +3708,7 @@ function buildChatMessageEl(msg, opts = {}) {
const metaLine = isAuto
? `<div class="clip-chat-meta"><i class="fa-solid fa-robot"></i> Auto-generated highlight</div>`
: `<div class="clip-chat-meta">${avatar}<span>Clipped by ${creator}</span></div>`;
-- text = `<a class="clip-chat-card${isAuto ? ' clip-chat-card-ai' : ''}" href="/clip/${clip.clip_id}" onclick="return handleLinkClick(event, '/clip/${clip.clip_id}')" title="Watch clip">
++ text = `<a class="clip-chat-card${isAuto ? ' clip-chat-card-ai' : ''}" href="${videoHref('clip', clip.clip_id)}" onclick="return handleLinkClick(event, '${videoHref('clip', clip.clip_id)}')" title="Watch clip">
${thumb}
<div class="clip-chat-info">
${label}
ddiff --git a/public/js/context-cards.js b/public/js/context-cards.js
iindex 0f119d13..6677ff6e 100644
---- a/public/js/context-cards.js
++++ b/public/js/context-cards.js
@@@ -1,44 +1,25 @@
/**
* context-cards.js — the connective tissue between clips, VODs, streams and channels.
*
-- * renderClipSourceCard(clip) clip page: the stream this clip came from as a rich card —
-- * VOD thumbnail with the clip's window marked on a timeline,
-- * timestamp badge, the stream's activity numbers, the
-- * after-show report, and the other clips cut from it.
-- * renderVodContextStrip(vodId) VOD page: activity strip (chat lines, chatters, peak/avg
-- * viewers, mic moments, sound commands, follows, tips,
-- * loudest chatters) + after-show report link.
* renderOfflineDiscover(username) offline channel screen: a discover board — live now,
* hot clips, fresh reports, the star of the day, streamers
* like this one — so an empty channel still sends people
* somewhere good.
*
-- * All three read the same two endpoints: GET /api/vods/:id/context and GET /api/home/discover.
++ * It reads GET /api/home/discover. (The clip page's source card and the VOD page's activity strip went with those
++ * pages to OpenVibe.Video, which reads GET /api/vods/:id/context for them.)
*/
(function () {
'use strict';
const n = (v) => Number(v || 0).toLocaleString();
const dur = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; };
const durWords = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; };
-- const go = (href) => `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`;
++ // Inside Live the SPA navigates; a recording or clip opens on openvibe.video (a plain link).
++ const go = (href) => (/^https?:\/\//.test(href) ? `href="${esc(href)}"` : `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`);
const ago = (ts) => { try { return timeAgo(ts); } catch { return ''; } };
const GRADE = { S: '#fbbf24', A: '#a78bfa', B: '#38bdf8', C: '#94a3b8' };
const av = (u, size) => `<span class="cc-avatar" style="width:${size}px;height:${size}px;${u && u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u && u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="" loading="lazy">` : esc(String((u && (u.display_name || u.username)) || '?').charAt(0).toUpperCase())}</span>`;
-- const statPills = (st) => {
-- if (!st) return '';
-- const items = [
-- ['fa-eye', n(st.peak_viewers), 'peak'],
-- st.avg_viewers != null ? ['fa-chart-line', n(st.avg_viewers), 'avg'] : null,
-- ['fa-comments', n(st.chat_messages), 'chat lines'],
-- ['fa-users', n(st.chatters), 'chatters'],
-- st.mic_moments ? ['fa-microphone-lines', n(st.mic_moments), 'mic moments'] : null,
-- st.sound_commands ? ['fa-volume-high', n(st.sound_commands), 'sounds'] : null,
-- st.follows_gained ? ['fa-heart', `+${n(st.follows_gained)}`, 'follows'] : null,
-- st.tips && st.tips.n ? ['fa-coins', n(st.tips.t), `in ${st.tips.n} tip${st.tips.n === 1 ? '' : 's'}`] : null,
-- ].filter(Boolean);
-- return `<div class="cc-stats">${items.map(([i, v, l]) => `<span class="cc-stat"><i class="fa-solid ${i}"></i><b>${v}</b><small>${esc(l)}</small></span>`).join('')}</div>`;
-- };
-- const clipTile = (c, opts = {}) => `<a class="cc-clip" ${go(`/clip/${c.id}`)} title="${esc(c.title || 'Clip')}">
++ const clipTile = (c, opts = {}) => `<a class="cc-clip" ${go(videoHref('clip', c.id))} title="${esc(c.title || 'Clip')}">
${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}
<span class="cc-clip-dur">${dur(c.duration_seconds)}</span>
${opts.at && c.start_time != null ? `<span class="cc-clip-at"><i class="fa-regular fa-clock"></i> ${dur(c.start_time)}</span>` : ''}
@@@ -46,88 +27,6 @@
<span class="cc-clip-meta">${c.view_count ? `<i class="fa-solid fa-eye"></i> ${n(c.view_count)} · ` : ''}${c.display_name || c.by ? esc(c.display_name || c.by) : ''}${c.created_at ? ` · ${esc(ago(c.created_at))}` : ''}</span>
</a>`;
-- async function fetchContext(vodId) {
-- if (!vodId) return null;
-- try { return await api(`/vods/${encodeURIComponent(vodId)}/context`); } catch { return null; }
-- }
--
-- // ── Clip page: "From this stream" card ─────────────────────
-- window.renderClipSourceCard = async function (clip) {
-- const host = document.getElementById('clp-stream-source');
-- if (!host || !clip) return;
-- if (clip.vod_visible === false) return; // source VOD gone or hidden: no context to ask for
-- const ctx = await fetchContext(clip.vod_id);
-- if (!ctx || !ctx.vod) return; // keep the plain text fallback
-- const total = Math.max(1, ctx.vod.duration_seconds || 0);
-- const start = Math.max(0, Number(clip.start_time) || 0), end = Math.min(total, Math.max(start + 1, Number(clip.end_time) || start + (clip.duration_seconds || 0)));
-- const left = Math.min(100, start / total * 100), width = Math.max(0.8, (end - start) / total * 100);
-- const canWatch = clip.vod_available !== false && ctx.vod.visibility !== 'private';
-- const vodHref = `/vod/${ctx.vod.id}?t=${Math.floor(start)}`;
-- const s = ctx.stream || {}, st = ctx.stats;
-- const others = (ctx.clips || []).filter(c => c.id !== clip.id).slice(0, 6);
-- const rc = ctx.recap;
-- host.classList.add('cc-host');
-- host.innerHTML = `
-- <div class="cc-card">
-- <div class="cc-kicker"><i class="fa-solid fa-tower-broadcast"></i> From this stream</div>
-- <div class="cc-main">
-- <a class="cc-thumb" ${canWatch ? go(vodHref) : 'href="#" onclick="return false"'} title="${canWatch ? 'Watch this moment in the full VOD' : 'VOD unavailable'}">
-- ${ctx.vod.thumbnail_url ? `<img src="${esc(ctx.vod.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-video"></i></span>'}
-- <span class="cc-thumb-dur">${dur(total)}</span>
-- <span class="cc-thumb-play"><i class="fa-solid fa-play"></i></span>
-- <span class="cc-timeline" aria-hidden="true"><i style="left:${left.toFixed(2)}%;width:${width.toFixed(2)}%"></i></span>
-- </a>
-- <div class="cc-body">
-- <div class="cc-title-row">
-- <a class="cc-title" ${canWatch ? go(`/vod/${ctx.vod.id}`) : 'href="#" onclick="return false"'}>${esc(s.title || ctx.vod.title || 'Stream')}</a>
-- ${rc ? `<a class="cc-grade" ${go(`/recap/${rc.stream_id}`)} title="${esc(rc.headline || 'After-show report')}" style="--g:${GRADE[rc.grade] || GRADE.B}">${esc(rc.grade)}</a>` : ''}
-- </div>
-- <div class="cc-sub">
-- ${ctx.streamer ? `${av(ctx.streamer, 20)} <a class="cc-name" ${go(`/@${ctx.streamer.username}`)}>${esc(ctx.streamer.display_name)}</a>` : ''}
-- ${s.started_at ? `<span>· ${esc(new Date(String(s.started_at).replace(' ', 'T') + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }))}</span>` : ''}
-- ${s.category ? `<span>· ${esc(s.category)}</span>` : ''}
-- ${ctx.vod.view_count ? `<span>· <i class="fa-solid fa-eye"></i> ${n(ctx.vod.view_count)} VOD views</span>` : ''}
-- </div>
-- <div class="cc-when">
-- <span class="cc-badge"><i class="fa-regular fa-clock"></i> ${dur(start)} → ${dur(end)}</span>
-- <span class="cc-when-text">${Math.round(start / total * 100)}% into a ${durWords(total)} stream</span>
-- </div>
-- ${statPills(st)}
-- ${rc && rc.headline ? `<a class="cc-recap" ${go(`/recap/${rc.stream_id}`)}><i class="fa-solid fa-clipboard-list"></i> <b>After-show report:</b> ${esc(rc.headline)}</a>` : ''}
-- <div class="cc-actions">
-- ${canWatch ? `<a class="btn btn-primary btn-sm" ${go(vodHref)}><i class="fa-solid fa-forward"></i> Watch this moment in the VOD</a>` : '<span class="muted" style="font-size:0.8rem"><i class="fa-solid fa-eye-slash"></i> The full VOD is no longer available</span>'}
-- ${ctx.streamer ? `<a class="btn btn-outline btn-sm" ${go(`/@${ctx.streamer.username}`)}><i class="fa-solid fa-user"></i> Channel</a>` : ''}
-- </div>
-- </div>
-- </div>
-- ${others.length ? `<div class="cc-more"><div class="cc-more-head"><i class="fa-solid fa-scissors"></i> More clips from this stream <span class="muted">(${others.length})</span></div><div class="cc-clips">${others.map(c => clipTile(c, { at: true })).join('')}</div></div>` : ''}
-- </div>`;
-- host.style.display = '';
-- };
--
-- // ── VOD page: activity strip ────────────────────────────────
-- window.renderVodContextStrip = async function (vodId) {
-- const anchor = document.getElementById('vp-stream-source');
-- if (!anchor) return;
-- let host = document.getElementById('vp-context');
-- if (!host) { host = document.createElement('div'); host.id = 'vp-context'; host.className = 'cc-host'; anchor.insertAdjacentElement('afterend', host); }
-- host.innerHTML = '';
-- const ctx = await fetchContext(vodId);
-- if (!ctx || !ctx.stats) { host.style.display = 'none'; return; }
-- const st = ctx.stats, rc = ctx.recap;
-- const top = (st.top_chatters || []).slice(0, 3);
-- host.innerHTML = `
-- <div class="cc-card cc-strip">
-- <div class="cc-kicker"><i class="fa-solid fa-bolt"></i> The room during this stream ${ctx.stream && ctx.stream.is_live ? '<span class="cc-live">LIVE · numbers so far</span>' : ''}</div>
-- ${statPills(st)}
-- <div class="cc-strip-foot">
-- ${top.length ? `<span class="cc-top"><i class="fa-solid fa-ranking-star"></i> Loudest: ${top.map((t, i) => `${['🥇', '🥈', '🥉'][i]} <a ${go(`/@${t.username}`)}>${esc(t.display_name || t.username)}</a> <small>${n(t.n)}</small>`).join(' ')}</span>` : ''}
-- ${rc ? `<a class="cc-recap" ${go(`/recap/${rc.stream_id}`)}><span class="cc-grade" style="--g:${GRADE[rc.grade] || GRADE.B}">${esc(rc.grade)}</span> <b>After-show report:</b> ${esc(rc.headline || '')}</a>` : ''}
-- </div>
-- </div>`;
-- host.style.display = '';
-- };
--
// ── Offline channel: discover board ─────────────────────────
window.renderOfflineDiscover = async function (username, mountEl) {
const host = mountEl || document.getElementById('ch-discover');
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex e31a92a1..fad99dd7 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -379,7 +379,7 @@ async function loadDashVods() {
${!v.is_public
? `<button class="btn btn-small btn-success" onclick="publishVod('${v.id}')"><i class="fa-solid fa-eye"></i> Publish</button>`
: `<button class="btn btn-small btn-outline" onclick="unpublishVod('${v.id}')"><i class="fa-solid fa-eye-slash"></i> Private</button>`}
-- <button class="btn btn-small" onclick="navigate('/vod/${v.id}')"><i class="fa-solid fa-play"></i></button>
++ <button class="btn btn-small" onclick="location.href=videoHref('vod', '${v.id}')" title="Watch on OpenVibe.Video"><i class="fa-solid fa-play"></i></button>
<button class="btn btn-small btn-danger" onclick="deleteVod('${v.id}')"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
@@@ -503,7 +503,7 @@ async function loadDashMyClips() {
${!cl.is_public
? `<button class="btn btn-small btn-success" onclick="dashToggleClipVisibility(${cl.id}, true, 'mine')"><i class="fa-solid fa-eye"></i> Publish</button>`
: `<button class="btn btn-small btn-outline" onclick="dashToggleClipVisibility(${cl.id}, false, 'mine')"><i class="fa-solid fa-eye-slash"></i> Unlist</button>`}
-- <button class="btn btn-small" onclick="navigate('/clip/${cl.id}')"><i class="fa-solid fa-play"></i></button>
++ <button class="btn btn-small" onclick="location.href=videoHref('clip', '${cl.id}')" title="Watch on OpenVibe.Video"><i class="fa-solid fa-play"></i></button>
${cl.can_delete ? `<button class="btn btn-small btn-danger" onclick="dashDeleteClip(${cl.id}, 'mine')"><i class="fa-solid fa-trash"></i></button>` : ''}
</div>
</div>
@@@ -546,7 +546,7 @@ async function loadDashStreamClips() {
${!cl.is_public
? `<button class="btn btn-small btn-success" onclick="dashToggleClipVisibility(${cl.id}, true)"><i class="fa-solid fa-eye"></i> Publish</button>`
: `<button class="btn btn-small btn-outline" onclick="dashToggleClipVisibility(${cl.id}, false)"><i class="fa-solid fa-eye-slash"></i> Unlist</button>`}
-- <button class="btn btn-small" onclick="navigate('/clip/${cl.id}')"><i class="fa-solid fa-play"></i></button>
++ <button class="btn btn-small" onclick="location.href=videoHref('clip', '${cl.id}')" title="Watch on OpenVibe.Video"><i class="fa-solid fa-play"></i></button>
<button class="btn btn-small btn-danger" onclick="dashDeleteClip(${cl.id}, 'stream')"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
@@@ -605,7 +605,7 @@ async function loadDashAiClips() {
${!cl.is_public
? `<button class="btn btn-small btn-success" onclick="dashToggleClipVisibility(${Number(cl.id)}, true, 'ai')"><i class="fa-solid fa-eye"></i> Publish</button>`
: `<button class="btn btn-small btn-outline" onclick="dashToggleClipVisibility(${Number(cl.id)}, false, 'ai')"><i class="fa-solid fa-eye-slash"></i> Unlist</button>`}
-- <button class="btn btn-small" onclick="navigate('/clip/${Number(cl.id)}')"><i class="fa-solid fa-play"></i></button>
++ <button class="btn btn-small" onclick="location.href=videoHref('clip', ${Number(cl.id)})" title="Watch on OpenVibe.Video"><i class="fa-solid fa-play"></i></button>
<button class="btn btn-small btn-danger" onclick="dashDeleteClip(${Number(cl.id)}, 'ai')"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
ddiff --git a/public/js/footer-live.js b/public/js/footer-live.js
iindex 3d549b3c..d8014cb9 100644
---- a/public/js/footer-live.js
++++ b/public/js/footer-live.js
@@@ -11,7 +11,7 @@
*/
(function () {
'use strict';
-- const COMPACT_PAGES = ['channel', 'vod-player', 'clip-player', 'broadcast', 'chat', 'rundown', 'recap'];
++ const COMPACT_PAGES = ['channel', 'broadcast', 'chat', 'rundown', 'recap'];
const LINKS = [
{
heading: 'Watch',
ddiff --git a/public/js/recap.js b/public/js/recap.js
iindex 1a6c6d1b..507fb412 100644
---- a/public/js/recap.js
++++ b/public/js/recap.js
@@@ -10,7 +10,8 @@
const dur = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; };
const when = (ts) => { const t = Date.parse(String(ts || '').replace(' ', 'T') + (String(ts || '').endsWith('Z') ? '' : 'Z')); return Number.isFinite(t) ? new Date(t).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''; };
const av = (u, size) => `<span class="recap-avatar" style="width:${size}px;height:${size}px;${u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="" loading="lazy">` : esc(String(u.display_name || u.username || '?').charAt(0).toUpperCase())}</span>`;
-- const go = (href) => `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`;
++ // Inside Live the SPA navigates; a recording or clip opens on openvibe.video (a plain link).
++ const go = (href) => (/^https?:\/\//.test(href) ? `href="${esc(href)}"` : `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`);
function curveSvg(curve, startedAt) {
if (!curve || curve.length < 2) return '';
@@@ -55,10 +56,10 @@
const medals = ['🥇', '🥈', '🥉', '4', '5'];
const topN = r.chat.top[0] ? r.chat.top[0].n : 1;
const chatters = r.chat.top.length ? `<div class="recap-card"><h3><i class="fa-solid fa-ranking-star"></i> Loudest in chat</h3><ol class="recap-chatters">${r.chat.top.map((c, i) => `<li><span class="recap-medal">${medals[i]}</span>${av(c, 26)}<a class="recap-chatter-name" ${go(`/@${c.username}`)}>${esc(c.display_name || c.username)}</a><span class="recap-chatter-bar"><i style="width:${Math.max(6, Math.round(c.n / topN * 100))}%"></i></span><span class="recap-chatter-n">${n(c.n)}</span></li>`).join('')}</ol>${r.chat.busiest_at ? `<div class="recap-fine">Busiest stretch: ${esc(when(r.chat.busiest_at))} (${n(r.chat.busiest_n)} lines in 5 min)</div>` : ''}</div>` : '';
-- const micLine = (m) => { const href = m.vod_id ? `/vod/${m.vod_id}?t=${Math.max(0, Math.round(m.sec || 0))}` : null; const inner = `“${esc(m.text)}”${m.aimed_at ? ` <span class="muted">at ${esc(m.aimed_at)}</span>` : ''}<span class="recap-mic-q">${m.quality}/10</span>`; return href ? `<a class="recap-mic" ${go(href)}><i class="fa-solid fa-play"></i> ${inner}</a>` : `<div class="recap-mic">${inner}</div>`; };
++ const micLine = (m) => { const href = m.vod_id ? videoHref('vod', m.vod_id, Math.max(0, Math.round(m.sec || 0))) : null; const inner = `“${esc(m.text)}”${m.aimed_at ? ` <span class="muted">at ${esc(m.aimed_at)}</span>` : ''}<span class="recap-mic-q">${m.quality}/10</span>`; return href ? `<a class="recap-mic" ${go(href)}><i class="fa-solid fa-play"></i> ${inner}</a>` : `<div class="recap-mic">${inner}</div>`; };
const moment = (w.moment || r.mic.length) ? `<div class="recap-card recap-moment"><h3><i class="fa-solid fa-fire"></i> Moment of the night</h3>${w.moment ? `<p class="recap-moment-text">${esc(w.moment)}</p>` : ''}${r.mic.length ? `<div class="recap-mics">${r.mic.map(micLine).join('')}</div>` : ''}</div>` : '';
-- const clips = r.clips.length ? `<div class="recap-section"><h3><i class="fa-solid fa-scissors"></i> Clips from this stream</h3><div class="recap-clips">${r.clips.map(c => `<a class="recap-clip" ${go(`/clip/${c.id}`)}>${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="recap-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}<span class="recap-clip-dur">${dur(c.duration_seconds)}</span><span class="recap-clip-title">${esc(c.title)}</span>${c.view_count ? `<span class="recap-clip-views"><i class="fa-solid fa-eye"></i> ${n(c.view_count)}</span>` : ''}</a>`).join('')}</div></div>` : '';
-- const vod = r.vod ? `<a class="recap-vod" ${go(`/vod/${r.vod.id}`)}>${r.vod.thumbnail_url ? `<img src="${esc(r.vod.thumbnail_url)}" alt="" loading="lazy">` : ''}<span class="recap-vod-body"><b><i class="fa-solid fa-play"></i> Watch the VOD</b><span>${dur(r.vod.duration_seconds)} · the whole stream, with the transcript and AI moments</span></span></a>` : '';
++ const clips = r.clips.length ? `<div class="recap-section"><h3><i class="fa-solid fa-scissors"></i> Clips from this stream</h3><div class="recap-clips">${r.clips.map(c => `<a class="recap-clip" ${go(videoHref('clip', c.id))}>${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="recap-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}<span class="recap-clip-dur">${dur(c.duration_seconds)}</span><span class="recap-clip-title">${esc(c.title)}</span>${c.view_count ? `<span class="recap-clip-views"><i class="fa-solid fa-eye"></i> ${n(c.view_count)}</span>` : ''}</a>`).join('')}</div></div>` : '';
++ const vod = r.vod ? `<a class="recap-vod" ${go(videoHref('vod', r.vod.id))}>${r.vod.thumbnail_url ? `<img src="${esc(r.vod.thumbnail_url)}" alt="" loading="lazy">` : ''}<span class="recap-vod-body"><b><i class="fa-solid fa-play"></i> Watch the VOD</b><span>${dur(r.vod.duration_seconds)} · the whole stream, with the transcript and AI moments</span></span></a>` : '';
const more = (data.more || []).length ? `<div class="recap-section"><h3><i class="fa-solid fa-clock-rotate-left"></i> More nights from ${esc(u.display_name)}</h3><div class="recap-more">${data.more.map(m => `<a class="recap-more-item" ${go(`/recap/${m.stream_id}`)}><span class="recap-grade-mini" style="--g:${(GRADE[m.grade] || GRADE.B).color}">${esc(m.grade)}</span><span class="recap-more-title">${esc(m.headline || m.title)}</span><span class="recap-more-meta">${esc(timeAgo(m.ended_at))} · ${dur(m.duration_seconds)} · peak ${n(m.peak_viewers)}</span></a>`).join('')}</div></div>` : '';
const shareUrl = `${location.origin}/recap/${id}`;
const shareText = `${w.headline || s.title} — ${u.display_name}'s stream report on OpenVibe.Live`;
ddiff --git a/public/js/rundown.js b/public/js/rundown.js
iindex a3dc9140..9ea3e24a 100644
---- a/public/js/rundown.js
++++ b/public/js/rundown.js
@@@ -51,7 +51,7 @@ function _rdTopicChip(t, extra = '') {
const [label, color] = RD_KIND[t.kind] || RD_KIND.other;
return _rdA(`/rundown/t/${encodeURIComponent(t.slug)}`, `${_rdEsc(t.title)}${extra}`, 'rd-chip', `${label}: ${t.title}`).replace('class="rd-chip"', `class="rd-chip" style="--rd-k:${color}"`);
}
--function _rdWatch(vodId, sec, label = 'Watch') { return vodId ? _rdA(`/vod/${vodId}?t=${Math.max(0, Math.floor(sec || 0))}`, `<i class="fa-solid fa-play" aria-hidden="true"></i> ${_rdEsc(label)}${sec ? ` <span class="rd-stamp">${_rdStamp(sec)}</span>` : ''}`, 'rd-watch') : ''; }
++function _rdWatch(vodId, sec, label = 'Watch') { return vodId ? _rdA(videoHref('vod', vodId, Math.max(0, Math.floor(sec || 0))), `<i class="fa-solid fa-play" aria-hidden="true"></i> ${_rdEsc(label)}${sec ? ` <span class="rd-stamp">${_rdStamp(sec)}</span>` : ''}`, 'rd-watch') : ''; }
function _rdParas(text) { return String(text || '').split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${_rdEsc(p)}</p>`).join(''); }
function _rdMe() { return (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null; }
function _rdEmpty(icon, text, action = '') { return `<div class="rd-empty"><i class="fa-solid ${icon}" aria-hidden="true"></i><p>${text}</p>${action}</div>`; }
@@@ -110,7 +110,7 @@ function _rdEpisodeCard(e, { user = true } = {}) {
<h3 class="rd-h3 rd-ep-title">${_rdA(href, _rdEsc(e.title))}</h3>
${user ? `<p class="rd-by">${_rdUser(e.user)}</p>` : ''}
${e.logline ? `<p class="rd-logline">${_rdEsc(e.logline)}</p>` : ''}
-- ${chapters.length ? `<ol class="rd-chapters">${chapters.map((c) => `<li>${e.vod_id ? _rdA(`/vod/${e.vod_id}?t=${c.sec}`, `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`) : `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`} ${_rdEsc(c.title)}</li>`).join('')}</ol>` : ''}
++ ${chapters.length ? `<ol class="rd-chapters">${chapters.map((c) => `<li>${e.vod_id ? _rdA(videoHref('vod', e.vod_id, c.sec), `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`) : `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`} ${_rdEsc(c.title)}</li>`).join('')}</ol>` : ''}
${(e.topics || []).length ? `<div class="rd-chips">${e.topics.slice(0, 5).map((t) => _rdTopicChip(t)).join('')}</div>` : ''}
</div>
</div>
@@@ -233,7 +233,7 @@ async function _rdEpisode(root, id) {
<p class="rd-actions">${e ? _rdWatch(e.vod_id, 0, 'Watch the stream') : ''}${s.is_live && u ? _rdA(_rdChannelHref(u), '<i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Watch live', 'rd-watch') : ''}</p>
</header>
${e && e.recap ? `<div class="rd-recap">${_rdParas(e.recap)}</div>` : (s.is_live ? '<p class="rd-quiet">The episode is written when the stream ends. Here is what the desk has heard so far.</p>' : '')}
-- ${e && e.chapters.length ? `<section aria-labelledby="rd-ch-h"><h2 id="rd-ch-h" class="rd-h2">Chapters</h2><ol class="rd-chapters rd-chapters-big">${e.chapters.map((c) => `<li>${e.vod_id ? _rdA(`/vod/${e.vod_id}?t=${c.sec}`, `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`) : `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`} ${c.segment_id ? `<a href="#rd-take-${c.segment_id}">${_rdEsc(c.title)}</a>` : _rdEsc(c.title)}</li>`).join('')}</ol></section>` : ''}
++ ${e && e.chapters.length ? `<section aria-labelledby="rd-ch-h"><h2 id="rd-ch-h" class="rd-h2">Chapters</h2><ol class="rd-chapters rd-chapters-big">${e.chapters.map((c) => `<li>${e.vod_id ? _rdA(videoHref('vod', e.vod_id, c.sec), `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`) : `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`} ${c.segment_id ? `<a href="#rd-take-${c.segment_id}">${_rdEsc(c.title)}</a>` : _rdEsc(c.title)}</li>`).join('')}</ol></section>` : ''}
${e ? `<div class="rd-card-foot rd-ep-react">${_rdReactions('episode', e.stream_id, e.reactions, e.my_reaction)}</div>` : ''}
<h2 class="rd-h2">What they talked about</h2>
${(d.segments || []).map((x) => _rdTake(x, { user: false })).join('') || _rdEmpty('fa-microphone-lines', 'Nothing written up from this stream.')}
ddiff --git a/scripts/perf/critical-css.js b/scripts/perf/critical-css.js
iindex 495f2bf7..dccb0f26 100644
---- a/scripts/perf/critical-css.js
++++ b/scripts/perf/critical-css.js
@@@ -19,7 +19,8 @@ const opt = (name, def) => { const i = args.indexOf(name); return i === -1 ? def
const BASE = String(opt('--base', 'https://openvibe.live')).replace(/\/+$/, '');
const SETTLE = Number(opt('--settle', 9000));
// The public routes people land on from search, links and the home page (signed out).
--const ROUTES = ['/', '/content', '/vods', '/clips', '/moments', '/rundown', '/chat', '/search?q=game', '/@iZoR_One', '/vod/5717', '/clip/441', '/pastes', '/docs'];
++// (VOD and clip pages are OpenVibe.Video's: /vod/<id> and /clip/<id> 301 there.)
++const ROUTES = ['/', '/content', '/vods', '/clips', '/moments', '/rundown', '/chat', '/search?q=game', '/@iZoR_One', '/pastes', '/docs'];
const WIDTHS = [[412, 900, true], [1366, 900, false]];
async function coverage(url, [width, height, mobile]) {
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 6c96cbc6..c82e68d8 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -361,7 +361,8 @@ async function tick(opts = {}) {
let tags = moment.tags || [];
const vodPath = `/vod/${v.vod_id}?t=${offset}`;
-- const vodLink = `${BASE_URL}${vodPath}`;
++ // The moment on the recording's page, OpenVibe.Video (vod_link in the metadata keeps Live's path form).
++ const vodLink = require('../web/video-url').videoUrl('vod', v.vod_id, offset) || `${BASE_URL}${vodPath}`;
const slug = _slug();
// Extract the real frame at this moment so the paste is a true IMAGE paste.
ddiff --git a/server/content/feed.js b/server/content/feed.js
iindex 1bdc06b0..24a1ba8f 100644
---- a/server/content/feed.js
++++ b/server/content/feed.js
@@@ -38,6 +38,7 @@ const db = require('../db/database');
const media = require('../media-client');
const pastesClient = require('../pastes-client');
const lookups = require('../media-proxy/lookups');
++const { videoUrl } = require('../web/video-url');
const { publicFeedItem } = require('../web/serializers');
const DEADLINE_MS = 4000;
@@@ -122,7 +123,7 @@ async function vodItem(v) {
if (banned(owner)) return null;
// Content is what people made: the card carries no AI overview (the VOD page still has it).
return {
-- kind: 'vod', id: Number(v.id), href: `/vod/${Number(v.id)}`,
++ kind: 'vod', id: Number(v.id), href: videoUrl('vod', v.id),
title: clean(v.title, 140) || 'Stream recording',
created_at: iso(v.created_at), views: num(v.view_count),
duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
@@@ -144,7 +145,7 @@ async function clipItem(c, { ai }) {
if (ai) { try { const s = await db.getClipAiState(c.id); summary = s && (s.ai_overview_short || s.ai_overview); } catch { /* */ } }
const by = !ai && creator && channelOwner && creator.id !== channelOwner.id ? person(creator) : null;
return {
-- kind: 'clip', id: Number(c.id), href: `/clip/${Number(c.id)}`,
++ kind: 'clip', id: Number(c.id), href: videoUrl('clip', c.id),
title: clean(c.title, 140) || 'Clip',
created_at: iso(c.created_at), views: num(c.view_count),
duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 29103fa4..31035ab5 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -14,6 +14,7 @@ const express = require('express');
const db = require('../db/database');
const { can } = require('../auth/permissions');
const media = require('../media-client');
++const { videoUrl } = require('../web/video-url');
const router = express.Router();
const SLOGAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // must match slogan-job INTERVAL_MS
@@@ -82,10 +83,10 @@ async function heroMedia() {
try {
const pool = await _mediaContent();
for (const v of pool.vods) {
-- items.push({ kind: 'vod', title: v.title || 'VOD', thumbnail: media.publicUrl(v.thumbnail_url) || media.thumbUrl(`vod-${v.id}`), href: `/vod/${v.id}`, username: v.username });
++ items.push({ kind: 'vod', title: v.title || 'VOD', thumbnail: media.publicUrl(v.thumbnail_url) || media.thumbUrl(`vod-${v.id}`), href: videoUrl('vod', v.id), username: v.username });
}
for (const c of pool.clips) {
-- items.push({ kind: 'clip', title: c.title || 'Clip', thumbnail: media.publicUrl(c.thumbnail_url) || media.thumbUrl(`clip-${c.id}`), href: `/clip/${c.id}`, username: c.username });
++ items.push({ kind: 'clip', title: c.title || 'Clip', thumbnail: media.publicUrl(c.thumbnail_url) || media.thumbUrl(`clip-${c.id}`), href: videoUrl('clip', c.id), username: c.username });
}
for (const p of pool.pastes) {
items.push({
@@@ -103,7 +104,7 @@ async function heroMedia() {
if (!m || !m.vodId) continue;
items.push({
kind: 'moment', title: m.title || 'AI Moment', thumbnail: m.thumbnail || null,
-- href: `/vod/${m.vodId}?t=${m.offset || 0}`, username: m.username,
++ href: videoUrl('vod', m.vodId, m.offset || 0), username: m.username,
});
}
} catch { /* */ }
@@@ -118,7 +119,7 @@ async function heroMoments() {
const hm = JSON.parse(await db.getState('home_hero_moments') || '{}');
return (hm.moments || [])
.filter(m => m && m.vodId && m.thumbnail)
-- .map(m => ({ title: m.title || 'AI Moment', thumbnail: m.thumbnail, href: `/vod/${m.vodId}?t=${m.offset || 0}`, username: m.username }));
++ .map(m => ({ title: m.title || 'AI Moment', thumbnail: m.thumbnail, href: videoUrl('vod', m.vodId, m.offset || 0), username: m.username }));
} catch { return []; }
}
ddiff --git a/server/index.js b/server/index.js
iindex f8590db6..5fce4e64 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -625,10 +625,11 @@ app.use('/css', assets.versionedStatic('/css'), express.static(path.join(assets.
app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.join(assets.PUBLIC_DIR, 'fragments'), { ...assetOpts, fallthrough: false }));
// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public
// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA
--// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.
--// Pastes live on openvibe.community: /p/<slug> 301s there, and it is mounted
--// before SEO so crawlers and people get the same redirect and Community is the one canonical page.
++// HTML routes (home, the vods/clips/pastes lists, channels, recaps); everything else falls through.
++// Pastes live on openvibe.community and recordings and clips on openvibe.video: /p/<slug>, /vod/<id> and
++// /clip/<id> 301 there, mounted before SEO so crawlers and people get the same redirect and one canonical page.
require('./web/paste-handover').register(app);
++require('./web/video-handover').register(app);
try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
// Standalone HTML pages (popout chat, kiosk, legal, OBS overlays…) go through the same asset rewrite
// as the SPA shell, so their script and stylesheet URLs can never drift out of date again.
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 0befc664..62e4ec99 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -785,7 +785,7 @@ async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {
type: 'CLIP_CREATED',
title: 'New Clip',
message: `${clipper.display_name || clipper.username} clipped your stream${title ? `: ${title}` : ''}`,
-- url: `https://openvibe.live/clip/${clipId || ''}`,
++ url: require('../web/video-url').videoUrl('clip', clipId) || 'https://openvibe.video/',
...actorInfo(clipper),
});
} catch { /* non-critical */ }
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex ffc526dd..b5640fc3 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -133,8 +133,8 @@ function llmsTxt() {
{ title: 'Home', url: `${b}/`, note: 'who is live now, recent VODs and clips' },
{ title: 'Content', url: `${b}/content`, note: `VODs, the clips people took, and pastes people wrote. JSON: ${b}/api/content/feed (cursor paging; ?type=vods|clips|pastes, ?sort=new|top)` },
{ title: 'Channel pages', url: `${b}/@<username>`, note: `the channel's videos in pages (${b}/@<username>?page=2)` },
-- { title: 'Recording pages', url: 'https://openvibe.video/v/<id>', note: 'the canonical watch page on OpenVibe.Video, with chapters, captions and chat replay; a moment: https://openvibe.video/v/<id>?t=<seconds> (Live\'s own /vod/<id> names it as canonical)' },
-- { title: 'Clip pages', url: 'https://openvibe.video/c/<id>', note: 'on OpenVibe.Video (Live\'s /clip/<id> names it as canonical)' },
++ { title: 'Recording pages', url: 'https://openvibe.video/v/<id>', note: 'the canonical watch page on OpenVibe.Video, with chapters, captions and chat replay; a moment: https://openvibe.video/v/<id>?t=<seconds> (Live\'s /vod/<id> redirects there)' },
++ { title: 'Clip pages', url: 'https://openvibe.video/c/<id>', note: 'on OpenVibe.Video (Live\'s /clip/<id> redirects there)' },
{ title: 'Pastes', url: 'https://openvibe.community/p/<slug>', note: 'pastes live on OpenVibe.Community (Live\'s /p/<slug> redirects there)' },
{ title: 'Chat', url: `${b}/chat` },
{ title: 'The Rundown', url: `${b}/rundown`, note: 'what streamers talked about, written up: topics, takes and episodes' },
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 90fd072a..69c7bda1 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -5,7 +5,8 @@
*
* The app is a client-rendered SPA (server/index.js serves public/index.html for every route),
* so crawlers/AI scrapers see an empty shell. This middleware intercepts the HTML routes we
-- * care about (home, vods/clips/pastes lists, and vod/clip/paste detail pages), and rewrites the
++ * care about (home, the vods/clips/pastes lists, channels and recaps; VODs, clips and pastes themselves are on
++ * openvibe.video and openvibe.community, linked there), and rewrites the
* <head> with route-specific machine-readable metadata + drops a <noscript> content snapshot so
* no-JS scrapers get real content. Everything is cached so it adds ~no per-request DB cost.
*
@@@ -68,7 +69,7 @@ const _feedPage = async (feed, page = 1) => await _cached(`feed:${feed}:${page}`
const _feedList = async (feed) => (await _feedPage(feed, 1)).items;
const { isPrivate } = require('../media-proxy/access');
const pageStatus = require('../web/page-status');
--const { videoUrl } = require('../web/video-url');
++const { videoUrl, VIDEO_BASE } = require('../web/video-url');
// ── One item (VOD, clip, paste) ──
// Fetched at most once at a time per id and kept a minute (a missing id 30 s, a failure not at
@@@ -139,22 +140,6 @@ async function _listWithin(key, fn, track) {
}
const _pub = (row) => row && !isPrivate(row) && Number(row.is_public) !== 0 && row.is_public !== false;
--// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so
--// descriptions/transcripts keep enriching the crawlable snapshot.
--async function _overlayAiState(row, kind) {
-- try {
-- const st = kind === 'clip' ? await db.getClipAiState(row.id) : await db.getVodAiState(row.id);
-- if (!st) return;
-- if (st.ai_overview_short) {
-- row.ai_overview_short = row.ai_overview_short || st.ai_overview_short;
-- row.ai_overview = row.ai_overview || st.ai_overview || st.ai_overview_short;
-- }
-- if (st.ai_transcript_json && !row.ai_transcript) {
-- try { row.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim(); } catch { /* */ }
-- }
-- } catch { /* */ }
--}
--
// Pastes live on openvibe.community: /p/<slug> is a redirect there (server/web/paste-handover.js,
// mounted before this middleware), and every paste link Live renders, here and in the SPA (which
// reads the base from the ov-pastes-base meta on every shell), points straight at Community, the
@@@ -162,6 +147,9 @@ async function _overlayAiState(row, kind) {
const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
const PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;
const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
++// Recordings and clips live on openvibe.video: the SPA reads the base from the ov-video-base meta on every shell
++// (public/js/app.js videoHref) and /vod/<id>, /clip/<id> 301 there (server/web/video-handover.js).
++const VIDEO_META = `<meta name="ov-video-base" content="${esc(VIDEO_BASE)}">`;
const SITE_NAME = 'OpenVibe.Live';
const DEFAULT_OG_IMAGE = '/og-image.png';
@@@ -207,16 +195,15 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
// Home
if (p === '/') return await _homeMeta();
// List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)
-- if (p === '/vods') return await _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));
-- if (p === '/clips') return await _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
++ if (p === '/vods') return await _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: videoUrl('vod', v.id), name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));
++ if (p === '/clips') return await _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: videoUrl('clip', c.id), name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
if (p === '/content') return await _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', async () => await _feedPage('content', page), { page });
if (p === '/moments') return await _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', async () => await _feedPage('moments', page), { page });
if (p === '/pastes') return await _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));
// Detail pages
let m;
-- if ((m = p.match(/^\/vod\/(\d+)$/))) return await _vodMeta(parseInt(m[1], 10));
-- if ((m = p.match(/^\/clip\/(\d+)$/))) return await _clipMeta(parseInt(m[1], 10));
++ // /vod/<id> and /clip/<id> 301 to OpenVibe.Video before this runs (server/web/video-handover.js).
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return await _pasteMeta(m[1]);
if ((m = p.match(CHANNEL_PATH_RE))) return await _channelMeta(m[1], page);
if ((m = p.match(/^\/recap\/(\d+)$/))) return await _recapMeta(parseInt(m[1], 10));
@@@ -303,7 +290,7 @@ async function _channelMeta(username, page = 1) {
if (vods.length) {
ld.push({
'@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,
-- itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: abs(`/vod/${Number(v.id)}`), name: clean(v.title || 'Stream recording', 110) })),
++ itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: videoUrl('vod', v.id), name: clean(v.title || 'Stream recording', 110) })),
});
}
ld.push(_breadcrumb([{ name: 'Home', url: '/' }, { name, url: basePath }, ...(page > 1 ? [{ name: `Videos, page ${page}`, url: canonicalPath }] : [])]));
@@@ -319,13 +306,13 @@ async function _channelMeta(username, page = 1) {
body += `<section><h2>AI overview</h2><p class="ai-disclosure">Written by OpenVibe's AI from ${esc(name)}'s past streams.</p><p>${esc(clean(ov.overview, 1200))}</p></section>`;
}
body += _itemSection(page > 1 ? `Videos, page ${page}` : 'Videos', vods.map((v) => ({
-- url: `/vod/${Number(v.id)}`, name: v.title || 'Stream recording',
++ url: videoUrl('vod', v.id), name: v.title || 'Stream recording',
meta: [_fmtDur(v.duration_seconds ?? v.duration), _day(v.created_at)].filter(Boolean).join(' · '),
})), vods.length || page > 1 ? null : (track.partial ? null : 'No videos yet.'));
if (vods.length || page > 1) body += _pager(basePath, page, pages, vods.length === CHANNEL_PAGE_SIZE, 'videos');
-- if (clips.length) body += _itemSection('Clips', (await Promise.all(clips.map(async (c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [await _clipperOf(c) ? `clipped by ${await _clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })))));
++ if (clips.length) body += _itemSection('Clips', (await Promise.all(clips.map(async (c) => ({ url: videoUrl('clip', c.id), name: c.title || 'Clip', meta: [await _clipperOf(c) ? `clipped by ${await _clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })))));
if (aiClips.length) {
-- body += _itemSection('AI Moments', aiClips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),
++ body += _itemSection('AI Moments', aiClips.map((c) => ({ url: videoUrl('clip', c.id), name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),
null, `<p class="ai-disclosure">Clips OpenVibe's AI cut from ${esc(name)}'s streams when chat reacted. No one clipped these.</p>`);
}
body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;
@@@ -496,8 +483,8 @@ async function _homeMeta() {
const ownerName = async (row, id) => row.display_name || row.username || _nameOf(await _channelOwner(id));
const liveItems = live.map(s => ({ url: `/@${s.username}`, name: s.title || `${s.display_name || s.username} live`, by: s.display_name || s.username, meta: s.category || 'live' }));
-- const vodItems = (await Promise.all(vods.map(async v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: await ownerName(v, v.user_id), meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }))));
-- const clipItems = (await Promise.all(clips.map(async c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: await ownerName(c, c.user_id), meta: _fmtDur(c.duration_seconds || c.duration) }))));
++ const vodItems = (await Promise.all(vods.map(async v => ({ url: videoUrl('vod', v.id), name: v.title || 'VOD', by: await ownerName(v, v.user_id), meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }))));
++ const clipItems = (await Promise.all(clips.map(async c => ({ url: videoUrl('clip', c.id), name: c.title || 'Clip', by: await ownerName(c, c.user_id), meta: _fmtDur(c.duration_seconds || c.duration) }))));
const pasteItems = pastes.map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text') }));
const statLine = stats ? `<p>${SITE_NAME} hosts ${stats.streamers || 0} streamers, ${stats.vods || 0} VODs, ${stats.clips || 0} clips, ${stats.pastes || 0} pastes and ${stats.chatMessages || 0} chat messages.</p>` : '';
@@@ -614,7 +601,7 @@ async function _channelOwner(userId) {
const _nameOf = (u) => (u ? (u.display_name || u.username) : null);
/**
-- * `/vod/<id>?t=<s>` for the moment an item came from, or null when the VOD is unknown, gone or
++ * The recording at the moment an item came from (its OpenVibe.Video page), or null when the VOD is unknown, gone or
* not public (a canonical must never point at a page that answers 404 to the crawler).
*/
async function _sourceMoment(vodId, seconds) {
@@@ -626,146 +613,6 @@ async function _sourceMoment(vodId, seconds) {
return { path: videoUrl('vod', vodId, t), vod: v };
}
--async function _vodMeta(id) {
-- const v = await _vodGet(id);
-- // Private: fall through exactly like an unknown id. This HTML is cached per id and shared by
-- // every visitor, so it never carries a private VOD's title, overview or transcript.
-- if (!v || isPrivate(v)) return null;
-- if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;
-- await _overlayAiState(v, 'vod');
-- const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');
-- const owner = await _channelOwner(v.user_id);
-- const author = v.display_name || v.username || _nameOf(owner);
-- const title = clean(v.title || `${author ? author + "'s " : ''}stream VOD`, 80);
-- const desc = clean(v.ai_overview_short || v.ai_overview || v.description || `Recorded live stream${author ? ' by ' + author : ''} on ${SITE_NAME}.`, 200);
-- const image = v.thumbnail_url ? media.publicUrl(v.thumbnail_url) : media.thumbUrl(`vod-${id}`);
-- const canonicalPath = `/vod/${id}`;
--
-- // The clips of this VOD: people's, then the AI's. Every one is also a moment of this video
-- // (schema.org Clip in hasPart), so the source carries its moments (roadmap 33.8).
-- const track = { partial: false };
-- const cr = await _listWithin(`vc:${id}`, async () => await media.listClips({ vod_id: id, limit: 30 }, { timeoutMs: 5000 }), track);
-- const all = ((cr && cr.clips) || []).filter((c) => _pub(c) && (!c.status || c.status === 'ready'));
-- const people = all.filter((c) => !isAiClip(c));
-- const ai = all.filter(isAiClip);
-- const at = (c) => Math.max(0, Math.floor(Number(c.start_time) || 0));
-- const parts = all.filter((c) => Number.isFinite(Number(c.start_time))).slice(0, 20).map((c) => ({
-- '@type': 'Clip', name: clean(c.title || (isAiClip(c) ? 'AI clip' : 'Clip'), 110),
-- startOffset: at(c), endOffset: Number.isFinite(Number(c.end_time)) ? Math.max(at(c) + 1, Math.ceil(Number(c.end_time))) : undefined,
-- url: abs(`/vod/${id}?t=${at(c)}`),
-- }));
-- const vo = {
-- ...seo.jsonLd.video({
-- name: title, url: abs(canonicalPath), description: desc, thumbnailUrl: [image],
-- uploadDate: isoDate(v.created_at) || undefined, duration: iso8601Duration(v.duration_seconds),
-- contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),
-- }),
-- interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(v.view_count) || 0 },
-- author: _authorLd(author), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
-- hasPart: parts.length ? parts : undefined,
-- };
-- const moment = (c) => `/vod/${id}?t=${at(c)}`;
-- let extraHtml = owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(author || owner.username)}'s channel</a></p>` : '';
-- extraHtml += _itemSection('Clips from this stream', (await Promise.all(people.map(async (c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [await _clipperOf(c) ? `clipped by ${await _clipperOf(c)}` : null, `at ${_fmtDur(at(c)) || '0:00'}`].filter(Boolean).join(' · ') })))));
-- if (ai.length) {
-- extraHtml += _itemSection('AI Moments from this stream', ai.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: `AI clip · at ${_fmtDur(at(c)) || '0:00'}` })),
-- null, '<p class="ai-disclosure">Cut automatically by OpenVibe\'s auto-clip workflow. No one clipped these.</p>');
-- extraHtml += `<p>${ai.slice(0, 6).map((c) => `<a href="${esc(abs(moment(c)))}">Jump to ${esc(_fmtDur(at(c)) || '0:00')}</a>`).join(' · ')}</p>`;
-- }
-- const snapshot = _detailSnapshot({
-- title, byline: author ? `Streamed by ${author}` : null, desc, extraHtml,
-- overview: v.ai_overview, transcript: v.ai_transcript, canonicalPath, watchLabel: 'Watch this VOD',
-- });
-- return {
-- title: `${title}${author ? ' — ' + author : ''} | ${SITE_NAME}`, description: desc,
-- // A public, finished recording's canonical page is on OpenVibe.Video; this page stays its og:url.
-- ...(indexable && !v.is_recording ? { canonicalPath: videoUrl('vod', id), ogUrlPath: canonicalPath } : { canonicalPath }),
-- image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
-- video: { duration: Math.floor(Number(v.duration_seconds) || 0) },
-- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(owner ? [{ name: author || owner.username, url: `/@${owner.username}` }] : [{ name: 'VODs', url: '/vods' }]), { name: title, url: canonicalPath }])],
-- snapshot,
-- cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined,
-- };
--}
--
--async function _clipMeta(id) {
-- const c = await _clipGet(id);
-- if (!c || isPrivate(c)) return null; // private: same as unknown (see _vodMeta)
-- if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;
-- await _overlayAiState(c, 'clip');
-- if (isAiClip(c)) return await _aiClipMeta(c, id);
-- const indexable = Number(c.is_public) === 1 && (!c.visibility || c.visibility === 'public');
-- // Media stores ids only: the clipper's name comes from Live's accounts.
-- const creator = c.display_name || c.username || _nameOf(await _channelOwner(c.user_id));
-- const title = clean(c.title || 'Clip', 80);
-- const desc = clean(c.ai_overview_short || c.ai_overview || c.description || `A clip from a live stream on ${SITE_NAME}${creator ? ', clipped by ' + creator : ''}.`, 200);
-- const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);
-- const canonicalPath = `/clip/${id}`;
-- const vo = {
-- ...seo.jsonLd.video({
-- name: title, url: abs(canonicalPath), description: desc, thumbnailUrl: [image],
-- uploadDate: isoDate(c.created_at) || undefined, duration: iso8601Duration(c.duration_seconds),
-- contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),
-- }),
-- interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(c.view_count) || 0 },
-- author: _authorLd(creator), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
-- };
-- const channel = await _channelOwner(c.channel_user_id);
-- const source = await _sourceMoment(c.vod_id, c.start_time);
-- const sourceHtml = (source || channel) ? `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch it in the full stream</a>` : ''}${source && channel ? ' · ' : ''}${channel ? `<a href="${esc(abs(`/@${channel.username}`))}">${esc(_nameOf(channel))}'s channel</a>` : ''}</p>` : '';
-- const snapshot = _detailSnapshot({
-- title, byline: creator ? `Clipped by ${creator}${channel && channel.id !== Number(c.user_id) ? ` from ${_nameOf(channel)}'s stream` : ''}` : null, desc,
-- extraHtml: sourceHtml,
-- overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath, watchLabel: 'Watch this clip',
-- });
-- return {
-- title: `${title} — clip | ${SITE_NAME}`, description: desc,
-- ...(indexable ? { canonicalPath: videoUrl('clip', id), ogUrlPath: canonicalPath } : { canonicalPath }),
-- image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
-- video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
-- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],
-- snapshot,
-- };
--}
--
--/** An auto-clip: "AI clip · from <streamer>'s stream", canonical to the VOD at the moment. */
--async function _aiClipMeta(c, id) {
-- const owner = await _channelOwner(c.channel_user_id != null ? c.channel_user_id : c.user_id);
-- const streamer = _nameOf(owner);
-- const from = streamer ? `${streamer}'s stream` : 'a live stream';
-- const title = clean(c.title || 'AI clip', 80);
-- const source = await _sourceMoment(c.vod_id, c.start_time);
-- const desc = clean(`AI-generated clip from ${from} on ${SITE_NAME}. ${c.ai_overview_short || c.ai_overview || c.description || ''}`, 200);
-- const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);
-- const selfPath = `/clip/${id}`;
-- const vo = {
-- ...seo.jsonLd.video({
-- name: title, url: abs(selfPath), description: desc, thumbnailUrl: [image],
-- uploadDate: isoDate(c.created_at) || undefined, duration: iso8601Duration(c.duration_seconds),
-- contentUrl: abs(selfPath), embedUrl: abs(selfPath),
-- }),
-- // No author or creator: a workflow cut this, not a person.
-- keywords: AI_KEYWORDS,
-- isBasedOn: source ? abs(source.path) : undefined,
-- publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
-- };
-- const sourceLink = source
-- ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`
-- : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');
-- const snapshot = _detailSnapshot({
-- title, byline: `AI clip · from ${from}`, desc,
-- extraHtml: `<p class="ai-disclosure">Cut automatically by OpenVibe's auto-clip workflow when chat reacted. No person clipped it.</p>${sourceLink}`,
-- overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath: selfPath, watchLabel: 'Watch this AI clip',
-- });
-- return {
-- title: `${title} — AI clip from ${from} | ${SITE_NAME}`, description: desc,
-- canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'video.other', robots: AI_ROBOTS,
-- video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
-- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],
-- snapshot,
-- };
--}
--
async function _pasteMeta(slug) {
const p = await _pasteGet(slug);
if (!p) return null;
@@@ -906,7 +753,7 @@ function _headBlock(meta) {
});
// og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.
if (meta.ogUrlPath) tags = tags.replace(`<meta property="og:url" content="${esc(canonical)}">`, `<meta property="og:url" content="${esc(abs(meta.ogUrlPath))}">`);
-- const parts = [PASTES_META, ...tags.split('\n')];
++ const parts = [PASTES_META, VIDEO_META, ...tags.split('\n')];
if (meta.video && meta.video.duration) {
parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);
parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);
@@@ -983,7 +830,7 @@ async function shellHtml(urlPath, status) {
let html = _base();
if (!html) return null;
const p = String(urlPath || '/');
-- html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}</head>`);
++ html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}${VIDEO_META}</head>`);
if (p === '/' && status !== 404) return html;
const headEnd = html.search(/<\/head>/i);
if (headEnd === -1) return html;
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex 874205cb..fb1c8ddd 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -20,9 +20,9 @@
* /dashboard/… /broadcast/… /chat/… /rundown/… any depth: these pages route sub-paths themselves (/arena/… redirects)
* /@user /@user/<slot> a user that exists; a renamed one's old name
* answers 301 → /@current (never a bare /name)
-- * /vod/:id /clip/:id a Media item this visitor may see — a private
-- * one is missing to everyone but its owners and
-- * staff (server/media-proxy/access.js)
++ * /vod/:id /clip/:id a numeric id 301s to OpenVibe.Video before this runs
++ * (server/web/video-handover.js); anything else is
++ * answered here (a 404)
* /p/:slug a paste that exists
* /recap/:streamId /stream/:id a stream that exists
*
ddiff --git a/server/web/video-handover.js b/server/web/video-handover.js
nnew file mode 100644
iindex 00000000..366056c2
---- /dev/null
++++ b/server/web/video-handover.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * Stream recordings (VODs) and clips are watched on openvibe.video: the old Live watch URLs keep working as a permanent
++ * redirect, so links in chat, search results, embeds and bookmarks survive, and a `?t=` start time goes along. Every VOD
++ * and clip link Live renders already points at OpenVibe.Video (server/web/video-url.js, public/js/app.js videoHref), so
++ * this only answers old links.
++ *
++ * Mounted BEFORE the SEO middleware (server/seo/pages.js), like the paste handover, so people and crawlers get the same
++ * 301 and openvibe.video is the one canonical home. OpenVibe.Video reads the items from Live's API and owns their pages:
++ * the player, AI chapters and captions, the chat replay, comments, clipping, and the owner's visibility and delete.
++ *
++ * /vods and /clips stay on Live: they are the Content feed with a filter (public/js/content-feed.js), linking out.
++ */
++const { videoUrl } = require('./video-url');
++
++const ID_RE = /^\d{1,15}$/;
++/** `?t=` as seconds: 95, or 1:35, or 1:01:35. */
++function secondsOf(t) {
++ if (typeof t !== 'string') return null;
++ if (/^\d{1,6}$/.test(t)) return Number(t);
++ const m = /^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(t);
++ return m ? Number(m[1] || 0) * 3600 + Number(m[2]) * 60 + Number(m[3]) : null;
++}
++
++/** Mount the /vod/:id and /clip/:id redirects to OpenVibe.Video (anything not an id falls through to Live's 404). */
++function register(app) {
++ for (const [prefix, kind] of [['vod', 'vod'], ['clip', 'clip']]) {
++ app.get([`/${prefix}/:id`, `/${prefix}/:id/`], (req, res, next) => {
++ const to = ID_RE.test(req.params.id) ? videoUrl(kind, req.params.id, secondsOf(req.query && req.query.t)) : null;
++ return to ? res.redirect(301, to) : next();
++ });
++ }
++}
++
++module.exports = { register, secondsOf };
ddiff --git a/test/clip-attribution.test.js b/test/clip-attribution.test.js
iindex c03ef57a..28266125 100644
---- a/test/clip-attribution.test.js
++++ b/test/clip-attribution.test.js
@@@ -9,9 +9,8 @@
* Moments (?auto_generated=1), each labelled;
* - the channel page lists people's clips (by and of the streamer) apart from its AI clips, which
* come back as their own labelled list with a total; "Clips Taken" asks for people's clips;
-- * - the clip page's attribution line says "AI clip · from <streamer>'s stream" for an auto-clip,
-- * and "Clipped by <clipper>" only for a person's clip (public/js/app-media.js, run against a
-- * small stand-in DOM).
++ * - (the clip page and its attribution line are OpenVibe.Video's since the handover,
++ * server/web/video-handover.js).
*
* node test/clip-attribution.test.js
*/
@@@ -20,7 +19,6 @@ const assert = require('assert');
const fs = require('fs');
const path = require('path');
const http = require('http');
--const vm = require('vm');
process.env.NODE_ENV = 'test';
const quiet = console.log;
@@@ -38,27 +36,6 @@ const CLIPS = [
const asked = [];
let honour = true;
--// ── A small stand-in DOM, enough for _renderClipAttribution ──
--class El {
-- constructor(tag) { this.tagName = tag; this.children = []; this.className = ''; this.attrs = {}; this.listeners = {}; this.href = ''; this.classList = { toggle: (c, on) => { const set = new Set(this.className.split(/\s+/).filter(Boolean)); if (on) set.add(c); else set.delete(c); this.className = [...set].join(' '); } }; }
-- set textContent(v) { this.children = v ? [String(v)] : []; }
-- get textContent() { return this.children.map((c) => (typeof c === 'string' ? c : c.textContent)).join(''); }
-- append(...nodes) { this.children.push(...nodes); }
-- setAttribute(k, v) { this.attrs[k] = v; }
-- addEventListener(t, fn) { this.listeners[t] = fn; }
-- find(tag) { for (const c of this.children) { if (typeof c === 'string') continue; if (c.tagName === tag) return c; const f = c.find(tag); if (f) return f; } return null; }
--}
--function loadAttribution() {
-- const src = fs.readFileSync(path.join(__dirname, '../public/js/app-media.js'), 'utf8');
-- const start = src.indexOf('function _renderClipAttribution(');
-- assert.ok(start > 0, 'app-media.js defines _renderClipAttribution');
-- const end = src.indexOf('\n}\n', start) + 3;
-- const ctx = { document: { createElement: (t) => new El(t) }, channelPath: (u) => `/@${u}`, handleLinkClick: () => false };
-- vm.createContext(ctx);
-- vm.runInContext(`${src.slice(start, end)}\nthis._renderClipAttribution = _renderClipAttribution;`, ctx);
-- return ctx._renderClipAttribution;
--}
--
const ids = (rows) => (rows || []).map((c) => c.id).sort();
let failures = 0;
@@@ -188,22 +165,6 @@ async function check(name, fn) {
assert.ok(!r.json.clips.some((c) => c.auto_generated), 'no AI clip');
});
-- await check('the clip page says "AI clip · from Alice\'s stream" for an auto-clip, never "Clipped by"', () => {
-- const render = loadAttribution();
-- const el = new El('div');
-- render(el, { auto_generated: true, username: 'alice', display_name: 'Alice', source_streamer_username: 'alice', source_streamer_display_name: 'Alice' });
-- const text = el.textContent;
-- assert.ok(text.startsWith(" AI clip · from Alice's stream"), text);
-- assert.ok(!/Clipped by/.test(text), text);
-- assert.ok(/No one clipped it/.test(text));
-- assert.strictEqual(el.find('a').href, '/@alice', 'links the channel');
-- assert.ok(/clp-ai-attribution/.test(el.className));
-- const person = new El('div');
-- render(person, { auto_generated: false, username: 'bob', display_name: 'Bob' });
-- assert.strictEqual(person.textContent, ' Clipped by Bob');
-- assert.ok(!/clp-ai-attribution/.test(person.className));
-- });
--
server.close();
quiet(failures ? `\n${failures} check(s) failed` : '\nclip attribution: all checks passed');
process.exit(failures ? 1 : 0);
ddiff --git a/test/clip-source-card.test.js b/test/clip-source-card.test.js
ddeleted file mode 100644
iindex c9d91828..00000000
---- a/test/clip-source-card.test.js
++++ /dev/null
@@@ -1,42 +0,0 @@
--/**
-- * The clip page's "From this stream" card (public/js/context-cards.js) asks /api/vods/:id/context
-- * only when the source VOD can answer: a clip whose VOD is gone or hidden (vod_visible === false
-- * from GET /api/clips/:id) makes no request, so the page logs no 404 (browser check, /clip/369).
-- * An older answer without vod_visible still asks, as before.
-- *
-- * node test/clip-source-card.test.js
-- */
--'use strict';
--const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
--const vm = require('vm');
--
--const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'context-cards.js'), 'utf8');
--
--(async () => {
-- const calls = [];
-- const host = { innerHTML: '', style: {}, classList: { add() {} } };
-- const ctx = {
-- window: {},
-- document: { getElementById: (id) => (id === 'clp-stream-source' ? host : null) },
-- api: async (p) => { calls.push(p); return { vod: { id: 7, duration_seconds: 100, visibility: 'public' }, clips: [] }; },
-- esc: (s) => String(s), timeAgo: () => '', handleLinkClick: () => false,
-- };
-- ctx.window = ctx;
-- vm.createContext(ctx);
-- vm.runInContext(src, ctx);
--
-- await ctx.renderClipSourceCard({ id: 1, vod_id: 5541, vod_visible: false, vod_available: false });
-- assert.deepStrictEqual(calls, [], 'a missing source VOD is not asked for');
-- assert.strictEqual(host.innerHTML, '', 'the plain text fallback stays');
--
-- await ctx.renderClipSourceCard({ id: 1, vod_id: 7, vod_visible: true, vod_available: true, start_time: 10, end_time: 20 });
-- assert.deepStrictEqual(calls, ['/vods/7/context']);
-- assert.match(host.innerHTML, /From this stream/);
--
-- await ctx.renderClipSourceCard({ id: 1, vod_id: 8 });
-- assert.deepStrictEqual(calls, ['/vods/7/context', '/vods/8/context'], 'no vod_visible (older server): asks as before');
--
-- console.log('clip source card: no context request for a missing source VOD');
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/css-syntax.test.js b/test/css-syntax.test.js
nnew file mode 100644
iindex 00000000..e4365522
---- /dev/null
++++ b/test/css-syntax.test.js
@@@ -0,0 +1,29 @@
++/**
++ * Every stylesheet Live serves parses as CSS (PostCSS, which sanitize-html already brings in). A stray `}` is not
++ * harmless: in a browser it turns the next rule's selector into garbage, so that rule is dropped without a word
++ * (style.css had an orphaned keyframe step that did exactly this, and home.css a trailing brace, until 2026-10-16).
++ *
++ * node test/css-syntax.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const postcss = require('postcss');
++
++const root = path.join(__dirname, '..', 'public');
++const files = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const f = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'vendor') walk(f); continue; }
++ if (e.name.endsWith('.css') && !e.name.endsWith('.min.css')) files.push(f);
++ }
++})(root);
++assert.ok(files.length >= 10, `found the stylesheets (${files.length})`);
++const errors = [];
++for (const f of files) {
++ try { postcss.parse(fs.readFileSync(f, 'utf8'), { from: f }); } catch (e) { errors.push(`${path.relative(root, f)}:${e.line}:${e.column} ${e.reason}`); }
++}
++assert.deepStrictEqual(errors, [], 'stylesheets with syntax errors');
++console.log(`css syntax: ${files.length} stylesheets parse`);
ddiff --git a/test/features-registry.test.js b/test/features-registry.test.js
iindex 67915f1c..acf77ac8 100644
---- a/test/features-registry.test.js
++++ b/test/features-registry.test.js
@@@ -74,7 +74,7 @@ ok(`${reg.routes.length} routes reference known features`);
// Server rendering.
const assets = require('../server/web/assets');
const doc = assets.document('index.html').html;
--const cases = [['/', ['/js/app-home.js', '/css/features/home.css'], null], ['/broadcast', ['/js/broadcast.js', '/css/features/broadcast.css'], 'broadcast'], ['/@someone', ['/js/app-channel.js', '/js/stream-player.js'], 'channel'], ['/vod/12', ['/js/app-media.js'], 'vod-player'], ['/documentation', ['/js/app-docs.js'], 'documentation']];
++const cases = [['/', ['/js/app-home.js', '/css/features/home.css'], null], ['/broadcast', ['/js/broadcast.js', '/css/features/broadcast.css'], 'broadcast'], ['/@someone', ['/js/app-channel.js', '/js/stream-player.js'], 'channel'], ['/documentation', ['/js/app-docs.js'], 'documentation']];
for (const [url, expectAssets, frag] of cases) {
const html = assets.renderRoute(doc, url);
for (const a of expectAssets) assert.ok(html.includes(a.endsWith('.css') ? `<link rel="stylesheet" href="${a}?v=` : `<script src="${a}?v=`), `${url} should include ${a}`);
ddiff --git a/test/first-paint-stability.test.js b/test/first-paint-stability.test.js
iindex 7b616d7b..7a2bc5b0 100644
---- a/test/first-paint-stability.test.js
++++ b/test/first-paint-stability.test.js
@@@ -19,7 +19,7 @@ const doc = assets.document('index.html').html;
const active = (html) => [...html.matchAll(/<section id="(page-[a-z-]+)" class="page active"/g)].map((m) => m[1]);
assert.deepStrictEqual(active(doc), ['page-home'], 'the shell itself shows home');
for (const [url, page] of [
-- ['/', 'page-home'], ['/@someone', 'page-channel'], ['/vod/12', 'page-vod-player'], ['/clip/7', 'page-clip-player'],
++ ['/', 'page-home'], ['/@someone', 'page-channel'],
['/chat', 'page-chat'], ['/content', 'page-content'], ['/dashboard', 'page-dashboard'],
]) {
assert.deepStrictEqual(active(assets.renderRoute(doc, url)), [page], `${url}: only ${page} is active`);
@@@ -43,21 +43,9 @@ assert.ok(chCss.indexOf('#page-channel.page:not(.ch-ready)') > chCss.indexOf('#p
assert.ok(chCss.includes('#page-channel:not(.ch-data) :is(#ch-offline-header, #ch-offline-screen) { visibility: hidden; }'), 'the offline header and screen are invisible (still laid out) until the data: social links add rows no outline can reserve');
ok('the channel holds its tabs and height until its data and offline blocks are in');
--// ── VOD and clip pages ──
--const mJs = read('public/js/app-media.js');
--const mCss = read('public/css/features/media.css');
--assert.ok(/function _mediaReady\(pageId, ready = true\)/.test(mJs) && /function _mediaHasData\(pageId\)/.test(mJs), 'one helper for both pages');
--for (const [id, block] of [['page-vod-player', 'blocks.push(loadVodAiTimeline(v.id));'], ['page-clip-player', 'blocks.push(renderClipSourceCard(cl));']]) {
-- assert.ok(mJs.includes(`_mediaReady('${id}', false);`) && mJs.includes(`_mediaHasData('${id}');`) && mJs.includes(`Promise.allSettled(blocks).then(() => _mediaReady('${id}'));`), `${id}: pending → data → ready`);
-- assert.ok(mJs.includes(block), `${id}: waits for its own blocks`);
--}
--assert.ok(mJs.includes('blocks.push(renderVodContextStrip(v.id));'), 'the VOD also waits for the activity strip');
--assert.ok(mCss.includes('#page-vod-player:not(.mp-ready) :is(#vp-ai-timeline, #vp-context, .vod-player-layout > .section-header, #vp-clips-grid, #vp-comments) { display: none; }'), 'VOD parts held back');
--assert.ok(mCss.includes('#page-clip-player:not(.mp-ready) #clp-comments { display: none; }'), 'clip comments held back');
--const style = read('public/css/style.css');
--const player = /\.vod-player-container \{([^}]*)\}/.exec(style);
--assert.ok(player && /aspect-ratio: 16 \/ 9;/.test(player[1]) && /max-height: 70vh;/.test(player[1]), 'the player keeps a 16:9 box (capped like the video) before the video knows its size');
--ok('VOD and clip pages keep the player box and hold back the late parts');
++// VOD and clip pages are OpenVibe.Video's since the handover (server/web/video-handover.js): nothing of theirs is here.
++assert.ok(!fs.existsSync(path.join(ROOT, 'public/js/app-media.js')) && !fs.existsSync(path.join(ROOT, 'public/css/features/media.css')), 'the old player is gone');
++ok('VOD and clip pages live on OpenVibe.Video');
// ── Home hero and early widgets ──
const index = read('public/index.html');
@@@ -83,9 +71,6 @@ const scriptingBlock = (css, rule) => {
for (const rule of ['#page-channel:not(.ch-ready) .channel-below-fold { display: none; }', '#page-channel:not(.ch-data) :is(#ch-offline-header, #ch-offline-screen)', '#page-channel.page:not(.ch-ready) { min-height: 100vh; }']) {
assert.ok(scriptingBlock(chCss, rule), `channel.css: "${rule}" only with scripting`);
}
--for (const rule of ['#page-vod-player:not(.mp-ready)', '#page-clip-player:not(.mp-ready) #clp-comments', ':is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-avatar']) {
-- assert.ok(scriptingBlock(mCss, rule), `media.css: "${rule}" only with scripting`);
--}
const noscriptText = (html) => ((/<noscript>([\s\S]*?)<\/noscript>/.exec(html) || [])[1] || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
for (const frag of ['chat', 'search']) {
const text = noscriptText(read(`public/fragments/${frag}.html`));
ddiff --git a/test/media-privacy.test.js b/test/media-privacy.test.js
iindex 0fca50d3..ccff6abb 100644
---- a/test/media-privacy.test.js
++++ b/test/media-privacy.test.js
@@@ -301,14 +301,9 @@ async function check(name, fn) {
assert.ok(!/secret/.test(r.text));
});
-- await check('server-rendered /vod/:id and /clip/:id: private is the unknown-id fall-through', async () => {
++ await check('/vod/:id and /clip/:id are OpenVibe.Video\'s pages: Live renders none of them, private or public', async () => {
const seo = require('../server/seo');
-- assert.strictEqual(await seo._pageMeta('/vod/101'), null);
-- assert.strictEqual(await seo._pageMeta('/vod/102'), null);
-- assert.strictEqual(await seo._pageMeta('/clip/201'), null);
-- assert.strictEqual(await seo._pageMeta('/vod/999'), null);
-- assert.ok(await seo._pageMeta('/vod/100'), 'public VODs still render');
-- assert.ok(await seo._pageMeta('/clip/200'), 'public clips still render');
++ for (const p of ['/vod/101', '/vod/102', '/clip/201', '/vod/999', '/vod/100', '/clip/200']) assert.strictEqual(await seo._pageMeta(p), null, p);
});
await check('recently-ended streams: only a public VOD from Media rides along', async () => {
ddiff --git a/test/player-a11y.test.js b/test/player-a11y.test.js
iindex 87ff84d5..9bc7b4f0 100644
---- a/test/player-a11y.test.js
++++ b/test/player-a11y.test.js
@@@ -8,7 +8,7 @@ const fs = require('fs');
const path = require('path');
const pub = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', f), 'utf8');
--for (const f of ['fragments/vod-player.html', 'fragments/clip-player.html', 'fragments/channel.html']) {
++for (const f of ['fragments/channel.html']) { // the VOD and clip players are OpenVibe.Video's
const html = pub(f);
const ranges = html.match(/<input[^>]*type="range"[^>]*>/g) || [];
assert.ok(ranges.length, `${f} has a volume slider`);
ddiff --git a/test/seo-moments.test.js b/test/seo-moments.test.js
iindex 5b66213f..5cb0d2b6 100644
---- a/test/seo-moments.test.js
++++ b/test/seo-moments.test.js
@@@ -2,11 +2,11 @@
* AI Moments pages (server/seo/pages.js, roadmap §33.4 and §33.8): what the AI made from a stream is
* labelled as AI-made, credited to no person, kept out of search and pointed at its source.
*
-- * - An auto-clip (Media auto_generated), an AI moment paste (Community origin 'ai') and an
-- * AI-written after-show recap render noindex,follow, with the canonical on the source VOD at
-- * the moment (/vod/<id>?t=<s>) when that VOD is known and public, else on themselves.
++ * - An AI moment paste (Community origin 'ai') and an AI-written after-show recap render
++ * noindex,follow, with the canonical on the source VOD at the moment (its OpenVibe.Video page)
++ * when that VOD is known and public, else on themselves. Clips and VODs are OpenVibe.Video's.
* - Their structured data names no Person and no author; it says AI-generated and links the source.
-- * - People's clips and pastes keep index,follow, their own canonical and their author.
++ * - People's pastes keep index,follow, their own canonical and their author.
* - The sitemap lists people's clips only, even when the upstream ignores the filter.
*
* Media and Community are stubbed in-process; the database is a temp file.
@@@ -102,47 +102,9 @@ async function check(name, fn) {
console.log('AI Moments pages');
-- await check('an auto-clip is noindex,follow, canonical to its VOD at the moment, labelled AI', async () => {
-- const m = await seo._pageMeta('/clip/20');
-- assert.strictEqual(m.robots, 'noindex,follow');
-- assert.strictEqual(m.canonicalPath, 'https://openvibe.video/v/10?t=125');
-- assert.strictEqual(m.ogUrlPath, '/clip/20', 'a shared link still previews the clip');
-- assert.ok(/AI clip from Alice's stream/.test(m.title), m.title);
-- assert.ok(/^AI-generated clip/.test(m.description), m.description);
-- assert.ok(m.snapshot.includes("AI clip · from Alice&#39;s stream"), 'the byline says what made it');
-- assert.ok(m.snapshot.includes('https://openvibe.video/v/10?t=125'), 'the page links its source, on OpenVibe.Video');
-- assert.ok(!/Clipped by/.test(m.snapshot), 'never "Clipped by" the streamer');
-- assertNoPerson(m, 'auto-clip');
-- const vo = mainLd(m);
-- assert.strictEqual(vo.keywords, 'AI-generated');
-- assert.strictEqual(vo.isBasedOn, 'https://openvibe.video/v/10?t=125');
-- const crumbs = m.jsonLd.find((n) => n['@type'] === 'BreadcrumbList').itemListElement.map((i) => i.name);
-- assert.deepStrictEqual(crumbs.slice(0, 2), ['Home', 'AI Moments']);
-- });
--
-- await check('the rendered page carries it: robots, canonical and og:url', async () => {
-- const html = seo.render(await seo._pageMeta('/clip/20'), '/clip/20');
-- assert.ok(html.includes('<meta name="robots" content="noindex,follow">'));
-- assert.ok(html.includes('<link rel="canonical" href="https://openvibe.video/v/10?t=125">'));
-- assert.ok(html.includes('<meta property="og:url" content="https://openvibe.live/clip/20">'));
-- assert.strictEqual((html.match(/<link rel="canonical"/g) || []).length, 1, 'one canonical');
-- assert.strictEqual((html.match(/<meta name="robots"/g) || []).length, 1, 'one robots tag');
-- });
--
-- await check('an auto-clip whose VOD is private keeps its own canonical (never a 404 target)', async () => {
-- const m = await seo._pageMeta('/clip/22');
-- assert.strictEqual(m.robots, 'noindex,follow');
-- assert.strictEqual(m.canonicalPath, '/clip/22');
-- assertNoPerson(m, 'auto-clip of a private VOD');
-- });
--
-- await check("a person's clip stays indexable, self-canonical, credited to the clipper", async () => {
-- const m = await seo._pageMeta('/clip/21');
-- assert.strictEqual(m.robots, 'index,follow');
-- assert.strictEqual(m.canonicalPath, 'https://openvibe.video/c/21', 'canonical on OpenVibe.Video');
-- assert.strictEqual(m.ogUrlPath, '/clip/21', 'this page stays its og:url');
-- assert.deepStrictEqual(mainLd(m).author, { '@type': 'Person', name: 'Bob' });
-- assert.ok(m.snapshot.includes('Clipped by Bob'));
++ await check('VOD and clip pages are OpenVibe.Video\'s: the renderer has nothing for them (they 301 there first)', async () => {
++ assert.strictEqual(await seo._pageMeta('/clip/20'), null);
++ assert.strictEqual(await seo._pageMeta('/vod/10'), null);
});
await check('an AI moment paste points at the VOD second it came from', async () => {
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex f5c6fd25..7ee52f79 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -6,11 +6,11 @@
* It used to render only when Accept named text/html, so a crawler or tool sending *\/* got the home
* page's title and canonical for /@finditfixit. Checked here, with the SEO middleware and the real
* SPA fallback mounted as server/index.js mounts them (Media stubbed in-process, temp database):
-- * - /@user (a channel row or just an account), /vod/:id and /clip/:id answer the same page for
-- * every Accept header and user agent, with their own head and body;
++ * - /@user (a channel row or just an account) answers the same page for every Accept header and
++ * user agent, with its own head and body (its videos and clips link OpenVibe.Video, where /vod/:id
++ * and /clip/:id 301);
* - a channel's videos page with ?page=N links (rel prev/next), each page self-canonical, and a
* page past the end noindex with the canonical on page 1;
-- * - a VOD lists its clips, people's then the AI's, and carries them as schema.org Clip parts;
* - a 404 is noindex with no canonical and no home metadata; no other shell page claims the home
* page as its canonical;
* - without JavaScript the body is shown (and the empty app shell hidden); with it, the route
@@@ -80,6 +80,7 @@ const express = require('express');
const seo = require('../server/seo');
const pageStatus = require('../server/web/page-status');
const app = express();
++require('../server/web/video-handover').register(app); // before SEO, as server/index.js mounts it
seo.register(app);
app.get('*', pageStatus.spaFallback(async (res, urlPath) => {
const html = await seo.shellHtml(urlPath, res.statusCode);
@@@ -94,7 +95,7 @@ function get(p, headers = {}) {
const req = http.request(base + p, { method: 'GET', headers }, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
-- res.on('end', () => resolve({ status: res.statusCode, body, cache: res.headers['cache-control'] || '' }));
++ res.on('end', () => resolve({ status: res.statusCode, body, cache: res.headers['cache-control'] || '', headers: res.headers }));
});
req.on('error', reject);
req.end();
@@@ -153,8 +154,8 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const b = bodyOf((await get('/@alice')).body);
assert.ok(b.includes('<h1>Alice (@alice)</h1>'), b.slice(0, 300));
assert.ok(b.includes('I stream woodworking.'));
-- for (let id = 100; id < 112; id++) assert.ok(b.includes(`https://openvibe.live/vod/${id}"`), `VOD ${id} listed`);
-- assert.ok(!b.includes('/vod/112"'), 'page 1 holds 12 videos');
++ for (let id = 100; id < 112; id++) assert.ok(b.includes(`https://openvibe.video/v/${id}"`), `VOD ${id} listed (on OpenVibe.Video)`);
++ assert.ok(!b.includes('/v/112"'), 'page 1 holds 12 videos');
assert.ok(b.includes('Build night 100</a> — 1:00:00 · '), 'an hour-long video reads 1:00:00');
assert.ok(!b.includes('SECRET VOD'), 'no private VOD');
assert.ok(b.includes('<a rel="next" href="https://openvibe.live/@alice?page=2">Older videos</a>'), 'next page link');
@@@ -172,12 +173,12 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.deepStrictEqual(canonicalsOf(p2), ['https://openvibe.live/@alice?page=2']);
assert.ok(/page 2/.test(titleOf(p2)), titleOf(p2));
const b2 = bodyOf(p2);
-- assert.ok(b2.includes('/vod/112"') && b2.includes('/vod/123"') && !b2.includes('/vod/111"'));
++ assert.ok(b2.includes('/v/112"') && b2.includes('/v/123"') && !b2.includes('/v/111"'));
assert.ok(b2.includes('<a rel="prev" href="https://openvibe.live/@alice">Newer videos</a>'));
assert.ok(b2.includes('<a rel="next" href="https://openvibe.live/@alice?page=3">'));
assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');
const b3 = bodyOf((await get('/@alice?page=3')).body);
-- assert.ok(b3.includes('/vod/129"') && !b3.includes('rel="next"'), 'the last page has no next link');
++ assert.ok(b3.includes('/v/129"') && !b3.includes('rel="next"'), 'the last page has no next link');
const past = (await get('/@alice?page=9')).body;
assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);
assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);
@@@ -198,7 +199,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
});
await check('a 404 is noindex, with no canonical and none of the home page\'s metadata', async () => {
-- for (const p of ['/@nobody', '/vod/999', '/clip/999', '/no-such-page', `/vod/199`]) {
++ for (const p of ['/@nobody', '/vod/abc', '/clip/x1', '/no-such-page']) {
for (const c of CLIENTS.slice(0, 3)) {
const r = await get(p, c.headers);
assert.strictEqual(r.status, 404, `${p} ${c.name}`);
@@@ -257,43 +258,13 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.deepStrictEqual(canonicalsOf(home), ['https://openvibe.live/']);
});
-- await check('/vod/100: its own head and a body with people\'s clips, then the AI\'s, as Clip parts', async () => {
-- for (const c of CLIENTS) {
-- const r = await get('/vod/100', c.headers);
-- assert.strictEqual(r.status, 200, c.name);
-- assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);
-- assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.video/v/100'], `${c.name}: the canonical page is on OpenVibe.Video`);
-- assert.ok(/^Build night 100 — Alice \| OpenVibe\.Live$/.test(titleOf(r.body)), titleOf(r.body));
-- }
-- const r = await get('/vod/100?t=125');
-- assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.video/v/100'], 'a timestamp link is the same page');
-- const vo = ldOf(r.body).find((n) => n['@type'] === 'VideoObject');
-- assert.deepStrictEqual(vo.author, { '@type': 'Person', name: 'Alice' });
-- assert.deepStrictEqual(vo.hasPart.map((x) => [x['@type'], x.startOffset, x.url]), [
-- ['Clip', 61, 'https://openvibe.live/vod/100?t=61'],
-- ['Clip', 125, 'https://openvibe.live/vod/100?t=125'],
-- ]);
-- const crumbs = ldOf(r.body).find((n) => n['@type'] === 'BreadcrumbList').itemListElement.map((i) => i.name);
-- assert.deepStrictEqual(crumbs, ['Home', 'Alice', 'Build night 100']);
-- const b = bodyOf(r.body);
-- assert.ok(b.includes('Streamed by Alice') && b.includes('https://openvibe.live/@alice'));
-- const people = b.indexOf('<h2>Clips from this stream</h2>'), ai = b.indexOf('<h2>AI Moments from this stream</h2>');
-- assert.ok(people > 0 && ai > people);
-- assert.ok(b.slice(people, ai).includes('clipped by Bob'));
-- assert.ok(b.slice(ai).includes('AI clip · at 2:05'));
-- });
--
-- await check('/clip/200: its own head and a body linking the moment in the full stream', async () => {
-- for (const c of CLIENTS) {
-- const r = await get('/clip/200', c.headers);
-- assert.strictEqual(r.status, 200, c.name);
-- assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);
-- assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.video/c/200'], `${c.name}: the canonical page is on OpenVibe.Video`);
-- assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
++ await check('/vod/:id and /clip/:id answer a 301 to OpenVibe.Video for every client, the moment kept', async () => {
++ for (const c of CLIENTS.slice(0, 3)) {
++ const v = await get('/vod/100?t=125', c.headers);
++ assert.deepStrictEqual([v.status, v.headers.location], [301, 'https://openvibe.video/v/100?t=125'], c.name);
++ const k = await get('/clip/200', c.headers);
++ assert.deepStrictEqual([k.status, k.headers.location], [301, 'https://openvibe.video/c/200'], c.name);
}
-- const b = bodyOf((await get('/clip/200')).body);
-- assert.ok(b.includes("Clipped by Bob from Alice&#39;s stream"), b.slice(0, 300));
-- assert.ok(b.includes('https://openvibe.video/v/100?t=61'), 'the moment, on OpenVibe.Video');
});
await check('without JavaScript the body shows (with the site links) and the empty app shell is hidden', async () => {
ddiff --git a/test/video-handover.test.js b/test/video-handover.test.js
nnew file mode 100644
iindex 00000000..8f956bb3
---- /dev/null
++++ b/test/video-handover.test.js
@@@ -0,0 +1,62 @@
++/**
++ * Recordings and clips are watched on openvibe.video (server/web/video-handover.js): /vod/<id> and /clip/<id> answer a
++ * 301 to the OpenVibe.Video page, with a ?t= start time as seconds (95, 1:35 or 0:01:35); anything that is not an id
++ * falls through to Live's own 404; the SPA shell names the origin in its ov-video-base meta (public/js/app.js videoHref),
++ * and no client code links a /vod/ or /clip/ page any more.
++ *
++ * node test/video-handover.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const express = require('express');
++
++process.env.NODE_ENV = 'test';
++
++(async () => {
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${err.stack}`); } };
++ const app = express();
++ require('../server/web/video-handover').register(app);
++ app.use((req, res) => res.status(404).send('live 404'));
++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++ const get = (p) => new Promise((resolve, reject) => {
++ http.get({ host: '127.0.0.1', port: server.address().port, path: p }, (res) => { res.resume(); resolve({ status: res.statusCode, location: res.headers.location || null }); }).on('error', reject);
++ });
++
++ await check('/vod/<id> and /clip/<id> 301 to OpenVibe.Video, the start time kept as seconds', async () => {
++ assert.deepStrictEqual(await get('/vod/12'), { status: 301, location: 'https://openvibe.video/v/12' });
++ assert.deepStrictEqual(await get('/vod/12/'), { status: 301, location: 'https://openvibe.video/v/12' });
++ assert.deepStrictEqual(await get('/vod/12?t=95'), { status: 301, location: 'https://openvibe.video/v/12?t=95' });
++ assert.deepStrictEqual(await get('/vod/12?t=1:35'), { status: 301, location: 'https://openvibe.video/v/12?t=95' });
++ assert.deepStrictEqual(await get('/vod/12?t=1:01:35'), { status: 301, location: 'https://openvibe.video/v/12?t=3695' });
++ assert.deepStrictEqual(await get('/clip/7'), { status: 301, location: 'https://openvibe.video/c/7' });
++ assert.deepStrictEqual(await get('/vod/12?t=soon'), { status: 301, location: 'https://openvibe.video/v/12' }, 'a junk start time is dropped');
++ });
++
++ await check('what is not an id stays Live\'s 404', async () => {
++ assert.strictEqual((await get('/vod/abc')).status, 404);
++ assert.strictEqual((await get('/vod/0')).status, 404);
++ assert.strictEqual((await get('/clip/1e3')).status, 404);
++ });
++
++ await check('the shell names OpenVibe.Video for the SPA, and no client code links a /vod/ or /clip/ page', async () => {
++ const seo = require('../server/seo/pages');
++ const html = await seo.shellHtml('/@someone', 200);
++ if (html) assert.ok(html.includes('<meta name="ov-video-base" content="https://openvibe.video">'), 'the meta is on the shell');
++ const dir = path.join(__dirname, '..', 'public', 'js');
++ const offenders = [];
++ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.js'))) {
++ const src = fs.readFileSync(path.join(dir, f), 'utf8');
++ src.split('\n').forEach((line, i) => { if (/(?<![\w/])\/(vod|clip)\/\$\{|['"`]\/(vod|clip)\/['"`]? *\+/.test(line) && !/\/api\//.test(line)) offenders.push(`${f}:${i + 1}`); });
++ }
++ assert.deepStrictEqual(offenders, [], 'every watch link goes through videoHref');
++ assert.ok(!fs.existsSync(path.join(dir, 'app-media.js')), 'the old player is gone');
++ });
++
++ server.close();
++ console.log(failures ? `\n${failures} check(s) failed` : '\nvideo handover: all checks passed');
++ process.exit(failures ? 1 : 0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/vod-clip-playback-url.test.js b/test/vod-clip-playback-url.test.js
iindex c47c67e6..62f8a358 100644
---- a/test/vod-clip-playback-url.test.js
++++ b/test/vod-clip-playback-url.test.js
@@@ -3,8 +3,7 @@
*
* The media proxies set playback_url from the Media row: the object's hls_url (Media's timeline playlist) when it
* has one, the byte route (/v/:id, /c/:id) when it does not (old objects, MP4-only). file_url is always the byte
-- * route, for anything that needs a real file. The server-rendered /vod/:id and /clip/:id pages never carry the
-- * playlist (their contentUrl is the page; there is no og:video URL and recordings are not in the sitemap).
++ * route, for anything that needs a real file. (The watch pages are OpenVibe.Video's: /vod/:id and /clip/:id 301 there.)
*
* The real routers run on the test database with Media stubbed in-process.
*
@@@ -120,18 +119,6 @@ async function check(name, fn) {
assert.strictEqual(by[201].file_url, media.clipUrl(201));
});
-- await check('server-rendered /vod/:id and /clip/:id never carry the playlist', async () => {
-- const seo = require('../server/seo');
-- for (const p of ['/vod/100', '/clip/200']) {
-- const meta = await seo._pageMeta(p);
-- assert.ok(meta, `${p} renders`);
-- const s = JSON.stringify(meta);
-- assert.ok(!/\.m3u8/.test(s), `${p} has no .m3u8`);
-- const vo = meta.jsonLd.find((x) => x && /VideoObject/.test(x['@type']));
-- if (vo && vo.contentUrl) assert.ok(!/\.m3u8/.test(vo.contentUrl), `${p} contentUrl is not a playlist`);
-- }
-- });
--
await check('the file routes still redirect to the byte route', async () => {
const v = await new Promise((resolve) => http.get({ port: server.address().port, path: '/api/vods/100/file' }, resolve));
assert.strictEqual(v.statusCode, 302);
ddiff --git a/test/vod-hls-source.test.js b/test/vod-hls-source.test.js
ddeleted file mode 100644
iindex c2055d30..00000000
---- a/test/vod-hls-source.test.js
++++ /dev/null
@@@ -1,118 +0,0 @@
--'use strict';
--// VOD and clip playback source (public/js/app-media.js): a finished VOD or clip with Media's timeline playlist
--// (hls_url) plays HLS, through hls.js or natively; a recording or a VOD without one keeps the file; a failed
--// playlist falls back to the file once, at the same position; the clip preview always cuts from the file.
--const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
--const vm = require('vm');
--
--const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-media.js'), 'utf8');
--const between = (startMark, endMark) => { const a = js.indexOf(startMark); const b = js.indexOf(endMark, a); assert.ok(a >= 0 && b > a, startMark); return js.slice(a, b); };
--const helpers = between('let _vodHls = null;', 'async function loadVodPlayer(');
--
--assert.match(js, /attachVodSource\(video, v\.is_recording \? null : v\.hls_url, `\$\{fileUrl\}\?t=\$\{Date\.now\(\)\}`, routeGen\)/, 'a finished VOD prefers its playlist; a recording keeps the file');
--assert.match(js, /attachVodSource\(video, cl\.hls_url, `\/api\/clips\/\$\{Number\(cl\.id\)\}\/file`, routeGen\)/, 'a clip prefers its playlist; its file is asked by id');
--const appJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
--assert.match(appJs, /stop\('vodDropHls'\);/, 'leaving the page drops the hls.js instance');
--assert.match(js, /preview\.src = video\.dataset\.fileUrl \|\| video\.src;/, 'the clip preview cuts from the file, not a blob: URL');
--assert.match(js, /if \(vodFallBackToFile\(video\)\) return;/, "the clip player's error message waits for the file fallback");
--
--class FakeVideo {
-- constructor(native = false) { this.native = native; this.dataset = {}; this.src = ''; this.currentTime = 0; this.listeners = {}; }
-- canPlayType(t) { return this.native && t === 'application/vnd.apple.mpegurl' ? 'maybe' : ''; }
-- addEventListener(e, f) { (this.listeners[e] = this.listeners[e] || []).push(f); }
-- removeEventListener(e, f) { this.listeners[e] = (this.listeners[e] || []).filter((x) => x !== f); }
-- fire(e) { for (const f of [...(this.listeners[e] || [])]) f(); }
--}
--function sandbox({ hlsSupported = true } = {}) {
-- const made = [];
-- class Hls {
-- static isSupported() { return hlsSupported; }
-- constructor(opts) { this.opts = opts; this.handlers = {}; this.destroyed = false; made.push(this); }
-- on(ev, f) { this.handlers[ev] = f; }
-- loadSource(u) { this.url = u; }
-- attachMedia(v) { this.media = v; }
-- destroy() { this.destroyed = true; }
-- }
-- Hls.Events = { ERROR: 'hlsError' };
-- const route = { gen: 1 };
-- const ov = { gen: () => route.gen, isCurrent: (g) => g === route.gen };
-- const ctx = { Hls, ov, document: { createElement() { throw new Error('the script is already loaded'); }, head: {} } };
-- vm.runInNewContext(`${helpers}; this.attachVodSource = attachVodSource; this.vodFallBackToFile = vodFallBackToFile; this.vodDropHls = vodDropHls;`, ctx);
-- ctx.route = route;
-- return { ctx, made };
--}
--
--(async () => {
-- { // hls.js path
-- const { ctx, made } = sandbox();
-- const v = new FakeVideo();
-- assert.strictEqual(await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_1/master.m3u8', '/api/vods/1/file?t=1'), 'hls');
-- assert.strictEqual(made[0].url, 'https://openvibe.media/o/obj_1/master.m3u8');
-- assert.strictEqual(made[0].media, v);
-- assert.strictEqual(v.dataset.fileUrl, '/api/vods/1/file?t=1');
-- v.currentTime = 42;
-- made[0].handlers.hlsError(null, { fatal: true }); // the playlist fails: the file takes over at the same spot
-- assert.strictEqual(made[0].destroyed, true);
-- assert.strictEqual(v.src, '/api/vods/1/file?t=1');
-- v.fire('loadedmetadata');
-- assert.strictEqual(v.currentTime, 42);
-- assert.strictEqual(ctx.vodFallBackToFile(v), false, 'it falls back once');
-- }
-- { // a non-fatal hls.js error changes nothing
-- const { ctx, made } = sandbox();
-- const v = new FakeVideo();
-- await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_2/master.m3u8', '/api/vods/2/file');
-- made[0].handlers.hlsError(null, { fatal: false });
-- assert.strictEqual(v.src, '');
-- assert.strictEqual(made[0].destroyed, false);
-- }
-- { // native HLS (Safari): the playlist is the src; a media error falls back
-- const { ctx, made } = sandbox();
-- const v = new FakeVideo(true);
-- assert.strictEqual(await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_3/master.m3u8', '/api/vods/3/file'), 'hls');
-- assert.strictEqual(made.length, 0);
-- assert.strictEqual(v.src, 'https://openvibe.media/o/obj_3/master.m3u8');
-- assert.strictEqual(ctx.vodFallBackToFile(v), true);
-- assert.strictEqual(v.src, '/api/vods/3/file');
-- }
-- { // no playlist, or no HLS support: the file
-- const { ctx } = sandbox();
-- const v = new FakeVideo();
-- assert.strictEqual(await ctx.attachVodSource(v, null, '/api/vods/4/file'), 'file');
-- assert.strictEqual(v.src, '/api/vods/4/file');
-- assert.strictEqual(ctx.vodFallBackToFile(v), false);
-- const { ctx: ctx2 } = sandbox({ hlsSupported: false });
-- const w = new FakeVideo();
-- assert.strictEqual(await ctx2.attachVodSource(w, 'https://openvibe.media/o/obj_4/master.m3u8', '/api/vods/5/file'), 'file');
-- assert.strictEqual(w.src, '/api/vods/5/file');
-- }
-- { // switching VODs drops the previous hls.js instance
-- const { ctx, made } = sandbox();
-- const v = new FakeVideo();
-- await ctx.attachVodSource(v, 'https://openvibe.media/o/a/master.m3u8', '/f/a');
-- await ctx.attachVodSource(v, 'https://openvibe.media/o/b/master.m3u8', '/f/b');
-- assert.strictEqual(made[0].destroyed, true);
-- assert.strictEqual(made[1].url, 'https://openvibe.media/o/b/master.m3u8');
-- }
-- { // leaving the page drops the instance; a load that finishes after leaving attaches nothing
-- const { ctx, made } = sandbox();
-- const v = new FakeVideo();
-- await ctx.attachVodSource(v, 'https://openvibe.media/o/x/master.m3u8', '/f/x', 1);
-- ctx.vodDropHls();
-- assert.strictEqual(made[0].destroyed, true, 'teardown destroys the instance');
-- ctx.route.gen = 2; // the person navigated away while a load was in flight
-- const w = new FakeVideo();
-- assert.strictEqual(await ctx.attachVodSource(w, 'https://openvibe.media/o/y/master.m3u8', '/f/y', 1), 'stale');
-- assert.strictEqual(made.length, 1, 'no new hls.js instance for a page that is gone');
-- assert.strictEqual(w.src, '');
-- }
-- // The media proxies keep Media's playlist URL on VOD and clip rows (behaviour: test/vod-clip-playback-url.test.js).
-- for (const f of ['vods.js', 'clips.js']) {
-- const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'media-proxy', f), 'utf8');
-- assert.match(src, /media\.setPlaybackUrls\(/, `${f} keeps hls_url`);
-- }
-- assert.match(fs.readFileSync(path.join(__dirname, '..', 'server', 'media-client.js'), 'utf8'), /row\.hls_url = publicUrl\(row\.hls_url\)/, 'media-client absolutizes hls_url');
-- console.log('vod hls source: all checks passed');
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/vod-tail-finalize.test.js b/test/vod-tail-finalize.test.js
iindex 65eae86f..90c3ef8c 100644
---- a/test/vod-tail-finalize.test.js
++++ b/test/vod-tail-finalize.test.js
@@@ -4,7 +4,7 @@
*
* Recording, storage and cutting are OpenVibe.Media's; this pins Live's side of it: the recorder's
* calls into Media for every ingest type, the live-VOD endpoints the VOD page polls, the playback URL
-- * it builds, and the page's tail/finalise/seek wiring (public/js/app-media.js).
++ * it builds (the watch page itself is OpenVibe.Video's).
*
* The fix it came with: /api/vods/:id/live-info reported `seekable: false` for every recording,
* because it only trusted a `seekable` field Media's rows do not have. The VOD page refreshes its
@@@ -230,23 +230,6 @@ async function check(name, fn) {
assert.ok(!recorder.isRecording(sid));
});
-- await check('the VOD page wires tail, finalise and seek to those endpoints', async () => {
-- const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-media.js'), 'utf8');
-- const player = src.slice(src.indexOf('async function loadVodPlayer('), src.indexOf('// Navigate to streamer on click'));
-- assert.match(player, /const fileUrl = `\/api\/vods\/\$\{Number\(v\.id\)\}\/file`;/, 'playback is by the VOD\'s id, never a file name (C-75)');
-- assert.match(player, /video\.src = `\$\{fileUrl\}\?t=\$\{Date\.now\(\)\}`/, 'and through Live\'s redirect, cache-busted');
-- assert.ok(!/api\/vods\/file\//.test(src), 'no player asks for a file by name');
-- assert.match(player, /api\(`\/vods\/\$\{v\.id\}\/live-info`\)/, 'a recording is polled through live-info');
-- assert.match(player, /if \(!info\.isRecording\) \{[\s\S]{0,400}loadVodPlayer\(v\.id\);/, 'finalisation reloads it as a finished VOD');
-- assert.match(player, /if \(info\.seekable\) \{[\s\S]{0,1400}video\.currentTime = Math\.min\(currentTime, dur\);/, 'the tail refresh keeps the viewer\'s position');
-- assert.match(player, /if \(dur > 2\) video\.currentTime = dur - 1;/, '"Jump to live" goes to the newest second');
-- assert.match(player, /video\.dataset\.serverDuration = \(serverDur > 0 && !v\.is_recording\) \? String\(serverDur\) : '';/, 'the probed duration is trusted only once finished');
-- assert.match(player, /if \(seekTo && seekTo > 0\) \{\s*target = seekTo;/, 'a ?t= deep link seeks the finished VOD');
-- assert.match(src, /if \(vd > 0 && sd > 0\) return \(vd > sd \* 1\.5\) \? sd : vd;/, 'an inflated container duration is clamped to the probed one');
-- const appSrc = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
-- assert.match(appSrc, /loadVodPlayer\(segments\[1\], Number\.isFinite\(_t\) && _t > 0 \? _t : null\)/, '/vod/:id?t= reaches the player');
-- });
--
server.close();
if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
quiet('\nAll VOD tail/finalise/seek checks passed');