openvibers/openvibe.live Commits
Live: VOD and clip pages move to OpenVibe.Video (merge on or after 2026-10-19) (#143)
Changes
@@@ -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 scriptspublic/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetchroutes: 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()@@@ -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 discoverboard (`ch-ready`, at most 2.5 s after the data) the tabs are out of the layout and the page keeps a full viewport, sothe 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, theactivity 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 ` ` instead of `hidden`; the "shipped" line is no longer `hidden`@@@ -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@@@ -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",@@@ -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; }}--}ddeleted file mode 100644@@@ -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; }@@@ -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: 0transform: 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 {@@@ -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": [ddeleted file mode 100644@@@ -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>--ddeleted file mode 100644@@@ -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>--@@@ -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>@@@ -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}`;@@@ -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);}ddeleted file mode 100644@@@ -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'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'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);--}@@@ -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 Liveevent?.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) {@@@ -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 '';}@@@ -130,8 +130,9 @@ function _aChannelLink(u, slot = null) { return typeof channelPath === 'functionfunction _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 = '') {@@@ -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}@@@ -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');@@@ -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>@@@ -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',@@@ -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`;@@@ -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.')}@@@ -19,7 +19,8 @@ const opt = (name, def) => { const i = args.indexOf(name); return i === -1 ? defconst 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]) {@@@ -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.@@@ -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)),@@@ -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 []; }}@@@ -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.@@@ -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 */ }@@@ -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' },@@@ -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 } = {}) {// Homeif (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 pageslet 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;@@@ -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*nnew file mode 100644@@@ -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 };@@@ -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);ddeleted file mode 100644@@@ -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); });nnew file mode 100644@@@ -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`);@@@ -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}`);@@@ -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('#passert.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`));@@@ -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 () => {@@@ -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'sconst html = pub(f);const ranges = html.match(/<input[^>]*type="range"[^>]*>/g) || [];assert.ok(ranges.length, `${f} has a volume slider`);@@@ -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'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 () => {@@@ -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 itseo.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'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 () => {nnew file mode 100644@@@ -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); });@@@ -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);ddeleted file mode 100644@@@ -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); });@@@ -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');
@@ -42,7 +42,7 @@ The SPA is plain JavaScript with no build step. What loads is decided per route:
@@ -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/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
public/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetch
routes: URL pattern → features
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/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(),
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()
route generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks()
@@ -166,7 +166,7 @@ placeholder markup, then the data, and then several parts that load on their own
@@ -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
("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
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.
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
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.
(`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 ` ` instead of `hidden`; the "shipped" line is no longer `hidden`
- **Home hero:** the countdown line ships as ` ` instead of `hidden`; the "shipped" line is no longer `hidden`
@@ -73,14 +73,17 @@ From the dashboard:
@@ -73,14 +73,17 @@ From the dashboard:
(`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once
(`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
- **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
## Playback URLs
@@ -422,9 +422,6 @@
@@ -422,9 +422,6 @@
".vod-ai-timeline-hint i",
".vod-ai-timeline-hint i",
".vod-ai-timeline-title i",
".vod-ai-timeline-title i",
".vod-extra-details .detail-chip i",
".vod-extra-details .detail-chip i",
- ".vod-player-container",
- ".vod-player-info h2",
- ".vod-player-main",
".vod-source-info i",
".vod-source-info i",
".volume-slider",
".volume-slider",
".volume-slider::-webkit-slider-runnable-track",
".volume-slider::-webkit-slider-runnable-track",
@@ -1165,4 +1165,3 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1165,4 +1165,3 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.cta-btn { width: 100%; }
.cta-btn { width: 100%; }
.cta-checks { gap: 4px 12px; font-size: 0.8rem; }
.cta-checks { gap: 4px 12px; font-size: 0.8rem; }
}
}
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,419 +0,0 @@
@@ -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; }
@@ -949,10 +949,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -949,10 +949,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
.brand-text { color: var(--text-primary); }
.brand-text { color: var(--text-primary); }
.brand-text strong { color: var(--accent); }
.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 */
/* Brand hover tooltip */
.nav-brand[data-brand-tooltip]::after {
.nav-brand[data-brand-tooltip]::after {
content: attr(data-brand-tooltip);
content: attr(data-brand-tooltip);
@@ -1549,16 +1545,7 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -1549,16 +1545,7 @@ body.ov-nav-overlay .navbar.ovnav-top {
.ch-paste-meta { font-size: 0.75rem; display: flex; gap: 6px; }
.ch-paste-meta { font-size: 0.75rem; display: flex; gap: 6px; }
/* ── Condensed VOD session group (many restart-split VODs → one expandable card) ── */
/* ── 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; }
.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 {
.stream-card {
display: block;
display: block;
@@ -2966,29 +2953,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2966,29 +2953,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
}
}
/* ── VOD/Clip Custom Player ─────────────────────────────────── */
/* ── 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 */
/* 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 {
@keyframes livePulse {
0%, 100% { opacity: 1; }
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
50% { opacity: 0.3; }
}
}
-.vp-jump-live-btn:hover { background: rgba(255,255,255,0.3); }
/* Live progress bar: red tint for the fill */
/* Live progress bar: red tint for the fill */
@@ -4980,7 +4950,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -4980,7 +4950,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.tab-bar .tab-btn { flex-shrink: 0; }
.tab-bar .tab-btn { flex-shrink: 0; }
/* ─── VOD/Clip Player Mobile ───────────────────────────── */
/* ─── VOD/Clip Player Mobile ───────────────────────────── */
- .vod-player-info h2 { font-size: 1.1rem; }
/* ─── Stream Grid (VODs/Clips list pages) ─────────────── */
/* ─── Stream Grid (VODs/Clips list pages) ─────────────── */
.stream-grid {
.stream-grid {
@@ -5040,7 +5009,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -5040,7 +5009,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.section-header h2 { font-size: 1.1rem; }
.section-header h2 { font-size: 1.1rem; }
/* VOD player tighter on small phones */
/* VOD player tighter on small phones */
- .vod-player-info h2 { font-size: 1rem; }
/* Pastes page header buttons stack */
/* Pastes page header buttons stack */
.page-header { flex-direction: column; align-items: flex-start; }
.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
@@ -5569,28 +5537,16 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
VOD / Clip Player Pages
VOD / Clip Player Pages
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
-.vod-player-info h2 { margin: 0 0 8px; }
-.vod-source-info i { color: var(--accent); }
/* Private VOD notice ─────────────────────────────────────── */
/* Private VOD notice ─────────────────────────────────────── */
/* Enhanced details row ───────────────────────────────────── */
/* Enhanced details row ───────────────────────────────────── */
-.vod-extra-details .detail-chip i { color: var(--accent); font-size: 0.75rem; }
/* VOD Clip Creator Modal ────────────────────────────────────── */
/* 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 {
@keyframes modalSlideUp {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
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 */
/* Preview player */
.clip-preview-wrap {
.clip-preview-wrap {
@@ -5610,20 +5566,6 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
@@ -5610,20 +5566,6 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
}
}
/* Timeline section */
/* 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 {
.clip-handle {
position: absolute; top: 50%; transform: translate(-50%, -50%);
position: absolute; top: 50%; transform: translate(-50%, -50%);
width: 20px; height: 28px; cursor: ew-resize; z-index: 3;
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
@@ -5636,9 +5578,6 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
transform: translateX(-1px); display: none; pointer-events: none;
transform: translateX(-1px); display: none; pointer-events: none;
box-shadow: 0 0 4px rgba(255,255,255,0.5);
box-shadow: 0 0 4px rgba(255,255,255,0.5);
}
}
-.clip-timeline-ticks {
- position: relative; height: 14px; margin-top: 4px;
-}
.clip-tick {
.clip-tick {
position: absolute; font-size: 0.65rem; color: var(--text-muted, #666);
position: absolute; font-size: 0.65rem; color: var(--text-muted, #666);
transform: translateX(-50%); white-space: nowrap;
transform: translateX(-50%); white-space: nowrap;
@@ -5670,16 +5609,6 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
@@ -5670,16 +5609,6 @@ button.uai-tl-more { background: none; border: none; cursor: pointer; padding: 0
}
}
/* Chat Replay Sidebar ─────────────────────────────────────── */
/* 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 Section ────────────────────────────────────────── */
.comments-header h3 {
.comments-header h3 {
@@ -8501,7 +8430,6 @@ body.theme-light .btn-outline:hover {
@@ -8501,7 +8430,6 @@ body.theme-light .btn-outline:hover {
/* Ensure smooth scrolling on mobile for all scroll containers */
/* Ensure smooth scrolling on mobile for all scroll containers */
.chat-messages,
.chat-messages,
-.chat-replay-messages,
.global-chat-messages,
.global-chat-messages,
.emote-picker-grid,
.emote-picker-grid,
.pastes-grid,
.pastes-grid,
@@ -9551,12 +9479,6 @@ body.theme-light .btn-outline:hover {
@@ -9551,12 +9479,6 @@ body.theme-light .btn-outline:hover {
line-height: 1.3;
line-height: 1.3;
}
}
.stream-card-ai i { opacity: 0.8; margin-right: 3px; }
.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. */
/* Flash the player briefly when a timeline/transcript jump lands. */
@keyframes vpSeekFlash {
@keyframes vpSeekFlash {
@@ -22,14 +22,6 @@
@@ -22,14 +22,6 @@
"channel"
"channel"
]
]
},
},
- "appMedia": {
- "css": [
- "/css/features/media.css"
- ],
- "js": [
- "/js/app-media.js"
- ]
- },
"feeds": {
"feeds": {
"css": [
"css": [
"/css/features/content.css"
"/css/features/content.css"
@@ -115,20 +107,6 @@
@@ -115,20 +107,6 @@
"toggleChannelChat"
"toggleChannelChat"
]
]
},
},
- "vodPage": {
- "fragment": "vod-player",
- "deps": [
- "context",
- "appMedia"
- ]
- },
- "clipPage": {
- "fragment": "clip-player",
- "deps": [
- "context",
- "appMedia"
- ]
- },
"voice": {
"voice": {
"js": [
"js": [
"/js/call.js",
"/js/call.js",
@@ -325,18 +303,6 @@
@@ -325,18 +303,6 @@
"search"
"search"
]
]
},
},
- {
- "path": "^/vod/[^/]+",
- "features": [
- "vodPage"
- ]
- },
- {
- "path": "^/clip/[^/]+",
- "features": [
- "clipPage"
- ]
- },
{
{
"path": "^/dashboard",
"path": "^/dashboard",
"features": [
"features": [
deleted file mode 100644
deleted file mode 100644
@@ -1,79 +0,0 @@
@@ -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>
-
deleted file mode 100644
deleted file mode 100644
@@ -1,177 +0,0 @@
@@ -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>
-
@@ -692,10 +692,8 @@
@@ -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>
<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 ────────────────────────────────────── -->
<!-- ── 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 ───────────────────────────────────── -->
<!-- ── 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 ─────────────────────────────────────── -->
<!-- ── 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>
<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>
@@ -114,7 +114,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -114,7 +114,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
if (liveVod && liveVod.vod) {
if (liveVod && liveVod.vod) {
const v = liveVod.vod;
const v = liveVod.vod;
liveVodHtml += `
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">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${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>
<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 = {})
@@ -134,7 +134,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const canManage = _channelCanManage(username);
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => _reloadChannelContent(username));
_selSetContext(canManage, () => _reloadChannelContent(username));
vodsGrid.innerHTML = liveVodHtml + vods.map(v => _selWrap('vod', v.id, `
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">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${_visBadge(v.visibility, v.is_public, canManage)}
${_visBadge(v.visibility, v.is_public, canManage)}
@@ -210,7 +210,7 @@ function renderChannelClipsSection(username, clips, meta = {}) {
@@ -210,7 +210,7 @@ function renderChannelClipsSection(username, clips, meta = {}) {
const canManage = _channelCanManage(username);
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => _reloadChannelContent(username));
_selSetContext(canManage, () => _reloadChannelContent(username));
clipsGrid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
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">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
@@ -291,7 +291,7 @@ function _renderClipsTakenGrid(username, data) {
@@ -291,7 +291,7 @@ function _renderClipsTakenGrid(username, data) {
return;
return;
}
}
grid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
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">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
@@ -333,7 +333,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
@@ -333,7 +333,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
if (clips.length) {
if (clips.length) {
grid.innerHTML = clips.map(cl => `
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">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${cl.stream_protocol ? protocolBadge(cl.stream_protocol) : ''}
${cl.stream_protocol ? protocolBadge(cl.stream_protocol) : ''}
@@ -367,7 +367,7 @@ function renderChannelAiClipsSection(username, clips, meta = {}) {
@@ -367,7 +367,7 @@ function renderChannelAiClipsSection(username, clips, meta = {}) {
grid.textContent = '';
grid.textContent = '';
if (!total) { renderVodsPagination('ch-ai-clips-pagination', 1, 0, pageSize, 'setChannelAiClipsPage', 'AI clips'); return; }
if (!total) { renderVodsPagination('ch-ai-clips-pagination', 1, 0, pageSize, 'setChannelAiClipsPage', 'AI clips'); return; }
for (const cl of clips) {
for (const cl of clips) {
- const href = `/clip/${Number(cl.id)}`;
+ const href = `${videoHref('clip', Number(cl.id))}`;
const card = document.createElement('a');
const card = document.createElement('a');
card.className = 'stream-card';
card.className = 'stream-card';
card.href = href;
card.href = href;
@@ -1906,7 +1906,7 @@ function _aiTimelineMomentHTML(mom, vodId) {
@@ -1906,7 +1906,7 @@ function _aiTimelineMomentHTML(mom, vodId) {
? `<img class="ai-tl-moment-thumb" src="${esc(thumbUrl)}" alt="" loading="lazy" onerror="this.remove()">`
? `<img class="ai-tl-moment-thumb" src="${esc(thumbUrl)}" alt="" loading="lazy" onerror="this.remove()">`
: '';
: '';
const jump = vodId
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>`;
: `<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>`;
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) {
@@ -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 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 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 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 _asLink = (label) => _vodHref ? `<a href="${_vodHref}" onclick="return handleLinkClick(event, '${_vodHref}')">${label}</a>` : label;
const aiTitle = _aiSessionTitle(s);
const aiTitle = _aiSessionTitle(s);
const mainTitle = aiTitle ? _asLink(esc(aiTitle)) : _asLink(esc(s.title || 'Untitled stream'));
const mainTitle = aiTitle ? _asLink(esc(aiTitle)) : _asLink(esc(s.title || 'Untitled stream'));
@@ -2054,7 +2054,7 @@ async function _aiTlToggleTranscript(btn) {
@@ -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 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 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">
const toolbar = `<div class="ai-tl-tr-bar">
<div class="ai-tl-tr-filter" role="tablist">
<div class="ai-tl-tr-filter" role="tablist">
<button type="button" class="active" data-f="all">All</button>
<button type="button" class="active" data-f="all">All</button>
@@ -2379,7 +2379,7 @@ function _aboutAiOverviewHTML() {
@@ -2379,7 +2379,7 @@ function _aboutAiOverviewHTML() {
let _aboutRecent = { username: null, vods: [], clips: [] };
let _aboutRecent = { username: null, vods: [], clips: [] };
function _aboutRowCard(kind, it) {
function _aboutRowCard(kind, it) {
const isVod = kind === 'vod';
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 gen = isVod ? `/api/thumbnails/generate/vod/${it.id}` : `/api/thumbnails/generate/clip/${it.id}`;
const views = Number(it.view_count || 0);
const views = Number(it.view_count || 0);
return `<a class="ch-about-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
return `<a class="ch-about-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
@@ -3162,7 +3162,7 @@ function _offlineCyclerRender() {
@@ -3162,7 +3162,7 @@ function _offlineCyclerRender() {
}
}
function _offlineTopCard(kind, item) {
function _offlineTopCard(kind, item) {
const isVod = kind === 'vod';
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 icon = isVod ? 'fa-video' : 'fa-scissors';
const label = isVod ? 'VOD' : 'Clip';
const label = isVod ? 'VOD' : 'Clip';
const thumbGen = isVod ? `/api/thumbnails/generate/vod/${item.id}` : `/api/thumbnails/generate/clip/${item.id}`;
const thumbGen = isVod ? `/api/thumbnails/generate/vod/${item.id}` : `/api/thumbnails/generate/clip/${item.id}`;
@@ -1475,7 +1475,7 @@ async function loadHomeRecentVods(opts) {
@@ -1475,7 +1475,7 @@ async function loadHomeRecentVods(opts) {
header.style.display = '';
header.style.display = '';
const _put = (html) => ovPutCards(grid, html, append);
const _put = (html) => ovPutCards(grid, html, append);
_put(vods.map(v => {
_put(vods.map(v => {
- const href = `/vod/${v.id}`;
+ const href = `${videoHref('vod', v.id)}`;
return `
return `
<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
<div class="stream-card-thumb">
<div class="stream-card-thumb">
@@ -1514,7 +1514,7 @@ async function loadHomeClips(opts) {
@@ -1514,7 +1514,7 @@ async function loadHomeClips(opts) {
if (header) header.style.display = '';
if (header) header.style.display = '';
const _put = (html) => ovPutCards(grid, html, append);
const _put = (html) => ovPutCards(grid, html, append);
_put(clips.map(c => `
_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">
<div class="stream-card-thumb">
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
${ovViewsBadge(c.view_count)}
${ovViewsBadge(c.view_count)}
@@ -1588,7 +1588,7 @@ function streamCardHTML(s, isLive) {
@@ -1588,7 +1588,7 @@ function streamCardHTML(s, isLive) {
const msRef = s.managed_stream_slug || s.managed_stream_id || null;
const msRef = s.managed_stream_slug || s.managed_stream_id || null;
navUrl = channelPath(s.username, msRef);
navUrl = channelPath(s.username, msRef);
} else if (!isLive && s.vod_id && s.vod_is_public) {
} else if (!isLive && s.vod_id && s.vod_is_public) {
- navUrl = `/vod/${s.vod_id}`;
+ navUrl = `${videoHref('vod', s.vod_id)}`;
} else {
} else {
navUrl = channelPath(s.username);
navUrl = channelPath(s.username);
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,1895 +0,0 @@
@@ -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'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'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);
-}
@@ -581,7 +581,7 @@ function _ssoHint() {
@@ -581,7 +581,7 @@ function _ssoHint() {
// What the signed-in account watches/opens here shows up in its network-wide History and the
// 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
// "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.
// 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;
let _historyTimer = null;
function _recordHistory(pageId) {
function _recordHistory(pageId) {
const type = _HISTORY_PAGES[pageId];
const type = _HISTORY_PAGES[pageId];
@@ -848,6 +848,7 @@ function ovViewsBadge(n) {
@@ -848,6 +848,7 @@ function ovViewsBadge(n) {
function handleLinkClick(event, urlPath, replace = false) {
function handleLinkClick(event, urlPath, replace = false) {
if (isModifiedLinkClick(event)) return true;
if (isModifiedLinkClick(event)) return true;
+ if (isOffSite(urlPath)) return true; // the link's own href leaves Live
event?.preventDefault?.();
event?.preventDefault?.();
navigate(urlPath, replace);
navigate(urlPath, replace);
return false;
return false;
@@ -910,20 +911,13 @@ function teardownRoute(nextPath) {
@@ -910,20 +911,13 @@ function teardownRoute(nextPath) {
// Loops other modules own. Each is optional — a module that isn't loaded simply isn't stopped.
// 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 { /* */ } };
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 { /* */ }
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.
// Channel strays that kept fetching or cycling off-route.
stop('stopGoalWidget');
stop('stopGoalWidget');
stop('_stopOfflineCycler');
stop('_stopOfflineCycler');
stop('_stopCaptions');
stop('_stopCaptions');
if (typeof _channelPastesTimer !== 'undefined' && _channelPastesTimer) { clearInterval(_channelPastesTimer); _channelPastesTimer = null; }
if (typeof _channelPastesTimer !== 'undefined' && _channelPastesTimer) { clearInterval(_channelPastesTimer); _channelPastesTimer = null; }
if (typeof _goalCycleTimer !== 'undefined' && _goalCycleTimer) { clearInterval(_goalCycleTimer); _goalCycleTimer = 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('stopAiViewerActivity');
stop('stopPlayerStatsPoll');
stop('stopPlayerStatsPoll');
// The broadcast desk's polls only matter while you are on it. Leaving them running elsewhere
// The broadcast desk's polls only matter while you are on it. Leaving them running elsewhere
@@ -937,7 +931,11 @@ function teardownRoute(nextPath) {
@@ -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) {
function navigate(urlPath, replace = false) {
+ if (isOffSite(urlPath)) { if (replace) location.replace(urlPath); else location.href = urlPath; return; }
teardownRoute(urlPath);
teardownRoute(urlPath);
// Normalize path
// Normalize path
@@ -1023,6 +1021,17 @@ function whenRouteReady(pageId, render) {
@@ -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). */
/** A paste's page on openvibe.community (the shell's ov-pastes-base meta names it). */
function pasteHref(slug) {
function pasteHref(slug) {
const meta = document.querySelector('meta[name="ov-pastes-base"]');
const meta = document.querySelector('meta[name="ov-pastes-base"]');
@@ -1074,15 +1083,11 @@ function routeFromURL() {
@@ -1074,15 +1083,11 @@ function routeFromURL() {
// Channels, VODs and clips through OpenVibe.Search (public/js/app-search.js).
// Channels, VODs and clips through OpenVibe.Search (public/js/app-search.js).
showPage('search');
showPage('search');
whenRouteReady('search', () => loadSearchPage());
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'));
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') {
} else if (segments[0] === 'dashboard') {
showPage('dashboard');
showPage('dashboard');
whenRouteReady('dashboard', () => { if (typeof loadDashboard === 'function') loadDashboard(); });
whenRouteReady('dashboard', () => { if (typeof loadDashboard === 'function') loadDashboard(); });
@@ -1267,7 +1272,7 @@ function ovWakeBrandMark() {
@@ -1267,7 +1272,7 @@ function ovWakeBrandMark() {
}, BRAND_AWAKE_MS);
}, 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 _pageLabel(page) { return _PAGE_LABELS[page] || String(page).replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
function showPage(page) {
function showPage(page) {
@@ -29,7 +29,7 @@ function _aaAvatar(u, size = 40) {
@@ -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 _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) {
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');
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 '';
return '';
}
}
@@ -130,8 +130,9 @@ function _aChannelLink(u, slot = null) { return typeof channelPath === 'function
@@ -130,8 +130,9 @@ function _aChannelLink(u, slot = null) { return typeof channelPath === 'function
function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }
function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }
function _aConsoleLink(u) { return `/arena/live/${encodeURIComponent(u.username)}`; }
function _aConsoleLink(u) { return `/arena/live/${encodeURIComponent(u.username)}`; }
function _aBeefLink(b) { return `/arena/beef/${b.id}`; }
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 _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 _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 = '') {
function _aBriefChip(f, extra = '') {
@@ -3708,7 +3708,7 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -3708,7 +3708,7 @@ function buildChatMessageEl(msg, opts = {}) {
const metaLine = isAuto
const metaLine = isAuto
? `<div class="clip-chat-meta"><i class="fa-solid fa-robot"></i> Auto-generated highlight</div>`
? `<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>`;
: `<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}
${thumb}
<div class="clip-chat-info">
<div class="clip-chat-info">
${label}
${label}
@@ -1,44 +1,25 @@
@@ -1,44 +1,25 @@
/**
/**
* context-cards.js — the connective tissue between clips, VODs, streams and channels.
* 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,
* renderOfflineDiscover(username) offline channel screen: a discover board — live now,
* hot clips, fresh reports, the star of the day, streamers
* hot clips, fresh reports, the star of the day, streamers
* like this one — so an empty channel still sends people
* like this one — so an empty channel still sends people
* somewhere good.
* 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 () {
(function () {
'use strict';
'use strict';
const n = (v) => Number(v || 0).toLocaleString();
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 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 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 ago = (ts) => { try { return timeAgo(ts); } catch { return ''; } };
const GRADE = { S: '#fbbf24', A: '#a78bfa', B: '#38bdf8', C: '#94a3b8' };
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 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>'}
${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>
<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>` : ''}
${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 @@
@@ -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>
<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>`;
</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 ─────────────────────────
// ── Offline channel: discover board ─────────────────────────
window.renderOfflineDiscover = async function (username, mountEl) {
window.renderOfflineDiscover = async function (username, mountEl) {
const host = mountEl || document.getElementById('ch-discover');
const host = mountEl || document.getElementById('ch-discover');
@@ -379,7 +379,7 @@ async function loadDashVods() {
@@ -379,7 +379,7 @@ async function loadDashVods() {
${!v.is_public
${!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-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 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>
<button class="btn btn-small btn-danger" onclick="deleteVod('${v.id}')"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
</div>
</div>
@@ -503,7 +503,7 @@ async function loadDashMyClips() {
@@ -503,7 +503,7 @@ async function loadDashMyClips() {
${!cl.is_public
${!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-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 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>` : ''}
${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>
</div>
</div>
@@ -546,7 +546,7 @@ async function loadDashStreamClips() {
@@ -546,7 +546,7 @@ async function loadDashStreamClips() {
${!cl.is_public
${!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-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 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>
<button class="btn btn-small btn-danger" onclick="dashDeleteClip(${cl.id}, 'stream')"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
</div>
</div>
@@ -605,7 +605,7 @@ async function loadDashAiClips() {
@@ -605,7 +605,7 @@ async function loadDashAiClips() {
${!cl.is_public
${!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-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 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>
<button class="btn btn-small btn-danger" onclick="dashDeleteClip(${Number(cl.id)}, 'ai')"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
</div>
</div>
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
*/
*/
(function () {
(function () {
'use strict';
'use strict';
- const COMPACT_PAGES = ['channel', 'vod-player', 'clip-player', 'broadcast', 'chat', 'rundown', 'recap'];
+ const COMPACT_PAGES = ['channel', 'broadcast', 'chat', 'rundown', 'recap'];
const LINKS = [
const LINKS = [
{
{
heading: 'Watch',
heading: 'Watch',
@@ -10,7 +10,8 @@
@@ -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 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 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 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) {
function curveSvg(curve, startedAt) {
if (!curve || curve.length < 2) return '';
if (!curve || curve.length < 2) return '';
@@ -55,10 +56,10 @@
@@ -55,10 +56,10 @@
const medals = ['🥇', '🥈', '🥉', '4', '5'];
const medals = ['🥇', '🥈', '🥉', '4', '5'];
const topN = r.chat.top[0] ? r.chat.top[0].n : 1;
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 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 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 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 shareUrl = `${location.origin}/recap/${id}`;
const shareText = `${w.headline || s.title} — ${u.display_name}'s stream report on OpenVibe.Live`;
const shareText = `${w.headline || s.title} — ${u.display_name}'s stream report on OpenVibe.Live`;
@@ -51,7 +51,7 @@ function _rdTopicChip(t, extra = '') {
@@ -51,7 +51,7 @@ function _rdTopicChip(t, extra = '') {
const [label, color] = RD_KIND[t.kind] || RD_KIND.other;
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}"`);
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 _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 _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>`; }
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 } = {}) {
@@ -110,7 +110,7 @@ function _rdEpisodeCard(e, { user = true } = {}) {
<h3 class="rd-h3 rd-ep-title">${_rdA(href, _rdEsc(e.title))}</h3>
<h3 class="rd-h3 rd-ep-title">${_rdA(href, _rdEsc(e.title))}</h3>
${user ? `<p class="rd-by">${_rdUser(e.user)}</p>` : ''}
${user ? `<p class="rd-by">${_rdUser(e.user)}</p>` : ''}
${e.logline ? `<p class="rd-logline">${_rdEsc(e.logline)}</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>` : ''}
${(e.topics || []).length ? `<div class="rd-chips">${e.topics.slice(0, 5).map((t) => _rdTopicChip(t)).join('')}</div>` : ''}
</div>
</div>
</div>
</div>
@@ -233,7 +233,7 @@ async function _rdEpisode(root, id) {
@@ -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>
<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>
</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.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>` : ''}
${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>
<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.')}
${(d.segments || []).map((x) => _rdTake(x, { user: false })).join('') || _rdEmpty('fa-microphone-lines', 'Nothing written up from this stream.')}
@@ -19,7 +19,8 @@ const opt = (name, def) => { const i = args.indexOf(name); return i === -1 ? def
@@ -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 BASE = String(opt('--base', 'https://openvibe.live')).replace(/\/+$/, '');
const SETTLE = Number(opt('--settle', 9000));
const SETTLE = Number(opt('--settle', 9000));
// The public routes people land on from search, links and the home page (signed out).
// 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]];
const WIDTHS = [[412, 900, true], [1366, 900, false]];
async function coverage(url, [width, height, mobile]) {
async function coverage(url, [width, height, mobile]) {
@@ -361,7 +361,8 @@ async function tick(opts = {}) {
@@ -361,7 +361,8 @@ async function tick(opts = {}) {
let tags = moment.tags || [];
let tags = moment.tags || [];
const vodPath = `/vod/${v.vod_id}?t=${offset}`;
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();
const slug = _slug();
// Extract the real frame at this moment so the paste is a true IMAGE paste.
// Extract the real frame at this moment so the paste is a true IMAGE paste.
@@ -38,6 +38,7 @@ const db = require('../db/database');
@@ -38,6 +38,7 @@ const db = require('../db/database');
const media = require('../media-client');
const media = require('../media-client');
const pastesClient = require('../pastes-client');
const pastesClient = require('../pastes-client');
const lookups = require('../media-proxy/lookups');
const lookups = require('../media-proxy/lookups');
+const { videoUrl } = require('../web/video-url');
const { publicFeedItem } = require('../web/serializers');
const { publicFeedItem } = require('../web/serializers');
const DEADLINE_MS = 4000;
const DEADLINE_MS = 4000;
@@ -122,7 +123,7 @@ async function vodItem(v) {
@@ -122,7 +123,7 @@ async function vodItem(v) {
if (banned(owner)) return null;
if (banned(owner)) return null;
// Content is what people made: the card carries no AI overview (the VOD page still has it).
// Content is what people made: the card carries no AI overview (the VOD page still has it).
return {
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',
title: clean(v.title, 140) || 'Stream recording',
created_at: iso(v.created_at), views: num(v.view_count),
created_at: iso(v.created_at), views: num(v.view_count),
duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
@@ -144,7 +145,7 @@ async function clipItem(c, { ai }) {
@@ -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 { /* */ } }
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;
const by = !ai && creator && channelOwner && creator.id !== channelOwner.id ? person(creator) : null;
return {
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',
title: clean(c.title, 140) || 'Clip',
created_at: iso(c.created_at), views: num(c.view_count),
created_at: iso(c.created_at), views: num(c.view_count),
duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
@@ -14,6 +14,7 @@ const express = require('express');
@@ -14,6 +14,7 @@ const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
const { can } = require('../auth/permissions');
const { can } = require('../auth/permissions');
const media = require('../media-client');
const media = require('../media-client');
+const { videoUrl } = require('../web/video-url');
const router = express.Router();
const router = express.Router();
const SLOGAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // must match slogan-job INTERVAL_MS
const SLOGAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // must match slogan-job INTERVAL_MS
@@ -82,10 +83,10 @@ async function heroMedia() {
@@ -82,10 +83,10 @@ async function heroMedia() {
try {
try {
const pool = await _mediaContent();
const pool = await _mediaContent();
for (const v of pool.vods) {
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) {
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) {
for (const p of pool.pastes) {
items.push({
items.push({
@@ -103,7 +104,7 @@ async function heroMedia() {
@@ -103,7 +104,7 @@ async function heroMedia() {
if (!m || !m.vodId) continue;
if (!m || !m.vodId) continue;
items.push({
items.push({
kind: 'moment', title: m.title || 'AI Moment', thumbnail: m.thumbnail || null,
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 { /* */ }
} catch { /* */ }
@@ -118,7 +119,7 @@ async function heroMoments() {
@@ -118,7 +119,7 @@ async function heroMoments() {
const hm = JSON.parse(await db.getState('home_hero_moments') || '{}');
const hm = JSON.parse(await db.getState('home_hero_moments') || '{}');
return (hm.moments || [])
return (hm.moments || [])
.filter(m => m && m.vodId && m.thumbnail)
.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 []; }
} catch { return []; }
}
}
@@ -625,10 +625,11 @@ app.use('/css', assets.versionedStatic('/css'), express.static(path.join(assets.
@@ -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 }));
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
// 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
// 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/paste-handover').register(app);
+require('./web/video-handover').register(app);
try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
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
// 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.
// as the SPA shell, so their script and stylesheet URLs can never drift out of date again.
@@ -785,7 +785,7 @@ async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {
@@ -785,7 +785,7 @@ async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {
type: 'CLIP_CREATED',
type: 'CLIP_CREATED',
title: 'New Clip',
title: 'New Clip',
message: `${clipper.display_name || clipper.username} clipped your stream${title ? `: ${title}` : ''}`,
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),
...actorInfo(clipper),
});
});
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -133,8 +133,8 @@ function llmsTxt() {
@@ -133,8 +133,8 @@ function llmsTxt() {
{ title: 'Home', url: `${b}/`, note: 'who is live now, recent VODs and clips' },
{ 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: '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: '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: '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: 'Chat', url: `${b}/chat` },
{ title: 'The Rundown', url: `${b}/rundown`, note: 'what streamers talked about, written up: topics, takes and episodes' },
{ title: 'The Rundown', url: `${b}/rundown`, note: 'what streamers talked about, written up: topics, takes and episodes' },
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
*
*
* The app is a client-rendered SPA (server/index.js serves public/index.html for every route),
* 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
* 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
* <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.
* 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}`
@@ -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 _feedList = async (feed) => (await _feedPage(feed, 1)).items;
const { isPrivate } = require('../media-proxy/access');
const { isPrivate } = require('../media-proxy/access');
const pageStatus = require('../web/page-status');
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) ──
// ── 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
// 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) {
@@ -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;
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,
// 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
// 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
// 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) {
@@ -162,6 +147,9 @@ async function _overlayAiState(row, kind) {
const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
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 PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;
const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
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 SITE_NAME = 'OpenVibe.Live';
const DEFAULT_OG_IMAGE = '/og-image.png';
const DEFAULT_OG_IMAGE = '/og-image.png';
@@ -207,16 +195,15 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
@@ -207,16 +195,15 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
// Home
// Home
if (p === '/') return await _homeMeta();
if (p === '/') return await _homeMeta();
// List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)
// 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 === '/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 === '/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 })));
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
// Detail pages
let m;
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(/^\/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(CHANNEL_PATH_RE))) return await _channelMeta(m[1], page);
if ((m = p.match(/^\/recap\/(\d+)$/))) return await _recapMeta(parseInt(m[1], 10));
if ((m = p.match(/^\/recap\/(\d+)$/))) return await _recapMeta(parseInt(m[1], 10));
@@ -303,7 +290,7 @@ async function _channelMeta(username, page = 1) {
@@ -303,7 +290,7 @@ async function _channelMeta(username, page = 1) {
if (vods.length) {
if (vods.length) {
ld.push({
ld.push({
'@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,
'@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 }] : [])]));
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) {
@@ -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 += `<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) => ({
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(' · '),
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.'));
})), 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 (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) {
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>`);
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>`;
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() {
@@ -496,8 +483,8 @@ async function _homeMeta() {
const ownerName = async (row, id) => row.display_name || row.username || _nameOf(await _channelOwner(id));
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 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 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>` : '';
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) {
@@ -614,7 +601,7 @@ async function _channelOwner(userId) {
const _nameOf = (u) => (u ? (u.display_name || u.username) : null);
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).
* not public (a canonical must never point at a page that answers 404 to the crawler).
*/
*/
async function _sourceMoment(vodId, seconds) {
async function _sourceMoment(vodId, seconds) {
@@ -626,146 +613,6 @@ async function _sourceMoment(vodId, seconds) {
@@ -626,146 +613,6 @@ async function _sourceMoment(vodId, seconds) {
return { path: videoUrl('vod', vodId, t), vod: v };
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) {
async function _pasteMeta(slug) {
const p = await _pasteGet(slug);
const p = await _pasteGet(slug);
if (!p) return null;
if (!p) return null;
@@ -906,7 +753,7 @@ function _headBlock(meta) {
@@ -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.
// 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))}">`);
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) {
if (meta.video && meta.video.duration) {
parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);
parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);
parts.push(`<meta property="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) {
@@ -983,7 +830,7 @@ async function shellHtml(urlPath, status) {
let html = _base();
let html = _base();
if (!html) return null;
if (!html) return null;
const p = String(urlPath || '/');
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;
if (p === '/' && status !== 404) return html;
const headEnd = html.search(/<\/head>/i);
const headEnd = html.search(/<\/head>/i);
if (headEnd === -1) return html;
if (headEnd === -1) return html;
@@ -20,9 +20,9 @@
@@ -20,9 +20,9 @@
* /dashboard/… /broadcast/… /chat/… /rundown/… any depth: these pages route sub-paths themselves (/arena/… redirects)
* /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
* /@user /@user/<slot> a user that exists; a renamed one's old name
* answers 301 → /@current (never a bare /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
* /p/:slug a paste that exists
* /recap/:streamId /stream/:id a stream that exists
* /recap/:streamId /stream/:id a stream that exists
*
*
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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 };
@@ -9,9 +9,8 @@
@@ -9,9 +9,8 @@
* Moments (?auto_generated=1), each labelled;
* Moments (?auto_generated=1), each labelled;
* - the channel page lists people's clips (by and of the streamer) apart from its AI clips, which
* - 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;
* 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
* node test/clip-attribution.test.js
*/
*/
@@ -20,7 +19,6 @@ const assert = require('assert');
@@ -20,7 +19,6 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const http = require('http');
const http = require('http');
-const vm = require('vm');
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
const quiet = console.log;
const quiet = console.log;
@@ -38,27 +36,6 @@ const CLIPS = [
@@ -38,27 +36,6 @@ const CLIPS = [
const asked = [];
const asked = [];
let honour = true;
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();
const ids = (rows) => (rows || []).map((c) => c.id).sort();
let failures = 0;
let failures = 0;
@@ -188,22 +165,6 @@ async function check(name, fn) {
@@ -188,22 +165,6 @@ async function check(name, fn) {
assert.ok(!r.json.clips.some((c) => c.auto_generated), 'no AI clip');
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();
server.close();
quiet(failures ? `\n${failures} check(s) failed` : '\nclip attribution: all checks passed');
quiet(failures ? `\n${failures} check(s) failed` : '\nclip attribution: all checks passed');
process.exit(failures ? 1 : 0);
process.exit(failures ? 1 : 0);
deleted file mode 100644
deleted file mode 100644
@@ -1,42 +0,0 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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`);
@@ -74,7 +74,7 @@ ok(`${reg.routes.length} routes reference known features`);
@@ -74,7 +74,7 @@ ok(`${reg.routes.length} routes reference known features`);
// Server rendering.
// Server rendering.
const assets = require('../server/web/assets');
const assets = require('../server/web/assets');
const doc = assets.document('index.html').html;
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) {
for (const [url, expectAssets, frag] of cases) {
const html = assets.renderRoute(doc, url);
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}`);
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}`);
@@ -19,7 +19,7 @@ const doc = assets.document('index.html').html;
@@ -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]);
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');
assert.deepStrictEqual(active(doc), ['page-home'], 'the shell itself shows home');
for (const [url, page] of [
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'],
['/chat', 'page-chat'], ['/content', 'page-content'], ['/dashboard', 'page-dashboard'],
]) {
]) {
assert.deepStrictEqual(active(assets.renderRoute(doc, url)), [page], `${url}: only ${page} is active`);
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
@@ -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');
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');
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 ──
// ── Home hero and early widgets ──
const index = read('public/index.html');
const index = read('public/index.html');
@@ -83,9 +71,6 @@ const scriptingBlock = (css, rule) => {
@@ -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; }']) {
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`);
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();
const noscriptText = (html) => ((/<noscript>([\s\S]*?)<\/noscript>/.exec(html) || [])[1] || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
for (const frag of ['chat', 'search']) {
for (const frag of ['chat', 'search']) {
const text = noscriptText(read(`public/fragments/${frag}.html`));
const text = noscriptText(read(`public/fragments/${frag}.html`));
@@ -301,14 +301,9 @@ async function check(name, fn) {
@@ -301,14 +301,9 @@ async function check(name, fn) {
assert.ok(!/secret/.test(r.text));
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');
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 () => {
await check('recently-ended streams: only a public VOD from Media rides along', async () => {
@@ -8,7 +8,7 @@ const fs = require('fs');
@@ -8,7 +8,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const pub = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', f), 'utf8');
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 html = pub(f);
const ranges = html.match(/<input[^>]*type="range"[^>]*>/g) || [];
const ranges = html.match(/<input[^>]*type="range"[^>]*>/g) || [];
assert.ok(ranges.length, `${f} has a volume slider`);
assert.ok(ranges.length, `${f} has a volume slider`);
@@ -2,11 +2,11 @@
@@ -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
* 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.
* 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.
* - 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.
* - 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.
* Media and Community are stubbed in-process; the database is a temp file.
@@ -102,47 +102,9 @@ async function check(name, fn) {
@@ -102,47 +102,9 @@ async function check(name, fn) {
console.log('AI Moments pages');
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'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 () => {
await check('an AI moment paste points at the VOD second it came from', async () => {
@@ -6,11 +6,11 @@
@@ -6,11 +6,11 @@
* It used to render only when Accept named text/html, so a crawler or tool sending *\/* got the home
* 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
* 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):
* 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
* - 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;
* 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
* - a 404 is noindex with no canonical and no home metadata; no other shell page claims the home
* page as its canonical;
* page as its canonical;
* - without JavaScript the body is shown (and the empty app shell hidden); with it, the route
* - without JavaScript the body is shown (and the empty app shell hidden); with it, the route
@@ -80,6 +80,7 @@ const express = require('express');
@@ -80,6 +80,7 @@ const express = require('express');
const seo = require('../server/seo');
const seo = require('../server/seo');
const pageStatus = require('../server/web/page-status');
const pageStatus = require('../server/web/page-status');
const app = express();
const app = express();
+require('../server/web/video-handover').register(app); // before SEO, as server/index.js mounts it
seo.register(app);
seo.register(app);
app.get('*', pageStatus.spaFallback(async (res, urlPath) => {
app.get('*', pageStatus.spaFallback(async (res, urlPath) => {
const html = await seo.shellHtml(urlPath, res.statusCode);
const html = await seo.shellHtml(urlPath, res.statusCode);
@@ -94,7 +95,7 @@ function get(p, headers = {}) {
@@ -94,7 +95,7 @@ function get(p, headers = {}) {
const req = http.request(base + p, { method: 'GET', headers }, (res) => {
const req = http.request(base + p, { method: 'GET', headers }, (res) => {
let body = '';
let body = '';
res.on('data', (c) => { body += c; });
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.on('error', reject);
req.end();
req.end();
@@ -153,8 +154,8 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -153,8 +154,8 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const b = bodyOf((await get('/@alice')).body);
const b = bodyOf((await get('/@alice')).body);
assert.ok(b.includes('<h1>Alice (@alice)</h1>'), b.slice(0, 300));
assert.ok(b.includes('<h1>Alice (@alice)</h1>'), b.slice(0, 300));
assert.ok(b.includes('I stream woodworking.'));
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('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('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');
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 () => {
@@ -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.deepStrictEqual(canonicalsOf(p2), ['https://openvibe.live/@alice?page=2']);
assert.ok(/page 2/.test(titleOf(p2)), titleOf(p2));
assert.ok(/page 2/.test(titleOf(p2)), titleOf(p2));
const b2 = bodyOf(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="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('<a rel="next" href="https://openvibe.live/@alice?page=3">'));
assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');
assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');
const b3 = bodyOf((await get('/@alice?page=3')).body);
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;
const past = (await get('/@alice?page=9')).body;
assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);
assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);
assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);
assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);
@@ -198,7 +199,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -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 () => {
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)) {
for (const c of CLIENTS.slice(0, 3)) {
const r = await get(p, c.headers);
const r = await get(p, c.headers);
assert.strictEqual(r.status, 404, `${p} ${c.name}`);
assert.strictEqual(r.status, 404, `${p} ${c.name}`);
@@ -257,43 +258,13 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -257,43 +258,13 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.deepStrictEqual(canonicalsOf(home), ['https://openvibe.live/']);
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'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 () => {
await check('without JavaScript the body shows (with the site links) and the empty app shell is hidden', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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); });
@@ -3,8 +3,7 @@
@@ -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
* 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
* 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.
* The real routers run on the test database with Media stubbed in-process.
*
*
@@ -120,18 +119,6 @@ async function check(name, fn) {
@@ -120,18 +119,6 @@ async function check(name, fn) {
assert.strictEqual(by[201].file_url, media.clipUrl(201));
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 () => {
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));
const v = await new Promise((resolve) => http.get({ port: server.address().port, path: '/api/vods/100/file' }, resolve));
assert.strictEqual(v.statusCode, 302);
assert.strictEqual(v.statusCode, 302);
deleted file mode 100644
deleted file mode 100644
@@ -1,118 +0,0 @@
@@ -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); });
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
*
*
* Recording, storage and cutting are OpenVibe.Media's; this pins Live's side of it: the recorder's
* 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
* 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,
* 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
* 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) {
@@ -230,23 +230,6 @@ async function check(name, fn) {
assert.ok(!recorder.isRecording(sid));
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();
server.close();
if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
quiet('\nAll VOD tail/finalise/seek checks passed');
quiet('\nAll VOD tail/finalise/seek checks passed');