PageSourceSearch

https://musicpracticeapp.netlify.app/app.js

js musicpracticeapp.netlify.app collected 2026-10-03 11:11:13 UTC 93,572 bytes, 2,601 lines download raw bytes

1// ==================== //
2// YouTube Player App   //
3// ==================== //
4
5// Account Manager Class
6class AccountManager {
7    constructor() {
8        this.accountType = this.getAccountType();
9        this.licenseKey = this.getLicenseKey();
10        this.welcomeShown = this.getWelcomeShown();
11        this.userId = this.getUserId();
12
13        // Check and update referral status on load
14        this.checkReferralExpiry();
15    }
16
17    getAccountType() {
18        return localStorage.getItem('musicPracticeAccountType') || 'free';
19    }
20
21    setAccountType(type) {
22        localStorage.setItem('musicPracticeAccountType', type);
23        this.accountType = type;
24    }
25
26    getLicenseKey() {
27        return localStorage.getItem('musicPracticeLicenseKey') || null;
28    }
29
30    setLicenseKey(key) {
31        localStorage.setItem('musicPracticeLicenseKey', key);
32        this.licenseKey = key;
33    }
34
35    getWelcomeShown() {
36        return localStorage.getItem('musicPracticeWelcomeShown') === 'true';
37    }
38
39    setWelcomeShown() {
40        localStorage.setItem('musicPracticeWelcomeShown', 'true');
41        this.welcomeShown = true;
42    }
43
44    isPaid() {
45        return this.accountType === 'paid';
46    }
47
48    validateLicenseKey(key) {
49        // Format: MUSIC-PRACTICE-XXXX-XXXX-XXXX
50        const pattern = /^MUSIC-PRACTICE-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
51        return pattern.test(key.toUpperCase());
52    }
53
54    upgrade(licenseKey) {
55        if (this.validateLicenseKey(licenseKey)) {
56            this.setAccountType('paid');
57            this.setLicenseKey(licenseKey);
58            return true;
59        }
60        return false;
61    }
62
63    // ==================== //
64    // Referral System      //
65    // ==================== //
66
67    getUserId() {
68        let userId = localStorage.getItem('musicPracticeUserId');
69        if (!userId) {
70            userId = 'user-' + this.generateRandomCode(12);
71            localStorage.setItem('musicPracticeUserId', userId);
72        }
73        return userId;
74    }
75
76    getReferralCode() {
77        return localStorage.getItem('musicPracticeReferralCode') || null;
78    }
79
80    setReferralCode(code) {
81        localStorage.setItem('musicPracticeReferralCode', code);
82    }
83
84    getReferredBy() {
85        return localStorage.getItem('musicPracticeReferredBy') || null;
86    }
87
88    setReferredBy(code) {
89        localStorage.setItem('musicPracticeReferredBy', code);
90    }
91
92    getReferralExpiry() {
93        return localStorage.getItem('musicPracticeReferralExpiry') || null;
94    }
95
96    setReferralExpiry(date) {
97        localStorage.setItem('musicPracticeReferralExpiry', date);
98    }
99
100    generateRandomCode(length) {
101        const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
102        let result = '';
103        for (let i = 0; i < length; i++) {
104            result += chars.charAt(Math.floor(Math.random() * chars.length));
105        }
106        return result;
107    }
108
109    async generateReferralCode() {
110        // Check if we already have a code
111        let code = this.getReferralCode();
112        if (code) return code;
113
114        // Generate a new code
115        code = 'REF-' + this.generateRandomCode(8);
116
117        // Try to register it with the backend
118        try {
119            const response = await fetch('/.netlify/functions/generate-referral-code', {
120                method: 'POST',
121                headers: { 'Content-Type': 'application/json' },
122                body: JSON.stringify({
123                    userId: this.userId,
124                    code: code
125                })
126            });
127
128            if (response.ok) {
129                const data = await response.json();
130                code = data.code || code;
131            }
132        } catch (e) {
133
134        }
135
136        this.setReferralCode(code);
137        return code;
138    }
139
140    isReferralActive() {
141        if (this.accountType !== 'referral') return false;
142
143        const expiry = this.getReferralExpiry();
144        if (!expiry) return false;
145
146        return new Date(expiry) > new Date();
147    }
148
149    checkReferralExpiry() {
150        if (this.accountType === 'referral' && !this.isReferralActive()) {
151            // Referral has expired, revert to free
152            this.setAccountType('free');
153            this.accountType = 'free';
154        }
155    }
156
157    upgradeToReferral() {
158        const expiry = new Date();
159        expiry.setMonth(expiry.getMonth() + 3); // 3 months from now
160
161        this.setAccountType('referral');
162        this.setReferralExpiry(expiry.toISOString());
163        this.accountType = 'referral';
164    }
165
166    getVideoLimit() {
167        if (this.accountType === 'paid') return Infinity;
168        if (this.accountType === 'referral' && this.isReferralActive()) return 5;
169        return 3;
170    }
171
172    getRemainingReferralDays() {
173        if (!this.isReferralActive()) return 0;
174
175        const expiry = new Date(this.getReferralExpiry());
176        const now = new Date();
177        const diffTime = expiry - now;
178        const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
179        return Math.max(0, diffDays);
180    }
181}
182
183class MusicPracticeApp {
184    constructor() {
185        // State
186        this.videos = [];
187        this.currentVideoId = null;
188        this.player = null;
189        this.playerReady = false;
190        this.loopStart = null;
191        this.loopEnd = null;
192        this.loopEnabled = false;
193        this.loopCheckInterval = null;
194        this.timeUpdateInterval = null;
195
196        // Practice Timer State
197        this._practiceTimerSeconds = 0;
198        this._practiceTimerInterval = null;
199        this._practiceTimerRunning = false;
200        this._loopRepCount = 0;
201        this._lastLoopSeekTime = 0;
202        this._practiceStatsInterval = null;
203
204        // Pending shareable URL settings (applied after player is ready)
205        this._pendingShareParams = null;
206
207        // Progressive Speed Training State
208        this._progressiveEnabled = false;
209        this._progressiveRepsPerStep = 3;      // loops before speed increase
210        this._progressiveSpeedStep = 0.05;     // speed increment per step
211        this._progressiveTargetSpeed = 1.0;    // goal speed
212        this._progressiveRepsAtCurrentSpeed = 0; // reps counted at current speed
213
214        // Metronome State
215        this._metronomeAudioCtx = null;
216        this._metronomeRunning = false;
217        this._metronomeBpm = 120;
218        this._metronomeTimeSig = [4, 4]; // [beats, noteValue]
219        this._metronomeVolume = 0.5;
220        this._metronomeBeatCount = 0;
221        this._metronomeNextNoteTime = 0;
222        this._metronomeSchedulerTimer = null;
223        this._metronomeLookahead = 25.0; // ms
224        this._metronomeScheduleAheadTime = 0.1; // seconds
225        this._metronomeTapTimes = [];
226
227        // YouTube API lazy-loading state
228        this._ytApiLoaded = false;
229        this._ytApiLoading = false;
230        this._ytApiReadyResolve = null;
231        this._ytApiReadyPromise = new Promise((resolve) => {
232            this._ytApiReadyResolve = resolve;
233        });
234
235        // Account Manager
236        this.accountManager = new AccountManager();
237
238        // YouTube API key (from environment variable via Netlify)
239        this.youtubeApiKey = window.YOUTUBE_API_KEY || '';
240
241        // DOM Elements
242        this.elements = {
243            videoUrlInput: document.getElementById('videoUrlInput'),
244            addVideoBtn: document.getElementById('addVideoBtn'),
245            videoList: document.getElementById('videoList'),
246            playerSection: document.getElementById('playerSection'),
247            currentVideoTitle: document.getElementById('currentVideoTitle'),
248            playPauseBtn: document.getElementById('playPauseBtn'),
249            skipToStartBtn: document.getElementById('skipToStartBtn'),
250            speedSlider: document.getElementById('speedSlider'),
251            speedDisplay: document.getElementById('speedDisplay'),
252            speedPresets: document.querySelectorAll('.btn-preset'),
253            seekBackBtn: document.getElementById('seekBackBtn'),
254            seekForwardBtn: document.getElementById('seekForwardBtn'),
255            timelineSlider: document.getElementById('timelineSlider'),
256            currentTime: document.getElementById('currentTime'),
257            duration: document.getElementById('duration'),
258            progressBar: document.getElementById('progressBar'),
259            loopOverlay: document.getElementById('loopOverlay'),
260            setLoopStartBtn: document.getElementById('setLoopStartBtn'),
261            setLoopEndBtn: document.getElementById('setLoopEndBtn'),
262            toggleLoopBtn: document.getElementById('toggleLoopBtn'),
263            clearLoopBtn: document.getElementById('clearLoopBtn'),
264            loopStartDisplay: document.getElementById('loopStartDisplay'),
265            loopEndDisplay: document.getElementById('loopEndDisplay'),
266            searchSection: document.getElementById('searchSection'),
267            searchInput: document.getElementById('searchInput'),
268            searchBtn: document.getElementById('searchBtn'),
269            searchResults: document.getElementById('searchResults')
270        };
271
272        this.init();
273    }
274
275    init() {
276        // Wake Lock state
277        this._wakeLock = null;
278
279        // Handle referral URL parameter
280        this.handleReferralFromUrl();
281
282        // Load videos from localStorage
283        this.loadVideos();
284        this.renderVideoList();
285
286        // Bind event listeners
287        this.bindEvents();
288
289        // Show search if API key available
290        if (this.youtubeApiKey) {
291            this.elements.searchSection.style.display = 'block';
292        }
293
294        // Check for shareable URL params
295        const hasShareParams = this.handleShareableUrl();
296
297        // Hide welcome banner for returning users; show for first-time (banner starts visible in HTML to avoid CLS)
298        if (hasShareParams) {
299            const banner = document.getElementById('welcomeBanner');
300            if (banner) banner.style.display = 'none';
301        } else {
302            this.showWelcomeBannerIfNeeded();
303        }
304
305        // Mark old welcome modal as shown so it never appears
306        if (!this.accountManager.welcomeShown) {
307            this.accountManager.setWelcomeShown();
308        }
309
310        // Initialize analytics
311        this.identifyUser();
312        this.trackEvent('page_loaded', {
313            hasApiKey: !!this.youtubeApiKey,
314            referredBy: this.accountManager.getReferredBy() || null,
315            sharedLink: hasShareParams
316        });
317
318        // Set account created date if not set
319        if (!localStorage.getItem('musicPracticeAccountCreated')) {
320            localStorage.setItem('musicPracticeAccountCreated', new Date().toISOString());
321        }
322
323        // Update account UI
324        this.updateAccountUI();
325
326        // Inject mobile speed presets (large tap targets near player)
327        this.injectMobileSpeedPresets();
328
329        // Request Wake Lock to keep screen on during practice
330        this.requestWakeLock();
331
332        // Re-acquire Wake Lock when page becomes visible again
333        document.addEventListener('visibilitychange', () => {
334            if (document.visibilityState === 'visible') {
335                this.requestWakeLock();
336            }
337        });
338
339        // Initialize practice timer & stats
340        this.initPracticeTimer();
341
342        // Initialize metronome
343        this.initMetronome();
344
345        // Initialize progressive speed training
346        this.initProgressiveTraining();
347
348        // YouTube API is lazy-loaded when a video is first needed (createPlayer)
349    }
350
351    handleReferralFromUrl() {
352        const urlParams = new URLSearchParams(window.location.search);
353        const refCode = urlParams.get('ref');
354
355        if (refCode && !this.accountManager.getReferredBy()) {
356            // Store the referral code
357            this.accountManager.setReferredBy(refCode);
358            this.trackEvent('referral_link_visited', { referralCode: refCode });
359
360            // Clean the URL
361            const url = new URL(window.location);
362            url.searchParams.delete('ref');
363            window.history.replaceState({}, '', url);
364        }
365    }
366
367    // ==================== //
368    // Shareable URLs       //
369    // ==================== //
370
371    handleShareableUrl() {
372        const urlParams = new URLSearchParams(window.location.search);
373        const videoId = urlParams.get('v');
374
375        if (!videoId) return false;
376
377        // Parse optional params
378        const start = parseFloat(urlParams.get('start'));
379        const end = parseFloat(urlParams.get('end'));
380        const speed = parseFloat(urlParams.get('speed'));
381
382        // Store pending settings to apply once player is ready
383        this._pendingShareParams = {
384            videoId: videoId,
385            start: isNaN(start) ? null : start,
386            end: isNaN(end) ? null : end,
387            speed: isNaN(speed) ? null : Math.max(0.25, Math.min(2, speed))
388        };
389
390        // Track the shared link visit
391        this.trackEvent('shared_link_visited', {
392            videoId: videoId,
393            hasLoop: !isNaN(start) && !isNaN(end),
394            speed: isNaN(speed) ? null : speed
395        });
396
397        // Clean the share params from URL (keep ref if present)
398        const url = new URL(window.location);
399        url.searchParams.delete('v');
400        url.searchParams.delete('start');
401        url.searchParams.delete('end');
402        url.searchParams.delete('speed');
403        window.history.replaceState({}, '', url.toString());
404
405        // Add video to library if not already there (don't count against limit for shared links)
406        if (!this.videos.find(v => v.id === videoId)) {
407            const video = {
408                id: videoId,
409                title: null, // Will be fetched
410                addedAt: Date.now()
411            };
412            this.videos.push(video);
413            this.saveVideos();
414            this.renderVideoList();
415
416            // Fetch title in background
417            this.fetchVideoTitle(videoId).then(title => {
418                if (title) {
419                    const v = this.videos.find(v => v.id === videoId);
420                    if (v) {
421                        v.title = title;
422                        this.saveVideos();
423                        this.renderVideoList();
424                    }
425                }
426            });
427        }
428
429        // Show player section and load the video
430        this.currentVideoId = videoId;
431        this.elements.playerSection.style.display = 'block';
432        this.renderVideoList();
433
434        // Show share section
435        const shareSection = document.getElementById('shareSection');
436        if (shareSection) shareSection.style.display = 'block';
437
438        // Show saved loops section
439        const saveSection = document.getElementById('saveLoopSection');
440        if (saveSection) saveSection.style.display = 'block';
441        this.renderSavedLoops(videoId);
442
443        // Show metronome section
444        const metronomeSection = document.getElementById('metronomeSection');
445        if (metronomeSection) metronomeSection.style.display = 'block';
446
447        // Create the player (settings applied in onPlayerReady)
448        this.createPlayer(videoId);
449
450        return true;
451    }
452
453    applyPendingShareParams() {
454        if (!this._pendingShareParams || !this.player || !this.playerReady) return;
455
456        const params = this._pendingShareParams;
457        this._pendingShareParams = null;
458
459        // Set speed
460        if (params.speed !== null) {
461            this.setSpeed(params.speed);
462            this.elements.speedSlider.value = params.speed;
463        }
464
465        // Set loop points
466        if (params.start !== null && params.end !== null && params.start < params.end) {
467            this.loopStart = params.start;
468            this.loopEnd = params.end;
469            this.elements.loopStartDisplay.textContent = this.formatTime(params.start);
470            this.elements.loopEndDisplay.textContent = this.formatTime(params.end);
471            this.updateLoopUI();
472            this.checkLoopButtonState();
473
474            // Enable the loop
475            this.loopEnabled = true;
476            this.elements.toggleLoopBtn.textContent = 'Disable Loop';
477            this.elements.toggleLoopBtn.classList.add('active');
478            this.startLoopCheck();
479
480            // Seek to loop start
481            this.player.seekTo(params.start, true);
482        } else if (params.start !== null) {
483            // Just start point, no loop - seek there
484            this.player.seekTo(params.start, true);
485        }
486
487        // Update the share URL display
488        this.updateShareUrl();
489
490        // Auto-play
491        this.player.playVideo();
492    }
493
494    generateShareUrl() {
495        if (!this.currentVideoId || !this.player || !this.playerReady) return null;
496
497        const url = new URL(window.location.origin + window.location.pathname);
498        url.searchParams.set('v', this.currentVideoId);
499
500        if (this.loopStart !== null) {
501            url.searchParams.set('start', Math.round(this.loopStart * 10) / 10);
502        }
503        if (this.loopEnd !== null) {
504            url.searchParams.set('end', Math.round(this.loopEnd * 10) / 10);
505        }
506
507        const currentSpeed = this.player.getPlaybackRate();
508        if (currentSpeed !== 1) {
509            url.searchParams.set('speed', currentSpeed);
510        }
511
512        return url.toString();
513    }
514
515    copyShareUrl() {
516        const url = this.generateShareUrl();
517        if (!url) {
518            alert('Load a video first to generate a share link.');
519            return;
520        }
521
522        try {
523            navigator.clipboard.writeText(url);
524            this.trackEvent('share_url_copied', { videoId: this.currentVideoId });
525
526            // Visual feedback
527            const btn = document.getElementById('copyShareUrlBtn');
528            if (btn) {
529                const originalText = btn.textContent;
530                btn.textContent = 'Copied!';
531                btn.classList.add('copied');
532                setTimeout(() => {
533                    btn.textContent = originalText;
534                    btn.classList.remove('copied');
535                }, 2000);
536            }
537        } catch (e) {
538            // Fallback for older browsers
539            const input = document.createElement('input');
540            input.value = url;
541            document.body.appendChild(input);
542            input.select();
543            document.execCommand('copy');
544            document.body.removeChild(input);
545        }
546    }
547
548    shareToTwitter() {
549        const url = this.generateShareUrl();
550        if (!url) {
551            alert('Load a video first to share.');
552            return;
553        }
554
555        const speed = this._speed.toFixed(2);
556        const speedText = speed === '1.00' ? 'normal speed' : `${speed}x speed`;
557        const text = `Practicing this section at ${speedText} 🎸\n\nFree tool to slow down & loop YouTube videos: `;
558        const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`;
559
560        window.open(twitterUrl, '_blank', 'width=550,height=420');
561        this.trackEvent('shared_to_twitter', { videoId: this.currentVideoId, speed: this._speed });
562    }
563
564    shareToReddit() {
565        const url = this.generateShareUrl();
566        if (!url) {
567            alert('Load a video first to share.');
568            return;
569        }
570
571        const title = 'Slow down & loop YouTube videos for practice - PracticeLoop';
572        const redditUrl = `https://reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`;
573
574        window.open(redditUrl, '_blank', 'width=850,height=600');
575        this.trackEvent('shared_to_reddit', { videoId: this.currentVideoId });
576    }
577
578    shareToFacebook() {
579        const url = this.generateShareUrl();
580        if (!url) {
581            alert('Load a video first to share.');
582            return;
583        }
584
585        const fbUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
586
587        window.open(fbUrl, '_blank', 'width=550,height=420');
588        this.trackEvent('shared_to_facebook', { videoId: this.currentVideoId });
589    }
590
591    updateShareUrl() {
592        const display = document.getElementById('shareUrlDisplay');
593        if (!display) return;
594
595        if (!this.currentVideoId) {
596            display.value = '';
597            display.placeholder = 'Load a video first';
598            return;
599        }
600
601        // Build a basic share URL even if player isn't ready yet
602        const url = new URL(window.location.origin + window.location.pathname);
603        url.searchParams.set('v', this.currentVideoId);
604
605        if (this.loopStart !== null) {
606            url.searchParams.set('start', Math.round(this.loopStart * 10) / 10);
607        }
608        if (this.loopEnd !== null) {
609            url.searchParams.set('end', Math.round(this.loopEnd * 10) / 10);
610        }
611
612        // Get speed from player if available, otherwise from slider
613        if (this.player && this.playerReady) {
614            const currentSpeed = this.player.getPlaybackRate();
615            if (currentSpeed !== 1) {
616                url.searchParams.set('speed', currentSpeed);
617            }
618        } else {
619            const sliderSpeed = parseFloat(this.elements.speedSlider.value);
620            if (sliderSpeed !== 1) {
621                url.searchParams.set('speed', sliderSpeed);
622            }
623        }
624
625        display.value = url.toString();
626    }
627
628    // ==================== //
629    // Analytics Tracking   //
630    // ==================== //
631
632    trackEvent(eventName, properties = {}) {
633        if (window.heap && typeof window.heap.track === 'function') {
634            const eventData = {
635                ...properties,
636                accountType: this.accountManager.accountType,
637                videoCount: this.videos.length
638            };
639
640            window.heap.track(eventName, eventData);
641        } else {
642
643        }
644    }
645
646    identifyUser() {
647        if (window.heap && typeof window.heap.addUserProperties === 'function') {
648            const userData = {
649                accountType: this.accountManager.accountType,
650                videoCount: this.videos.length,
651                hasYouTubeApiKey: !!this.youtubeApiKey
652            };
653
654            window.heap.addUserProperties(userData);
655        } else {
656
657        }
658    }
659
660    // ==================== //
661    // Mobile Speed Presets //
662    // ==================== //
663
664    injectMobileSpeedPresets() {
665        const speedControl = document.querySelector('.speed-control');
666        if (!speedControl) return;
667
668        const presets = [
669            { label: '50%', speed: 0.5 },
670            { label: '65%', speed: 0.65 },
671            { label: '75%', speed: 0.75 },
672            { label: '100%', speed: 1.0 }
673        ];
674
675        const container = document.createElement('div');
676        container.className = 'speed-presets-mobile';
677
678        presets.forEach(p => {
679            const btn = document.createElement('button');
680            btn.className = 'btn-preset-mobile' + (p.speed === 1.0 ? ' active' : '');
681            btn.textContent = p.label;
682            btn.dataset.speed = p.speed;
683            btn.addEventListener('click', () => {
684                this.setSpeed(p.speed);
685                this.elements.speedSlider.value = p.speed;
686                // Update active state on mobile presets
687                container.querySelectorAll('.btn-preset-mobile').forEach(b => b.classList.remove('active'));
688                btn.classList.add('active');
689            });
690            container.appendChild(btn);
691        });
692
693        // Insert before the slider label
694        speedControl.insertBefore(container, speedControl.firstChild);
695
696        // Add wake lock indicator below controls
697        const controls = document.querySelector('.controls');
698        if (controls) {
699            const indicator = document.createElement('div');
700            indicator.className = 'wake-lock-indicator';
701            indicator.id = 'wakeLockIndicator';
702            indicator.textContent = 'Screen will stay on while practicing';
703            controls.appendChild(indicator);
704        }
705    }
706
707    // ==================== //
708    // Wake Lock API        //
709    // ==================== //
710
711    async requestWakeLock() {
712        if (!('wakeLock' in navigator)) {
713
714            return;
715        }
716
717        try {
718            this._wakeLock = await navigator.wakeLock.request('screen');
719
720
721            const indicator = document.getElementById('wakeLockIndicator');
722            if (indicator) indicator.classList.add('active');
723
724            this._wakeLock.addEventListener('release', () => {
725
726                const ind = document.getElementById('wakeLockIndicator');
727                if (ind) ind.classList.remove('active');
728            });
729        } catch (err) {
730            // Wake Lock request failed (e.g., low battery, not supported)
731
732        }
733    }
734
735    async releaseWakeLock() {
736        if (this._wakeLock) {
737            try {
738                await this._wakeLock.release();
739                this._wakeLock = null;
740            } catch (err) {
741
742            }
743        }
744    }
745
746    // ==================== //
747    // YouTube API Lazy Load //
748    // ==================== //
749
750    loadYouTubeAPI() {
751        // Already loaded
752        if (this._ytApiLoaded) {
753            return this._ytApiReadyPromise;
754        }
755
756        // Already loading, just return the promise
757        if (this._ytApiLoading) {
758            return this._ytApiReadyPromise;
759        }
760
761        this._ytApiLoading = true;
762
763
764        // Dynamically inject the script tag
765        const script = document.createElement('script');
766        script.src = 'https://www.youtube.com/iframe_api';
767        script.onerror = () => {
768
769            this._ytApiLoading = false;
770            this.hidePlayerLoading();
771        };
772        document.head.appendChild(script);
773
774        return this._ytApiReadyPromise;
775    }
776
777    onYouTubeAPIReady() {
778        this._ytApiLoaded = true;
779        this._ytApiLoading = false;
780
781
782        if (this._ytApiReadyResolve) {
783            this._ytApiReadyResolve();
784        }
785    }
786
787    showPlayerLoading() {
788        const playerDiv = document.getElementById('player');
789        if (playerDiv && !playerDiv.querySelector('.yt-loading-placeholder')) {
790            const placeholder = document.createElement('div');
791            placeholder.className = 'yt-loading-placeholder';
792            placeholder.innerHTML = '<div class="yt-loading-spinner"></div><p>Loading player...</p>';
793            playerDiv.appendChild(placeholder);
794        }
795    }
796
797    hidePlayerLoading() {
798        const placeholder = document.querySelector('.yt-loading-placeholder');
799        if (placeholder) {
800            placeholder.remove();
801        }
802    }
803
804    bindEvents() {
805        // Add video
806        this.elements.addVideoBtn.addEventListener('click', () => this.addVideo());
807        this.elements.videoUrlInput.addEventListener('keypress', (e) => {
808            if (e.key === 'Enter') this.addVideo();
809        });
810
811        // Search
812        if (this.youtubeApiKey) {
813            this.elements.searchBtn.addEventListener('click', () => this.searchVideos());
814            this.elements.searchInput.addEventListener('keypress', (e) => {
815                if (e.key === 'Enter') this.searchVideos();
816            });
817        }
818
819        // Playback controls
820        this.elements.playPauseBtn.addEventListener('click', () => this.togglePlayPause());
821        this.elements.skipToStartBtn.addEventListener('click', () => this.skipToStart());
822        this.elements.speedSlider.addEventListener('input', (e) => this.setSpeed(parseFloat(e.target.value)));
823        this.elements.speedPresets.forEach(btn => {
824            btn.addEventListener('click', (e) => {
825                const speed = parseFloat(e.target.dataset.speed);
826                this.setSpeed(speed);
827                this.elements.speedSlider.value = speed;
828            });
829        });
830
831        // Seek controls
832        this.elements.seekBackBtn.addEventListener('click', () => this.seek(-5));
833        this.elements.seekForwardBtn.addEventListener('click', () => this.seek(5));
834
835        // Timeline
836        this.elements.timelineSlider.addEventListener('input', (e) => this.seekToPercent(e.target.value));
837
838        // Loop controls
839        this.elements.setLoopStartBtn.addEventListener('click', () => this.setLoopStart());
840        this.elements.setLoopEndBtn.addEventListener('click', () => this.setLoopEnd());
841        this.elements.toggleLoopBtn.addEventListener('click', () => this.toggleLoop());
842        this.elements.clearLoopBtn.addEventListener('click', () => this.clearLoop());
843
844        // Welcome banner dismiss
845        const welcomeDismissBtn = document.getElementById('welcomeDismissBtn');
846        if (welcomeDismissBtn) {
847            welcomeDismissBtn.addEventListener('click', () => this.dismissWelcomeBanner());
848        }
849
850        // Shortcuts help toggle
851        const shortcutsHelpBtn = document.getElementById('shortcutsHelpBtn');
852        if (shortcutsHelpBtn) {
853            shortcutsHelpBtn.addEventListener('click', (e) => {
854                e.stopPropagation();
855                const tooltip = document.getElementById('shortcutsTooltip');
856                if (tooltip) {
857                    tooltip.classList.toggle('visible');
858                }
859            });
860
861            // Close tooltip when clicking elsewhere
862            document.addEventListener('click', (e) => {
863                const tooltip = document.getElementById('shortcutsTooltip');
864                const helpContainer = document.getElementById('shortcutsHelp');
865                if (tooltip && helpContainer && !helpContainer.contains(e.target)) {
866                    tooltip.classList.remove('visible');
867                }
868            });
869        }
870
871        // Keyboard shortcuts
872        document.addEventListener('keydown', (e) => {
873            // Don't fire if typing in an input
874            if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
875            if (e.code === 'Space') { e.preventDefault(); this.togglePlayPause(); }
876            if (e.key === '[') this.setLoopStart();
877            if (e.key === ']') this.setLoopEnd();
878            if (e.key === 'l' || e.key === 'L') { if (this.loopStart !== null && this.loopEnd !== null) this.toggleLoop(); }
879            if (e.key === 'ArrowLeft') { e.preventDefault(); this.seek(-5); }
880            if (e.key === 'ArrowRight') { e.preventDefault(); this.seek(5); }
881        });
882    }
883
884    // ==================== //
885    // Video Management     //
886    // ==================== //
887
888    extractVideoId(url) {
889        // Support various YouTube URL formats
890        const patterns = [
891            /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
892            /^([a-zA-Z0-9_-]{11})$/ // Direct video ID
893        ];
894
895        for (const pattern of patterns) {
896            const match = url.match(pattern);
897            if (match && match[1]) {
898                return match[1];
899            }
900        }
901        return null;
902    }
903
904    async addVideo(videoId = null, videoTitle = null) {
905        // If no videoId provided, extract from URL input
906        if (!videoId) {
907            const url = this.elements.videoUrlInput.value.trim();
908            if (!url) {
909                alert('Please enter a YouTube URL');
910                return;
911            }
912
913            videoId = this.extractVideoId(url);
914            if (!videoId) {
915                alert('Invalid YouTube URL. Please check and try again.');
916                return;
917            }
918        }
919
920        // Check video limit based on account type
921        const videoLimit = this.accountManager.getVideoLimit();
922
923
924
925
926
927
928
929        if (this.videos.length >= videoLimit) {
930
931            this.showUpgradeModal();
932            return;
933        }
934
935        // Check if video already exists
936        if (this.videos.find(v => v.id === videoId)) {
937            alert('This video is already in your library');
938            return;
939        }
940
941        // Try to fetch video title if not provided
942        if (!videoTitle) {
943            videoTitle = await this.fetchVideoTitle(videoId);
944        }
945
946        // Add video
947        const video = {
948            id: videoId,
949            title: videoTitle || null,
950            addedAt: Date.now()
951        };
952
953        this.videos.push(video);
954        this.saveVideos();
955        this.renderVideoList();
956
957        // Hide welcome banner if visible
958        this.dismissWelcomeBanner();
959
960        // Clear input
961        this.elements.videoUrlInput.value = '';
962
963        // Confetti! (lazy-loaded)
964        if (window.confetti) {
965            confetti.burst();
966        } else if (!this._confettiLoading) {
967            this._confettiLoading = true;
968            const s = document.createElement('script');
969            s.src = 'confetti.js';
970            s.onload = () => { if (window.confetti) confetti.burst(); };
971            document.head.appendChild(s);
972        }
973
974        // Track event
975        this.trackEvent('video_added', {
976            method: videoId && videoTitle ? 'search' : 'url',
977            videoId: videoId
978        });
979
980        // Check if this is a referred user's first video - trigger referral reward
981        if (this.videos.length === 1 && this.accountManager.getReferredBy()) {
982            this.recordReferralSignup();
983        }
984
985        // Auto-select if first video
986        if (this.videos.length === 1) {
987            this.selectVideo(videoId);
988        }
989    }
990
991    async fetchVideoTitle(videoId) {
992        try {
993            // Try YouTube oEmbed API (no auth required)
994            const response = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
995            if (response.ok) {
996                const data = await response.json();
997                return data.title;
998            }
999        } catch (e) {
1000
1001        }
1002        return null;
1003    }
1004
1005    async searchVideos() {
1006        if (!this.youtubeApiKey) return;
1007
1008        const query = this.elements.searchInput.value.trim();
1009        if (!query) {
1010            alert('Please enter a search term');
1011            return;
1012        }
1013
1014        try {
1015            const response = await fetch(
1016                `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(query)}&type=video&maxResults=5&key=${this.youtubeApiKey}`
1017            );
1018
1019            if (!response.ok) {
1020                throw new Error('Search failed');
1021            }
1022
1023            const data = await response.json();
1024            // Track event
1025            this.trackEvent('search_performed', {query: query, resultCount: data.items.length});
1026            this.renderSearchResults(data.items);
1027        } catch (e) {
1028
1029            alert('Search failed. Please check your API key.');
1030        }
1031    }
1032
1033    renderSearchResults(items) {
1034        if (!items || items.length === 0) {
1035            this.elements.searchResults.innerHTML = '<p style="color: #6b7280; padding: 1rem; text-align: center;">No results found</p>';
1036            return;
1037        }
1038
1039        this.elements.searchResults.innerHTML = items.map(item => `
1040            <div class="search-result-item" data-video-id="${item.id.videoId}" data-video-title="${this.escapeHtml(item.snippet.title)}">
1041                <img src="${item.snippet.thumbnails.default.url}" alt="${this.escapeHtml(item.snippet.title)}">
1042                <div class="search-result-info">
1043                    <h4>${this.escapeHtml(item.snippet.title)}</h4>
1044                    <p>${this.escapeHtml(item.snippet.channelTitle)}</p>
1045                </div>
1046            </div>
1047        `).join('');
1048
1049        // Add click listeners
1050        document.querySelectorAll('.search-result-item').forEach(item => {
1051            item.addEventListener('click', () => {
1052                const videoId = item.dataset.videoId;
1053                const videoTitle = item.dataset.videoTitle;
1054                this.addVideo(videoId, videoTitle);
1055                this.elements.searchResults.innerHTML = '';
1056                this.elements.searchInput.value = '';
1057            });
1058        });
1059    }
1060
1061    escapeHtml(text) {
1062        const div = document.createElement('div');
1063        div.textContent = text;
1064        return div.innerHTML;
1065    }
1066
1067    removeVideo(videoId) {
1068        if (confirm('Remove this video from your library?')) {
1069            this.videos = this.videos.filter(v => v.id !== videoId);
1070            this.saveVideos();
1071            this.renderVideoList();
1072
1073            // Track event
1074            this.trackEvent('video_removed', {videoId: videoId});
1075
1076            // If current video was removed, clear player
1077            if (this.currentVideoId === videoId) {
1078                this.currentVideoId = null;
1079                this.elements.playerSection.style.display = 'none';
1080            }
1081        }
1082    }
1083
1084    selectVideo(videoId) {
1085        this.currentVideoId = videoId;
1086        this.clearLoop();
1087
1088        // Track event
1089        this.trackEvent('video_selected', {videoId: videoId});
1090
1091        // Show player section
1092        this.elements.playerSection.style.display = 'block';
1093
1094        // Show share section and update URL
1095        const shareSection = document.getElementById('shareSection');
1096        if (shareSection) shareSection.style.display = 'block';
1097        this.updateShareUrl();
1098
1099        // Show saved loops section
1100        const saveSection = document.getElementById('saveLoopSection');
1101        if (saveSection) saveSection.style.display = 'block';
1102        this.renderSavedLoops(videoId);
1103
1104        // Show session stats section
1105        if (this._sessionStatsSection) {
1106            this._sessionStatsSection.style.display = 'block';
1107        }
1108
1109        // Show metronome section
1110        const metronomeSection = document.getElementById('metronomeSection');
1111        if (metronomeSection) metronomeSection.style.display = 'block';
1112
1113        // Update active state in list
1114        this.renderVideoList();
1115
1116        // Load video in player
1117        if (this.playerReady && this.player) {
1118            this.player.loadVideoById(videoId);
1119            this.elements.currentVideoTitle.textContent = `Video ID: ${videoId}`;
1120        } else {
1121            // Player not ready yet, create it
1122            this.createPlayer(videoId);
1123        }
1124    }
1125
1126    // ==================== //
1127    // YouTube Player       //
1128    // ==================== //
1129
1130    async createPlayer(videoId) {
1131        // Show loading placeholder while API loads
1132        this.showPlayerLoading();
1133
1134        // Ensure YouTube API is loaded
1135        if (!window.YT || !window.YT.Player) {
1136            await this.loadYouTubeAPI();
1137        }
1138
1139        this.hidePlayerLoading();
1140
1141        // The YT.Player constructor replaces the target element, so ensure
1142        // the div exists and is not already a player instance
1143        const playerDiv = document.getElementById('player');
1144        if (!playerDiv) {
1145            console.error('Player div not found');
1146            return;
1147        }
1148
1149        this.player = new YT.Player('player', {
1150            height: '100%',
1151            width: '100%',
1152            videoId: videoId,
1153            playerVars: {
1154                controls: 0,
1155                modestbranding: 1,
1156                rel: 0
1157            },
1158            events: {
1159                onReady: (event) => this.onPlayerReady(event),
1160                onStateChange: (event) => this.onPlayerStateChange(event)
1161            }
1162        });
1163    }
1164
1165    onPlayerReady(event) {
1166        this.playerReady = true;
1167
1168
1169        // Start time update interval
1170        this.startTimeUpdate();
1171
1172        // Update video title if possible
1173        if (this.player && this.player.getVideoData) {
1174            const videoData = this.player.getVideoData();
1175            if (videoData && videoData.title) {
1176                this.elements.currentVideoTitle.textContent = videoData.title;
1177            }
1178        }
1179
1180        // Apply any pending shareable URL settings
1181        if (this._pendingShareParams) {
1182            // Small delay to ensure player is fully initialized and duration is available
1183            setTimeout(() => this.applyPendingShareParams(), 500);
1184        }
1185    }
1186
1187    onPlayerStateChange(event) {
1188        // Update play/pause button
1189        if (event.data === YT.PlayerState.PLAYING) {
1190            this.elements.playPauseBtn.classList.add('playing');
1191            this.startPracticeTimer();
1192        } else {
1193            this.elements.playPauseBtn.classList.remove('playing');
1194            if (event.data === YT.PlayerState.PAUSED || event.data === YT.PlayerState.ENDED) {
1195                this.pausePracticeTimer();
1196            }
1197        }
1198
1199        // Update video info when video loads
1200        if (event.data === YT.PlayerState.PLAYING || event.data === YT.PlayerState.PAUSED) {
1201            const videoData = this.player.getVideoData();
1202            if (videoData && videoData.title) {
1203                this.elements.currentVideoTitle.textContent = videoData.title;
1204            }
1205        }
1206    }
1207
1208    // ==================== //
1209    // Playback Controls    //
1210    // ==================== //
1211
1212    togglePlayPause() {
1213        if (!this.player || !this.playerReady) return;
1214
1215        const state = this.player.getPlayerState();
1216        if (state === YT.PlayerState.PLAYING) {
1217            this.player.pauseVideo();
1218            this.trackEvent('playback_toggled', {action: 'pause'});
1219        } else {
1220            this.player.playVideo();
1221            this.trackEvent('playback_toggled', {action: 'play'});
1222        }
1223    }
1224
1225    skipToStart() {
1226        if (!this.player || !this.playerReady) return;
1227        this.player.seekTo(0, true);
1228    }
1229
1230    setSpeed(speed) {
1231        if (!this.player || !this.playerReady) return;
1232
1233        this.player.setPlaybackRate(speed);
1234        this.elements.speedDisplay.textContent = `${speed.toFixed(2)}x`;
1235
1236        // Track event
1237        this.trackEvent('speed_changed', {speed: speed});
1238
1239        // Update preset buttons
1240        this.elements.speedPresets.forEach(btn => {
1241            const btnSpeed = parseFloat(btn.dataset.speed);
1242            if (Math.abs(btnSpeed - speed) < 0.01) {
1243                btn.classList.add('active');
1244            } else {
1245                btn.classList.remove('active');
1246            }
1247        });
1248
1249        // Update mobile preset buttons
1250        document.querySelectorAll('.btn-preset-mobile').forEach(btn => {
1251            const btnSpeed = parseFloat(btn.dataset.speed);
1252            if (Math.abs(btnSpeed - speed) < 0.01) {
1253                btn.classList.add('active');
1254            } else {
1255                btn.classList.remove('active');
1256            }
1257        });
1258
1259        // Update share URL with new speed
1260        this.updateShareUrl();
1261
1262        // Update session stats speed display
1263        if (this._statCurrentSpeed) {
1264            this._statCurrentSpeed.textContent = speed.toFixed(2) + 'x';
1265        }
1266    }
1267
1268    seek(seconds) {
1269        if (!this.player || !this.playerReady) return;
1270
1271        const currentTime = this.player.getCurrentTime();
1272        const newTime = Math.max(0, currentTime + seconds);
1273        this.player.seekTo(newTime, true);
1274    }
1275
1276    seekToPercent(percent) {
1277        if (!this.player || !this.playerReady) return;
1278
1279        const duration = this.player.getDuration();
1280        const time = (percent / 100) * duration;
1281        this.player.seekTo(time, true);
1282    }
1283
1284    // ==================== //
1285    // Loop Controls        //
1286    // ==================== //
1287
1288    setLoopStart() {
1289        if (!this.player || !this.playerReady) return;
1290
1291        this.loopStart = this.player.getCurrentTime();
1292        this.elements.loopStartDisplay.textContent = this.formatTime(this.loopStart);
1293        this.updateLoopUI();
1294        this.checkLoopButtonState();
1295        this.updateShareUrl();
1296
1297        // Track event
1298        this.trackEvent('loop_start_set', {timestamp: this.loopStart});
1299    }
1300
1301    setLoopEnd() {
1302        if (!this.player || !this.playerReady) return;
1303
1304        this.loopEnd = this.player.getCurrentTime();
1305        this.elements.loopEndDisplay.textContent = this.formatTime(this.loopEnd);
1306        this.updateLoopUI();
1307        this.checkLoopButtonState();
1308        this.updateShareUrl();
1309
1310        // Track event
1311        this.trackEvent('loop_end_set', {timestamp: this.loopEnd});
1312    }
1313
1314    toggleLoop() {
1315        if (this.loopStart === null || this.loopEnd === null) return;
1316
1317        this.loopEnabled = !this.loopEnabled;
1318
1319        if (this.loopEnabled) {
1320            this.elements.toggleLoopBtn.textContent = 'Disable Loop';
1321            this.elements.toggleLoopBtn.classList.add('active');
1322            this.startLoopCheck();
1323            // Track event
1324            this.trackEvent('loop_enabled', {
1325                startTime: this.loopStart,
1326                endTime: this.loopEnd,
1327                duration: this.loopEnd - this.loopStart
1328            });
1329        } else {
1330            this.elements.toggleLoopBtn.textContent = 'Enable Loop';
1331            this.elements.toggleLoopBtn.classList.remove('active');
1332            this.stopLoopCheck();
1333            // Track event
1334            this.trackEvent('loop_disabled');
1335        }
1336
1337        this.updateLoopUI();
1338    }
1339
1340    clearLoop() {
1341        this.loopStart = null;
1342        this.loopEnd = null;
1343        this.loopEnabled = false;
1344
1345        this.elements.loopStartDisplay.textContent = '--:--';
1346        this.elements.loopEndDisplay.textContent = '--:--';
1347        this.elements.toggleLoopBtn.textContent = 'Enable Loop';
1348        this.elements.toggleLoopBtn.classList.remove('active');
1349
1350        // Reset loop rep count
1351        this._loopRepCount = 0;
1352        this.updateSessionStatsUI();
1353
1354        this.stopLoopCheck();
1355        this.updateLoopUI();
1356        this.checkLoopButtonState();
1357        this.updateShareUrl();
1358    }
1359
1360    checkLoopButtonState() {
1361        const canEnableLoop = this.loopStart !== null && this.loopEnd !== null && this.loopStart < this.loopEnd;
1362        this.elements.toggleLoopBtn.disabled = !canEnableLoop;
1363    }
1364
1365    updateLoopUI() {
1366        const overlay = this.elements.loopOverlay;
1367
1368        if (this.loopStart !== null && this.loopEnd !== null && this.player && this.playerReady) {
1369            const duration = this.player.getDuration();
1370            const startPercent = (this.loopStart / duration) * 100;
1371            const endPercent = (this.loopEnd / duration) * 100;
1372
1373            overlay.style.left = `${startPercent}%`;
1374            overlay.style.width = `${endPercent - startPercent}%`;
1375            overlay.style.display = 'block';
1376        } else {
1377            overlay.style.display = 'none';
1378        }
1379    }
1380
1381    startLoopCheck() {
1382        this.stopLoopCheck();
1383        this.loopCheckInterval = setInterval(() => {
1384            if (!this.player || !this.playerReady || !this.loopEnabled) return;
1385
1386            const currentTime = this.player.getCurrentTime();
1387            if (currentTime >= this.loopEnd) {
1388                this.player.seekTo(this.loopStart, true);
1389                // Count loop repetitions (debounce with 500ms window)
1390                const now = Date.now();
1391                if (now - this._lastLoopSeekTime > 500) {
1392                    this._loopRepCount++;
1393                    this.updateSessionStatsUI();
1394                    // Progressive speed training: auto-increment after N reps
1395                    if (this._progressiveEnabled) {
1396                        this._progressiveRepsAtCurrentSpeed++;
1397                        this.updateProgressiveUI();
1398                        if (this._progressiveRepsAtCurrentSpeed >= this._progressiveRepsPerStep) {
1399                            this.progressiveSpeedUp();
1400                        }
1401                    }
1402                }
1403                this._lastLoopSeekTime = now;
1404            }
1405        }, 100); // Check every 100ms
1406    }
1407
1408    stopLoopCheck() {
1409        if (this.loopCheckInterval) {
1410            clearInterval(this.loopCheckInterval);
1411            this.loopCheckInterval = null;
1412        }
1413    }
1414
1415    // ==================== //
1416    // Time Updates         //
1417    // ==================== //
1418
1419    startTimeUpdate() {
1420        this.stopTimeUpdate();
1421        this.timeUpdateInterval = setInterval(() => {
1422            if (!this.player || !this.playerReady) return;
1423
1424            try {
1425                const currentTime = this.player.getCurrentTime();
1426                const duration = this.player.getDuration();
1427
1428                // Update time displays
1429                this.elements.currentTime.textContent = this.formatTime(currentTime);
1430                this.elements.duration.textContent = this.formatTime(
1430duration);
1431
1432                // Update timeline
1433                const percent = (currentTime / duration) * 100;
1434                this.elements.timelineSlider.value = percent;
1435                this.elements.progressBar.style.width = `${percent}%`;
1436            } catch (e) {
1437                // Ignore errors when player is transitioning
1438            }
1439        }, 250); // Update every 250ms
1440    }
1441
1442    stopTimeUpdate() {
1443        if (this.timeUpdateInterval) {
1444            clearInterval(this.timeUpdateInterval);
1445            this.timeUpdateInterval = null;
1446        }
1447    }
1448
1449    formatTime(seconds) {
1450        if (!seconds || isNaN(seconds) || !isFinite(seconds)) {
1451            return '0:00';
1452        }
1453
1454        const mins = Math.floor(seconds / 60);
1455        const secs = Math.floor(seconds % 60);
1456        return `${mins}:${secs.toString().padStart(2, '0')}`;
1457    }
1458
1459    // ==================== //
1460    // Rendering            //
1461    // ==================== //
1462
1463    renderVideoList() {
1464        if (this.videos.length === 0) {
1465            this.elements.videoList.innerHTML = '<p style="color: #6b7280; text-align: center; padding: 2rem;">No videos yet. Add your first practice video above!</p>';
1466            return;
1467        }
1468
1469        this.elements.videoList.innerHTML = this.videos
1470            .map(video => {
1471                const title = video.title || 'Video';
1472                const date = new Date(video.addedAt).toLocaleDateString();
1473                return `
1474                    <div class="video-item ${this.currentVideoId === video.id ? 'active' : ''}" data-video-id="${video.id}">
1475                        <div class="video-item-header">
1476                            <h3>${this.escapeHtml(title)}</h3>
1477                            <button class="btn btn-danger" onclick="app.removeVideo('${video.id}')" aria-label="Remove video">×</button>
1478                        </div>
1479                        <div class="video-id">Added: ${date}</div>
1480                    </div>
1481                `;
1482            })
1483            .join('');
1484
1485        // Add click listeners to video items
1486        document.querySelectorAll('.video-item').forEach(item => {
1487            const videoId = item.dataset.videoId;
1488            item.addEventListener('click', (e) => {
1489                // Don't select if clicking remove button
1490                if (!e.target.classList.contains('btn-danger')) {
1491                    this.selectVideo(videoId);
1492                }
1493            });
1494        });
1495    }
1496
1497    // ==================== //
1498    // LocalStorage         //
1499    // ==================== //
1500
1501    loadVideos() {
1502        try {
1503            const stored = localStorage.getItem('musicPracticeVideos');
1504            if (stored) {
1505                this.videos = JSON.parse(stored);
1506            }
1507        } catch (e) {
1508            console.error('Error loading videos from localStorage:', e);
1509            this.videos = [];
1510        }
1511    }
1512
1513    saveVideos() {
1514        try {
1515            localStorage.setItem('musicPracticeVideos', JSON.stringify(this.videos));
1516        } catch (e) {
1517            console.error('Error saving videos to localStorage:', e);
1518        }
1519    }
1520
1521    // ==================== //
1522    // Modal System         //
1523    // ==================== //
1524
1525    // ==================== //
1526    // Welcome Banner       //
1527    // ==================== //
1528
1529    showWelcomeBannerIfNeeded() {
1530        const dismissed = localStorage.getItem('musicPracticeOnboardingDismissed') === 'true';
1531        const banner = document.getElementById('welcomeBanner');
1532        if (!banner) return;
1533
1534        if (dismissed || this.videos.length > 0) {
1535            banner.style.display = 'none';
1536        } else {
1537            this.trackEvent('welcome_banner_shown');
1538        }
1539    }
1540
1541    dismissWelcomeBanner() {
1542        const banner = document.getElementById('welcomeBanner');
1543        if (banner) {
1544            banner.style.display = 'none';
1545        }
1546        localStorage.setItem('musicPracticeOnboardingDismissed', 'true');
1547        this.trackEvent('welcome_banner_dismissed');
1548    }
1549
1550    showWelcomeModal() {
1551        const modal = this.createModal('welcome');
1552        modal.innerHTML = `
1553            <div class="modal-content welcome-modal">
1554                <button class="modal-close" onclick="app.closeModal('welcome')">&times;</button>
1555                <h2>Welcome to PracticeLoop! 🎵</h2>
1556                <p>Thanks for trying out this tool designed by musicians, for musicians.</p>
1557
1558                <div class="benefits">
1559                    <div class="benefit">
1560                        <span class="benefit-icon">🎯</span>
1561                        <div>
1562                            <h3>Master Difficult Passages</h3>
1563                            <p>Slow down tricky sections to 25% speed without changing pitch. Practice at your own pace!</p>
1564                        </div>
1565                    </div>
1566                    <div class="benefit">
1567                        <span class="benefit-icon">🔁</span>
1568                        <div>
1569                            <h3>Perfect Your Timing with Loops</h3>
1570                            <p>Set precise loop points to repeat challenging measures over and over until you've got it.</p>
1571                        </div>
1572                    </div>
1573                    <div class="benefit">
1574                        <span class="benefit-icon">📚</span>
1575                        <div>
1576                            <h3>Build Your Practice Library</h3>
1577                            <p>Save all your practice videos in one place for instant access anytime you practice.</p>
1578                        </div>
1579                    </div>
1580                </div>
1581
1582                <div class="account-info">
1583                    <p><strong>Free Account:</strong> 3 videos + 3 saved loops</p>
1584                    <p><strong>Referral Bonus:</strong> Share with friends to get 5 videos for 3 months!</p>
1585                    <p><strong>Paid Account:</strong> Unlimited videos forever</p>
1586                </div>
1587
1588                <div class="modal-actions">
1589                    <button class="btn btn-primary" onclick="app.closeModal('welcome')">Get Started (Free)</button>
1590                    <a href="https://pay.tide.co/products/music-pract-dojo6MnC" target="_blank"
1591                       onclick="app.trackEvent('upgrade_link_clicked', {source: 'welcome'})"
1592                       class="btn btn-secondary">Upgrade to Unlimited</a>
1593                </div>
1594            </div>
1595        `;
1596        document.body.appendChild(modal);
1597        this.accountManager.setWelcomeShown();
1598        this.trackEvent('welcome_shown');
1599    }
1600
1601    showUpgradeModal() {
1602        const videoLimit = this.accountManager.getVideoLimit();
1603        const isReferral = this.accountManager.accountType === 'referral';
1604
1605        const modal = this.createModal('upgrade');
1606        modal.innerHTML = `
1607            <div class="modal-content upgrade-modal">
1608                <button class="modal-close" onclick="app.closeModal('upgrade')">&times;</button>
1609                <h2>You're practising! Keep going.</h2>
1610                <p>You've saved ${videoLimit} video${videoLimit > 1 ? 's' : ''} -- that's the limit on the free plan. Unlock unlimited to keep your practice library growing.</p>
1611
1612                <div class="upgrade-features-list">
1613                    <div class="upgrade-feature-item"><span class="check-icon">&#10003;</span> Unlimited videos</div>
1614                    <div class="upgrade-feature-item"><span class="check-icon">&#10003;</span> Unlimited saved loops</div>
1615                    <div class="upgrade-feature-item"><span class="check-icon">&#10003;</span> Progressive speed training</div>
1616                    <div class="upgrade-feature-item"><span class="check-icon">&#10003;</span> One-time payment, yours forever</div>
1617                </div>
1618
1619                <div class="upgrade-options">
1620                    <div class="upgrade-option upgrade-option-primary">
1621                        <a href="https://pay.tide.co/products/music-pract-dojo6MnC" target="_blank"
1622                           onclick="app.trackEvent('upgrade_link_clicked', {source: 'limit'})"
1623                           class="btn btn-primary btn-upgrade-main">Upgrade Now</a>
1624                        <span class="upgrade-price-hint">One-time purchase</span>
1625                    </div>
1626
1627                    ${!isReferral ? `
1628                    <div class="upgrade-option upgrade-option-alt">
1629                        <p class="upgrade-or">or</p>
1630                        <button class="btn btn-share-earn" onclick="app.closeModal('upgrade'); app.showReferralModal();">Share with a friend for free bonus videos</button>
1631                    </div>
1632                    ` : ''}
1633                </div>
1634
1635                <div class="modal-actions">
1636                    <button class="btn btn-secondary" onclick="app.closeModal('upgrade')">Maybe Later</button>
1637                </div>
1638            </div>
1639        `;
1640        document.body.appendChild(modal);
1641        this.trackEvent('video_add_blocked', { currentLimit: videoLimit });
1642    }
1643
1644    showLicenseKeyModal() {
1645        const modal = this.createModal('license');
1646        modal.innerHTML = `
1647            <div class="modal-content license-modal">
1648                <button class="modal-close" onclick="app.closeModal('license')">&times;</button>
1649                <h2>Enter License Key</h2>
1650                <p>Enter your license key to unlock unlimited videos.</p>
1651
1652                <input type="text" id="licenseKeyInput" placeholder="MUSIC-PRACTICE-XXXX-XXXX-XXXX"
1653                       class="license-input" maxlength="30">
1654                <div id="licenseError" class="error-message" style="display: none;"></div>
1655
1656                <div class="modal-actions">
1657                    <button class="btn btn-primary" onclick="app.activateLicenseKey()">Activate</button>
1658                    <button class="btn btn-secondary" onclick="app.closeModal('license')">Cancel</button>
1659                </div>
1660            </div>
1661        `;
1662        document.body.appendChild(modal);
1663    }
1664
1665    activateLicenseKey() {
1666        const input = document.getElementById('licenseKeyInput');
1667        const key = input.value.trim().toUpperCase();
1668        const error = document.getElementById('licenseError');
1669
1670        if (this.accountManager.upgrade(key)) {
1671            this.trackEvent('license_key_entered', {success: true});
1672            this.trackEvent('account_upgraded');
1673            this.closeModal('license');
1674            alert('Success! Your account has been upgraded to unlimited videos.');
1675            this.updateAccountUI();
1676            this.identifyUser();
1677        } else {
1678            this.trackEvent('license_key_entered', {success: false});
1679            error.textContent = 'Invalid license key format. Please check and try again.';
1680            error.style.display = 'block';
1681        }
1682    }
1683
1684    createModal(id) {
1685        const modal = document.createElement('div');
1686        modal.className = 'modal-overlay';
1687        modal.id = `modal-${id}`;
1688        return modal;
1689    }
1690
1691    closeModal(id) {
1692        const modal = document.getElementById(`modal-${id}`);
1693        if (modal) {
1694            if (id === 'welcome') {
1695                this.trackEvent('welcome_dismissed');
1696            }
1697            modal.remove();
1698        }
1699    }
1700
1701    updateAccountUI() {
1702        const accountBadge = document.getElementById('accountBadge');
1703        if (accountBadge) {
1704            const accountType = this.accountManager.accountType;
1705            if (accountType === 'paid') {
1706                accountBadge.textContent = 'Unlimited';
1707                accountBadge.className = 'account-badge paid';
1708            } else if (accountType === 'referral' && this.accountManager.isReferralActive()) {
1709                const days = this.accountManager.getRemainingReferralDays();
1710                accountBadge.textContent = `Referral (5 videos, ${days}d left)`;
1711                accountBadge.className = 'account-badge referral';
1712            } else {
1713                accountBadge.textContent = 'Free (3 videos)';
1714                accountBadge.className = 'account-badge free';
1715            }
1716        }
1717
1718        const buyLink = document.getElementById('buyLicenseLink');
1719        if (buyLink) {
1720            buyLink.style.display = this.accountManager.isPaid() ? 'none' : 'inline-block';
1721        }
1722
1723        const licenseLink = document.getElementById('licenseKeyLink');
1724        if (licenseLink) {
1725            licenseLink.style.display = this.accountManager.isPaid() ? 'none' : 'inline-block';
1726        }
1727
1728        const shareBtn = document.getElementById('shareReferralBtn');
1729        if (shareBtn) {
1730            // Show Share & Earn for free users, hide for paid (they can still share if they want via other means)
1731            shareBtn.style.display = this.accountManager.isPaid() ? 'none' : 'inline-flex';
1732        }
1733    }
1734
1735    // ==================== //
1736    // Referral System UI   //
1737    // ==================== //
1738
1739    async showReferralModal() {
1740        const code = await this.accountManager.generateReferralCode();
1741        const referralLink = `${window.location.origin}${window.location.pathname}?ref=${code}`;
1742
1743        const modal = this.createModal('referral');
1744        modal.innerHTML = `
1745            <div class="modal-content referral-modal">
1746                <button class="modal-close" onclick="app.closeModal('referral')">&times;</button>
1747                <h2>Share & Earn Free Videos</h2>
1748                <p>Share your link with friends. When they sign up and add their first video, you'll get <strong>5 videos for 3 months</strong>!</p>
1749
1750                <div class="referral-link-box">
1751                    <input type="text" id="referralLinkInput" value="${referralLink}" readonly>
1752                    <button class="btn btn-primary" onclick="app.copyReferralLink()">Copy</button>
1753                </div>
1754
1755                <div class="share-buttons">
1756                    <button class="btn btn-share btn-whatsapp" onclick="app.shareVia('whatsapp')">
1757                        <span>WhatsApp</span>
1758                    </button>
1759                    <button class="btn btn-share btn-twitter" onclick="app.shareVia('twitter')">
1760                        <span>Twitter</span>
1761                    </button>
1762                    <button class="btn btn-share btn-email" onclick="app.shareVia('email')">
1763                        <span>Email</span>
1764                    </button>
1765                </div>
1766
1767                <div class="referral-info">
1768                    <p><strong>Your referral code:</strong> ${code}</p>
1769                    ${this.accountManager.isReferralActive()
1770                        ? `<p class="referral-status active">You have ${this.accountManager.getRemainingReferralDays()} days of referral benefits remaining!</p>`
1771                        : '<p class="referral-status">Share to unlock 5 videos for 3 months</p>'
1772                    }
1773                </div>
1774            </div>
1775        `;
1776        document.body.appendChild(modal);
1777        this.trackEvent('referral_modal_opened');
1778    }
1779
1780    copyReferralLink() {
1781        const input = document.getElementById('referralLinkInput');
1782        input.select();
1783        input.setSelectionRange(0, 99999); // Mobile support
1784
1785        try {
1786            navigator.clipboard.writeText(input.value);
1787            this.trackEvent('referral_link_copied');
1788
1789            // Visual feedback
1790            const btn = input.nextElementSibling;
1791            const originalText = btn.textContent;
1792            btn.textContent = 'Copied!';
1793            btn.classList.add('copied');
1794            setTimeout(() => {
1795                btn.textContent = originalText;
1796                btn.classList.remove('copied');
1797            }, 2000);
1798        } catch (e) {
1799
1800        }
1801    }
1802
1803    shareVia(platform) {
1804        const code = this.accountManager.getReferralCode();
1805        const referralLink = `${window.location.origin}${window.location.pathname}?ref=${code}`;
1806        const text = "I've been using this awesome music practice tool to slow down songs and loop tricky sections. Try it out!";
1807
1808        let shareUrl;
1809        switch (platform) {
1810            case 'whatsapp':
1811                shareUrl = `https://wa.me/?text=${encodeURIComponent(text + ' ' + referralLink)}`;
1812                break;
1813            case 'twitter':
1814                shareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(referralLink)}`;
1815                break;
1816            case 'email':
1817                shareUrl = `mailto:?subject=${encodeURIComponent('Check out this music practice tool!')}&body=${encodeURIComponent(text + '\n\n' + referralLink)}`;
1818                break;
1819            default:
1820                return;
1821        }
1822
1823        this.trackEvent('referral_shared', { platform });
1824        window.open(shareUrl, '_blank');
1825    }
1826
1827    async recordReferralSignup() {
1828        const referredBy = this.accountManager.getReferredBy();
1829        if (!referredBy) return;
1830
1831        try {
1832            const response = await fetch('/.netlify/functions/record-referral', {
1833                method: 'POST',
1834                headers: { 'Content-Type': 'application/json' },
1835                body: JSON.stringify({
1836                    referrerCode: referredBy,
1837                    newUserId: this.accountManager.userId
1838                })
1839            });
1840
1841            if (response.ok) {
1842                this.trackEvent('referral_signup_recorded', { referrerCode: referredBy });
1843            }
1844        } catch (e) {
1845
1846        }
1847    }
1848
1849    async checkAndApplyReferralReward() {
1850        // This can be called to check if the user has earned a referral reward
1851        try {
1852            const response = await fetch(`/.netlify/functions/check-referral-status?userId=${this.accountManager.userId}`);
1853            if (response.ok) {
1854                const data = await response.json();
1855                if (data.hasReward && this.accountManager.accountType === 'free') {
1856                    this.accountManager.upgradeToReferral();
1857                    this.updateAccountUI();
1858                    this.trackEvent('referral_reward_applied');
1859                    alert('Congratulations! A friend signed up using your link. You now have 5 videos for 3 months!');
1860                }
1861            }
1862        } catch (e) {
1863
1864        }
1865    }
1866
1867    // ==================== //
1868    // Practice Timer        //
1869    // ==================== //
1870
1871    initPracticeTimer() {
1872        // Load today's existing practice time from localStorage
1873        const log = this.getPracticeLog();
1874        const todayKey = this.getTodayKey();
1875        const existingMinutes = log[todayKey] || 0;
1876
1877        // DOM references for timer/stats
1878        this._timerChip = document.getElementById('practiceTimer');
1879        this._statSessionTime = document.getElementById('statSessionTime');
1880        this._statTodayTotal = document.getElementById('statTodayTotal');
1881        this._statLoopReps = document.getElementById('statLoopReps');
1882        this._statCurrentSpeed = document.getElementById('statCurrentSpeed');
1883        this._streakIndicator = document.getElementById('streakIndicator');
1884        this._streakText = document.getElementById('streakText');
1885        this._sessionStatsSection = document.getElementById('sessionStatsSection');
1886
1887        // Update today total display with existing time
1888        if (this._statTodayTotal) {
1889            this._statTodayTotal.textContent = this.formatTimerMinSec(existingMinutes * 60);
1890        }
1891
1892        // Calculate and display streak
1893        this.updateStreakDisplay();
1894
1895        // Bind collapsible toggle
1896        const toggle = document.getElementById('sessionStatsToggle');
1897        const body = document.getElementById('sessionStatsBody');
1898        if (toggle && body) {
1899            toggle.addEventListener('click', () => {
1900                toggle.classList.toggle('open');
1901                body.classList.toggle('open');
1902            });
1903        }
1904
1905        // Save practice time periodically (every 30s) and on page unload
1906        this._practiceStatsInterval = setInterval(() => this.savePracticeTime(), 30000);
1907        window.addEventListener('beforeunload', () => this.savePracticeTime());
1908    }
1909
1910    startPracticeTimer() {
1911        if (this._practiceTimerRunning) return;
1912        this._practiceTimerRunning = true;
1913
1914        // Show session stats section when user starts playing
1915        if (this._sessionStatsSection) {
1916            this._sessionStatsSection.style.display = 'block';
1917        }
1918
1919        // Add active class to timer chip
1920        if (this._timerChip) {
1921            this._timerChip.classList.add('active');
1922        }
1923
1924        this._practiceTimerInterval = setInterval(() => {
1925            this._practiceTimerSeconds++;
1926            this.updateTimerDisplay();
1927
1928            // Update session stats every 5 seconds
1929            if (this._practiceTimerSeconds % 5 === 0) {
1930                this.updateSessionStatsUI();
1931            }
1932        }, 1000);
1933    }
1934
1935    pausePracticeTimer() {
1936        if (!this._practiceTimerRunning) return;
1937        this._practiceTimerRunning = false;
1938
1939        if (this._practiceTimerInterval) {
1940            clearInterval(this._practiceTimerInterval);
1941            this._practiceTimerInterval = null;
1942        }
1943
1944        // Remove active class from timer chip
1945        if (this._timerChip) {
1946            this._timerChip.classList.remove('active');
1947        }
1948
1949        // Save immediately on pause
1950        this.savePracticeTime();
1951    }
1952
1953    updateTimerDisplay() {
1954        const display = this.formatTimerMinSec(this._practiceTimerSeconds);
1955
1956        // Update the chip near speed controls
1957        if (this._timerChip) {
1958            this._timerChip.textContent = display;
1959        }
1960
1961        // Update session time stat card
1962        if (this._statSessionTime) {
1963            this._statSessionTime.textContent = display;
1964        }
1965    }
1966
1967    updateSessionStatsUI() {
1968        // Today total = existing stored minutes + current session seconds
1969        const log = this.getPracticeLog();
1970        const todayKey = this.getTodayKey();
1971        const existingSeconds = (log[todayKey] || 0) * 60;
1972        const todayTotal = existingSeconds + this._practiceTimerSeconds;
1973
1974        if (this._statTodayTotal) {
1975            this._statTodayTotal.textContent = this.formatTimerMinSec(todayTotal);
1976        }
1977
1978        if (this._statLoopReps) {
1979            this._statLoopReps.textContent = this._loopRepCount;
1980        }
1981
1982        if (this._statCurrentSpeed && this.player && this.playerReady) {
1983            try {
1984                this._statCurrentSpeed.textContent = this.player.getPlaybackRate().toFixed(2) + 'x';
1985            } catch (e) { /* ignore */ }
1986        }
1987    }
1988
1989    formatTimerMinSec(totalSeconds) {
1990        const mins = Math.floor(totalSeconds / 60);
1991        const secs = Math.floor(totalSeconds % 60);
1992        return `${mins}:${secs.toString().padStart(2, '0')}`;
1993    }
1994
1995    // ==================== //
1996    // Practice Persistence  //
1997    // ==================== //
1998
1999    getTodayKey() {
2000        const now = new Date();
2001        const y = now.getFullYear();
2002        const m = String(now.getMonth() + 1).padStart(2, '0');
2003        const d = String(now.getDate()).padStart(2, '0');
2004        return `${y}-${m}-${d}`;
2005    }
2006
2007    getPracticeLog() {
2008        try {
2009            const stored = localStorage.getItem('practiceLog');
2010            return stored ? JSON.parse(stored) : {};
2011        } catch (e) {
2012            return {};
2013        }
2014    }
2015
2016    savePracticeTime() {
2017        if (this._practiceTimerSeconds < 1) return;
2018
2019        const log = this.getPracticeLog();
2020        const todayKey = this.getTodayKey();
2021        const existingMinutes = log[todayKey] || 0;
2022        // Add current session seconds converted to minutes (fractional)
2023        const sessionMinutes = this._practiceTimerSeconds / 60;
2024
2025        // We store the total for today. Since we keep adding, reset session counter
2026        // after saving to avoid double-counting.
2027        log[todayKey] = existingMinutes + sessionMinutes;
2028
2029        // Prune entries older than 30 days
2030        const cutoff = new Date();
2031        cutoff.setDate(cutoff.getDate() - 30);
2032        for (const key of Object.keys(log)) {
2033            if (new Date(key) < cutoff) {
2034                delete log[key];
2035            }
2036        }
2037
2038        try {
2039            localStorage.setItem('practiceLog', JSON.stringify(log));
2040        } catch (e) {
2041            console.error('Error saving practice log:', e);
2042        }
2043
2044        // Reset session counter since we just persisted it
2045        this._practiceTimerSeconds = 0;
2046
2047        // Update streak display after save
2048        this.updateStreakDisplay();
2049    }
2050
2051    calculateStreak() {
2052        const log = this.getPracticeLog();
2053        const today = new Date();
2054        today.setHours(0, 0, 0, 0);
2055
2056        let streak = 0;
2057        let checkDate = new Date(today);
2058
2059        // If user has practiced today (either stored or current session > 0), count today
2060        const todayKey = this.getTodayKey();
2061        const hasTodayPractice = (log[todayKey] && log[todayKey] > 0) || this._practiceTimerSeconds > 0;
2062
2063        if (!hasTodayPractice) {
2064            // Check if they practiced yesterday (streak still valid, just haven't started today)
2065            checkDate.setDate(checkDate.getDate() - 1);
2066            const yesterdayKey = this.formatDateKey(checkDate);
2067            if (!log[yesterdayKey] || log[yesterdayKey] <= 0) {
2068                return 0;
2069            }
2070            streak = 1;
2071            checkDate.setDate(checkDate.getDate() - 1);
2072        } else {
2073            streak = 1;
2074            checkDate.setDate(checkDate.getDate() - 1);
2075        }
2076
2077        // Count consecutive days backwards
2078        while (true) {
2079            const key = this.formatDateKey(checkDate);
2080            if (log[key] && log[key] > 0) {
2081                streak++;
2082                checkDate.setDate(checkDate.getDate() - 1);
2083            } else {
2084                break;
2085            }
2086        }
2087
2088        return streak;
2089    }
2090
2091    formatDateKey(date) {
2092        const y = date.getFullYear();
2093        const m = String(date.getMonth() + 1).padStart(2, '0');
2094        const d = String(date.getDate()).padStart(2, '0');
2095        return `${y}-${m}-${d}`;
2096    }
2097
2098    updateStreakDisplay() {
2099        const streak = this.calculateStreak();
2100
2101        if (this._streakIndicator && this._streakText) {
2102            if (streak >= 3) {
2103                this._streakIndicator.style.display = 'flex';
2104                this._streakText.textContent = `${streak} day streak!`;
2105            } else {
2106                this._streakIndicator.style.display = 'none';
2107            }
2108        }
2109    }
2110
2111    // ==================== //
2112    // Saved Loops           //
2113    // ==================== //
2114
2115    getSavedLoops(videoId) {
2116        try {
2117            const stored = localStorage.getItem(`musicPracticeLoops_${videoId}`);
2118            return stored ? JSON.parse(stored) : [];
2119        } catch(e) { return []; }
2120    }
2121
2122    getAllSavedLoopsCount() {
2123        let total = 0;
2124        this.videos.forEach(v => {
2125            total += this.getSavedLoops(v.id).length;
2126        });
2127        return total;
2128    }
2129
2130    saveLoop(videoId, name, start, end) {
2131        const isPaid = this.accountManager.isPaid();
2132        if (!isPaid && this.getAllSavedLoopsCount() >= 3) {
2133            this.showSaveLoopUpgradeModal();
2134            return false;
2135        }
2136        const loops = this.getSavedLoops(videoId);
2137        loops.push({ name: name || `Loop ${loops.length + 1}`, start, end, savedAt: Date.now() });
2138        localStorage.setItem(`musicPracticeLoops_${videoId}`, JSON.stringify(loops));
2139        this.renderSavedLoops(videoId);
2140        this.trackEvent('loop_saved', { videoId, name, duration: end - start });
2141        return true;
2142    }
2143
2144    deleteSavedLoop(videoId, index) {
2145        const loops = this.getSavedLoops(videoId);
2146        loops.splice(index, 1);
2147        localStorage.setItem(`musicPracticeLoops_${videoId}`, JSON.stringify(loops));
2148        this.renderSavedLoops(videoId);
2149    }
2150
2151    loadSavedLoop(loop) {
2152        this.loopStart = loop.start;
2153        this.loopEnd = loop.end;
2154        this.elements.loopStartDisplay.textContent = this.formatTime(loop.start);
2155        this.elements.loopEndDisplay.textContent = this.formatTime(loop.end);
2156        this.updateLoopUI();
2157        this.checkLoopButtonState();
2158        this.updateShareUrl();
2159        if (this.player && this.playerReady) {
2160            this.player.seekTo(loop.start, true);
2161        }
2162        this.trackEvent('saved_loop_loaded', { name: loop.name });
2163    }
2164
2165    renderSavedLoops(videoId) {
2166        const container = document.getElementById('savedLoopsContainer');
2167        if (!container) return;
2168        const loops = this.getSavedLoops(videoId);
2169        const isPaid = this.accountManager.isPaid();
2170        const totalCount = this.getAllSavedLoopsCount();
2171
2172        if (loops.length === 0) {
2173            container.innerHTML = '<p class="no-loops-hint">No saved loops yet. Set a loop and click "Save Loop" to keep it.</p>';
2174            return;
2175        }
2176
2177        container.innerHTML = loops.map((loop, i) => `
2178            <div class="saved-loop-item">
2179                <div class="saved-loop-info">
2180                    <span class="saved-loop-name">${this.escapeHtml(loop.name)}</span>
2181                    <span class="saved-loop-times">${this.formatTime(loop.start)} - ${this.formatTime(loop.end)}</span>
2182                </div>
2183                <div class="saved-loop-actions">
2184                    <button class="btn btn-loop-load" onclick="app.loadSavedLoop(${JSON.stringify(loop).replace(/"/g, '&quot;')})">Load</button>
2185                    <button class="btn btn-loop-delete" onclick="app.deleteSavedLoop('${videoId}', ${i})">x</button>
2186                </div>
2187            </div>
2188        `).join('');
2189
2190        // Update save button state
2191        const saveBtn = document.getElementById('saveLoopBtn');
2192        if (saveBtn && !isPaid) {
2193            saveBtn.disabled = totalCount >= 3;
2194            if (totalCount >= 3) {
2195                saveBtn.title = 'Upgrade to save more loops';
2196            }
2197        }
2198    }
2199
2200    doSaveLoop() {
2201        if (this.loopStart === null || this.loopEnd === null || this.loopStart >= this.loopEnd) {
2202            alert('Please set a valid loop start and end point first.');
2203            return;
2204        }
2205        const nameInput = document.getElementById('loopNameInput');
2206        const name = nameInput ? nameInput.value.trim() : '';
2207        const success = this.saveLoop(this.currentVideoId, name, this.loopStart, this.loopEnd);
2208        if (success && nameInput) nameInput.value = '';
2209    }
2210
2211    showSaveLoopUpgradeModal() {
2212        const modal = this.createModal('saveloop-upgrade');
2213        modal.innerHTML = `
2214            <div class="modal-content upgrade-modal">
2215                <button class="modal-close" onclick="app.closeModal('saveloop-upgrade')">&times;</button>
2216                <h2>Save More Loops</h2>
2217                <p>Free accounts can save up to 3 loops. Upgrade to save unlimited loops across all your practice videos.</p>
2218                <div class="modal-actions">
2219                    <a href="https://pay.tide.co/products/music-pract-dojo6MnC" target="_blank"
2220                       onclick="app.trackEvent('upgrade_link_clicked', {source: 'loop_limit'})"
2221                       class="btn btn-primary">Upgrade Now</a>
2222                    <button class="btn btn-secondary" onclick="app.closeModal('saveloop-upgrade')">Maybe Later</button>
2223                </div>
2224            </div>
2225        `;
2226        document.body.appendChild(modal);
2227    }
2228
2229    // ==================== //
2230    // Metronome            //
2231    // ==================== //
2232
2233    initMetronome() {
2234        // DOM references
2235        this._metronomeBpmInput = document.getElementById('metronomeBpmInput');
2236        this._metronomeTimeSigSelect = document.getElementById('metronomeTimeSig');
2237        this._metronomeTapBtn = document.getElementById('metronomeTapBtn');
2238        this._metronomeStartStopBtn = document.getElementById('metronomeStartStopBtn');
2239        this._metronomeBeatDot = document.getElementById('metronomeBeatDot');
2240        this._metronomeVolumeSlider = document.getElementById('metronomeVolume');
2241        this._metronomeToggle = document.getElementById('metronomeToggle');
2242        this._metronomeBodyEl = document.getElementById('metronomeBody');
2243
2244        if (!this._metronomeToggle) return;
2245
2246        // Collapsible toggle (starts collapsed)
2247        this._metronomeToggle.addEventListener('click', () => {
2248            this._metronomeToggle.classList.toggle('open');
2249            this._metronomeBodyEl.classList.toggle('open');
2250        });
2251
2252        // BPM input
2253        if (this._metronomeBpmInput) {
2254            this._metronomeBpmInput.addEventListener('change', () => {
2255                let val = parseInt(this._metronomeBpmInput.value, 10);
2256                if (isNaN(val) || val < 20) val = 20;
2257                if (val > 300) val = 300;
2258                this._metronomeBpmInput.value = val;
2259                this._metronomeBpm = val;
2260            });
2261        }
2262
2263        // Time signature
2264        if (this._metronomeTimeSigSelect) {
2265            this._metronomeTimeSigSelect.addEventListener('change', () => {
2266                const val = this._metronomeTimeSigSelect.value;
2267                const parts = val.split('/');
2268                this._metronomeTimeSig = [parseInt(parts[0], 10), parseInt(parts[1], 10)];
2269                this._metronomeBeatCount = 0;
2270            });
2271        }
2272
2273        // Volume slider
2274        if (this._metronomeVolumeSlider) {
2275            this._metronomeVolumeSlider.addEventListener('input', () => {
2276                this._metronomeVolume = parseInt(this._metronomeVolumeSlider.value, 10) / 100;
2277            });
2278        }
2279
2280        // Start/Stop button
2281        if (this._metronomeStartStopBtn) {
2282            this._metronomeStartStopBtn.addEventListener('click', () => {
2283                if (this._metronomeRunning) {
2284                    this.metronomeStop();
2285                } else {
2286                    this.metronomeStart();
2287                }
2288            });
2289        }
2290
2291        // Tap tempo button
2292        if (this._metronomeTapBtn) {
2293            this._metronomeTapBtn.addEventListener('click', () => {
2294                this.metronomeTap();
2295            });
2296        }
2297    }
2298
2299    metronomeEnsureAudioContext() {
2300        if (!this._metronomeAudioCtx) {
2301            this._metronomeAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
2302        }
2303        if (this._metronomeAudioCtx.state === 'suspended') {
2304            this._metronomeAudioCtx.resume();
2305        }
2306        return this._metronomeAudioCtx;
2307    }
2308
2309    metronomeStart() {
2310        const ctx = this.metronomeEnsureAudioContext();
2311        this._metronomeRunning = true;
2312        this._metronomeBeatCount = 0;
2313        this._metronomeNextNoteTime = ctx.currentTime;
2314
2315        // Start the scheduler loop
2316        this.metronomeScheduler();
2317
2318        // Update UI
2319        if (this._metronomeStartStopBtn) {
2320            this._metronomeStartStopBtn.textContent = 'Stop';
2321            this._metronomeStartStopBtn.classList.add('running');
2322        }
2323
2324        this.trackEvent('metronome_started', { bpm: this._metronomeBpm, timeSig: this._metronomeTimeSig.join('/') });
2325    }
2326
2327    metronomeStop() {
2328        this._metronomeRunning = false;
2329
2330        if (this._metronomeSchedulerTimer) {
2331            clearTimeout(this._metronomeSchedulerTimer);
2332            this._metronomeSchedulerTimer = null;
2333        }
2334
2335        // Clear visual beat
2336        if (this._metronomeBeatDot) {
2337            this._metronomeBeatDot.classList.remove('flash', 'flash-downbeat');
2338        }
2339
2340        // Update UI
2341        if (this._metronomeStartStopBtn) {
2342            this._metronomeStartStopBtn.textContent = 'Start';
2343            this._metronomeStartStopBtn.classList.remove('running');
2344        }
2345
2346        this.trackEvent('metronome_stopped');
2347    }
2348
2349    metronomeScheduler() {
2350        if (!this._metronomeRunning) return;
2351
2352        const ctx = this._metronomeAudioCtx;
2353
2354        // Schedule notes that fall within the lookahead window
2355        while (this._metronomeNextNoteTime < ctx.currentTime + this._metronomeScheduleAheadTime) {
2356            this.metronomeScheduleNote(this._metronomeNextNoteTime, this._metronomeBeatCount);
2357            this.metronomeAdvanceBeat();
2358        }
2359
2360        this._metronomeSchedulerTimer = setTimeout(() => this.metronomeScheduler(), this._metronomeLookahead);
2361    }
2362
2363    metronomeScheduleNote(time, beatNumber) {
2364        const ctx = this._metronomeAudioCtx;
2365        const beatsPerMeasure = this._metronomeTimeSig[0];
2366        const isDownbeat = (beatNumber % beatsPerMeasure) === 0;
2367
2368        // Create click sound using OscillatorNode
2369        const osc = ctx.createOscillator();
2370        const gainNode = ctx.createGain();
2371
2372        osc.connect(gainNode);
2373        gainNode.connect(ctx.destination);
2374
2375        // Different pitch for downbeat vs other beats
2376        if (isDownbeat) {
2377            osc.frequency.value = 1000; // Higher pitch for downbeat
2378        } else {
2379            osc.frequency.value = 800; // Lower pitch for other beats
2380        }
2381
2382        osc.type = 'sine';
2383
2384        // Volume envelope - short click
2385        const volume = this._metronomeVolume;
2386        gainNode.gain.setValueAtTime(volume, time);
2387        gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
2388
2389        osc.start(time);
2390        osc.stop(time + 0.05);
2391
2392        // Schedule visual flash
2393        const delay = Math.max(0, (time - ctx.currentTime) * 1000);
2394        setTimeout(() => {
2395            this.metronomeFlashBeat(isDownbeat);
2396        }, delay);
2397    }
2398
2399    metronomeAdvanceBeat() {
2400        const secondsPerBeat = 60.0 / this._metronomeBpm;
2401        this._metronomeNextNoteTime += secondsPerBeat;
2402        this._metronomeBeatCount++;
2403    }
2404
2405    metronomeFlashBeat(isDownbeat) {
2406        if (!this._metronomeBeatDot) return;
2407
2408        // Remove any existing flash classes
2409        this._metronomeBeatDot.classList.remove('flash', 'flash-downbeat');
2410
2411        // Force reflow to restart animation
2412        void this._metronomeBeatDot.offsetWidth;
2413
2414        // Add the appropriate flash class
2415        if (isDownbeat) {
2416            this._metronomeBeatDot.classList.add('flash-downbeat');
2417        } else {
2418            this._metronomeBeatDot.classList.add('flash');
2419        }
2420
2421        // Remove flash after a short duration
2422        setTimeout(() => {
2423            if (this._metronomeBeatDot) {
2424                this._metronomeBeatDot.classList.remove('flash', 'flash-downbeat');
2425            }
2426        }, 100);
2427    }
2428
2429    metronomeTap() {
2430        const now = Date.now();
2431
2432        // Reset if gap > 2 seconds
2433        if (this._metronomeTapTimes.length > 0) {
2434            const lastTap = this._metronomeTapTimes[this._metronomeTapTimes.length - 1];
2435            if (now - lastTap > 2000) {
2436                this._metronomeTapTimes = [];
2437            }
2438        }
2439
2440        this._metronomeTapTimes.push(now);
2441
2442        // Keep only last 8 taps
2443        if (this._metronomeTapTimes.length > 8) {
2444            this._metronomeTapTimes.shift();
2445        }
2446
2447        // Need at least 2 taps to calculate BPM
2448        if (this._metronomeTapTimes.length >= 2) {
2449            const intervals = [];
2450            for (let i = 1; i < this._metronomeTapTimes.length; i++) {
2451                intervals.push(this._metronomeTapTimes[i] - this._metronomeTapTimes[i - 1]);
2452            }
2453            const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
2454            let bpm = Math.round(60000 / avgInterval);
2455
2456            // Clamp to valid range
2457            bpm = Math.max(20, Math.min(300, bpm));
2458
2459            this._metronomeBpm = bpm;
2460            if (this._metronomeBpmInput) {
2461                this._metronomeBpmInput.value = bpm;
2462            }
2463        }
2464    }
2465    // ==================== //
2466    // Progressive Speed    //
2467    // ==================== //
2468
2469    initProgressiveTraining() {
2470        const toggle = document.getElementById('progressiveToggle');
2471        const repsInput = document.getElementById('progressiveReps');
2472        const stepInput = document.getElementById('progressiveStep');
2473        const targetInput = document.getElementById('progressiveTarget');
2474
2475        if (toggle) {
2476            toggle.addEventListener('change', () => {
2477                this._progressiveEnabled = toggle.checked;
2478                this._progressiveRepsAtCurrentSpeed = 0;
2479                const controls = document.getElementById('progressiveControls');
2480                if (controls) controls.style.display = toggle.checked ? 'flex' : 'none';
2481                this.updateProgressiveUI();
2482                this.trackEvent('progressive_toggled', { enabled: toggle.checked });
2483            });
2484        }
2485        if (repsInput) {
2486            repsInput.addEventListener('change', () => {
2487                this._progressiveRepsPerStep = Math.max(1, Math.min(20, parseInt(repsInput.value) || 3));
2488                repsInput.value = this._progressiveRepsPerStep;
2489            });
2490        }
2491        if (stepInput) {
2492            stepInput.addEventListener('change', () => {
2493                this._progressiveSpeedStep = Math.max(0.01, Math.min(0.25, parseFloat(stepInput.value) || 0.05));
2494                stepInput.value = this._progressiveSpeedStep;
2495            });
2496        }
2497        if (targetInput) {
2498            targetInput.addEventListener('change', () => {
2499                this._progressiveTargetSpeed = Math.max(0.25, Math.min(2, parseFloat(targetInput.value) || 1.0));
2500                targetInput.value = this._progressiveTargetSpeed;
2501            });
2502        }
2503    }
2504
2505    progressiveSpeedUp() {
2506        if (!this.player || !this.playerReady) return;
2507
2508        const currentSpeed = this.player.getPlaybackRate();
2509        const newSpeed = Math.min(this._progressiveTargetSpeed, currentSpeed + this._progressiveSpeedStep);
2510
2511        // Round to 2 decimal places to avoid floating point drift
2512        const rounded = Math.round(newSpeed * 100) / 100;
2513
2514        if (rounded > currentSpeed) {
2515            this.setSpeed(rounded);
2516            this.elements.speedSlider.value = rounded;
2517            this._progressiveRepsAtCurrentSpeed = 0;
2518            this.showProgressiveNotification(rounded);
2519            this.trackEvent('progressive_speed_up', { newSpeed: rounded, targetSpeed: this._progressiveTargetSpeed });
2520        }
2521
2522        // Check if we've reached the target
2523        if (rounded >= this._progressiveTargetSpeed) {
2524            this._progressiveEnabled = false;
2525            const toggle = document.getElementById('progressiveToggle');
2526            if (toggle) toggle.checked = false;
2527            const controls = document.getElementById('progressiveControls');
2528            if (controls) controls.style.display = 'none';
2529            this.showProgressiveComplete();
2530            this.trackEvent('progressive_target_reached', { targetSpeed: this._progressiveTargetSpeed });
2531        }
2532
2533        this.updateProgressiveUI();
2534    }
2535
2536    showProgressiveNotification(speed) {
2537        const notification = document.createElement('div');
2538        notification.className = 'progressive-notification';
2539        notification.textContent = `Speed up! Now at ${speed.toFixed(2)}x`;
2540        document.body.appendChild(notification);
2541        // Trigger animation
2542        requestAnimationFrame(() => notification.classList.add('show'));
2543        setTimeout(() => {
2544            notification.classList.remove('show');
2545            setTimeout(() => notification.remove(), 300);
2546        }, 1500);
2547    }
2548
2549    showProgressiveComplete() {
2550        const notification = document.createElement('div');
2551        notification.className = 'progressive-notification progressive-complete';
2552        notification.textContent = `Target speed reached! You did it!`;
2553        document.body.appendChild(notification);
2554        requestAnimationFrame(() => notification.classList.add('show'));
2555        setTimeout(() => {
2556            notification.classList.remove('show');
2557            setTimeout(() => notification.remove(), 300);
2558        }, 3000);
2559    }
2560
2561    updateProgressiveUI() {
2562        const progress = document.getElementById('progressiveProgress');
2563        const statusText = document.getElementById('progressiveStatus');
2564        if (!progress || !statusText) return;
2565
2566        if (!this._progressiveEnabled) {
2567            progress.style.width = '0%';
2568            statusText.textContent = '';
2569            return;
2570        }
2571
2572        const pct = Math.min(100, (this._progressiveRepsAtCurrentSpeed / this._progressiveRepsPerStep) * 100);
2573        progress.style.width = pct + '%';
2574
2575        const currentSpeed = this.player && this.playerReady ? this.player.getPlaybackRate() : 0;
2576        const repsLeft = this._progressiveRepsPerStep - this._progressiveRepsAtCurrentSpeed;
2577        statusText.textContent = `${repsLeft} rep${repsLeft !== 1 ? 's' : ''} at ${currentSpeed.toFixed(2)}x until speed up`;
2578    }
2579}
2580
2581// ==================== //
2582// Initialize App       //
2583// ==================== //
2584
2585let app;
2586
2587// YouTube API callback - called by the YouTube IFrame API script when loaded
2588function onYouTubeIframeAPIReady() {
2589    if (app) {
2590        app.onYouTubeAPIReady();
2591    }
2592}
2593
2594// Initialize app when DOM is ready
2595if (document.readyState === 'loading') {
2596    document.addEventListener('DOMContentLoaded', () => {
2597        app = new MusicPracticeApp();
2598    });
2599} else {
2600    app = new MusicPracticeApp();
2601}

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.