PageSourceSearch

https://hypercities.com/js/origins.js?v=origins-2

js hypercities.com collected 2026-10-03 21:39:06 UTC 12,239 bytes, 169 lines download raw bytes

1const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 &amp;
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.