Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/app/exercise/exercise.page/exercise.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
}"
(answerSelected)="onAnswerSelected($event)"
></app-answers-layout>
<div class="video-container"></div>
<ng-template let-answerConfig #answerButton>
<div cdkDropList>
@if (answerConfig.answer; as answer) {
Expand Down
5 changes: 5 additions & 0 deletions src/app/exercise/exercise.page/exercise.page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,8 @@
ion-title {
padding: 0;
}

.video-container {
flex-grow: 1;
text-align: center;
}
36 changes: 33 additions & 3 deletions src/app/exercise/exercise.page/state/exercise-state.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -75,6 +76,7 @@ export class ExerciseStateService implements OnDestroy {
readonly answerList = this._answerList.asReadonly();
private _answerToLabelStringMap: Record<string, string> =
this._getAnswerToLabelStringMap();
private readonly _alertController = inject(AlertController);

constructor() {
listenToChanges(this, '_currentQuestion')
Expand All @@ -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 {
Expand Down Expand Up @@ -462,7 +471,27 @@ export class ExerciseStateService implements OnDestroy {
private async _loadYoutubeQuestion(
question: Exercise.YouTubeQuestion,
): Promise<void> {
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(
Expand All @@ -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();
Expand All @@ -495,6 +524,7 @@ export class ExerciseStateService implements OnDestroy {
},
},
],
() => this._handleAutoPlayBlocked(),
);
this._adaptiveExercise.questionStartedPlaying();
await this._youtubePlayer.onStop();
Expand Down
77 changes: 50 additions & 27 deletions src/app/services/you-tube-player.service.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<void> = 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<YouTubeCallbackDescriptor>({
comparator: (
a: YouTubeCallbackDescriptor,
Expand All @@ -39,39 +42,43 @@ export class YouTubePlayerService {
return this._onCurrentVideoLoaded;
}

get isPlaying() {
return this._isPlaying$.value;
}

constructor() {
this._startTimeListener();
}

/**
* This method will not load the video if it's already loaded
* */
async loadVideoById(videoId: string): Promise<void> {
async loadVideoById(
videoId: string,
onAutoplayBlocked: () => void,
): Promise<void> {
if (this._currentlyLoadedVideoId !== videoId) {
this._currentlyLoadedVideoId = videoId;
this._isVideoLoading = true;
this._onCurrentVideoLoaded = this._youTubePlayer
this._onCurrentVideoLoaded = this._youTubePlayer()
.loadVideoById(videoId)
.then(() => {
return new Promise<void>((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;
Expand All @@ -91,14 +98,15 @@ export class YouTubePlayerService {
videoId: string,
time: number,
callbacks: YouTubeCallbackDescriptor[] = [],
onAutoplayBlocked: () => void,
): Promise<void> {
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);
Expand All @@ -107,7 +115,7 @@ export class YouTubePlayerService {

async stop(): Promise<void> {
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);
}
Expand All @@ -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 {
Expand All @@ -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<HTMLElement>('.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;
});
}
}
4 changes: 4 additions & 0 deletions src/style/overrides/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ ion-toggle,
ion-select {
flex-grow: 0;
}

.above-toaster {
z-index: 60003 !important;
}