1const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); 2const duration = f => f.durationLabel.includes('hour') ? f.durationLabel : Math.floor(f.duration/60)+':'+String(f.duration%60).padStart(2,'0'); 3let playerApi; 4function loadPlayerApi() { 5 if (window.YT?.Player) return Promise.resolve(window.YT); 6 if (playerApi) return playerApi; 7 playerApi = new Promise((resolve,reject)=>{ 8 const script=document.createElement('script'); 9 const fail=()=>{clearTimeout(timeout);script.remove();playerApi=null;reject(new Error('Player diagnostics unavailable'));}; 10 const timeout=setTimeout(fail,15000); 11 const previous=window.onYouTubeIframeAPIReady; 12 window.onYouTubeIframeAPIReady=()=>{clearTimeout(timeout);previous?.();resolve(window.YT);}; 13 script.src='https://www.youtube.com/iframe_api'; 14 script.onerror=fail; 15 document.head.append(script); 16 }); 17 return playerApi; 18} 19 20export class Origins { 21 constructor() { 22 this.dialog=document.querySelector('#origins-window'); 23 this.content=this.dialog.querySelector('[data-origins-content]'); 24 this.films=[]; 25 this.visited=new Set(); 26 this.filters={query:'',place:'',topic:'',time:''}; 27 this.dialog.addEventListener('click',e=>{ 28 const b=e.target.closest('button'); 29 if(b?.hasAttribute('data-origins-close')) this.dialog.close(); 30 else if(b?.dataset.film) this.select(b.dataset.film,true); 31 else if(b?.hasAttribute('data-origins-play')) this.play(); 32 else if(b?.hasAttribute('data-origins-reset')) this.reset(); 33 else if(b?.dataset.step) this.step(Number(b.dataset.step)); 34 else if(b?.hasAttribute('data-origins-reload')) this.load(); 35 else if(e.target===this.dialog){ 36 const r=this.dialog.getBoundingClientRect(); 37 if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)this.dialog.close(); 38 } 39 }); 40 this.dialog.addEventListener('input',e=>{ 41 const key=e.target.dataset.filter; 42 if(key){this.filters[key]=e.target.value;this.renderResults();} 43 }); 44 this.dialog.addEventListener('close',()=>{this.stop();this.content.replaceChildren();this.returnFocus?.focus({preventScroll:true});}); 45 this.dialog.addEventListener('keydown',e=>{ 46 e.stopPropagation(); 47 if(e.target.closest('input,select,textarea'))return; 48 if(e.key==='ArrowRight'||e.key==='ArrowLeft'){e.preventDefault();this.step(e.key==='ArrowRight'?1:-1);} 49 }); 50 } 51 async open(returnFocus){this.returnFocus=returnFocus;this.dialog.showModal();await this.load();} 52 async load(){ 53 this.content.innerHTML='<p role="status">Opening the archiveâ¦</p>'; 54 try{ 55 if(!this.films.length){ 56 const r=await fetch(new URL('../data/origins-videos.json',import.meta.url)); 57 if(!r.ok)throw new Error('Catalogue unavailable'); 58 this.films=(await r.json()).videos; 59 } 60 if(!this.dialog.open)return; 61 this.shell();this.select(this.selected?.id||'1dgdlzlFeVI'); 62 }catch{ 63 if(this.dialog.open)this.content.innerHTML='<p>The catalogue could not load. <button data-origins-reload>Try again</button> or visit the archive link below. Local previews need an HTTP server, not file://.</p>'; 64 } 65 } 66 shell(){ 67 const options=values=>[...new Set(values.filter(Boolean))].sort().map(v=>'<option value="'+esc(v)+'">'+esc(v)+'</option>').join(''); 68 this.content.innerHTML=` 69 <section class="origins-browser" aria-label="Browse recordings"> 70 <details class="origins-browse" open><summary>Browse the archive <span>${this.films.length} recordings</span></summary> 71 <label class="origins-search">Search the archive<input type="search" data-filter="query" placeholder="A place, person, or ideaâ¦" aria-label="Search recordings"></label> 72 <div class="origins-filters"> 73 <label>Place<select data-filter="place"><option value="">All places</option>${options(this.films.map(f=>f.place))}<option value="unknown">Not yet located</option></select></label> 74 <label>Topic<select data-filter="topic"><option value="">All topics</option>${options(this.films.flatMap(f=>f.topics))}</select></label> 75 <label>Time<select data-filter="time"><option value="">All times</option><optgroup label="Time depicted"><option value="subject:2009">2009 · protests</option><option value="subject:Late antiquity">Late antiquity</option><option value="subject:Holocaust and remembrance">Holocaust &
75 remembrance</option></optgroup><optgroup label="Uploaded, not recorded"><option value="upload:2010">Uploaded 2010</option><option value="upload:2016">Uploaded 2016</option></optgroup></select></label> 76 </div> 77 <div class="origins-result-meta"><span data-result-count role="status"></span><button data-origins-reset>Reset</button></div> 78 <nav class="origins-results" aria-label="Recordings" data-results></nav> 79 </details> 80 </section> 81 <article class="origins-film" aria-labelledby="origins-question" data-film-detail></article>`; 82 for(const [key,value]of Object.entries(this.filters))this.content.querySelector('[data-filter="'+key+'"]').value=value; 83 } 84 matches(){ 85 const {query,place,topic,time}=this.filters; 86 const words=query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean); 87 return this.films.filter(f=>{ 88 const text=[f.title,f.place,...f.topics,f.subjectPeriod,f.uploadedYear].join(' ').toLocaleLowerCase(); 89 return words.every(w=>text.includes(w))&&(!place||(place==='unknown'?!f.place:f.place===place))&&(!topic||f.topics.includes(topic))&&(!time||(time.startsWith('upload:')?f.uploadedYear===Number(time.slice(7)):f.subjectPeriod===time.slice(8))); 90 }); 91 } 92 reset(){this.filters={query:'',place:'',topic:'',time:''};this.content.querySelectorAll('[data-filter]').forEach(i=>{i.value='';});this.renderResults();} 93 renderResults(){ 94 this.results=this.matches(); 95 this.content.querySelector('[data-result-count]').textContent=this.results.length+' of '+this.films.length+' recordings'; 96 this.content.querySelector('[data-results]').innerHTML=this.results.length?this.results.map(f=>`<button data-film="${f.id}" aria-current="${f.id===this.selected?.id}" class="${this.visited.has(f.id)?'is-visited':''}"><i aria-hidden="true"></i><span>${esc(f.title)}<small>${esc(f.place||f.topics[0])} · ${esc(duration(f))}</small></span></button>`).join(''):'<p>No recordings match. Try another place, topic, or time.</p>'; 97 const index=this.results.findIndex(f=>f.id===this.selected?.id); 98 const position=this.content.querySelector('[data-position]'); 99 if(!position)return; 100 position.textContent=index<0?'Selected film is outside these results':(index+1)+' / '+this.results.length; 101 this.content.querySelector('[data-step="-1"]').disabled=index<=0; 102 this.content.querySelector('[data-step="1"]').disabled=!this.results.length||index>=this.results.length-1; 103 } 104 step(direction){ 105 if(!this.results?.length)return; 106 const next=this.results[this.results.findIndex(f=>f.id===this.selected?.id)+direction]; 107 if(next)this.select(next.id,true); 108 } 109 related(film){ 110 // Named editorial matches, not an invented fixed six-film sequence. 111 const candidates=this.films.filter(f=>f.id!==film.id).map(f=>({film:f,place:!!film.place&&f.place===film.place,topic:f.topics.find(t=>film.topics.includes(t))})); 112 const place=candidates.find(c=>c.place&&!this.visited.has(c.film.id))||candidates.find(c=>c.place); 113 const topic=candidates.find(c=>c.topic&&!c.place&&!this.visited.has(c.film.id))||candidates.find(c=>c.topic&&c!==place&&!this.visited.has(c.film.id))||candidates.find(c=>c.topic&&c!==place); 114 return [place,topic].filter(Boolean); 115 } 116 select(id,focus=false){ 117 const f=this.films.find(f=>f.id===id);if(!f)return; 118 this.stop();this.selected=f;this.visited.add(id); 119 this.content.querySelector('[data-film-detail]').innerHTML=` 120 <p class="origins-location">${esc(f.place||f.topics[0])} <span>·</span> ${esc(duration(f))}</p> 121 <h2 id="origins-question" tabindex="-1">${esc(f.title)}</h2> 122 <div class="origins-screen"><button class="origins-play" data-origins-play aria-label="Play ${esc(f.title)}"><img src="https://i.ytimg.com/vi/${f.id}/hqdefault.jpg" alt="" width="480" height="360"><span class="origins-play-label"><i aria-hidden="true">â·</i> PLAY RECORDING</span></button></div> 123 <p class="origins-playback-status" data-player-status role="status">${f.embedRestriction?'This recording is age-restricted by YouTube; it may require watching there.':''}</p> 124 <div class="origins-player-links"><button data-origins-play>Reload player</button><a href="${f.source}" target="_blank" rel="noopener">Watch on YouTube â</a></div> 125 ${f.description?'<p class="origins-note">'+esc(f.description)+'</p>':''} 126 <p class="origins-provenance">Uploaded ${f.uploadedYear}${f.subjectPeriod?' · Subject: '+esc(f.subjectPeriod):''}. <span>Upload date is not the recording date.</span></p> 127 <div class="origins-sequence"><button data-step="-1">â Previous video</button><span data-position></span><button data-step="1">Next video â</button></div> 128 <section class="origins-related" aria-label="Related recordings"><p>Explore a connection</p>${this.related(f).map(c=>`<button data-film="${c.film.id}"><small>${esc(c.place?'Same place · '+f.place:'Shared topic · '+c.topic)}</small><span>${esc(c.film.title)} â</span></button>`).join('')||'<span>Choose another recording from the archive.</span>'}</section>`; 129 this.renderResults(); 130 if(focus){ 131 if(matchMedia('(max-width:780px)').matches)this.content.querySelector('.origins-browse').open=false; 132 this.content.querySelector('h2').focus({preventScroll:true}); 133 this.dialog.scrollTop=0; 134 } 135 } 136 stop(){ 137 this.playRequest=null;clearTimeout(this.playerTimeout); 138 this.player?.destroy();this.player=null; 139 this.content.querySelector('iframe')?.remove(); 140 } 141 async play(){ 142 this.stop();const request={};this.playRequest=request; 143 const film=this.selected,screen=this.content.querySelector('.origins-screen'),status=this.content.querySelector('[data-player-status]'); 144 status.textContent=''; 145 // Complete standard embed first; optional API diagnostics never gate playback. 146 // Set referrer policy BEFORE navigating, rather than after the API inserts it. 147 const frame=document.createElement('iframe'); 148 frame.title=film.title;frame.referrerPolicy='strict-origin-when-cross-origin'; 149 frame.allow='autoplay; encrypted-media; fullscreen; picture-in-picture';frame.allowFullscreen=true; 150 const params=new URLSearchParams({playsinline:'1',rel:'0',enablejsapi:'1'}); 151 if(/^https?:$/.test(location.protocol))params.set('origin',location.origin); 152 frame.src='https://www.youtube-nocookie.com/embed/'+film.id+'?'+params; 153 screen.replaceChildren(frame); 154 this.playerTimeout=setTimeout(()=>{if(this.playRequest===request)status.textContent='If the player stays blank, reload it or watch on YouTube. Some browsers restrict embedded playback.';},15000); 155 try{ 156 const api=await loadPlayerApi(); 157 if(this.playRequest!==request||!this.dialog.open)return; 158 this.player=new api.Player(frame,{events:{ 159 onReady:()=>
159{if(this.playRequest===request){clearTimeout(this.playerTimeout);status.textContent='';}}, 160 onStateChange:e=>{if(this.playRequest===request&&e.data===1){clearTimeout(this.playerTimeout);status.textContent='';}}, 161 onError:e=>{ 162 if(this.playRequest!==request)return;clearTimeout(this.playerTimeout); 163 const reasons={2:'Invalid video address.',5:'YouTube could not play this video in this browser.',100:'This recording is unavailable on YouTube.',101:'The owner does not allow this recording to be embedded.',150:'The owner does not allow this recording to be embedded.',153:'YouTube could not verify this previewâs referring site. Try the published site or watch on YouTube.'}; 164 status.textContent=(reasons[e.data]||'YouTube could not play this recording here.')+' (YouTube '+e.data+')'; 165 } 166 }}); 167 }catch{/* The ordinary iframe remains available without the API. */} 168 } 169}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.