1+ // Demo Hub — hash-based router & views
2+ const app = document . getElementById ( "app" ) ;
3+
4+ function esc ( s ) {
5+ return String ( s ) . replace ( / [ & < > " ' ] / g, ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ c ] ) ) ;
6+ }
7+
8+ // Accepts a bare ID, a full watch URL, a youtu.be link, or a playlist URL.
9+ // Returns { videoId, playlistId } — either may be null.
10+ function parseYouTube ( input ) {
11+ if ( ! input ) return { videoId : null , playlistId : null } ;
12+ const raw = String ( input ) . trim ( ) ;
13+ // Bare 11-char video id (no slashes / query)
14+ if ( / ^ [ \w - ] { 11 } $ / . test ( raw ) ) return { videoId : raw , playlistId : null } ;
15+ let videoId = null ,
16+ playlistId = null ;
17+ try {
18+ const u = new URL ( raw ) ;
19+ videoId = u . searchParams . get ( "v" ) ;
20+ playlistId = u . searchParams . get ( "list" ) ;
21+ if ( ! videoId && u . hostname . includes ( "youtu.be" ) ) {
22+ videoId = u . pathname . slice ( 1 ) . split ( "/" ) [ 0 ] || null ;
23+ }
24+ if ( ! videoId && u . pathname . includes ( "/embed/" ) ) {
25+ videoId = u . pathname . split ( "/embed/" ) [ 1 ] . split ( "/" ) [ 0 ] || null ;
26+ }
27+ } catch ( e ) {
28+ // Not a URL — fall through with nulls
29+ }
30+ return { videoId : videoId || null , playlistId : playlistId || null } ;
31+ }
32+
33+ // Build a privacy-friendly embed URL that supports single videos AND (unlisted) playlists.
34+ // Pass { jsapi: true } to enable the IFrame API (needed for auto-advance).
35+ function embedUrl ( video , opts = { } ) {
36+ const parsed = parseYouTube ( video . youtubeId ) ;
37+ const playlistId = video . playlistId || parsed . playlistId ;
38+ const base = { rel : "0" , modestbranding : "1" , playsinline : "1" } ;
39+ if ( opts . jsapi ) {
40+ base . enablejsapi = "1" ;
41+ base . origin = location . origin ;
42+ }
43+ if ( opts . autoplay ) {
44+ base . autoplay = "1" ;
45+ }
46+ const params = new URLSearchParams ( base ) ;
47+ let path ;
48+ if ( parsed . videoId ) {
49+ path = parsed . videoId ;
50+ // Do NOT add list= here — it causes YouTube to handle playlist navigation internally,
51+ // which prevents the ENDED event from firing for our auto-advance logic.
52+ // We manage playlist navigation ourselves via setupAutoAdvance.
53+ } else if ( playlistId && ! parsed . videoId ) {
54+ // Playlist-only (no specific video): use the playlist embed endpoint.
55+ return `https://www.youtube-nocookie.com/embed/videoseries?${ params . toString ( ) } &list=${ encodeURIComponent ( playlistId ) } ` ;
56+ } else {
57+ path = "" ;
58+ }
59+ return `https://www.youtube-nocookie.com/embed/${ path } ?${ params . toString ( ) } ` ;
60+ }
61+
62+ // Auto-advance: load YT IFrame API once, attach player after iframe loads.
63+ let _ytReady = false ;
64+ let _ytReadyCbs = [ ] ;
65+ function onYouTubeIframeAPIReady ( ) {
66+ _ytReady = true ;
67+ _ytReadyCbs . forEach ( fn => fn ( ) ) ;
68+ _ytReadyCbs = [ ] ;
69+ }
70+ function whenYTReady ( fn ) {
71+ if ( _ytReady ) { fn ( ) ; return ; }
72+ _ytReadyCbs . push ( fn ) ;
73+ if ( ! document . querySelector ( 'script[src*="iframe_api"]' ) ) {
74+ const s = document . createElement ( "script" ) ;
75+ s . src = "https://www.youtube.com/iframe_api" ;
76+ document . head . appendChild ( s ) ;
77+ }
78+ }
79+
80+ let _currentPlayer = null ;
81+ let _autoplayNext = false ; // set true when auto-advancing so next video autoplays
82+
83+ function setupAutoAdvance ( nextHash , shouldAutoplay ) {
84+ // Destroy previous player instance cleanly.
85+ if ( _currentPlayer ) {
86+ try { _currentPlayer . destroy ( ) ; } catch ( e ) { }
87+ _currentPlayer = null ;
88+ }
89+
90+ whenYTReady ( ( ) => {
91+ const iframe = document . getElementById ( "yt-player" ) ;
92+ if ( ! iframe ) return ; // user navigated away
93+ _currentPlayer = new YT . Player ( iframe , {
94+ events : {
95+ onReady : ( e ) => {
96+ // If this render was triggered by auto-advance, start playing immediately.
97+ if ( shouldAutoplay ) {
98+ try { e . target . playVideo ( ) ; } catch ( err ) { }
99+ }
100+ } ,
101+ onStateChange : ( e ) => {
102+ if ( e . data === YT . PlayerState . ENDED && nextHash ) {
103+ if ( location . hash === nextHash ) {
104+ render ( true ) ; // pass autoplay flag
105+ } else {
106+ _autoplayNext = true ;
107+ location . hash = nextHash ;
108+ }
109+ }
110+ }
111+ }
112+ } ) ;
113+ } ) ;
114+ }
115+
116+ function thumbUrl ( video ) {
117+ const { videoId } = parseYouTube ( video . youtubeId ) ;
118+ return videoId
119+ ? `https://img.youtube.com/vi/${ videoId } /hqdefault.jpg`
120+ : "https://img.youtube.com/vi/videoseries/hqdefault.jpg" ;
121+ }
122+
123+ function render ( forceAutoplay ) {
124+ const hash = location . hash . slice ( 1 ) ; // e.g. /product/crm/video/crm-1
125+ const parts = hash . split ( "/" ) . filter ( Boolean ) ;
126+ const isHome = parts . length === 0 || parts [ 0 ] !== "product" ;
127+ document . querySelector ( ".navbar" ) . classList . toggle ( "is-home" , isHome ) ;
128+ if ( parts [ 0 ] === "product" && parts [ 2 ] === "video" ) {
129+ renderVideo ( parts [ 1 ] , parts [ 3 ] , forceAutoplay || _autoplayNext ) ;
130+ } else if ( parts [ 0 ] === "product" ) {
131+ renderDashboard ( parts [ 1 ] ) ;
132+ } else {
133+ renderHome ( ) ;
134+ }
135+ window . scrollTo ( 0 , 0 ) ;
136+ }
137+
138+ function renderHome ( ) {
139+ const cards = PRODUCTS . map (
140+ ( p ) => `
141+ <div class="glass card" onclick="location.hash='#/product/${ p . id } '">
142+ <div class="icon">${ p . iconSvg ? `<img src="${ p . iconSvg } " alt="${ esc ( p . name ) } " style="width:32px;height:32px;object-fit:contain;" />` : p . icon } </div>
143+ <h3>${ esc ( p . name ) } </h3>
144+ <p>${ esc ( p . tagline ) } </p>
145+ ${ p . videos && p . videos . length > 0 ? `<div class="count">${ p . videos . length } video${ p . videos . length === 1 ? "" : "s" } →</div>` : "" }
146+ </div>`
147+ ) . join ( "" ) ;
148+
149+ app . innerHTML = `
150+ <div class="home-hero-wrap fade">
151+ <div class="hero-orb orb-1"></div>
152+ <div class="hero-orb orb-2"></div>
153+ <div class="hero-orb orb-3"></div>
154+ <div class="hero-glass">
155+ <div class="hero-logo-row">
156+ <img src="icons/browserstack-icon.svg" alt="BrowserStack" class="hero-logo" />
157+ <span class="hero-brand">BrowserStack</span>
158+ </div>
159+ <h1 class="hero-title">Welcome to the <span class="hero-grad">Demo Hub</span></h1>
160+ <p class="hero-sub">Explore, Learn, Launch</p>
161+ <p class="hero-desc">Explore interactive demos of BrowserStack's powerful testing features, all in one place. Whether you're new or looking to deepen your product knowledge, this hub helps you quickly understand, experience, and onboard with BrowserStack.</p>
162+ <div class="hero-chips">
163+ <span class="chip">🚀 Onboarding</span>
164+ <span class="chip">🎬 Video Walkthroughs</span>
165+ <span class="chip">📚 Documentation</span>
166+ </div>
167+ </div>
168+ </div>
169+ <h2 class="section-label fade">Choose a product</h2>
170+ <div class="grid fade">${ cards } </div>
171+ <footer class="site-footer fade">
172+ <div class="footer-inner">
173+ <div class="footer-brand">
174+ <img src="icons/browserstack-icon.svg" alt="BrowserStack" width="28" height="28" />
175+ <span>BrowserStack Demo Hub</span>
176+ </div>
177+ <p class="footer-desc">Your one-stop destination for BrowserStack product walkthroughs, onboarding videos, and documentation.</p>
178+ <div class="footer-links">
179+ <a href="https://www.browserstack.com/docs" target="_blank" rel="noopener">Documentation</a>
180+ <a href="https://www.browserstack.com/contact" target="_blank" rel="noopener">Support</a>
181+ <a href="https://www.browserstack.com/blog" target="_blank" rel="noopener">Blog</a>
182+ <a href="https://www.browserstack.com/pricing" target="_blank" rel="noopener">Pricing</a>
183+ </div>
184+ <p class="footer-copy">© ${ new Date ( ) . getFullYear ( ) } BrowserStack. All rights reserved.</p>
185+ </div>
186+ </footer>` ;
187+ }
188+
189+ function renderDashboard ( pid ) {
190+ const p = PRODUCTS . find ( ( x ) => x . id === pid ) ;
191+ if ( ! p ) return renderHome ( ) ;
192+ const cards = p . videos . map (
193+ ( v ) => `
194+ <div class="glass card vthumb" onclick="location.hash='#/product/${ p . id } /video/${ v . id } '">
195+ <div class="thumbwrap">
196+ <img class="thumbimg" src="${ thumbUrl ( v ) } " alt="${ esc ( v . title ) } " onerror="this.classList.add('noimg')" />
197+ <div class="play"><span>▶</span></div>
198+ ${ v . duration ? `<span class="dur">${ esc ( v . duration ) } </span>` : "" }
199+ </div>
200+ <div class="meta">
201+ <h3>${ esc ( v . title ) } </h3>
202+ <p>${ esc ( v . description ) . slice ( 0 , 90 ) } ${ v . description . length > 90 ? "…" : "" } </p>
203+ </div>
204+ </div>`
205+ ) . join ( "" ) ;
206+ app . innerHTML = `
207+ <div class="crumbs fade"><a onclick="location.hash='#/'">BrowserStack Demo Hub</a><span>›</span><span>${ esc ( p . name ) } </span></div>
208+ <header class="hero fade" style="text-align:left;margin-bottom:32px;">
209+ <h1 style="font-size:2.4rem;display:flex;align-items:center;gap:12px;">
210+ ${ p . iconSvg ? `<img src="${ p . iconSvg } " alt="" style="width:36px;height:36px;object-fit:contain;flex-shrink:0;" />` : `<span>${ p . icon } </span>` }
211+ ${ esc ( p . name ) }
212+ </h1>
213+ <h5>${ esc ( p . tagline ) } </h5>
214+ </header>
215+ <div class="grid fade">${ cards } </div>` ;
216+ }
217+
218+ function renderVideo ( pid , vid , autoplay ) {
219+ const p = PRODUCTS . find ( ( x ) => x . id === pid ) ;
220+ const idx = p ? p . videos . findIndex ( ( x ) => x . id === vid ) : - 1 ;
221+ const v = idx >= 0 ? p . videos [ idx ] : null ;
222+ if ( ! v ) return renderHome ( ) ;
223+ // Accepts both plain URL strings and { label, url } objects.
224+ function renderLink ( item , emoji ) {
225+ const url = typeof item === "string" ? item : item . url ;
226+ const label = typeof item === "string"
227+ ? decodeURIComponent ( url . split ( "/" ) . filter ( Boolean ) . pop ( ) . replace ( / - / g, " " ) )
228+ : item . label ;
229+ return `<li><a href="${ esc ( url ) } " target="_blank" rel="noopener">${ emoji } ${ esc ( label ) } </a></li>` ;
230+ }
231+ const docs = v . docs . map ( ( d ) => renderLink ( d , "📄" ) ) . join ( "" ) ;
232+ const links = v . links . map ( ( l ) => renderLink ( l , "🔗" ) ) . join ( "" ) ;
233+ const playlist = p . videos
234+ . map (
235+ ( item , i ) => `
236+ <li class="pl-item ${ item . id === v . id ? "active" : "" } " data-vid="${ item . id } " onclick="location.hash='#/product/${ p . id } /video/${ item . id } '">
237+ <span class="pl-index">${ i + 1 } </span>
238+ <img class="pl-thumb" src="${ thumbUrl ( item ) } " alt="" onerror="this.classList.add('noimg')" />
239+ <span class="pl-meta">
240+ <span class="pl-title">${ esc ( item . title ) } </span>
241+ <span class="pl-sub">
242+ ${ item . duration ? `<span class="pl-dur">${ esc ( item . duration ) } </span>` : "" }
243+ ${ item . id === v . id ? '<span class="pl-now">Now playing</span>' : "" }
244+ </span>
245+ </span>
246+ </li>`
247+ )
248+ . join ( "" ) ;
249+ app . innerHTML = `
250+ <div class="crumbs fade">
251+ <a onclick="location.hash='#/'">BrowserStack Demo Hub</a><span>›</span>
252+ <a onclick="location.hash='#/product/${ p . id } '">${ esc ( p . name ) } </a><span>›</span>
253+ <span>${ esc ( v . title ) } </span>
254+ </div>
255+ <div class="detail fade">
256+ <div class="glass player-wrap">
257+ <iframe id="yt-player" src="${ embedUrl ( v , { jsapi : true , autoplay : _autoplayNext } ) } " title="${ esc ( v . title ) } " allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
258+ </div>
259+ <div class="glass panel desc-panel">
260+ <h2>${ esc ( v . title ) } </h2>
261+ <p>${ esc ( v . description ) } </p>
262+ </div>
263+ <div class="side">
264+ <div class="glass panel pl-panel">
265+ <h4>▶ Playlist · ${ p . videos . length } videos</h4>
266+ <ul class="playlist">${ playlist } </ul>
267+ </div>
268+ <div class="glass panel">
269+ <h4>Documentation</h4>
270+ <ul class="linklist">${ docs || "<li><p>None yet.</p></li>" } </ul>
271+ </div>
272+ <div class="glass panel">
273+ <h4>Relevant Links</h4>
274+ <ul class="linklist">${ links || "<li><p>None yet.</p></li>" } </ul>
275+ </div>
276+ </div>
277+ </div>` ;
278+ // Consume the autoplay flag (reset after use so manual navigation doesn't autoplay).
279+ _autoplayNext = false ;
280+ // Auto-advance to the next video when this one ends; pass autoplay flag to start playing.
281+ const next = p . videos [ idx + 1 ] ;
282+ setupAutoAdvance ( next ? `#/product/${ p . id } /video/${ next . id } ` : null , ! ! autoplay ) ;
283+ // Keep the active playlist item in view.
284+ const active = document . querySelector ( ".pl-item.active" ) ;
285+ if ( active ) active . scrollIntoView ( { block : "nearest" } ) ;
286+ }
287+
288+ // Dark mode toggle — persists in localStorage
289+ function initDarkMode ( ) {
290+ const saved = localStorage . getItem ( "theme" ) ;
291+ if ( saved === "dark" ) document . documentElement . setAttribute ( "data-theme" , "dark" ) ;
292+ updateToggleIcon ( ) ;
293+ }
294+ function updateToggleIcon ( ) {
295+ const btn = document . getElementById ( "darkToggle" ) ;
296+ if ( ! btn ) return ;
297+ const isDark = document . documentElement . getAttribute ( "data-theme" ) === "dark" ;
298+ btn . querySelector ( ".toggle-icon" ) . textContent = isDark ? "☀️" : "🌙" ;
299+ btn . title = isDark ? "Switch to light mode" : "Switch to dark mode" ;
300+ }
301+ document . getElementById ( "darkToggle" ) . addEventListener ( "click" , ( ) => {
302+ const isDark = document . documentElement . getAttribute ( "data-theme" ) === "dark" ;
303+ if ( isDark ) {
304+ document . documentElement . removeAttribute ( "data-theme" ) ;
305+ localStorage . setItem ( "theme" , "light" ) ;
306+ } else {
307+ document . documentElement . setAttribute ( "data-theme" , "dark" ) ;
308+ localStorage . setItem ( "theme" , "dark" ) ;
309+ }
310+ updateToggleIcon ( ) ;
311+ } ) ;
312+ initDarkMode ( ) ;
313+
314+ window . addEventListener ( "hashchange" , render ) ;
315+ render ( ) ;
0 commit comments