@@ -28,6 +28,7 @@ const THEME_META = {
2828
2929const state = {
3030 stops : [ ] ,
31+ stopsByTheme : new Map ( ) ,
3132 index : 0 ,
3233 activeTheme : "Thermal" ,
3334 locale : localeFromUrl ( ) ,
@@ -251,6 +252,14 @@ let progressFrame = 0;
251252let progressTrackWidth = 0 ;
252253const imageLoadCache = new Map ( ) ;
253254
255+ function scheduleIdleTask ( callback , timeout = 1000 ) {
256+ if ( "requestIdleCallback" in window ) {
257+ window . requestIdleCallback ( callback , { timeout } ) ;
258+ } else {
259+ window . setTimeout ( callback , 0 ) ;
260+ }
261+ }
262+
254263function syncProgressTrackWidth ( ) {
255264 progressTrackWidth = ui . track . clientWidth || progressTrackWidth ;
256265}
@@ -322,6 +331,7 @@ function showKeyboardHints() {
322331function buildWaveform ( seed ) {
323332 clearNode ( ui . waveform ) ;
324333 const count = 48 ;
334+ const fragment = document . createDocumentFragment ( ) ;
325335
326336 for ( let i = 0 ; i < count ; i ++ ) {
327337 const bar = document . createElement ( "span" ) ;
@@ -330,8 +340,10 @@ function buildWaveform(seed) {
330340 bar . style . setProperty ( "--h" , `${ height } %` ) ;
331341 bar . style . setProperty ( "--d" , `${ i * 35 } ms` ) ;
332342 bar . dataset . index = i ;
333- ui . waveform . appendChild ( bar ) ;
343+ fragment . appendChild ( bar ) ;
334344 }
345+
346+ ui . waveform . appendChild ( fragment ) ;
335347}
336348
337349function updateWaveform ( percent ) {
@@ -469,6 +481,16 @@ function derivePaletteFromImage(src, fallbackPalette, fallbackSrc = "") {
469481 } ) ;
470482}
471483
484+ function scheduleImagePalette ( imageSrc , fallbackPalette , fallbackSrc , expectedId ) {
485+ scheduleIdleTask ( ( ) => {
486+ derivePaletteFromImage ( imageSrc , fallbackPalette , fallbackSrc ) . then ( ( palette ) => {
487+ const selected = state . stops [ state . index ] ;
488+ if ( ! selected || selected . id !== expectedId ) return ;
489+ applyDerivedPalette ( palette ) ;
490+ } ) ;
491+ } , 1200 ) ;
492+ }
493+
472494function applyDerivedPalette ( palette ) {
473495 [ ui . body , ui . playerCard , ui . strip ] . forEach ( ( node ) => {
474496 node . style . setProperty ( "--theme-color" , palette . accent ) ;
@@ -671,11 +693,7 @@ function updateAtmosphere(stop) {
671693 setBackdropImage ( loaded , localized . title ) ;
672694 } ) ;
673695
674- derivePaletteFromImage ( imageSrc , fallbackPalette , fallbackSrc ) . then ( ( palette ) => {
675- const selected = state . stops [ state . index ] ;
676- if ( ! selected || selected . id !== expectedId ) return ;
677- applyDerivedPalette ( palette ) ;
678- } ) ;
696+ scheduleImagePalette ( imageSrc , fallbackPalette , fallbackSrc , expectedId ) ;
679697}
680698
681699function getStopNumber ( index ) {
@@ -697,9 +715,7 @@ function applyTheme(theme) {
697715}
698716
699717function themeStops ( theme ) {
700- return state . stops
701- . map ( ( stop , index ) => ( { ...stop , index } ) )
702- . filter ( ( stop ) => stop . theme === theme ) ;
718+ return state . stopsByTheme . get ( theme ) || [ ] ;
703719}
704720
705721function activePlaybackTheme ( ) {
@@ -740,9 +756,12 @@ function themeCount(theme) {
740756
741757function buildThemeTabs ( ) {
742758 clearNode ( ui . themeTabs ) ;
759+ const fragment = document . createDocumentFragment ( ) ;
760+ const text = copy ( ) ;
743761
744762 THEME_ORDER . forEach ( ( theme ) => {
745763 const meta = getThemeMeta ( theme ) ;
764+ const countValue = themeCount ( theme ) ;
746765 const themeLabel = localizeThemeName ( theme , state . locale ) ;
747766 const button = document . createElement ( "button" ) ;
748767 button . type = "button" ;
@@ -754,7 +773,7 @@ function buildThemeTabs() {
754773 button . setAttribute ( "role" , "tab" ) ;
755774 button . setAttribute ( "aria-controls" , "chip-carousel" ) ;
756775 button . setAttribute ( "aria-selected" , String ( theme === state . activeTheme ) ) ;
757- button . setAttribute ( "aria-label" , copy ( ) . selectTheme ( themeLabel , themeCount ( theme ) ) ) ;
776+ button . setAttribute ( "aria-label" , text . selectTheme ( themeLabel , countValue ) ) ;
758777 button . addEventListener ( "click" , ( ) => setActiveTheme ( theme , true ) ) ;
759778
760779 const swatch = document . createElement ( "span" ) ;
@@ -767,15 +786,19 @@ function buildThemeTabs() {
767786
768787 const count = document . createElement ( "span" ) ;
769788 count . className = "theme-count" ;
770- count . textContent = themeCount ( theme ) ;
789+ count . textContent = countValue ;
771790
772791 button . append ( swatch , name , count ) ;
773- ui . themeTabs . appendChild ( button ) ;
792+ fragment . appendChild ( button ) ;
774793 } ) ;
794+
795+ ui . themeTabs . appendChild ( fragment ) ;
775796}
776797
777- function buildChipCarousel ( ) {
798+ function buildChipCarousel ( { deferImages = false } = { } ) {
778799 clearNode ( ui . chipCarousel ) ;
800+ const fragment = document . createDocumentFragment ( ) ;
801+ const text = copy ( ) ;
779802
780803 themeStops ( state . activeTheme ) . forEach ( ( stop ) => {
781804 const meta = getThemeMeta ( stop . theme ) ;
@@ -785,7 +808,7 @@ function buildChipCarousel() {
785808 chip . className = "specimen-chip stamp-chip" ;
786809 chip . dataset . index = String ( stop . index ) ;
787810 chip . style . setProperty ( "--theme-color" , meta . color ) ;
788- chip . setAttribute ( "aria-label" , copy ( ) . selectStop ( localized . title ) ) ;
811+ chip . setAttribute ( "aria-label" , text . selectStop ( localized . title ) ) ;
789812 chip . addEventListener ( "click" , ( ) => {
790813 selectStop ( stop . index , ! ui . audio . paused , { keepActiveTheme : true } ) ;
791814 } ) ;
@@ -798,7 +821,11 @@ function buildChipCarousel() {
798821 photo . decoding = "async" ;
799822 photo . setAttribute ( "fetchpriority" , "low" ) ;
800823 photo . dataset . fallbackSrc = originalImageSrc ( stop . imagePath ) ;
801- photo . src = thumbnailPhotoSrc ( stop ) ;
824+ if ( deferImages ) {
825+ photo . dataset . src = thumbnailPhotoSrc ( stop ) ;
826+ } else {
827+ photo . src = thumbnailPhotoSrc ( stop ) ;
828+ }
802829 photo . addEventListener ( "error" , onChipPhotoError ) ;
803830 chip . appendChild ( photo ) ;
804831 }
@@ -813,7 +840,16 @@ function buildChipCarousel() {
813840 theme . textContent = localized . theme ;
814841
815842 chip . append ( label , title , theme ) ;
816- ui . chipCarousel . appendChild ( chip ) ;
843+ fragment . appendChild ( chip ) ;
844+ } ) ;
845+
846+ ui . chipCarousel . appendChild ( fragment ) ;
847+ }
848+
849+ function hydrateDeferredChipImages ( ) {
850+ ui . chipCarousel . querySelectorAll ( ".chip-photo[data-src]" ) . forEach ( ( photo ) => {
851+ photo . src = photo . dataset . src ;
852+ photo . removeAttribute ( "data-src" ) ;
817853 } ) ;
818854}
819855
@@ -1001,6 +1037,7 @@ function onChipPhotoError(e) {
10011037
10021038 if ( fallbackSrc ) {
10031039 img . removeAttribute ( "data-fallback-src" ) ;
1040+ img . removeAttribute ( "data-src" ) ;
10041041 img . src = fallbackSrc ;
10051042 } else {
10061043 img . remove ( ) ;
@@ -1435,6 +1472,18 @@ function validateStops(stops) {
14351472 } ) ;
14361473}
14371474
1475+ function indexStops ( stops ) {
1476+ return stops . map ( ( stop , index ) => ( { ...stop , index } ) ) ;
1477+ }
1478+
1479+ function groupStopsByTheme ( stops ) {
1480+ const buckets = new Map ( THEME_ORDER . map ( ( theme ) => [ theme , [ ] ] ) ) ;
1481+ stops . forEach ( ( stop ) => {
1482+ buckets . get ( stop . theme ) ?. push ( stop ) ;
1483+ } ) ;
1484+ return buckets ;
1485+ }
1486+
14381487async function loadRoute ( ) {
14391488 try {
14401489 const res = await fetch ( ROUTE_URL ) ;
@@ -1443,14 +1492,21 @@ async function loadRoute() {
14431492 const stops = await res . json ( ) ;
14441493 validateStops ( stops ) ;
14451494
1446- state . stops = stops ;
1447- const startingIndex = initialStopIndex ( stops ) ;
1448- state . activeTheme = stops [ startingIndex ] . theme ;
1495+ state . stops = indexStops ( stops ) ;
1496+ state . stopsByTheme = groupStopsByTheme ( state . stops ) ;
1497+ const startingIndex = initialStopIndex ( state . stops ) ;
1498+ state . activeTheme = state . stops [ startingIndex ] . theme ;
14491499 applyTheme ( state . activeTheme ) ;
14501500 buildThemeTabs ( ) ;
1451- buildChipCarousel ( ) ;
14521501 setMinimized ( false ) ;
14531502 selectStop ( startingIndex ) ;
1503+ scheduleIdleTask ( ( ) => {
1504+ if ( ! ui . chipCarousel . children . length ) {
1505+ buildChipCarousel ( { deferImages : true } ) ;
1506+ updateThemeNav ( ) ;
1507+ }
1508+ hydrateDeferredChipImages ( ) ;
1509+ } , 900 ) ;
14541510 showStatus ( copy ( ) . statusLoaded ( stops . length ) ) ;
14551511 } catch ( err ) {
14561512 showStatus ( err . message , true ) ;
0 commit comments