Youtube iframe api: interact with MediaSession
Asked Answered
K

0

12

I have an angular app with the Youtube IFrame API. When the website/pwa is used on an android device, and a Youtube video is playing, the app automatically creates a notification in the android notification tray, allowing you to see what video is currently playing.

Basic media notification

I created my own playlist-controller, so I'm not using queueVideoById since I don't like the way the default youtube-iframe playlist-controller works (you cannot remove queued videos afterwards).

From my AppComponent I'm loading the YouTube IFrame API

app.component.ts

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit, OnDestroy {
  constructor(private youtubeHelper: YoutubeHelper) {
    this.youtubeHelper.loadApi().then((isLoaded) => {
      this.player.playSong(song.playerInfo.id);
    });
  }

  @ViewChild('player') player: YoutubePlayerComponent;
}

app.component.html

<body>
  <youtube-player #player [domId]="'player'" ... (previousPressed)="onPreviousPressed()" (nextPressed)="onNextPressed()"></youtube-player>
</body>

This helper is used to load the IFrame API script. LoadApi inserts a script-tag with the src set to the youtube-iframe-url:

import { Injectable } from '@angular/core';
import { Promise } from 'q';
import { BehaviorSubject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class YoutubeHelper {
  static scriptTag: HTMLScriptElement = null;

  public loadApi() {
    return Promise<boolean>((resolve, reject) => {
      if (typeof window !== 'undefined') {
        if (YoutubeHelper.scriptTag === null) {
          window['onYouTubeIframeAPIReady'] = () => {
            this.apiReady.next(true);
            resolve(true);
          };

          YoutubeHelper.scriptTag = window.document.createElement('script');
          YoutubeHelper.scriptTag.src = 'https://www.youtube.com/iframe_api';

          const firstScriptTag = window.document.getElementsByTagName('script')[0];
          firstScriptTag.parentNode.insertBefore(YoutubeHelper.scriptTag, firstScriptTag);
        } else {
          resolve(true);
        }
      } else {
        resolve(false);
      }
    });
  }

  public unloadApi() {
    return Promise((resolve) => {
      if (typeof window !== 'undefined') {
        if (YoutubeHelper.scriptTag !== null) {
          YoutubeHelper.scriptTag.parentNode.removeChild(YoutubeHelper.scriptTag);
          YoutubeHelper.scriptTag = null;
          //console.log('Removed YouTube iframe api');

          this.apiReady.next(false);
        }
      }
    });
  }

  public apiReady = new BehaviorSubject<boolean>(
    (typeof window === 'undefined')
      ? false
      : window['YT'] !== undefined
  );
}

When the iframe api script is inserted in the dom, the YoutubePlayerComponent deals with this by creating an instance of YT.Player (this happens at the this.youtubeHelper.apiReady subscription):

/// <reference path="../../../../node_modules/@types/youtube/index.d.ts" />

import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { YoutubeHelper } from '../../helpers/youtube-api.helper';
import { SongProgress } from '../../entities/song-progress';
import { Song } from '../../entities/song';
import { BehaviorSubject } from 'rxjs';

@Component({
  selector: 'youtube-player',
  templateUrl: './youtube-player.component.html',
  styleUrls: ['./youtube-player.component.scss']
})
export class YoutubePlayerComponent implements OnInit {
  constructor(private youtubeHelper: YoutubeHelper) {
  }

  @Input() domId: string;

  // Properties omitted for breverity

  @Output() previousPressed: EventEmitter<any> = new EventEmitter();
  @Output() nextPressed: EventEmitter<any> = new EventEmitter();

  private player: YT.Player;
  private isPlayerReady = new BehaviorSubject<boolean>(false);

  ngOnInit() {
  }

  ngAfterViewInit() {
    this.youtubeHelper.apiReady.subscribe((ready) => {
      if (ready && !this.player) {
        this.player = new YT.Player(this.domId, {
          width: this.width,
          height: this.height,
          playerVars: {
            autoplay: this._autoplay
          },
          events: {
            onReady: (event) => {
              this.isPlayerReady.next(true);
            },
            onStateChange: (state: { data: YT.PlayerState }) => {
            }
          }
        });

        // Attempt to give the player methods my very own implementation
        this.player.previousVideo = () => {
          this.previousPressed.emit();
        };
        this.player.nextVideo = () => {
          this.nextPressed.emit();
        };
        console.log('player', this.player);
      }
    });
  }

  static mediaInit = false;
  public playSong(youtubeId: string) {
    if (this.isPlayerReady.value) {
      this.player.loadVideoById(youtubeId);
    } else {
      console.log('Player not yet ready');
      this.isPlayerReady.subscribe((value) => {
        if (value === true) {
          this.player.loadVideoById(youtubeId);
        }
      });
    }

    // Here is the code that's supposed to interact with the media session in the iframe.
    if (!YoutubePlayerComponent.mediaInit) {
      YoutubePlayerComponent.mediaInit = true;
      let frame = <HTMLIFrameElement>document.getElementById(this.domId);

      frame.contentWindow.navigator.mediaSession.setActionHandler('previoustrack', () => {
        this.previousPressed.emit();
      });
      frame.contentWindow.navigator.mediaSession.setActionHandler('nexttrack', () => {
        this.nextPressed.emit();
      });
    }
  }

  public play() { this.player.playVideo(); }
  // Pause, stop methods omitted for breverity
}

After that the isPlayerReady is triggered, allowing a video to be played:

When calling YoutubePlayerComponent.playSong, it either waits for the YoutubePlayer to be ready (isPlayerReady) or immediately calls this.player.loadVideoById, which plays the requested video.

Having said this, the question is:

By default when a video is playing inside my website/pwa it displays a media notification as shown in the first image. I want to be able to navigate to the previous/next track in my own playlist. So I want to intercept these events:

Media notification with previous/next buttons

According to this link it's possible to modify the mediaSession of the Window after playing a video, attaching custom handlers to the previous/next buttons in the android tray. But since the video (and off course the mediaSession) is being played from the iframe, it's not possible to modify the metadata:

navigator.mediaSession.metadata = new MediaMetadata({
  title: 'Never Gonna Give You Up',
  artist: 'Rick Astley',
  album: 'Whenever You Need Somebody'
});

would become:

document.getElementById('player').contentWindow.navigator.mediaSession.metadata = new MediaMetadata({
  title: 'Never Gonna Give You Up',
  artist: 'Rick Astley',
  album: 'Whenever You Need Somebody'
});

And just to check, while a video is playing the mediaSession of the mainWindow is empty:

navigator.mediaSession.metadata
> null

And the actual mediaSession should be available in:

document.getElementById("player").contentWindow.navigator.mediaSession
> Blocked a frame with origin "https://example.com" from accessing a cross-origin frame

So is there any way I can add previous/next buttons to the notification while using the Youtube iframe API, and handle these events?

If necessary, the entire project is available on GitHub

Kanpur answered 10/6, 2020 at 9:59 Comment(1)
I would love to know the answer to this too...Caleb

© 2022 - 2024 — McMap. All rights reserved.