Skip to content

Commit

Permalink
Refactor video player
Browse files Browse the repository at this point in the history
  • Loading branch information
undyingwraith committed Feb 7, 2025
1 parent 092d7e7 commit 0bb49b3
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 95 deletions.
132 changes: 37 additions & 95 deletions packages/ui/src/components/organisms/VideoPlayer/VideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,27 @@
import { Fullscreen, FullscreenExit, Pause, PlayArrow, VolumeDown, VolumeUp } from '@mui/icons-material';
import { IconButton, Slider, Stack } from '@mui/material';
import { computed, useComputed, useSignal, useSignalEffect } from '@preact/signals-react';
import { IIpfsService, IIpfsServiceSymbol, IVideoFile } from 'ipmc-interfaces';
import { IMediaPlayerService, IMediaPlayerServiceSymbol } from '../../../services';
import { IVideoFile } from 'ipmc-interfaces';
import React from 'react';
//@ts-ignore
import shaka from 'shaka-player';
import { useService } from '../../../context';
import { useHotkey } from '../../../hooks';
import { FileInfoDisplay } from '../../atoms/FileInfoDisplay';
import styles from './VideoPlayer.module.css';

function createShakaIpfsPlugin(ipfs: IIpfsService): shaka.extern.SchemePlugin {
return async (uri: string, request: shaka.extern.Request, requestType: shaka.net.NetworkingEngine.RequestType, progressUpdated: shaka.extern.ProgressUpdated, headersReceived: shaka.extern.HeadersReceived, config: shaka.extern.SchemePluginConfig) => {
const fullPath = uri.substring(uri.indexOf('://') + 3);
const paths = fullPath.split('/');
const cid = paths.shift()!;
const path = paths.join('/');

headersReceived({});

const data = await ipfs.fetch(cid, path);

return {
uri: uri,
originalUri: uri,
data: data,
status: 200,
};
};
}

export function VideoPlayer(props: { file: IVideoFile; autoPlay?: boolean; }) {
const ipfs = useService<IIpfsService>(IIpfsServiceSymbol);
const mediaPlayer = useService<IMediaPlayerService>(IMediaPlayerServiceSymbol);

const videoRef = useSignal<HTMLVideoElement | null>(null);
const containerRef = useSignal<HTMLDivElement | null>(null);
const progressRef = useSignal<HTMLDivElement | null>(null);
const progressBarRef = useSignal<HTMLDivElement | null>(null);
const playerRef = useSignal<any | null>(null);
const subtitles = useSignal<any[]>([]);
const languages = useSignal<string[]>([]);
const playing = useSignal<boolean>(props.autoPlay ?? false);
const fullScreen = useSignal<boolean>(false);
const volume = useSignal<number>(1);
const overlayVisible = useSignal<boolean>(false);

useSignalEffect(() => {
if (containerRef.value != null) {
if (containerRef.value) {
let timeout: NodeJS.Timeout;
const abortController = new AbortController();
containerRef.value.addEventListener('mousemove', () => {
Expand All @@ -67,42 +43,23 @@ export function VideoPlayer(props: { file: IVideoFile; autoPlay?: boolean; }) {
});

useSignalEffect(() => {
if (videoRef.value != null && progressRef.value != null) {
if (videoRef.value) {
//Event handlers
videoRef.value.addEventListener('timeupdate', handleProgress);

return mediaPlayer.initializeVideo(videoRef.value, props.file);
}
return () => { };
});

useSignalEffect(() => {
if (progressRef.value) {
progressRef.value.addEventListener('click', scrub);
let mousedown = false;
progressRef.value.addEventListener('mousedown', () => (mousedown = true));
progressRef.value.addEventListener('mousemove', (e) => mousedown && scrub(e));
progressRef.value.addEventListener('mouseup', () => (mousedown = false));

// Shaka player init
shaka.net.NetworkingEngine.registerScheme('ipfs', createShakaIpfsPlugin(ipfs), 1, false);
const player = new shaka.Player();
player.configure({
streaming: {
rebufferingGoal: 5,
bufferingGoal: 30,
}
});
playerRef.value = player;
player.attach(videoRef.value)
.then(() => player.load(`ipfs://${props.file.cid}/${props.file.video.name}`))
.then(() => {
subtitles.value = player.getTextTracks();
languages.value = player.getAudioLanguages();
})
.catch((ex: any) => {
console.error(ex);
});

return () => {
player.unload();
player.destroy();
};
}

return () => { };
});

useSignalEffect(() => {
Expand All @@ -120,20 +77,6 @@ export function VideoPlayer(props: { file: IVideoFile; autoPlay?: boolean; }) {
}
}

function togglePlay() {
if (playing.value) {
if (videoRef.value) {
videoRef.value.pause();
playing.value = false;
}
} else {
videoRef.value?.play()
.then(() => {
playing.value = true;
});
}
}

function toggleFullScreen() {
if (fullScreen.value) {
document.exitFullscreen()
Expand All @@ -156,7 +99,7 @@ export function VideoPlayer(props: { file: IVideoFile; autoPlay?: boolean; }) {
}

useHotkey({ key: 'F' }, () => toggleFullScreen());
useHotkey({ key: 'Space' }, () => togglePlay());
useHotkey({ key: 'Space' }, () => mediaPlayer.togglePlay());

const progress = useComputed(() => (
<div className={styles.progress} ref={(ref) => progressRef.value = ref}>
Expand All @@ -167,41 +110,46 @@ export function VideoPlayer(props: { file: IVideoFile; autoPlay?: boolean; }) {
return (
<div className={styles.outerContainer}>
<div className={styles.innerContainer} ref={(ref) => containerRef.value = ref}>
<video
controls={false}
ref={(ref) => {
videoRef.value = ref;
}}
preload={'metadata'}
className={styles.video}
/>
{useComputed(() => (
<div className={`${styles.videoOverlay} ${overlayVisible.value ? styles.visible : ''}`}>
<div>
<FileInfoDisplay file={props.file} />
</div>
<div className={styles.spacer} />
<div className={styles.spacer} onClick={() => mediaPlayer.togglePlay()} onDoubleClick={() => toggleFullScreen()} />
<div className={styles.toolbar}>
<IconButton onClick={() => togglePlay()}>
{computed(() => playing.value ? <Pause /> : <PlayArrow />)}
<IconButton onClick={() => mediaPlayer.togglePlay()}>
{computed(() => mediaPlayer.playing.value ? <Pause /> : <PlayArrow />)}
</IconButton>
<div className={styles.spacer} />
<div>
Language
<select>
{computed(() => languages.value.map(l => (
{computed(() => mediaPlayer.languages.value.map(l => (
<option>{l}</option>
)))}
</select>
Subtitle
<select onChange={(ev) => {
if (ev.currentTarget.value !== 'null') {
playerRef.value.selectTextTrack(ev.currentTarget.value);
playerRef.value.setTextTrackVisibility(true);
} else {
playerRef.value.setTextTrackVisibility(false);
}
mediaPlayer.selectSubtitle(ev.currentTarget.value !== 'null' ? ev.currentTarget.value : undefined);
}}>
<option value="null">None</option>
{computed(() => subtitles.value.map(l => (
{computed(() => mediaPlayer.subtitles.value.map(l => (
<option>{l.language}</option>
)))}
</select>
</div>
<Stack spacing={2} direction="row" sx={{ alignItems: 'center', width: 250 }}>
<VolumeDown />
<IconButton onClick={() => { volume.value = 0; }}>
<VolumeDown />
</IconButton>
{computed(() => (
<Slider
value={volume.value}
Expand All @@ -210,24 +158,18 @@ export function VideoPlayer(props: { file: IVideoFile; autoPlay?: boolean; }) {
max={1}
step={0.05} />
))}
<VolumeUp />
<IconButton onClick={() => { volume.value = 1; }}>
<VolumeUp />
</IconButton>
</Stack>
<IconButton onClick={() => toggleFullScreen()}>
{computed(() => playing.value ? <FullscreenExit /> : <Fullscreen />)}
{computed(() => fullScreen.value ? <FullscreenExit /> : <Fullscreen />)}
</IconButton>
</div>
{progress}
</div>
))}
<video
controls={false}
ref={(ref) => {
videoRef.value = ref;
}}
preload={'metadata'}
className={styles.video}
/>
</div>
</div>
);
}
};
42 changes: 42 additions & 0 deletions packages/ui/src/services/MediaPlayerService/IMediaPlayerService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Signal } from '@preact/signals-react';
import { IVideoFile } from 'ipmc-interfaces';

export const IMediaPlayerServiceSymbol = Symbol.for('MediaPlayerSymbol');

/**
* Service to controll media playback.
*/
export interface IMediaPlayerService {
/**
* Initializes a video player.
* @param el Video element to use.
* @param file File to play.
*/
initializeVideo(el: HTMLVideoElement, file: IVideoFile): () => void;

/**
* Toggles play state.
*/
togglePlay(): void;

/**
* Select a subtitle track.
* @param trackName Subtitle track to use (undefined if none).
*/
selectSubtitle(trackName?: string): void;

/**
* Available languages, only available for videos.
*/
languages: Signal<any[]>;

/**
* Available subtitles, only available for videos.
*/
subtitles: Signal<any[]>;

/**
* Whether or not media is currently playing.
*/
playing: Signal<boolean>;
}
113 changes: 113 additions & 0 deletions packages/ui/src/services/MediaPlayerService/MediaPlayerService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Signal } from '@preact/signals-react';
import { inject, injectable } from 'inversify';
import { type IIpfsService, IIpfsServiceSymbol, type ILogService, ILogServiceSymbol, IVideoFile } from 'ipmc-interfaces';
import { IMediaPlayerService } from './IMediaPlayerService';
//@ts-ignore
import shaka from 'shaka-player';

type Request = shaka.extern.Request;
type RequestType = shaka.net.NetworkingEngine.RequestType;
type ProgressUpdated = shaka.extern.ProgressUpdated;
type HeadersReceived = shaka.extern.HeadersReceived;
type SchemePluginConfig = shaka.extern.SchemePluginConfig;

@injectable()
export class MediaPlayerService implements IMediaPlayerService {
public constructor(
@inject(IIpfsServiceSymbol) private readonly ipfs: IIpfsService,
@inject(ILogServiceSymbol) private readonly log: ILogService,
) {
this.shakaPlugin = this.shakaPlugin.bind(this);
shaka.net.NetworkingEngine.registerScheme('ipfs', this.shakaPlugin, 1, false);
}

public initializeVideo(el: HTMLVideoElement, file: IVideoFile): () => void {
this.videoEl = el;
const player = new shaka.Player();
this.player = player;
player.addEventListener('error', (error: any) => this.log.error(`Error code ${error.code} object ${error}`));
player.configure({
streaming: {
rebufferingGoal: 5,
bufferingGoal: 30,
}
});
player.attach(el)
.then(() => player.load(`ipfs://${file.cid}/${file.video.name}`))
.then(() => {
this.subtitles.value = player.getTextTracks();
this.languages.value = player.getAudioLanguages();
})
.catch((ex: any) => {
this.log.error(ex);
});

return () => {
this.languages.value = [];
this.subtitles.value = [];
this.videoEl = undefined;
this.player = undefined;
player.unload();
player.destroy();
this.playing.value = false;
};
}

public togglePlay() {
if (this.videoEl) {
if (this.playing.value) {
this.videoEl.pause();
this.playing.value = false;
} else {
this.videoEl?.play()
.then(() => {
this.playing.value = true;
});
}
}
}

public selectSubtitle(trackName?: string) {
if (trackName) {
this.player.value.selectTextTrack(trackName);
this.player.value.setTextTrackVisibility(true);
} else {
this.player.value.setTextTrackVisibility(false);
}

}

public languages = new Signal([]);

public subtitles = new Signal([]);

public playing = new Signal(false);

private async shakaPlugin(
uri: string,
request: Request,
requestType: RequestType,
progressUpdated: ProgressUpdated,
headersReceived: HeadersReceived,
config: SchemePluginConfig
) {
const fullPath = uri.substring(uri.indexOf('://') + 3);
const paths = fullPath.split('/');
const cid = paths.shift()!;
const path = paths.join('/');

headersReceived({});

const data = await this.ipfs.fetch(cid, path);

return {
uri: uri,
originalUri: uri,
data: data,
status: 200,
};
};

private videoEl: HTMLVideoElement | undefined;
private player: shaka.Player | undefined;
}
2 changes: 2 additions & 0 deletions packages/ui/src/services/MediaPlayerService/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { type IMediaPlayerService, IMediaPlayerServiceSymbol } from './IMediaPlayerService';
export { MediaPlayerService } from './MediaPlayerService';
Loading

0 comments on commit 0bb49b3

Please sign in to comment.