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