@@ -92,7 +92,7 @@
}
. btn : hover { background : rgba ( 255 , 255 , 255 , .1 ) ; }
. btn : active { transform : scale ( .97 ) ; }
. btn . pri { border : 0 ; background : var ( - - grad ) ; color : #fff ; box-shadow : 0 10 px 26 px -10 px var ( - - a1 ) ; }
. btn . pri { border : 0 ; background : #6143cf ; color : #fff ; box-shadow : none ; }
. btn . pri : hover { filter : brightness ( 1.1 ) ; }
. btn . ok { border : 0 ; background : var ( - - ok ) ; color : #06210f ; }
. btn . dng { color : var ( - - bad ) ; border-color : rgba ( 255 , 107 , 107 , .35 ) ; background : rgba ( 255 , 107 , 107 , .07 ) ; }
@@ -164,6 +164,13 @@
. song-row small { color : var ( - - text - dim ) ; font-size : 12 px ; }
. song-row . st { flex : none ; font : 700 11 px var ( - - ui ) ; padding : 3 px 9 px ; border-radius : 99 px ; border : 1 px solid var ( - - line -2 ) ; color : var ( - - text - dim ) ; }
. song-row . st . has { color : var ( - - ok ) ; border-color : rgba ( 74 , 222 , 128 , .4 ) ; }
. song-open { display : flex ; align-items : center ; gap : 10 px ; min-width : 0 ; flex : 1 ; padding : 0 ; border : 0 ; background : none ; color : inherit ; text-align : left ; }
. whisper-request { min-height : 44 px ; flex : none ; }
. whisper-draft { margin-block : 14 px ; }
. whisper-draft pre { white-space : pre-wrap ; overflow-wrap : anywhere ; max-height : 180 px ; overflow : auto ; font : 400 14 px / 1.55 var ( - - ui ) ; color : var ( - - text ) ; }
# whisperStatus { color : var ( - - text - dim ) ; font-size : 14 px ; margin-block : 10 px ; }
. song { flex-wrap : wrap ; }
@ media ( max-width : 480px ) { . song-row { flex-wrap : wrap ; } . song-open { flex-basis : 100 % ; min-height : 44 px ; } }
. chips { display : flex ; flex-wrap : wrap ; gap : 8 px ; margin-top : 12 px ; }
. chip { height : 34 px ; padding : 0 13 px ; border-radius : 99 px ; border : 1 px solid var ( - - line -2 ) ; background : rgba ( 255 , 255 , 255 , .04 ) ; font-size : 13 px ; font-weight : 600 ; color : var ( - - text -2 ) ; max-width : 100 % ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
. chip : hover { color : var ( - - text ) ; border-color : var ( - - a1 ) ; }
@@ -211,7 +218,7 @@
}
. tc : hover { border-color : var ( - - a2 ) ; color : var ( - - text ) ; }
. ln . untimed . tc { color : var ( - - warn ) ; border-style : dashed ; border-color : rgba ( 255 , 180 , 84 , .55 ) ; }
. ln-text { flex : 1 1 auto ; min-width : 0 ; height : 38 px ; padding : 0 8 px ; border : 0 ; border-radius : 9 px ; background : transparent ; font-size : 16 px ; }
. ln-text { flex : 1 1 auto ; min-width : 0 ; min- height: 38 px ; height : auto ; padding : 7 px 8 px ; resize : vertical ; overflow : hidden ; line-height : 1.3 ; border : 0 ; border-radius : 9 px ; background : transparent ; font-size : 16 px ; }
. ln . section . ln-text { font-weight : 800 ; text-transform : uppercase ; letter-spacing : .06 em ; font-size : 13 px ; color : var ( - - a2 ) ; }
. ln . cue . ln-text { font-style : italic ; color : var ( - - warn ) ; }
. ln . now { background : rgba ( 124 , 92 , 255 , .16 ) ; border-color : rgba ( 124 , 92 , 255 , .55 ) ; box-shadow : 0 0 0 1 px rgba ( 124 , 92 , 255 , .25 ) , 0 10 px 30 px -16 px var ( - - a1 ) ; }
@@ -257,6 +264,26 @@
. tap small { display : block ; font-weight : 600 ; opacity : .8 ; font-size : 11 px ; letter-spacing : 0 ; max-width : 84 px ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
. tap : active { transform : scale ( .93 ) ; }
. analytics-grid { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 180 px , 1 fr ) ) ; gap : 12 px ; margin : 20 px 0 ; }
. metric { padding : 18 px ; background : var ( - - bg -2 ) ; border : 1 px solid var ( - - line -2 ) ; border-radius : 14 px ; }
. metric b { display : block ; font-size : 26 px ; margin : 4 px 0 ; }
. metric small , . metric span { color : var ( - - text -2 ) ; }
. analytics-form { display : flex ; flex-wrap : wrap ; align-items : end ; gap : 12 px ; }
. analytics-form label { display : grid ; gap : 5 px ; min-width : 0 ; }
. analytics-form . query { flex : 1 1 250 px ; }
. analytics-form . field { width : 100 % ; min-width : 0 ; }
. analytics-form button { min-height : 44 px ; }
. analytics-table { overflow-x : auto ; margin-top : 12 px ; }
. analytics-table table { width : 100 % ; }
. collection-job { padding : 14 px 0 ; border-bottom : 1 px solid var ( - - line -2 ) ; overflow-wrap : anywhere ; }
. collection-job p { margin : 4 px 0 ; color : var ( - - text -2 ) ; }
# metadataDetail { white-space : pre-wrap ; overflow-wrap : anywhere ; max-height : 400 px ; overflow : auto ; font-size : 13 px ; }
@ media ( max-width : 480px ) { . analytics-grid { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; } . metric { padding : 12 px ; } . metric b { font-size : 22 px ; } }
@ media ( min-width : 761px ) and ( max-width : 1100px ) {
. top { flex-wrap : wrap ; }
. tabs { order : 2 ; width : 100 % ; margin-left : 0 ; overflow-x : auto ; }
. tabs . tab { flex : none ; }
}
. nav { display : none ; }
@ media ( max-width : 760px ) {
. tabs , . top . ghost . desk { display : none ; }
@@ -347,6 +374,7 @@
< div class = "ring" id = "ring" > < span id = "ringTxt" > 0/0< / span > < / div >
< div style = "min-width:0" > < h1 id = "songTitle" > < / h1 > < small id = "songMeta" > < / small > < / div >
< span class = "grow" > < / span >
< button class = "btn sm whisper-request" id = "whisperBtn" type = "button" > Transcribe with Whisper< / button >
< button class = "ghost" id = "closeSong" type = "button" > Change song< / button >
< / div >
@@ -386,8 +414,15 @@
< / div >
< div id = "msg" class = "msg" > < / div >
< p id = "whisperStatus" role = "status" aria-live = "polite" > < / p >
< div id = "whisperResult" class = "card whisper-draft hidden" >
< h2 style = "font-size:18px;margin:0 0 8px" > Whisper transcript ready< / h2 >
< p class = "muted" > Saved lyrics stay unchanged until you review and save this draft.< / p >
< pre id = "whisperPreview" > < / pre >
< button id = "whisperUse" class = "btn" type = "button" > Use transcript in editor< / button >
< / div >
< div id = "lines" class = "lines" > < / div >
< textarea id = "raw" class = "hidden" spellcheck = "false" > < / textarea >
< textarea id = "raw" aria-label = "Lyrics as text; prefix continuation lines with vertical bar and space" class = "hidden" spellcheck = "false" > < / textarea >
< div class = "row" style = "margin-top:14px" >
< button class = "btn sm" id = "addLine" type = "button" > + Add line< / button >
< span class = "muted" style = "font-size:12.5px" > Shortcuts: < code > Space< / code > stamp (Tap-sync) · < code > S< / code > stamp selected · < code > K< / code > play · < code > ↑↓< / code > select · < code > ⌘/Ctrl S< / code > save< / span >
@@ -480,6 +515,35 @@
< / section >
<!-- ================= ACCESS ================= -->
< section id = "p-analytics" class = "panel" >
< h1 class = "page" > Server analytics< / h1 >
< p class = "lead" > Video storage, listening activity and the metadata library.< / p >
< button class = "btn" type = "button" id = "analyticsRefresh" > Refresh analytics< / button >
< p id = "analyticsStatus" role = "status" aria-live = "polite" > < / p >
< div class = "analytics-grid" id = "analyticsMetrics" > < / div >
< div class = "card" > < h2 > Remaining disk space< / h2 > < div id = "analyticsVolumes" > < / div > < p class = "muted" > Volumes on the same device share the reported free space. Stored media totals exclude temporary files and filesystem overhead.< / p > < / div >
< div class = "card" > < h2 > Metadata collector< / h2 >
< p class = "muted" > Search and save descriptive video metadata and thumbnails. Video and audio files are not downloaded.< / p >
< form id = "collectorForm" class = "analytics-form" >
< label class = "query" > Search< input id = "collectorQuery" class = "field" type = "search" maxlength = "200" required placeholder = "Cathedral of Praise Worship" / > < / label >
< label > Total video limit< input id = "collectorLimit" class = "field" type = "number" min = "1" max = "500" value = "30" required / > < / label >
< label > Related-search depth< select id = "collectorDepth" class = "field" > < option value = "0" > 0 · Search only< / option > < option value = "1" > 1 · Channels and topics< / option > < option value = "2" > 2 · Two related rounds< / option > < option value = "3" > 3 · Three related rounds< / option > < / select > < / label >
< button class = "btn pri" type = "submit" id = "collectorStart" > Collect metadata< / button >
< / form >
< p class = "muted" > The limit applies across every round. Depth follows channels and tags found in video metadata, with at most 24 searches. A collection can return fewer videos when results repeat or sources are unavailable. Thumbnails follow the server’ s storage budget.< / p >
< p id = "collectorStatus" role = "status" aria-live = "polite" > < / p > < div id = "collectionJobs" > < / div >
< / div >
< div class = "card" > < h2 > Metadata library< / h2 >
< form id = "metadataFilterForm" class = "analytics-form" > < label class = "query" > Filter saved metadata< input id = "metadataFilter" class = "field" type = "search" maxlength = "200" placeholder = "Title, channel or tag" / > < / label > < button class = "btn" type = "submit" > Filter< / button > < / form >
< p id = "metadataCount" class = "muted" > < / p >
< div class = "analytics-table" > < table id = "metadataTable" > < / table > < / div >
< div class = "row" > < button id = "metadataPrev" class = "btn" type = "button" > Previous< / button > < button id = "metadataNext" class = "btn" type = "button" > Next< / button > < / div >
< details id = "metadataDetails" class = "hidden" > < summary > Video metadata< / summary > < pre id = "metadataDetail" > < / pre > < / details >
< / div >
< div class = "card" > < h2 > Discovery sources< / h2 > < div class = "analytics-table" > < table id = "analyticsSources" > < / table > < / div > < / div >
< div class = "card" > < h2 > Most played< / h2 > < div id = "analyticsTop" > < / div > < / div >
< / section >
< section id = "p-access" class = "panel" >
< h1 class = "page" > API access< / h1 >
< p class = "lead" > Tokens let scripts write shared lyrics and chapters. A token is shown once — store it in your secrets vault.< / p >
@@ -505,6 +569,7 @@
< nav class = "nav" id = "tabsBottom" role = "tablist" > < / nav >
< / div >
< script src = "lyrics-core.js" > < / script >
< script >
( function ( ) {
'use strict' ;
@@ -537,9 +602,10 @@
library : '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>' ,
peers : '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>' ,
history : '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>' ,
analytics : '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>' ,
access : '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>' ,
} ;
const TABS = [ [ 'editor' , 'Editor' ] , [ 'reports' , 'Reports' ] , [ 'library' , 'Library' ] , [ 'peers' , 'P2P' ] , [ 'history' , 'History' ] , [ 'access' , 'API' ] ] ;
const TABS = [ [ 'editor' , 'Editor' ] , [ 'reports' , 'Reports' ] , [ 'library' , 'Library' ] , [ 'peers' , 'P2P' ] , [ 'history' , 'History' ] , [ 'analytics' , 'Stats' ] , [ ' access' , 'API' ] ] ;
let tab = 'editor' ;
function buildTabs ( ) {
const html = TABS . map ( ( [ k , l ] ) => ` <button class="tab" role="tab" data-tab=" ${ k } " aria-selected="false"><svg viewBox="0 0 24 24"> ${ ICONS [ k ] } </svg><span> ${ l } </span> ${ k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : '' } </button> ` ) . join ( '' ) ;
@@ -558,6 +624,7 @@
if ( name === 'peers' ) loadP2p ( ) ;
if ( name === 'library' ) loadUploads ( ) ;
if ( name === 'access' ) loadTokens ( ) ;
if ( name === 'analytics' ) loadAnalytics ( ) ;
window . scrollTo ( { top : 0 } ) ;
}
function setBadge ( n ) {
@@ -596,6 +663,67 @@
} ) ;
$ ( 'logoutBtn' ) . addEventListener ( 'click' , async ( ) => { await api ( '/api/admin/logout' , { method : 'POST' } ) ; showLogin ( ) ; } ) ;
/* ---------------- server analytics / metadata collection ---------------- */
let analyticsTimer = null , metadataOffset = 0 ;
const bytes = value => { let size = Number ( value ) || 0 , unit = 0 ; const units = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ] ; while ( size >= 1024 && unit < 4 ) { size /= 1024 ; unit ++ ; } return ` ${ size . toFixed ( unit ? 1 : 0 ) } ${ units [ unit ] } ` ; } ;
async function loadAnalytics ( ) {
$ ( 'analyticsStatus' ) . textContent = 'Loading server analytics…' ;
try {
const j = await api ( '/api/admin/analytics' ) ;
if ( ! j . ok ) throw Error ( j . error || 'Analytics unavailable.' ) ;
const ready = j . media . find ( row => row . status === 'ready' ) || { } , uploadBytes = j . uploads . reduce ( ( total , row ) => total + Number ( row . bytes ) , 0 ) ;
const metrics = [
[ 'Stored media' , bytes ( Number ( ready . bytes || 0 ) + uploadBytes ) , ` ${ ready . count || 0 } cached videos · ${ j . uploads . reduce ( ( sum , row ) => sum + Number ( row . count ) , 0 ) } uploads ` ] ,
[ 'Metadata videos' , Number ( j . catalog . videos ) . toLocaleString ( ) , ` ${ j . details . videos } with detailed extractor metadata ` ] ,
[ 'Saved thumbnails' , Number ( j . thumbnails . saved || 0 ) . toLocaleString ( ) , ` ${ bytes ( j . thumbnails . bytes ) } · ${ j . thumbnails . pending || 0 } awaiting download ` ] ,
[ 'Recorded plays' , Number ( j . listening . plays ) . toLocaleString ( ) , ` ${ j . listening . videos } different videos ` ] ,
[ 'Cached video size' , bytes ( ready . bytes ) , ` ${ Math . round ( Number ( ready . seconds || 0 ) / 3600 ) } hours of audio/video ` ] ,
[ 'Upload size' , bytes ( uploadBytes ) , 'Server media library' ] ,
[ 'Metadata size' , bytes ( Number ( j . catalog . bytes ) + Number ( j . details . bytes ) ) , 'JSON payloads; database indexes excluded' ] ,
[ 'Cache queue' , j . media . filter ( row => row . status !== 'ready' ) . reduce ( ( sum , row ) => sum + Number ( row . count ) , 0 ) , j . media . filter ( row => row . status !== 'ready' ) . map ( row => ` ${ row . status } : ${ row . count } ` ) . join ( ' · ' ) || 'No pending downloads' ] ,
] ;
$ ( 'analyticsMetrics' ) . innerHTML = metrics . map ( ( [ label , value , detail ] ) => ` <div class="metric"><span> ${ esc ( label ) } </span><b> ${ esc ( value ) } </b><small> ${ esc ( detail ) } </small></div> ` ) . join ( '' ) ;
$ ( 'analyticsVolumes' ) . innerHTML = j . volumes . map ( v => ` <p><b> ${ esc ( v . label ) } </b> · ${ v . unavailable ? 'Storage unavailable' : ` ${ bytes ( v . free ) } available of ${ bytes ( v . total ) } · device ${ esc ( v . device ) } ` } </p> ` ) . join ( '' ) ;
$ ( 'analyticsSources' ) . innerHTML = '<tr><th>Source</th><th>Videos</th><th>Discoveries</th></tr>' + j . sources . map ( row => ` <tr><td> ${ esc ( row . source ) } </td><td> ${ esc ( row . videos ) } </td><td> ${ esc ( row . discoveries ) } </td></tr> ` ) . join ( '' ) ;
$ ( 'analyticsTop' ) . innerHTML = j . topPlayed . length ? j . topPlayed . map ( row => ` <p> ${ esc ( row . title ) } · <b> ${ esc ( row . plays ) } plays</b></p> ` ) . join ( '' ) : '<p class="muted">No listening activity recorded yet.</p>' ;
$ ( 'analyticsStatus' ) . textContent = 'Updated ' + new Date ( ) . toLocaleTimeString ( ) ;
await Promise . all ( [ loadCollections ( ) , loadMetadata ( ) ] ) ;
} catch ( error ) { $ ( 'analyticsStatus' ) . textContent = error . message || 'Could not load server analytics.' ; }
}
async function loadCollections ( ) {
try {
const j = await api ( '/api/admin/collections' ) ; if ( ! j . ok ) return ;
$ ( 'collectionJobs' ) . innerHTML = j . jobs . length ? j . jobs . map ( job => ` <div class="collection-job"><b> ${ esc ( job . query ) } </b> · ${ esc ( job . status ) } <p> ${ job . collected } / ${ job . maxVideos } videos collected · ${ job . enriched } enriched · ${ job . failed } failed · ${ job . searches } searches · depth ${ job . depth } </p> ${ job . current ? ` <p>Working on ${ esc ( job . current ) } </p> ` : '' } ${ job . error ? ` <p> ${ esc ( job . error ) } </p> ` : '' } ${ job . errors ? . length ? ` <details><summary>Extraction errors</summary> ${ job . errors . map ( e => ` <p> ${ esc ( e . id ) } : ${ esc ( e . error ) } </p> ` ) . join ( '' ) } </details> ` : '' } ${ [ 'queued' , 'running' ] . includes ( job . status ) ? ` <button class="btn" type="button" data-cancel-collection=" ${ esc ( job . id ) } ">Stop collection</button> ` : '' } </div> ` ) . join ( '' ) : '<p class="muted">No collections yet.</p>' ;
clearTimeout ( analyticsTimer ) ; analyticsTimer = null ;
if ( tab === 'analytics' && j . jobs . some ( job => [ 'queued' , 'running' ] . includes ( job . status ) ) ) analyticsTimer = setTimeout ( loadCollections , 4000 ) ;
} catch { $ ( 'collectorStatus' ) . textContent = 'Connection interrupted. Collection continues on the server.' ; }
}
async function loadMetadata ( ) {
const j = await api ( ` /api/admin/metadata?q= ${ encodeURIComponent ( $ ( 'metadataFilter' ) . value ) } &offset= ${ metadataOffset } ` ) ; if ( ! j . ok ) return ;
$ ( 'metadataCount' ) . textContent = ` ${ j . total } saved videos · ${ j . total ? metadataOffset + 1 : 0 } – ${ metadataOffset + j . videos . length } ` ;
$ ( 'metadataTable' ) . innerHTML = '<tr><th>Video</th><th>Metadata</th><th>Thumbnail</th></tr>' + j . videos . map ( v => ` <tr><td><button class="btn sm" type="button" data-metadata-id=" ${ esc ( v . id ) } "> ${ esc ( v . title ) } </button><br><small> ${ esc ( v . channel ) } · ${ Number ( v . duration ) > 0 ? Math . round ( Number ( v . duration ) ) + 's' : 'Duration unavailable' } </small></td><td> ${ v . enriched ? 'Detailed' : 'Search card' } </td><td> ${ v . thumbnailBytes ? bytes ( v . thumbnailBytes ) : 'Pending / unavailable' } </td></tr> ` ) . join ( '' ) ;
$ ( 'metadataPrev' ) . disabled = metadataOffset === 0 ; $ ( 'metadataNext' ) . disabled = metadataOffset + 50 >= j . total ;
}
$ ( 'analyticsRefresh' ) . addEventListener ( 'click' , loadAnalytics ) ;
$ ( 'collectorForm' ) . addEventListener ( 'submit' , async e => {
e . preventDefault ( ) ; $ ( 'collectorStart' ) . disabled = true ; $ ( 'collectorStatus' ) . textContent = 'Queuing collection…' ;
try {
const j = await api ( '/api/admin/collections' , { method : 'POST' , body : { query : $ ( 'collectorQuery' ) . value , maxVideos : Number ( $ ( 'collectorLimit' ) . value ) , depth : Number ( $ ( 'collectorDepth' ) . value ) } } ) ;
$ ( 'collectorStatus' ) . textContent = j . ok ? 'Collection queued. You can leave this page while it runs.' : j . error || 'Could not start collection.' ;
if ( j . ok ) await loadCollections ( ) ;
} catch { $ ( 'collectorStatus' ) . textContent = 'Could not reach the server.' ; }
finally { $ ( 'collectorStart' ) . disabled = false ; }
} ) ;
$ ( 'collectionJobs' ) . addEventListener ( 'click' , async e => { const btn = e . target . closest ( '[data-cancel-collection]' ) ; if ( ! btn ) return ; await api ( '/api/admin/collections/' + btn . dataset . cancelCollection + '/cancel' , { method : 'POST' , body : { } } ) ; await loadCollections ( ) ; } ) ;
$ ( 'metadataFilterForm' ) . addEventListener ( 'submit' , e => { e . preventDefault ( ) ; metadataOffset = 0 ; loadMetadata ( ) ; } ) ;
$ ( 'metadataPrev' ) . addEventListener ( 'click' , ( ) => { metadataOffset = Math . max ( 0 , metadataOffset - 50 ) ; loadMetadata ( ) ; } ) ;
$ ( 'metadataNext' ) . addEventListener ( 'click' , ( ) => { metadataOffset += 50 ; loadMetadata ( ) ; } ) ;
$ ( 'metadataTable' ) . addEventListener ( 'click' , async e => {
const btn = e . target . closest ( '[data-metadata-id]' ) ; if ( ! btn ) return ;
const j = await api ( '/api/admin/metadata/' + btn . dataset . metadataId ) ; if ( ! j . ok ) return ;
$ ( 'metadataDetail' ) . textContent = JSON . stringify ( j . metadata , null , 2 ) ; $ ( 'metadataDetails' ) . classList . remove ( 'hidden' ) ; $ ( 'metadataDetails' ) . open = true ;
} ) ;
/* ---------------- tokens / inject ---------------- */
async function loadTokens ( ) {
const j = await api ( '/api/admin/tokens' ) ;
@@ -776,13 +904,18 @@
function paintSongs ( ) {
const q = $ ( 'songFilter' ) . value . trim ( ) . toLowerCase ( ) ;
const rows = SONGS . filter ( ( r ) => ! q || ( r . title + ' ' + ( r . sub || '' ) ) . toLowerCase ( ) . includes ( q ) ) ;
$ ( 'songList' ) . innerHTML = rows . length ? rows . map ( ( r ) => ` <button type="button" class="song-row " data-open=" ${ esc ( r . id ) } ">
$ ( 'songList' ) . innerHTML = rows . length ? rows . map ( ( r ) => ` <div class="song-row">< button type="button" class="song-open " data-open=" ${ esc ( r . id ) } ">
<span style="min-width:0;flex:1"><b> ${ esc ( r . title ) } </b><small> ${ esc ( r . sub || '' ) } </small></span>
<span class="st ${ r . lines > 0 ? 'has' : '' } "> ${ r . lines > 0 ? ` ${ r . lines } lines ` : r . lines === 0 ? 'no lyrics' : 'open' } </span></button>` ) . join ( '' )
<span class="st ${ r . lines > 0 ? 'has' : '' } "> ${ r . lines > 0 ? ` ${ r . lines } lines ` : r . lines === 0 ? 'no lyrics' : 'open' } </span></button>
<button type="button" class="btn sm whisper-request" data-whisper=" ${ esc ( r . id ) } " aria-label="Transcribe ${ esc ( r . title ) } with Whisper">Whisper</button></div> ` ) . join ( '' )
: '<div class="muted" style="padding:10px">Nothing matches — paste a link above to open any YouTube song.</div>' ;
paintWhisper ( ) ;
}
$ ( 'songFilter' ) . addEventListener ( 'input' , paintSongs ) ;
$ ( 'songList' ) . addEventListener ( 'click' , ( e ) => { const b = e . target . closest ( '[data-open]' ) ; if ( b ) leOpen ( b . dataset . open ) ; } ) ;
$ ( 'songList' ) . addEventListener ( 'click' , ( e ) => {
const whisper = e . target . closest ( '[data-whisper]' ) ; if ( whisper ) { startWhisper ( whisper . dataset . whisper ) ; return ; }
const b = e . target . closest ( '[data-open]' ) ; if ( b ) leOpen ( b . dataset . open ) ;
} ) ;
$ ( 'recentChips' ) . addEventListener ( 'click' , ( e ) => { const b = e . target . closest ( '[data-open]' ) ; if ( b ) leOpen ( b . dataset . open ) ; } ) ;
/* ---------------- p2p ---------------- */
@@ -907,6 +1040,67 @@
$ ( 'save' ) . disabled = ! on ;
$ ( 'discard' ) . disabled = ! on ;
}
const whisperJobs = new Map ( ) , whisperStarting = new Set ( ) ;
let whisperTimer = null ;
const activeWhisper = job => job && ( job . status === 'queued' || job . status === 'running' ) ;
function paintWhisper ( ) {
document . querySelectorAll ( '[data-whisper]' ) . forEach ( btn => {
const job = whisperJobs . get ( btn . dataset . whisper ) ;
btn . disabled = whisperStarting . has ( btn . dataset . whisper ) || activeWhisper ( job ) ;
btn . textContent = job ? . status === 'running' ? 'Transcribing…' : job ? . status === 'queued' ? 'Queued' : 'Whisper' ;
} ) ;
const job = whisperJobs . get ( LE . id ) ;
$ ( 'whisperBtn' ) . disabled = whisperStarting . has ( LE . id ) || activeWhisper ( job ) ;
$ ( 'whisperBtn' ) . textContent = activeWhisper ( job ) ? ( job . status === 'queued' ? 'Whisper queued' : 'Whisper transcribing…' ) : 'Transcribe with Whisper' ;
const labels = { 'loading-model' : 'Whisper is loading its model…' , 'downloading-audio' : 'Whisper is reading the song’ s audio…' , transcribing : 'Whisper is transcribing the vocals. This can take several minutes.' } ;
$ ( 'whisperStatus' ) . textContent = job ? . status === 'queued' ? 'Queued for Whisper. Waiting for the lyrics worker.'
: job ? . status === 'running' ? labels [ job . stage ] || 'Whisper is working…'
: job ? . status === 'failed' ? 'Whisper could not finish: ' + ( job . error || 'Try again.' ) : '' ;
const ready = job ? . status === 'complete' && job . result ? . lines ? . length ;
$ ( 'whisperResult' ) . classList . toggle ( 'hidden' , ! ready ) ;
$ ( 'whisperPreview' ) . textContent = ready ? job . result . lines . map ( l => l . text ) . join ( '\n' ) : '' ;
}
async function refreshWhisper ( id ) {
try {
const result = await api ( '/api/admin/transcriptions/' + encodeURIComponent ( id ) ) ;
if ( result . ok ) whisperJobs . set ( id , result . job ) ;
paintWhisper ( ) ; scheduleWhisper ( ) ;
} catch { /* a reconnect or another poll can recover the durable job */ }
}
function scheduleWhisper ( ) {
if ( whisperTimer || ! [ ... whisperJobs . values ( ) ] . some ( activeWhisper ) ) return ;
whisperTimer = setTimeout ( async ( ) => {
whisperTimer = null ;
for ( const [ id , job ] of whisperJobs ) if ( activeWhisper ( job ) ) await refreshWhisper ( id ) ;
scheduleWhisper ( ) ;
} , 5000 ) ;
}
async function startWhisper ( id ) {
if ( ! id || whisperStarting . has ( id ) || activeWhisper ( whisperJobs . get ( id ) ) ) return ;
whisperStarting . add ( id ) ; paintWhisper ( ) ;
try {
if ( LE . id !== id || ! LE . loaded ) { const opened = await leOpen ( id ) ; if ( opened === false ) return ; }
const result = await api ( '/api/admin/transcriptions/' + encodeURIComponent ( id ) , { method : 'POST' , body : { } } ) ;
if ( ! result . ok ) { say ( result . error || 'Whisper could not start.' , 'err' ) ; return ; }
whisperJobs . set ( id , result . job ) ; paintWhisper ( ) ; scheduleWhisper ( ) ;
} catch { say ( 'Could not reach Whisper. Try again when connected.' , 'err' ) ; }
finally { whisperStarting . delete ( id ) ; paintWhisper ( ) ; }
}
$ ( 'whisperBtn' ) . addEventListener ( 'click' , ( ) => startWhisper ( LE . id ) ) ;
$ ( 'whisperUse' ) . addEventListener ( 'click' , ( ) => {
const job = whisperJobs . get ( LE . id ) ; if ( job ? . status !== 'complete' || ! job . result ? . lines ? . length ) return ;
if ( LE . dirty && ! confirm ( 'Replace your unsaved editor text with the Whisper draft? Undo can restore it.' ) ) return ;
edit ( ( ) => {
LE . lines = job . result . lines . map ( l => ( { ... l } ) ) ; LE . offset = 0 ;
LE . tags = [ ... LE . tags . filter ( t => ! /^(from |auto-transcribed)/i . test ( t ) ) , ... job . result . tags ] ;
LE . raw = false ; $ ( 'raw' ) . classList . add ( 'hidden' ) ; $ ( 'lines' ) . classList . remove ( 'hidden' ) ;
$ ( 'rawBtn' ) . textContent = '✎ Edit as text' ; $ ( 'tags' ) . value = LE . tags . join ( ', ' ) ;
} ) ;
render ( ) ; draw ( ) ; syncBars ( ) ;
$ ( 'whisperResult' ) . classList . add ( 'hidden' ) ;
say ( 'Whisper draft loaded. Check the words and timing, then Save to publish; Undo restores the previous text.' , 'ok' ) ;
} ) ;
function progress ( ) {
const timed = LE . lines . filter ( ( l ) => l . kind === 'line' && l . t != null ) . length ;
const all = LE . lines . filter ( ( l ) => l . kind === 'line' ) . length ;
@@ -919,10 +1113,10 @@
}
/* undo / redo — whole-document snapshots, typing is coalesced */
const snap = ( ) => JSON . stringify ( { l : LE . lines , t : LE . tags } ) ;
const snap = ( ) => JSON . stringify ( { l : LE . lines , t : LE . tags , o : LE . offset } ) ;
function pushUndo ( ) { LE . undo . push ( snap ( ) ) ; if ( LE . undo . length > 100 ) LE . undo . shift ( ) ; LE . redo = [ ] ; undoBtns ( ) ; }
function undoBtns ( ) { $ ( 'bUndo' ) . disabled = ! LE . undo . length ; $ ( 'bRedo' ) . disabled = ! LE . redo . length ; }
function restore ( s ) { const o = JSON . parse ( s ) ; LE . lines = o . l ; LE . tags = o . t ; $ ( 'tags' ) . value = LE . tags . join ( ', ' ) ; render ( ) ; setDirty ( true ) ; undoBtns ( ) ; }
function restore ( s ) { const o = JSON . parse ( s ) ; LE . lines = o . l ; LE . tags = o . t ; LE . offset = o . o ? ? LE . offset ; $ ( 'tags' ) . value = LE . tags . join ( ', ' ) ; render ( ) ; setDirty ( true ) ; undoBtns ( ) ; }
function doUndo ( ) { if ( ! LE . undo . length ) return ; LE . redo . push ( snap ( ) ) ; restore ( LE . undo . pop ( ) ) ; say ( 'Undone' ) ; }
function doRedo ( ) { if ( ! LE . redo . length ) return ; LE . undo . push ( snap ( ) ) ; restore ( LE . redo . pop ( ) ) ; say ( 'Redone' ) ; }
let typing = null ;
@@ -957,6 +1151,7 @@
$ ( 'raw' ) . classList . add ( 'hidden' ) ; $ ( 'lines' ) . classList . remove ( 'hidden' ) ; $ ( 'rawBtn' ) . textContent = '✎ Edit as text' ;
await loadFlags ( ) ;
render ( ) ; setDirty ( false ) ; undoBtns ( ) ; syncBars ( ) ;
paintWhisper ( ) ; refreshWhisper ( id ) ;
say ( '' ) ;
if ( ! keepAudio ) initAudio ( id , streams ) ;
draw ( ) ;
@@ -993,7 +1188,7 @@
row . innerHTML = `
<div class="ln-main">
<button class="tc" data-act="chip" type="button"></button>
<input class="ln-text" value=" ${ esc ( line . text ) } " placeholder="Type the line…" aria-label="Line text" / >
<textarea class="ln-text" rows="1" placeholder="Type the line… (Shift+Enter for a line break)" aria-label="Line text"> ${ esc ( line . text ) } </textarea >
<span class="flagchip hidden" data-flag><svg viewBox="0 0 24 24"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg><b></b></span>
<button class="more" data-act="more" type="button" aria-label="Line options">⋯</button>
</div>
@@ -1005,9 +1200,12 @@
<button class="btn" data-act="nudge" data-d="-0.2" type="button">− 0.2</button>
<button class="btn" data-act="nudge" data-d="0.2" type="button">+0.2</button>
<select class="mini" data-kind aria-label="Line type"><option value="line">Line</option><option value="section">Section</option><option value="cue">Cue</option></select>
<button class="btn" data-act="break" type="button" title="Same timestamp, both lines highlight together">↵ Line break</button>
<button class="btn" data-act="add" type="button">+ Below</button>
<button class="btn dng" data-act="del" type="button">Delete</button>
</div> ` ;
const text = row . querySelector ( '.ln-text' ) ;
text . rows = Math . max ( 1 , line . text . split ( '\n' ) . length ) ;
row . querySelector ( '[data-kind]' ) . value = line . kind ;
return row ;
}
@@ -1036,6 +1234,9 @@
LE . rows = LE . lines . map ( ( l , i ) => { const r = rowEl ( l , i ) ; box . appendChild ( r ) ; return r ; } ) ;
if ( ! LE . lines . length ) box . innerHTML = '<div class="empty">No lines yet — press “+ Add line”, paste text with ✎ Edit as text, or pull them from LRCLIB (⋯ tools).</div>' ;
LE . lines . forEach ( ( _ , i ) => paintRow ( i ) ) ;
const fields = LE . rows . map ( row => row . querySelector ( '.ln-text' ) ) ;
const heights = fields . map ( field => field . scrollHeight ) ;
fields . forEach ( ( field , i ) => { field . style . height = heights [ i ] + 'px' ; } ) ;
paintFlags ( ) ; progress ( ) ; lastIdx = - 2 ; tick ( ) ; tapLabel ( ) ;
$ ( 'songMeta' ) . textContent = $ ( 'songMeta' ) . textContent . replace ( /\d+ lines/ , ` ${ LE . lines . length } lines ` ) ;
}
@@ -1109,6 +1310,13 @@
else if ( act === 'set' ) stamp ( i ) ;
else if ( act === 'seek' ) { seek ( ( LE . lines [ i ] . t || 0 ) + LE . offset ) ; if ( LE . audio && LE . audio . paused ) LE . audio . play ( ) . catch ( ( ) => { } ) ; }
else if ( act === 'nudge' ) { if ( LE . lines [ i ] . t == null ) return ; edit ( ( ) => { LE . lines [ i ] . t = r2 ( Math . max ( 0 , LE . lines [ i ] . t + Number ( btn . dataset . d ) ) ) ; } ) ; paintRow ( i ) ; draw ( ) ; }
else if ( act === 'break' ) {
const text = LE . rows [ i ] . querySelector ( '.ln-text' ) ;
const pos = text . selectionStart ;
edit ( ( ) => { LE . lines [ i ] . text = text . value . slice ( 0 , pos ) + '\n' + text . value . slice ( text . selectionEnd ) ; } ) ;
render ( ) ; select ( i ) ;
const target = LE . rows [ i ] . querySelector ( '.ln-text' ) ; target . focus ( ) ; target . setSelectionRange ( pos + 1 , pos + 1 ) ;
}
else if ( act === 'add' ) { edit ( ( ) => LE . lines . splice ( i + 1 , 0 , { t : null , text : '' , kind : 'line' } ) ) ; render ( ) ; select ( i + 1 ) ; LE . rows [ i + 1 ] . querySelector ( '.ln-text' ) . focus ( ) ; }
else if ( act === 'del' ) { edit ( ( ) => LE . lines . splice ( i , 1 ) ) ; LE . sel = - 1 ; render ( ) ; draw ( ) ; }
else if ( act === 'resolve' ) {
@@ -1118,7 +1326,7 @@
} ) ;
$ ( 'lines' ) . addEventListener ( 'input' , ( e ) => {
const row = e . target . closest ( '.ln' ) ; if ( ! row ) return ; const i = Number ( row . dataset . i ) ;
if ( e . target . classList . contains ( 'ln-text' ) ) { typed ( ) ; LE . lines [ i ] . text = e . target . value ; setDirty ( true ) ; tapLabel ( ) ; }
if ( e . target . classList . contains ( 'ln-text' ) ) { typed ( ) ; LE . lines [ i ] . text = e . target . value ; e . target . rows = Math . max ( 1 , e . target . value . split ( '\n' ) . length ) ; e . target . style . height = 'auto' ; e . target . style . height = e . target . scrollHeight + 'px' ; setDirty ( true ) ; tapLabel ( ) ; }
} ) ;
$ ( 'lines' ) . addEventListener ( 'change' , ( e ) => {
const row = e . target . closest ( '.ln' ) ; if ( ! row ) return ; const i = Number ( row . dataset . i ) ;
@@ -1128,6 +1336,7 @@
$ ( 'lines' ) . addEventListener ( 'focusin' , ( e ) => { const row = e . target . closest ( '.ln' ) ; if ( row && Number ( row . dataset . i ) !== LE . sel ) select ( Number ( row . dataset . i ) ) ; } ) ;
$ ( 'lines' ) . addEventListener ( 'keydown' , ( e ) => {
if ( e . key !== 'Enter' || ! e . target . classList . contains ( 'ln-text' ) ) return ;
if ( e . shiftKey ) return ; // Native textarea break stays in the current timed cue.
e . preventDefault ( ) ;
const i = Number ( e . target . closest ( '.ln' ) . dataset . i ) ;
edit ( ( ) => LE . lines . splice ( i + 1 , 0 , { t : null , text : '' , kind : LE . lines [ i ] . kind === 'section' ? 'line' : LE . lines [ i ] . kind } ) ) ;
@@ -1199,17 +1408,10 @@
$ ( 'raw' ) . classList . toggle ( 'hidden' , ! LE . raw ) ; $ ( 'lines' ) . classList . toggle ( 'hidden' , LE . raw ) ;
$ ( 'rawBtn' ) . textContent = LE . raw ? '≡ Back to lines' : '✎ Edit as text' ;
if ( LE . raw ) {
$ ( 'raw' ) . value = LE . lines . map ( ( l ) => ( l . t == null ? '' : ` [ ${ fmtT ( l . t ) } ] ` ) + ( l . kind === 'section' ? '# ' : l . k ind === 'cue' ? '! ' : '' ) + l . text ) . join ( '\n' ) ;
$ ( 'raw' ) . value = LyricsCore . toText ( { lines : LE . l ines } ) ;
} else {
pushUndo ( ) ;
LE . lines = $ ( 'raw' ) . value . split ( '\n ' ) . map ( ( raw ) => {
let rest = raw . trim ( ) , t = null ;
const m = rest . match ( /^\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]\s*/ ) ;
if ( m ) { t = r2 ( Number ( m [ 1 ] ) * 60 + Number ( m [ 2 ] . replace ( ':' , '.' ) ) ) ; rest = rest . slice ( m [ 0 ] . length ) ; }
let kind = 'line' ;
if ( rest . startsWith ( '# ' ) ) { kind = 'section' ; rest = rest . slice ( 2 ) ; } else if ( rest . startsWith ( '! ' ) ) { kind = 'cue' ; rest = rest . slice ( 2 ) ; }
return { t , text : rest , kind } ;
} ) . filter ( ( l ) => l . text ) ;
LE . lines = LyricsCore . fromText ( $ ( 'raw ' ) . value ) . lines ;
setDirty ( true ) ; render ( ) ; draw ( ) ;
}
syncBars ( ) ;
@@ -1242,12 +1444,12 @@
leOpen ( id ) ;
} ) ;
function leOpen ( id , focusText ) {
if ( LE . dirty && id !== LE . id && ! confirm ( 'Discard the unsaved changes to the song you have open?' ) ) return ;
if ( LE . dirty && id !== LE . id && ! confirm ( 'Discard the unsaved changes to the song you have open?' ) ) return false ;
showTab ( 'editor' ) ;
if ( id === LE . id && LE . loaded && focusText ) {
const i = LE . lines . findIndex ( ( l ) => l . text === focusText ) ; if ( i >= 0 ) select ( i , true , true ) ; return ;
}
leLoad ( id , false , focusText ) ;
return leLoad ( id , false , focusText ) ;
}
function renderDocs ( ) {