279 lines
8.8 KiB
TypeScript
279 lines
8.8 KiB
TypeScript
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<boolean>(() => {
|
|
// Check if offline
|
|
if (!this.connectivityService.isOnline()) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
public currentCantoId = signal<string | null>(null);
|
|
public isPlaying = signal<boolean>(false);
|
|
public videoProgress = signal<number>(0);
|
|
public videoDuration = signal<number>(0);
|
|
public isPlayerReady = signal<boolean>(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;
|
|
}
|
|
}
|
|
}
|