Skip to content

Commit

Permalink
Merge pull request #264 from e-picsa/review/ft-video-playback
Browse files Browse the repository at this point in the history
Review/ft video playback
  • Loading branch information
chrismclarke authored Apr 20, 2024
2 parents 0f44f17 + 3a82e53 commit b32971d
Show file tree
Hide file tree
Showing 5 changed files with 244 additions and 133 deletions.
12 changes: 12 additions & 0 deletions libs/shared/src/features/video-player/schema/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as schema from './schema_v0';

// Re-export schema to provide latest version without need to refactor additonal code

export const COLLECTION = schema.COLLECTION_V0;
export type IVideoPlayerEntry = schema.IVideoPlayerEntry_V0;
export const SCHEMA = schema.SCHEMA_V0;

// Ensure blank templates always recreated from scratch
export const ENTRY_TEMPLATE = schema.ENTRY_TEMPLATE_V0;

export const COLLECTION_NAME = 'video_player';
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@ import { RxJsonSchema } from 'rxdb';

import type { IPicsaCollectionCreator } from '../../../services/core/db_v2/models/index';

export interface IVideoPlayback {
const SCHEMA_VERSION = 0;

export interface IVideoPlayerEntry_V0 {
videoId: string;
currentTime: number;
totalTime: number;
playbackPercentage: number;
}

export const videoPlaybackSchema: RxJsonSchema<IVideoPlayback> = {
export const ENTRY_TEMPLATE_V0: (videoId: string) => IVideoPlayerEntry_V0 = (videoId) => ({
videoId,
currentTime: 0,
totalTime: 0,
playbackPercentage: 0,
});

export const SCHEMA_V0: RxJsonSchema<IVideoPlayerEntry_V0> = {
title: 'video playback schema',
version: 0,
version: SCHEMA_VERSION,
type: 'object',
primaryKey: 'videoId',
properties: {
Expand All @@ -31,7 +40,7 @@ export const videoPlaybackSchema: RxJsonSchema<IVideoPlayback> = {
required: ['videoId', 'currentTime', 'totalTime', 'playbackPercentage'],
};

export const videoPlayback: IPicsaCollectionCreator<IVideoPlayback> = {
schema: videoPlaybackSchema,
export const COLLECTION_V0: IPicsaCollectionCreator<IVideoPlayerEntry_V0> = {
schema: SCHEMA_V0,
isUserCollection: true,
};
240 changes: 163 additions & 77 deletions libs/shared/src/features/video-player/video-player.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ScreenOrientation } from '@capacitor/screen-orientation';
import { CapacitorVideoPlayer, CapacitorVideoPlayerPlugin, capVideoPlayerOptions } from 'capacitor-video-player';

import { AnalyticsService } from '../../services/core/analytics.service';
import { VideoPlayerService } from '../../services/video-player.service';
import { VideoPlayerService } from './video-player.service';

// Fix listeners missing from type
// https://github.com/jepiqueau/capacitor-video-player/blob/master/docs/API.md#listeners
Expand Down Expand Up @@ -67,9 +67,7 @@ export class VideoPlayerComponent implements OnDestroy {

private pauseTime = 0;

private videoID: string;

totalDuration = 0;
totalTime: number;

playbackPercentage: number;

Expand All @@ -80,74 +78,109 @@ export class VideoPlayerComponent implements OnDestroy {
) {}

async ngOnInit() {
await this.playerService.ready();
await this.loadVideoState();
}

private async loadVideoState() {
await this.playerService.init();
const videoState = await this.playerService.getVideoState(this.playerId);
const videoID = videoState?._data.videoId;
if (videoState && videoID === this.playerId) {
this.videoID = videoID;
if (videoState) {
this.pauseTime = videoState.currentTime;
this.playbackPercentage = videoState.playbackPercentage;
this.totalDuration = videoState.totalTime;
}
}

async ngOnDestroy() {
await this.videoPlayer.stopAllPlayers();
this.removeListeners();
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl);
}
}

private async saveVideoState() {
this.totalDuration = (await this.videoPlayer.getDuration({ playerId: this.playerId })).value;
// Getting the total time of the video
const currenttime = await this.videoPlayer.getCurrentTime({ playerId: this.playerId });
const totalTimeResult = await this.videoPlayer.getDuration({ playerId: this.playerId });
this.pauseTime = currenttime.value || this.pauseTime;
this.totalTime = totalTimeResult.value || this.totalTime || 1;

if (this.totalDuration) {
this.playbackPercentage = (this.pauseTime / this.totalDuration) * 100;
} else {
this.playbackPercentage = 0;
}
// Calculating the playback percentage
this.playbackPercentage = (this.pauseTime / this.totalTime) * 100;

// Saving the video state
const videoState = {
videoId: this.playerId,
currentTime: this.pauseTime || 0,
totalTime: this.totalDuration || 0,
playbackPercentage: this.playbackPercentage || 0,
currentTime: this.pauseTime,
totalTime: this.totalTime,
playbackPercentage: this.playbackPercentage,
};
await this.playerService.updateVideoState(videoState);
}

async ngOnDestroy() {
await this.saveVideoState();
await this.videoPlayer.stopAllPlayers();
this.removeListeners();
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl);
}
}

public async pauseVideo() {
return this.videoPlayer.pause({ playerId: this.playerId });
}

public async playVideo() {
// Remove thumbnail from future playback
this.thumbnail = undefined;
// Stop playback from any other players
await this.videoPlayer.stopAllPlayers();
if (Capacitor.isNativePlatform()) {
this.initialised = false;
}
// Track video play event
this.analyticsService.trackVideoPlay(this.playerId);
// Initialise player any time playback triggered in case url updated (e.g. downloaded after init)
await this.initPlayer();
this.videoPlayer.play({ playerId: this.playerId }).then(() => {
if (this.pauseTime > 0) {
setTimeout(() => {
this.videoPlayer.setCurrentTime({ playerId: this.playerId, seektime: this.pauseTime });
}, 500);

if (!this.initialised) {
await this.initPlayer();
}

if (this.pauseTime) {
await this.setPlayerInitialTime(this.pauseTime);
}
await this.videoPlayer.play({ playerId: this.playerId });
}

/** Set the initial time for video player feedback */
private async setPlayerInitialTime(seektime = 0) {
if (Capacitor.isNativePlatform()) {
// Hack - on android the seek time can only be set after confirmation the player is ready
await this.waitForPlayerReady();
await this.videoPlayer.setCurrentTime({ playerId: this.playerId, seektime });
} else {
// HACK - on web the setCurrentTime method does not work unless video already running
// so manually detect video element and set time
const videoEl = this.getWebVideoEl();
if (videoEl) {
videoEl.currentTime = seektime;
}
}
}
private getWebVideoEl() {
const containerEl = document.querySelector(`#vc_${this.id}`);
if (containerEl) {
const videoEl = containerEl.querySelector('video');
return videoEl;
}
return null;
}

/**
* Detect when player ready event has been fired
* Used on android when triggering a full screen video
*/
private async waitForPlayerReady() {
return new Promise((resolve) => {
const playerReadyCalback = (e: capVideoListener) => {
if (e.fromPlayerId === this.playerId) {
this.videoPlayer.removeListener('jeepCapVideoPlayerReady', playerReadyCalback);
resolve(true);
}
};
this.videoPlayer.addListener('jeepCapVideoPlayerReady', playerReadyCalback);
});
}

private async initPlayer() {
if (this.initialised) return;
if (!this.source) return;
const url = this.convertSourceToUrl(this.source);
const { clientWidth } = this.elementRef.nativeElement;
Expand All @@ -169,9 +202,13 @@ export class VideoPlayerComponent implements OnDestroy {
}
// Merge default options with user override
this.playerOptions = { ...defaultOptions, ...this.options };
await this.videoPlayer.initPlayer(this.playerOptions);
const res = await this.videoPlayer.initPlayer(this.playerOptions);
this.addListeners();
this.initialised = true;
// HACK - on web play only needs to initialise once but on Android needs to init every playback
if (!Capacitor.isNativePlatform()) {
this.initialised = true;
}
return res;
}

/** Video player requires url source, handle conversion from blob or internal asset url */
Expand All @@ -193,57 +230,106 @@ export class VideoPlayerComponent implements OnDestroy {
* Currently mostly just used for toggling play button display
*********************************************************************************/

/**
* Add listener for play events
* Use named functions to allow removal on destroy
*/
private addListeners() {
this.videoPlayer.addListener('jeepCapVideoPlayerReady', this.handlePlayerReady.bind(this));
this.videoPlayer.addListener('jeepCapVideoPlayerPlay', this.handlePlayerPlay.bind(this));
this.videoPlayer.addListener('jeepCapVideoPlayerPause', this.handlePlayerPause.bind(this));
this.videoPlayer.addListener('jeepCapVideoPlayerEnded', this.handlePlayerEnded.bind(this));
this.videoPlayer.addListener('jeepCapVideoPlayerExit', this.handlePlayerExit.bind(this));
private handlePlayerReady() {
// console.log('[Video Player] ready');
this.showPlayButton = true;
}
private handlePlayerPlay() {
// console.log('[Video Player] play');

/** Remove all event listeners */
private removeListeners() {
this.videoPlayer.removeListener('jeepCapVideoPlayerReady', this.handlePlayerReady);
this.videoPlayer.removeListener('jeepCapVideoPlayerPlay', this.handlePlayerPlay);
this.videoPlayer.removeListener('jeepCapVideoPlayerPause', this.handlePlayerPause);
this.videoPlayer.removeListener('jeepCapVideoPlayerEnded', this.handlePlayerEnded);
this.videoPlayer.removeListener('jeepCapVideoPlayerExit', this.handlePlayerExit);
this.showPlayButton = false;
}
private handlePlayerReady() {

private async handlePlayerPause(currentTime: number) {
// console.log('[Video Player] pause', currentTime);

this.pauseTime = currentTime;
this.showPlayButton = true;
await this.saveVideoState();
}

private async handlePlayerPlay(e: { fromPlayerId: string }) {
// Events can be emitted from any player, so only update play button of current player id
if (e.fromPlayerId === this.playerId) {
this.showPlayButton = false;
this.totalDuration = (await this.videoPlayer.getDuration({ playerId: this.playerId })).value;
}
private async handlePlayerEnded(currentTime: number) {
// console.log('[Video Player] ended', currentTime);
this.showPlayButton = true;
await this.saveVideoState();
}

private async handlePlayerPause(e: { fromPlayerId: string; currentTime: number }) {
if (e.fromPlayerId === this.playerId) {
this.pauseTime = e.currentTime;
this.showPlayButton = true;
await this.saveVideoState();
private async handlePlayerExit() {
// console.log('[Video Player] exit');
// HACK - player exit event not bound to specific player instance so do not update video state
// this means that progress state cannot be saved if user exits video without first pausing
// (this only applies to fullscreen videos played on native devices)

// Ensure player does not stay stuck in landscape mode
if (Capacitor.isNativePlatform()) {
await ScreenOrientation.unlock();
}
this.showPlayButton = true;
}

private handlePlayerEnded() {
this.showPlayButton = true;
private listeners: { event: IVideoEvent; callback: (e: capVideoListener) => void }[] = [];

/**
* Add listener for play events
* Use named functions to allow removal on destroy
*
* Events can be emitted from any player, so only all events only trigger callback
* when matching player ID
*/
private addListeners() {
// Ensure no previous listeners remain (on android listeners need to be registered every time playback starts)
this.removeListeners();

// Ready
const jeepCapVideoPlayerReady = (e: capVideoListener) => {
if (e.fromPlayerId === this.playerId) {
this.handlePlayerReady();
}
};
this.videoPlayer.addListener('jeepCapVideoPlayerReady', jeepCapVideoPlayerReady);
this.listeners.push({ event: 'jeepCapVideoPlayerReady', callback: jeepCapVideoPlayerReady });

// Play
const jeepCapVideoPlayerPlay = (e: capVideoListener) => {
if (e.fromPlayerId === this.playerId) {
this.handlePlayerPlay();
}
};
this.videoPlayer.addListener('jeepCapVideoPlayerPlay', jeepCapVideoPlayerPlay);
this.listeners.push({ event: 'jeepCapVideoPlayerPlay', callback: jeepCapVideoPlayerPlay });

// Pause
const jeepCapVideoPlayerPause = (e: capVideoListener) => {
if (e.fromPlayerId === this.playerId) {
this.handlePlayerPause(e.currentTime);
}
};
this.videoPlayer.addListener('jeepCapVideoPlayerPause', jeepCapVideoPlayerPause);
this.listeners.push({ event: 'jeepCapVideoPlayerPause', callback: jeepCapVideoPlayerPause });

// Ended
const jeepCapVideoPlayerEnded = (e: capVideoListener) => {
if (e.fromPlayerId === this.playerId) {
this.handlePlayerEnded(e.currentTime);
}
};
this.videoPlayer.addListener('jeepCapVideoPlayerEnded', jeepCapVideoPlayerEnded);
this.listeners.push({ event: 'jeepCapVideoPlayerEnded', callback: jeepCapVideoPlayerEnded });

// Exit - NOTE - different callback
const jeepCapVideoPlayerExit = (e: { dismiss?: boolean; currentTime: number }) => {
this.handlePlayerExit();
};
this.videoPlayer.addListener('jeepCapVideoPlayerExit', jeepCapVideoPlayerExit);
this.listeners.push({ event: 'jeepCapVideoPlayerExit', callback: jeepCapVideoPlayerExit });
}

private async handlePlayerExit(e: { currentTime: number }) {
this.pauseTime = e.currentTime;
this.showPlayButton = true;
if (Capacitor.isNativePlatform() && ScreenOrientation) {
await ScreenOrientation.unlock();
/** Remove all event listeners */
private removeListeners() {
for (const { event, callback } of this.listeners) {
this.videoPlayer.removeListener(event, callback);
}
await this.saveVideoState();
this.listeners = [];
}
}

Expand Down
Loading

0 comments on commit b32971d

Please sign in to comment.