ghenry22 / cordova-plugin-music-controls2

A Cordova plugin displaying music controls in notifications (cordova-plugin-music-controls)
MIT License
59 stars 58 forks source link

XCode 15: plugin no longer working on iOS. Does not show info, does not register control events #103

Open silencio opened 3 months ago

silencio commented 3 months ago

This plugin completely fails in my Cordova app built with XCode 15 on iOS.

Instead of my metadata and custom events handlers, I only get a basic info (the current window title) + a play/pause button linked to the active HTML audio.

This error is showing up: [assertion] Error acquiring assertion: <Error Domain=RBSServiceErrorDomain Code=1 "(originator doesn't have entitlement com.apple.runningboard.assertions.webkit AND originator doesn't have entitlement com.apple.multitasking.systemappassertions)" UserInfo={NSLocalizedFailureReason=(originator doesn't have entitlement com.apple.runningboard.assertions.webkit AND originator doesn't have entitlement com.apple.multitasking.systemappassertions)}>

[ProcessSuspension] 0x10d001c60 - ProcessAssertion::acquireSync Failed to acquire RBS assertion 'WebKit Media Playback' for process with PID=XXX, error: Error Domain=RBSServiceErrorDomain Code=1 "(originator doesn't have entitlement com.apple.runningboard.assertions.webkit AND originator doesn't have entitlement com.apple.multitasking.systemappassertions)" UserInfo={NSLocalizedFailureReason=(originator doesn't have entitlement com.apple.runningboard.assertions.webkit AND originator doesn't have entitlement com.apple.multitasking.systemappassertions)}

These are private entitlements not exposed to developers.

pinguluk commented 1 month ago

A workaround is to check if the platform is ios and use MediaSession API instead, to create and update the media/music controller

Example from my code:

import { Component, OnInit, ChangeDetectorRef, ViewChild, ElementRef } from '@angular/core';
import { Platform, NavController, ModalController } from '@ionic/angular';
import { ActivatedRoute } from "@angular/router";
import { MusicControls } from '@ionic-native/music-controls/ngx';

import { FirebaseProvider } from '../../../services/firebase/firebase';
import { ApiService } from '../../../services/api/api';
import { ModalHelper } from 'src/app/services/helper/modal';
import { LoginModal } from 'src/app/components/login-modal/login-modal.modal';
import { UserProvider } from 'src/app/services/user/user';
import { BackgroundMode } from '@ionic-native/background-mode/ngx';

@Component({
    selector: 'app-hei-vibes-playlist',
    templateUrl: './radio.page.html',
    styleUrls: ['./radio.page.scss'],
})
export class RadioPage {
    @ViewChild('audioPlayer') audioPlayerRef: ElementRef;
    audio: HTMLAudioElement;

    tracks: any = [
       ...
    ];

    played: any = [];

    isPlaying: boolean = false;
    buffering: boolean = false;

    currentTrack: any = {};
    currentTrackIndex: number | null = null;

    buttonIcon: string;

    currentTrackListenStartTime: number?;
    logCurrentTrackListeningInterval: any;
    logCurrentTrackListeningIntervalNumber: number = 5000; // 5 seconds

    constructor(
        public platform: Platform,
        public navCtrl: NavController,
        public api: ApiService,
        private fb: FirebaseProvider,
        public modalCtrl: ModalController,
        public modalHelper: ModalHelper,
        private user: UserProvider,
        private musicControls: MusicControls,
        private backgroundMode: BackgroundMode,
    ) { }

    ngAfterViewInit() {
        this.audio = this.audioPlayerRef.nativeElement;
        this.setupAudioListeners();
    }

    ionViewWillEnter() {
        if (this.platform.is('android')) {
            if (this.backgroundMode.isEnabled() || this.backgroundMode.isActive()) {
                return;
            }

            this.backgroundMode.enable();
            this.backgroundMode.on("activate").subscribe(() => {
                this.backgroundMode.disableWebViewOptimizations();
                this.backgroundMode.disableBatteryOptimizations();
            });
        }

        if (this.user.get()) {
            this.play(0);
        }
    }

    ionViewDidEnter() {
        this.fb.setScreenName("...");
    }

    ionViewWillLeave() {
        this.stop();
        if (this.platform.is('android')) {
            this.backgroundMode.disable();
        }
    }

    ionViewDidLeave() {
        this.destroyMusicControls();
    }

    setupAudioListeners() {
        this.audio.onplay = () => {
            console.log('Audio playback onplay');
            this.isPlaying = true;
            this.buffering = false;
            this.startListeningTracking();

            if (this.platform.is('android')) {
                this.musicControls.updateIsPlaying(true);

            } else {
                // @ts-ignore
                navigator.mediaSession.playbackState = "playing";
            }

            this.createMusicControls();
        };

        this.audio.onpause = () => {
            console.log('Audio playback onpause');
            this.isPlaying = false;
            this.clearListeningTracking();

            if (this.platform.is('android')) {
                this.musicControls.updateIsPlaying(false);
            }
            else {
                // @ts-ignore
                navigator.mediaSession.playbackState = "paused";
            }
        };

        this.audio.onended = () => {
            console.log('Audio playback onended');
            this.next();
        };

        this.audio.onerror = () => {
            console.log('Audio playback onerror');
            console.error('Audio playback error');
            this.clearListeningTracking();
        };

        this.audio.onwaiting = () => {
            console.log('Audio playback onwaiting');
            this.buffering = true;
        };

        this.audio.oncanplay = () => {
            console.log('Audio playback oncanplay');
            this.buffering = false;
        };
    }

    play(trackIndex: number | null = null) {
        // If user is not logged in, show login modal
        if (!this.user.get()) {
            this.showLoginModal();
            return;
        }

        // If there are no tracks, return
        if (this.tracks.length === 0) return;

        // We reset the listening tracking
        this.clearListeningTracking();

        // If track index is provided
        if (trackIndex !== null) {
            // If it's different than the current track index, play the given track
            if (this.currentTrackIndex !== trackIndex) {
                if (this.currentTrack.title) {
                    this.logCurrentTrackListeningDuration(this.currentTrack);
                }
                this.playStream(trackIndex);
            }
        }
        // If track index is not provided (this.play())
        else {
            // If it's currently playing, pause
            if (this.isPlaying) {
                this.pause();
            }
            // Else if it's not playing, resume 
            else {
                this.resumeAndBufferToLive();
            }
        }
    }

    resumeAndBufferToLive() {
        // First, pause the audio
        this.audio.pause();

        // Clear the source and reload it to get the latest stream
        const currentSrc = this.audio.src;
        this.audio.src = '';
        this.audio.load();
        this.audio.src = currentSrc;

        // Start buffering
        this.buffering = true;

        // Attempt to play
        this.audio.play().then(() => {
            console.log('Resumed and buffered to live successfully');
            this.buffering = false;
        }).catch(error => {
            console.error('Error resuming and buffering to live:', error);
            this.buffering = false;
        });
    }

    playStream(trackIndex: number) {
        this.played.push(trackIndex);
        this.currentTrackIndex = trackIndex;
        this.currentTrack = this.tracks[trackIndex];

        this.fb.logEvent("...", {
            title: this.currentTrack.title,
            genre: this.currentTrack.genre
        });

        this.audio.src = this.currentTrack.stream_url;
        this.audio.load();
        this.audio.play();
    }

    pause() {
        this.audio.pause();
    }

    stop() {
        this.audio.pause();
        this.audio.currentTime = 0;
        this.isPlaying = false;
        this.clearListeningTracking();
        this.currentTrackIndex = -1;
        this.currentTrack = { title: '' };
    }

    previous() {
        let index: number;

        // If current track is the first track, play the last track
        if (this.currentTrackIndex === 0) {
            index = this.tracks.length - 1;
        }
        // Else play the previous track
        else {
            index = this.currentTrackIndex - 1;
        }

        this.play(index);
    }

    next() {
        let index: number;

        // If current track is the last track, play the first track
        if (this.currentTrackIndex === this.tracks.length - 1) {
            index = 0;
        }
        // Else play the next track
        else {
            index = this.currentTrackIndex + 1;
        }

        this.play(index);
    }

    get playIcon() {
        return `assets/icons/track_${this.isPlaying ? 'pause' : 'play'}.svg`;
    }

    startListeningTracking() {
        this.currentTrackListenStartTime = Date.now();
        this.logCurrentTrackListeningInterval = setInterval(() => {
            this.logCurrentTrackListeningDuration(this.currentTrack);
        }, this.logCurrentTrackListeningIntervalNumber);
    }

    clearListeningTracking() {
        clearInterval(this.logCurrentTrackListeningInterval);
        this.currentTrackListenStartTime = null;
    }

    logCurrentTrackListeningDuration(currentTrack) {
        if (!this.currentTrackListenStartTime) return;

        const currentTrackListenDuration = Date.now() - this.currentTrackListenStartTime;

        const listeningDuration = {
            track: currentTrack.title,
            genre: currentTrack.genre,
            short_listen_time: this.logCurrentTrackListeningIntervalNumber / 1000,
            current_total_listen_time: Number((currentTrackListenDuration / 1000).toFixed(2))
        };

        console.log('Listening duration', listeningDuration);
        this.fb.logEvent("...", listeningDuration);
    }

    showLoginModal() {
        if (this.modalHelper.modalInstances.includes('login-modal')) return;

        this.modalHelper.modalInstances.push('login-modal');
        this.modalCtrl.create({
            component: LoginModal,
            id: 'login-modal',
            cssClass: "login-modal",
            backdropDismiss: false,
        }).then(modal => {
            modal.present();
            modal.onDidDismiss().then(() => {
                this.modalHelper.modalInstances = this.modalHelper.modalInstances.filter(e => e !== 'login-modal');
                if (this.user.get()) {
                    this.play(0);
                }
            });
        });
    }

    async createMusicControls() {
        // If Android, create music controller via plugin
        if (this.platform.is('android')) {
            console.log("Creating music controls Android");
            await this.musicControls.create({
                track: this.currentTrack.title,        // optional, default : ''
                artist: this.currentTrack.genre,                       // optional, default : ''
                //cover       : 'assets/imgs/vibes.png',      // optional, default : nothing
                cover: "https://www.example.com/image.png",      // optional, default : nothing
                // cover can be a local path (use fullpath 'file:///storage/emulated/...', or only 'my_image.jpg' if my_image.jpg is in the www folder of your app)
                //           or a remote url ('http://...', 'https://...', 'ftp://...')
                isPlaying: true,                         // optional, default : true
                dismissable: true,                         // optional, default : false

                // hide previous/next/close buttons:
                hasPrev: true,      // show previous button, optional, default: true
                hasNext: true,      // show next button, optional, default: true
                hasClose: true,       // show close button, optional, default: false

                // iOS only, optional
                //album       : 'Absolution',     // optional, default: ''
                hasSkipForward: true,  // show skip forward button, optional, default: false
                hasSkipBackward: true, // show skip backward button, optional, default: false
                skipForwardInterval: 15, // display number for skip forward, optional, default: 0
                skipBackwardInterval: 15, // display number for skip backward, optional, default: 0
                hasScrubbing: false, // enable scrubbing from control center and lockscreen progress bar, optional

                // Android only, optional
                // text displayed in the status bar when the notification (and the ticker) are updated, optional
                ticker: this.currentTrack.title,
                // All icons default to their built-in android equivalents
                // The supplied drawable name, e.g. 'media_play', is the name of a drawable found under android/res/drawable* folders
                playIcon: 'media_pause',
                pauseIcon: 'media_play',
                prevIcon: 'media_prev',
                nextIcon: 'media_next',
                closeIcon: 'media_close',
                notificationIcon: 'notification'
            }).then((s) => {
                console.log("musicControls created");
                //this.api.debugger(s);
            }).catch((e) => {
                //this.api.debugger(e);
            });

            this.musicControls.subscribe().subscribe((action) => {
                const message = JSON.parse(action).message;
                console.log('musicControls', message);

                switch (message) {
                    case 'music-controls-next':
                        this.next();
                        break;
                    case 'music-controls-previous':
                        this.previous();
                        break;
                    case 'music-controls-pause':
                        this.play();
                        break;
                    case 'music-controls-play':
                        this.pause();
                        break;
                    case 'music-controls-destroy':
                        this.destroyMusicControls();
                        break;

                    // External controls (iOS only)
                    case 'music-controls-toggle-play-pause':
                        this.play();
                        break;
                    case 'music-controls-seek-to':
                        // Do something
                        break;
                    case 'music-controls-skip-forward':
                        this.next();
                        break;
                    case 'music-controls-skip-backward':
                        this.previous();
                        break;

                    // Headset events (Android only)
                    // All media button events are listed below
                    case 'music-controls-media-button':
                        // Do something
                        break;
                    case 'music-controls-headset-unplugged':
                        // Do something
                        break;
                    case 'music-controls-headset-plugged':
                        // Do something
                        break;
                    default:
                        break;
                }
            });
            this.musicControls.listen(); // activates the observable above
            this.musicControls.updateIsPlaying(true);
        }
        // Else iOS (or web) and use MediaSession API to create music controller
        else {
            // @ts-ignore
            if ('mediaSession' in navigator) {
                // @ts-ignore
                navigator.mediaSession.metadata = new MediaMetadata({
                    title: this.currentTrack.title,
                    artist: this.currentTrack.genre,
                    album: 'Radio',
                    artwork: [
                        { src: 'https://www.example.com/image.png', sizes: '96x96', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '128x128', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '192x192', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '256x256', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '384x384', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '512x512', type: 'image/png' },
                    ]
                });

                // @ts-ignore
                navigator.mediaSession.setActionHandler('play', () => {
                    this.play();
                });
                // @ts-ignore
                navigator.mediaSession.setActionHandler('pause', () => {
                    this.pause();
                });
                // @ts-ignore
                navigator.mediaSession.setActionHandler('previoustrack', () => {
                    this.previous();
                });
                // @ts-ignore
                navigator.mediaSession.setActionHandler('nexttrack', () => {
                    this.next();
                });

                // @ts-ignore
                navigator.mediaSession.playbackState = this.isPlaying ? "playing" : "paused";
            }
        }
    }

    destroyMusicControls(action = false) {
        if (this.platform.is('android')) {
            this.musicControls.destroy();
        }
        else {
            if ('mediaSession' in navigator) {
                // @ts-ignore
                navigator.mediaSession.metadata = null;
                // @ts-ignore
                navigator.mediaSession.setActionHandler('play', null);
                // @ts-ignore
                navigator.mediaSession.setActionHandler('pause', null);
                // @ts-ignore
                navigator.mediaSession.setActionHandler('previoustrack', null);
                // @ts-ignore
                navigator.mediaSession.setActionHandler('nexttrack', null);
            }
        }
    }
}

Basically, instead of await this.musicControls.create({... you'll use the MediaSession API

...
// @ts-ignore
            if ('mediaSession' in navigator) {
                // @ts-ignore
                navigator.mediaSession.metadata = new MediaMetadata({
                    title: this.currentTrack.title,
                    artist: this.currentTrack.genre,
                    album: 'Radio',
                    artwork: [
                        { src: 'https://www.example.com/image.png', sizes: '96x96', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '128x128', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '192x192', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '256x256', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '384x384', type: 'image/png' },
                        { src: 'https://www.example.com/image.png', sizes: '512x512', type: 'image/png' },
                    ]
                });

                // @ts-ignore
                navigator.mediaSession.setActionHandler('play', () => {
                    this.play();
                });
                // @ts-ignore
                navigator.mediaSession.setActionHandler('pause', () => {
                    this.pause();
                });
                // @ts-ignore
                navigator.mediaSession.setActionHandler('previoustrack', () => {
                    this.previous();
                });
                // @ts-ignore
                navigator.mediaSession.setActionHandler('nexttrack', () => {
                    this.next();
                });

                // @ts-ignore
                navigator.mediaSession.playbackState = this.isPlaying ? "playing" : "paused";
            }
...

Keep in mind that Android WebView doesn't support this API unfortunately , so you'll have to stick with the plugin for Android and the MediaSession API for iOS (and web/desktop).