diff --git a/src/app/exercise/exercise.page/components/answers-layout/answers-layout.component.scss b/src/app/exercise/exercise.page/components/answers-layout/answers-layout.component.scss index 30539af5..19b76398 100644 --- a/src/app/exercise/exercise.page/components/answers-layout/answers-layout.component.scss +++ b/src/app/exercise/exercise.page/components/answers-layout/answers-layout.component.scss @@ -8,7 +8,6 @@ .answers-layout { &__answers-rows-container, &__answers-buttons-auto-layout-container { - flex-grow: 1; display: flex; flex-direction: column; gap: math.div($unit, 2); diff --git a/src/app/exercise/exercise.page/exercise.page.html b/src/app/exercise/exercise.page/exercise.page.html index a72a36b6..d3fb9fd1 100644 --- a/src/app/exercise/exercise.page/exercise.page.html +++ b/src/app/exercise/exercise.page/exercise.page.html @@ -71,6 +71,7 @@ }" (answerSelected)="onAnswerSelected($event)" > +
@if (answerConfig.answer; as answer) { diff --git a/src/app/exercise/exercise.page/exercise.page.scss b/src/app/exercise/exercise.page/exercise.page.scss index 2dab8c1d..154200f0 100644 --- a/src/app/exercise/exercise.page/exercise.page.scss +++ b/src/app/exercise/exercise.page/exercise.page.scss @@ -31,3 +31,8 @@ ion-title { padding: 0; } + +.video-container { + flex-grow: 1; + text-align: center; +} diff --git a/src/app/exercise/exercise.page/state/exercise-state.service.ts b/src/app/exercise/exercise.page/state/exercise-state.service.ts index 72aa6297..a6c365b1 100644 --- a/src/app/exercise/exercise.page/state/exercise-state.service.ts +++ b/src/app/exercise/exercise.page/state/exercise-state.service.ts @@ -1,6 +1,7 @@ -import { Injectable, OnDestroy, inject, signal } from '@angular/core'; +import { Injectable, OnDestroy, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; +import { AlertController } from '@ionic/angular'; import * as _ from 'lodash'; import { defaults } from 'lodash'; import { filter, map } from 'rxjs/operators'; @@ -75,6 +76,7 @@ export class ExerciseStateService implements OnDestroy { readonly answerList = this._answerList.asReadonly(); private _answerToLabelStringMap: Record = this._getAnswerToLabelStringMap(); + private readonly _alertController = inject(AlertController); constructor() { listenToChanges(this, '_currentQuestion') @@ -90,6 +92,13 @@ export class ExerciseStateService implements OnDestroy { this._dronePlayer.stopDrone(); } }); + + effect(() => { + if (this._youtubePlayer.isPlaying() && this._autoPlayAlert) { + this._autoPlayAlert.dismiss(); + this._autoPlayAlert = null; + } + }); } get playerReady(): boolean { @@ -462,7 +471,27 @@ export class ExerciseStateService implements OnDestroy { private async _loadYoutubeQuestion( question: Exercise.YouTubeQuestion, ): Promise { - await this._youtubePlayer.loadVideoById(question.videoId); + await this._youtubePlayer.loadVideoById(question.videoId, () => + this._handleAutoPlayBlocked(), + ); + } + + private _autoPlayAlert: HTMLIonAlertElement | null = null; + private async _handleAutoPlayBlocked() { + if (this._autoPlayAlert) { + return; + } + this._autoPlayAlert = await this._alertController.create({ + message: + 'Autoplay for videos is not support on iOS. To work around this, please manually press the video to start', + subHeader: 'Press Play on the Video To Start', + buttons: ["I'll Press Play on the Video"], + cssClass: 'above-toaster', + }); + await this._autoPlayAlert.present(); + this._autoPlayAlert.onDidDismiss().then(() => { + this._autoPlayAlert = null; + }); } private async _playYouTubeQuestion( @@ -471,7 +500,7 @@ export class ExerciseStateService implements OnDestroy { if (this._destroyed) { return; } - if (this._youtubePlayer.isVideoLoading) { + if (this._youtubePlayer.isVideoLoading && !this._autoPlayAlert) { this._showMessage('Video is loading...'); this._youtubePlayer.onCurrentVideoLoaded.then(() => { this._hideMessage(); @@ -495,6 +524,7 @@ export class ExerciseStateService implements OnDestroy { }, }, ], + () => this._handleAutoPlayBlocked(), ); this._adaptiveExercise.questionStartedPlaying(); await this._youtubePlayer.onStop(); diff --git a/src/app/services/you-tube-player.service.ts b/src/app/services/you-tube-player.service.ts index 4716ee9f..dcfa64cd 100644 --- a/src/app/services/you-tube-player.service.ts +++ b/src/app/services/you-tube-player.service.ts @@ -1,10 +1,9 @@ -import { Injectable } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { computed, Injectable } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import * as PriorityQueue from 'js-priority-queue'; import { BehaviorSubject, interval, NEVER } from 'rxjs'; import { filter, switchMap, take } from 'rxjs/operators'; import PlayerFactory from 'youtube-player'; -import { YouTubePlayer } from 'youtube-player/dist/types'; export interface YouTubeCallbackDescriptor { seconds: number; @@ -17,11 +16,15 @@ const TIME_STAMP_POLLING: number = 200; providedIn: 'root', }) export class YouTubePlayerService { + private _elm = this._getHostElement(); private _isVideoLoading: boolean = false; private _currentlyLoadedVideoId: string | null = null; private _onCurrentVideoLoaded: Promise = Promise.resolve(); private _isPlaying$ = new BehaviorSubject(false); - private _youTubePlayer: YouTubePlayer = this._getYouTubePlayer(); + readonly isPlaying = toSignal(this._isPlaying$.asObservable(), { + initialValue: false, + }); + private _youTubePlayer = this._getYouTubePlayer(); private _callBackQueue = new PriorityQueue({ comparator: ( a: YouTubeCallbackDescriptor, @@ -39,10 +42,6 @@ export class YouTubePlayerService { return this._onCurrentVideoLoaded; } - get isPlaying() { - return this._isPlaying$.value; - } - constructor() { this._startTimeListener(); } @@ -50,28 +49,36 @@ export class YouTubePlayerService { /** * This method will not load the video if it's already loaded * */ - async loadVideoById(videoId: string): Promise { + async loadVideoById( + videoId: string, + onAutoplayBlocked: () => void, + ): Promise { if (this._currentlyLoadedVideoId !== videoId) { this._currentlyLoadedVideoId = videoId; this._isVideoLoading = true; - this._onCurrentVideoLoaded = this._youTubePlayer + this._onCurrentVideoLoaded = this._youTubePlayer() .loadVideoById(videoId) .then(() => { return new Promise((resolve) => { - const listener = this._youTubePlayer.on( + const listener = this._youTubePlayer().on( 'stateChange', ({ data }) => { + console.log('stateChange', data); // todo + if (data === 1) { // @ts-ignore - this._youTubePlayer.off(listener); + this._youTubePlayer().off(listener); resolve(); } + if (data === -1) { + onAutoplayBlocked(); + } }, ); }); }) .then(() => { - this._youTubePlayer.pauseVideo(); // we don't always want it to start playing immediately + this._youTubePlayer().pauseVideo(); // we don't always want it to start playing immediately }) .then(() => { this._isVideoLoading = false; @@ -91,14 +98,15 @@ export class YouTubePlayerService { videoId: string, time: number, callbacks: YouTubeCallbackDescriptor[] = [], + onAutoplayBlocked: () => void, ): Promise { console.log('play', videoId, time); if (videoId !== this._currentlyLoadedVideoId) { - await this.loadVideoById(videoId); + await this.loadVideoById(videoId, onAutoplayBlocked); } await this._onCurrentVideoLoaded; // it's possible loadVideoById was invoked by another function but video is not loaded yet - await this._youTubePlayer.seekTo(time, true); - await this._youTubePlayer.playVideo(); + await this._youTubePlayer().seekTo(time, true); + await this._youTubePlayer().playVideo(); this._isPlaying$.next(true); callbacks.forEach((callback) => { this._callBackQueue.queue(callback); @@ -107,7 +115,7 @@ export class YouTubePlayerService { async stop(): Promise { if (this._isPlaying$.value) { - await this._youTubePlayer.pauseVideo(); // used instead of stopVideo to avoid resetting of the play position + await this._youTubePlayer().pauseVideo(); // used instead of stopVideo to avoid resetting of the play position this._callBackQueue.clear(); this._isPlaying$.next(false); } @@ -126,15 +134,8 @@ export class YouTubePlayerService { } } - private _getYouTubePlayer(): YouTubePlayer { - const elm = document.createElement('div'); - // Expose the following code for debugging purposes - // elm.style['position'] = 'absolute'; - // elm.style['top'] = '0'; - // elm.style['width'] = '100px'; - // elm.style['height'] = '100px'; - document.body.appendChild(elm); - return PlayerFactory(elm); + private _getYouTubePlayer() { + return computed(() => PlayerFactory(this._elm())); } private _startTimeListener(): void { @@ -154,11 +155,33 @@ export class YouTubePlayerService { return; } const nextCallback = this._callBackQueue.peek(); - const currentTime = await this._youTubePlayer.getCurrentTime(); + const currentTime = await this._youTubePlayer().getCurrentTime(); if (currentTime > nextCallback.seconds - TIME_STAMP_POLLING / 2000) { this._callBackQueue.dequeue(); nextCallback.callback(); } }); } + + private _getHostElement() { + return computed(() => { + const elm = document.createElement('div'); + let host = + document.querySelector('.video-container') ?? + document.body; + + // if (!elm) { + // console.warn('Video container not found'); + + // elm = document.createElement('div'); + // } + // Expose the following code for debugging purposes + // elm.style['position'] = 'absolute'; + // elm.style['top'] = '0'; + elm.style['width'] = '70%'; + elm.style['height'] = '150px'; + host.appendChild(elm); + return elm; + }); + } } diff --git a/src/style/overrides/index.scss b/src/style/overrides/index.scss index 27aead97..589170e5 100644 --- a/src/style/overrides/index.scss +++ b/src/style/overrides/index.scss @@ -11,3 +11,7 @@ ion-toggle, ion-select { flex-grow: 0; } + +.above-toaster { + z-index: 60003 !important; +}