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')">×</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')">×</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">✓</span> Unlimited videos</div> 1614 <div class="upgrade-feature-item"><span class="check-icon">✓</span> Unlimited saved loops</div> 1615 <div class="upgrade-feature-item"><span class="check-icon">✓</span> Progressive speed training</div> 1616 <div class="upgrade-feature-item"><span class="check-icon">✓</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')">×</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')">×</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, '"')})">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')">×</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.