Files
canti/src/app/services/youtube-player.service.ts
T

272 lines
8.4 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';
@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);
public isPlayerSupported = computed<boolean>(() => {
// Check if offline
if (!this.connectivityService.isOnline()) {
return false;
}
// Check if iPhone/iPad/iPod
const userAgent = window.navigator.userAgent.toLowerCase();
const isIOS = /iphone|ipad|ipod/.test(userAgent);
if (isIOS) {
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) 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;
}
}
}