media-manager.service.ts raw
1 import { atom, getDefaultStore } from 'jotai'
2
3 export const hasBackgroundAudioAtom = atom(false)
4 const store = getDefaultStore()
5
6 /**
7 * MediaManagerService coordinates HTML5 media playback across the app.
8 *
9 * Since we use privacy-preserving thumbnails instead of embedded players
10 * for YouTube and X/Twitter, this service only handles native HTML5 media
11 * (audio/video elements).
12 */
13 class MediaManagerService extends EventTarget {
14 static instance: MediaManagerService
15
16 private currentMedia: HTMLMediaElement | null = null
17
18 constructor() {
19 super()
20 }
21
22 public static getInstance(): MediaManagerService {
23 if (!MediaManagerService.instance) {
24 MediaManagerService.instance = new MediaManagerService()
25 }
26 return MediaManagerService.instance
27 }
28
29 pause(media: HTMLMediaElement | null) {
30 if (!media) {
31 return
32 }
33 if (isPipElement(media)) {
34 return
35 }
36 if (this.currentMedia === media) {
37 this.currentMedia = null
38 }
39 media.pause()
40 }
41
42 autoPlay(media: HTMLMediaElement) {
43 if (
44 document.pictureInPictureElement &&
45 isMediaPlaying(document.pictureInPictureElement as HTMLMediaElement)
46 ) {
47 return
48 }
49 if (
50 store.get(hasBackgroundAudioAtom) &&
51 this.currentMedia &&
52 isMediaPlaying(this.currentMedia)
53 ) {
54 return
55 }
56 this.play(media)
57 }
58
59 play(media: HTMLMediaElement | null) {
60 if (!media) {
61 return
62 }
63 if (document.pictureInPictureElement && document.pictureInPictureElement !== media) {
64 ;(document.pictureInPictureElement as HTMLMediaElement).pause()
65 }
66 if (this.currentMedia && this.currentMedia !== media) {
67 this.currentMedia.pause()
68 }
69 this.currentMedia = media
70 if (isMediaPlaying(media)) {
71 return
72 }
73
74 this.currentMedia.play().catch((error) => {
75 console.error('Error playing media:', error)
76 this.currentMedia = null
77 })
78 }
79
80 playAudioBackground(src: string, time: number = 0) {
81 this.dispatchEvent(new CustomEvent('playAudioBackground', { detail: { src, time } }))
82 store.set(hasBackgroundAudioAtom, true)
83 }
84
85 stopAudioBackground() {
86 this.dispatchEvent(new Event('stopAudioBackground'))
87 store.set(hasBackgroundAudioAtom, false)
88 }
89 }
90
91 const instance = MediaManagerService.getInstance()
92 export default instance
93
94 function isMediaPlaying(media: HTMLMediaElement) {
95 return media.currentTime > 0 && !media.paused && !media.ended && media.readyState >= 2
96 }
97
98 function isPipElement(media: HTMLMediaElement) {
99 if (document.pictureInPictureElement === media) {
100 return true
101 }
102 return (media as any).webkitPresentationMode === 'picture-in-picture'
103 }
104