import { Injectable, signal, effect, inject, computed } from '@angular/core'; import { CantiService } from './canti.service'; import { SettingsService } from './settings.service'; import { MediaSessionService } from './media-session.service'; import { MyCantiService } from './my-canti.service'; import { ConnectivityService } from './connectivity.service'; import { ComunitaService } from './comunita.service'; import { PlaylistService } from './playlist.service'; @Injectable({ providedIn: 'root' }) export class YoutubePlayerService { private cantiService = inject(CantiService); private settingsService = inject(SettingsService); private mediaSessionService = inject(MediaSessionService); private myCantiService = inject(MyCantiService); private connectivityService = inject(ConnectivityService); private comunitaService = inject(ComunitaService); private playlistService = inject(PlaylistService); public isPlayerSupported = computed(() => { // Check if offline if (!this.connectivityService.isOnline()) { return false; } return true; }); public currentCantoId = signal(null); public isPlaying = signal(false); public videoProgress = signal(0); public videoDuration = signal(0); public isPlayerReady = signal(false); private player: any = null; private progressInterval: any = null; private onEndedCallback: (() => void) | null = null; private onErrorCallback: (() => void) | null = null; private silentAudio: HTMLAudioElement | null = null; private lastNextCallback: (() => void) | null = null; private lastPrevCallback: (() => void) | null = null; constructor() { this.loadYoutubeAPI(); this.setupMediaSession(); this.initSilentAudio(); this.setupBackgroundPersistence(); // Auto-stop player when not supported (e.g. going offline) effect(() => { const supported = this.isPlayerSupported(); if (!supported && this.currentCantoId()) { this.stop(); } }); } private initSilentAudio() { // 1-second silent MP3 base64 const silentSrc = 'data:audio/mpeg;base64,SUQzBAAAAAABAFRYWFhYAAAADAAAY29udGVudAB0eXBlAGF1ZGlvL21wZWdB///+8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; this.silentAudio = new Audio(silentSrc); this.silentAudio.loop = true; } private setupBackgroundPersistence() { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && this.isPlaying() && this.player) { // Ensure player is actually playing when returning to the app try { if (this.player.getPlayerState() !== (window as any).YT.PlayerState.PLAYING) { this.player.playVideo(); } } catch (e) {} } }); } public setMediaSessionCallbacks(next?: () => void, prev?: () => void) { if (next) this.lastNextCallback = next; if (prev) this.lastPrevCallback = prev; this.mediaSessionService.initActionHandlers({ play: () => this.resume(), pause: () => this.pause(), seekto: (time) => this.seekTo(time), next: this.lastNextCallback || undefined, prev: this.lastPrevCallback || undefined }); } private setupMediaSession() { this.setMediaSessionCallbacks(); } private loadYoutubeAPI() { if ((window as any).YT) return; const tag = document.createElement('script'); tag.src = "https://www.youtube.com/iframe_api"; const firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); (window as any).onYouTubeIframeAPIReady = () => { this.isPlayerReady.set(true); }; } public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void) { if (this.currentCantoId() === cantoId && this.player) { this.onEndedCallback = onEnded || null; this.onErrorCallback = onError || null; const currentTime = this.player.getCurrentTime(); // Only seek if the difference is significant (more than 2 seconds) if (startTime > 0 && Math.abs(currentTime - startTime) > 2) { this.player.seekTo(startTime, true); } this.resume(); return; } this.onEndedCallback = onEnded || null; this.onErrorCallback = onError || null; let canto = this.cantiService.getCantoById(cantoId); if (!canto) { canto = this.myCantiService.myCanti().find(c => c.id === cantoId); } if (!canto) { canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId); } if (!canto) { canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId); } if (!canto) { canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId); } if (!canto) return; const videoId = this.cantiService.getYoutubeId(canto.link_youtube); if (!videoId) { if (onError) onError(); return; } if (!(window as any).YT || !(window as any).YT.Player) { setTimeout(() => this.initPlayer(cantoId, startTime, onEnded, onError), 200); return; } // Reuse existing player if possible if (this.player && this.player.loadVideoById) { this.currentCantoId.set(cantoId); this.mediaSessionService.updateMetadata(cantoId); this.player.loadVideoById({ videoId: videoId, startSeconds: startTime }); if (this.silentAudio) this.silentAudio.play().catch(() => {}); return; } this.destroyPlayer(); this.currentCantoId.set(cantoId); this.mediaSessionService.updateMetadata(cantoId); this.player = new (window as any).YT.Player('global-yt-player-container', { height: '1', width: '1', videoId: videoId, playerVars: { autoplay: 1, playsinline: 1, mute: 0, modestbranding: 1, rel: 0, controls: 0, disablekb: 1, start: startTime }, events: { onReady: (event: any) => { event.target.unMute(); event.target.setVolume(100); this.videoDuration.set(event.target.getDuration()); this.startPolling(); if (startTime > 0) { event.target.seekTo(startTime, true); } event.target.playVideo(); this.isPlaying.set(true); if (this.silentAudio) this.silentAudio.play().catch(() => {}); }, onStateChange: (event: any) => { const state = event.data; if (state === (window as any).YT.PlayerState.PLAYING) { this.isPlaying.set(true); this.videoDuration.set(this.player.getDuration()); this.mediaSessionService.setPlaybackState('playing'); // Re-apply handlers to prevent YT from overriding them this.setMediaSessionCallbacks(); } else if (state === (window as any).YT.PlayerState.PAUSED) { this.isPlaying.set(false); this.mediaSessionService.setPlaybackState('paused'); // Re-apply handlers even when paused this.setMediaSessionCallbacks(); } else if (state === (window as any).YT.PlayerState.ENDED) { this.isPlaying.set(false); this.mediaSessionService.setPlaybackState('none'); if (this.onEndedCallback) this.onEndedCallback(); } }, onError: (event: any) => { console.error('Global YT Player Error:', event.data); if (this.onErrorCallback) this.onErrorCallback(); } } }); } public pause() { if (this.player) { this.player.pauseVideo(); this.isPlaying.set(false); if (this.silentAudio) this.silentAudio.pause(); } } public resume() { if (this.player) { this.player.playVideo(); this.isPlaying.set(true); if (this.silentAudio) this.silentAudio.play().catch(() => {}); } } public togglePlayPause() { if (this.isPlaying()) { this.pause(); } else { this.resume(); } } public seekTo(seconds: number) { if (this.player) { this.player.seekTo(seconds, true); } } public stop() { this.destroyPlayer(); this.currentCantoId.set(null); this.isPlaying.set(false); } private startPolling() { if (this.progressInterval) clearInterval(this.progressInterval); this.progressInterval = setInterval(() => { if (this.player && this.player.getCurrentTime) { this.videoProgress.set(this.player.getCurrentTime()); } }, 500); } private destroyPlayer() { if (this.progressInterval) { clearInterval(this.progressInterval); this.progressInterval = null; } if (this.player) { try { this.player.destroy(); } catch (e) {} this.player = null; } } }