Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 24 additions & 18 deletions packages/core/src/tech/BaseTech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export enum PlaybackState {
BUFFERING,

BITRATE_CHANGE,
PLAYER_STOPPED
PLAYER_STOPPED,
}

export interface IVideoLevel {
Expand Down Expand Up @@ -57,7 +57,7 @@ export function getTextTrackId(textTrack) {
}

export default class BaseTech extends EventEmitter {
public name = "BaseTech";
public name = 'BaseTech';
protected video: HTMLVideoElement;
protected state: IPlayerState;

Expand All @@ -82,47 +82,47 @@ export default class BaseTech extends EventEmitter {
this.video.addEventListener('play', (this.onPlay = this.onPlay.bind(this)));
this.video.addEventListener(
'playing',
(this.onPlaying = this.onPlaying.bind(this))
(this.onPlaying = this.onPlaying.bind(this)),
);
this.video.addEventListener(
'pause',
(this.onPause = this.onPause.bind(this))
(this.onPause = this.onPause.bind(this)),
);
this.video.addEventListener(
'timeupdate',
(this.onTimeUpdate = this.onTimeUpdate.bind(this))
(this.onTimeUpdate = this.onTimeUpdate.bind(this)),
);
this.video.addEventListener(
'waiting',
(this.onWaiting = this.onWaiting.bind(this))
(this.onWaiting = this.onWaiting.bind(this)),
);
this.video.addEventListener(
'seeking',
(this.onSeeking = this.onSeeking.bind(this))
(this.onSeeking = this.onSeeking.bind(this)),
);
this.video.addEventListener(
'seeked',
(this.onSeeked = this.onSeeked.bind(this))
(this.onSeeked = this.onSeeked.bind(this)),
);
this.video.addEventListener(
'canplay',
(this.onLoadedData = this.onLoadedData.bind(this))
(this.onLoadedData = this.onLoadedData.bind(this)),
);
this.video.addEventListener(
'volumechange',
(this.onVolumeChange = this.onVolumeChange.bind(this))
(this.onVolumeChange = this.onVolumeChange.bind(this)),
);
this.video.addEventListener(
'ended',
(this.onEnded = this.onEnded.bind(this))
(this.onEnded = this.onEnded.bind(this)),
);

// @ts-ignore
if (this.video.audioTracks) {
// @ts-ignore
this.video.audioTracks.addEventListener(
'change',
(this.onAudioTrackChange = this.onAudioTrackChange.bind(this))
(this.onAudioTrackChange = this.onAudioTrackChange.bind(this)),
);
}
}
Expand Down Expand Up @@ -204,7 +204,7 @@ export default class BaseTech extends EventEmitter {
}

protected onVolumeChange() {
const fields: { [s: string]: number | boolean | string } = {
const fields: { [s: string]: number | boolean | string } = {
volume: this.video.volume,
};
if (this.state.isMuted !== this.isMuted) {
Expand All @@ -217,12 +217,18 @@ export default class BaseTech extends EventEmitter {

protected onAudioTrackChange() {
this.updateState({ audioTracks: this.audioTracks });
this.emit(PlayerEvent.AUDIO_TRACK_CHANGE);
this.emit(
PlayerEvent.AUDIO_TRACK_CHANGE,
this.audioTracks.find((audioTrack) => audioTrack.enabled),
);
}

protected onTextTrackChange() {
this.updateState({ textTracks: this.textTracks });
this.emit(PlayerEvent.TEXT_TRACK_CHANGE);
this.emit(
PlayerEvent.TEXT_TRACK_CHANGE,
this.textTracks.find((textTrack) => textTrack.enabled),
);
}

protected onEnded() {
Expand Down Expand Up @@ -278,7 +284,7 @@ export default class BaseTech extends EventEmitter {

get audioTrack() {
const audioTrack = this.audioTracks.find(
(audioTrack) => audioTrack.enabled
(audioTrack) => audioTrack.enabled,
);
return audioTrack?.id;
}
Expand Down Expand Up @@ -348,7 +354,7 @@ export default class BaseTech extends EventEmitter {
}
return playPromise.then(
() => true,
() => false
() => false,
);
}

Expand Down Expand Up @@ -386,7 +392,7 @@ export default class BaseTech extends EventEmitter {
audioTracks: [],
textTracks: [],
volume: this.video.volume,
...defaultState
...defaultState,
});
}

Expand Down
36 changes: 22 additions & 14 deletions packages/eyevinn-skin/src/EyevinnSkin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import TextTrackButton from './components/buttons/textTrack/textTrackButton';
import AirPlayButton from './components/buttons/airplayButton/AirPlayButton';
import CastButton from './components/buttons/castButton/CastButton';
import VideoQualityButton from './components/buttons/videoQuality/VideoQualityButton';
import { useAirPlay, usePlayer } from './util/hooks';
import ContextMenu from './components/contextMenu/ContextMenu';
import CastOverlay from './components/castOverlay/CastOverlay';
Expand All @@ -29,7 +30,7 @@ export default function EyevinnSkin({
rootElement,
}: {
player: WebPlayer;
castAppId: string;
castAppId?: string;
rootElement: HTMLElement;
}) {
const skinContainerRef = useRef<HTMLDivElement>();
Expand All @@ -40,6 +41,7 @@ export default function EyevinnSkin({
toggleMute,
changeAudioTrack,
changeTextTrack,
changeVideoLevel,
seekByPercentage,
seekByChange,
seekToLive,
Expand Down Expand Up @@ -188,8 +190,18 @@ export default function EyevinnSkin({
isSeekable={state?.isSeekable}
/>
)}
<div class={style.divider} />
<CastButton />
{(!state?.isLive || state?.isSeekable) && (
<Timeline
isLive={state?.isLive}
isAtLiveEdge={state?.isAtLiveEdge}
isSeekable={state?.isSeekable}
handleSeek={seekByPercentage}
handleSeekToLive={seekToLive}
currentTime={state?.currentTime}
duration={state?.duration}
/>
)}
{castAppId != null && <CastButton />}
{airplayAvailable && <AirPlayButton onClick={toggleAirPlay} />}
{state?.isCasting === false && !!state?.textTracks.length && (
<TextTrackButton
Expand All @@ -203,6 +215,13 @@ export default function EyevinnSkin({
onChange={changeAudioTrack}
/>
)}
{state?.isCasting === false && player.getVideoLevels().length > 0 && (
<VideoQualityButton
videoLevels={player.getVideoLevels()}
currentLevel={player.currentLevel}
onChange={changeVideoLevel}
/>
)}
<VolumeControls
muted={state?.isMuted}
toggleMute={toggleMute}
Expand All @@ -214,17 +233,6 @@ export default function EyevinnSkin({
onClick={toggleFullscreen}
/>
</div>
{(!state?.isLive || state?.isSeekable) && (
<Timeline
isLive={state?.isLive}
isAtLiveEdge={state?.isAtLiveEdge}
isSeekable={state?.isSeekable}
handleSeek={seekByPercentage}
handleSeekToLive={seekToLive}
currentTime={state?.currentTime}
duration={state?.duration}
/>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,15 @@
height: 1.5em;

cursor: pointer;
}

/* Hide container when cast launcher is not visible */
.container:has(google-cast-launcher[style*="display: none"]),
.container:has(google-cast-launcher[hidden]),
.container:has(google-cast-launcher[style*="visibility: hidden"]) {
width: 0;
height: 0;
overflow: hidden;
margin: 0;
padding: 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { h } from 'preact';
import { useCallback, useState, useEffect } from 'preact/hooks';
import classNames from 'classnames';
import style from './videoQuality.module.css';

function CogIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
);
}

export interface IVideoLevel {
id: any;
width: number;
height: number;
bitrate: number;
}

export default function VideoQualityButton({
videoLevels = [],
currentLevel,
onChange,
}) {
const [open, setOpen] = useState(false);

const onRadioChange = useCallback(
(evt) => {
const target = evt.target;
const value = target.value;
if (value === 'auto') {
onChange(null);
} else {
const levelIndex = parseInt(value, 10);
const level = videoLevels[levelIndex];
if (level) {
onChange(level);
}
}
setOpen(false);
},
[onChange, videoLevels]
);

const handleOptionClick = useCallback(
(value) => {
if (value === 'auto') {
onChange(null);
} else {
const levelIndex = parseInt(value, 10);
const level = videoLevels[levelIndex];
if (level) {
onChange(level);
}
}
setOpen(false);
},
[onChange, videoLevels]
);

const formatLevelLabel = (level: IVideoLevel) => {
return `${level.width}x${level.height}, ${Math.round(
level.bitrate / 1024
)}kbps`;
};

const getCurrentLevelLabel = () => {
if (!currentLevel) {
return 'AUTO';
}
return formatLevelLabel(currentLevel);
};

// Close menu when clicking outside
useEffect(() => {
if (!open) return;

const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Element;
if (target && !target.closest(`.${style.container}`)) {
setOpen(false);
}
};

document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [open]);

if (!videoLevels || videoLevels.length === 0) {
return null;
}

const handleLabelClick = useCallback(
(e, value) => {
e.stopPropagation();
e.preventDefault();
handleOptionClick(value);
},
[handleOptionClick]
);

return (
<div
class={style.container}
onClick={useCallback(
(e: MouseEvent) => {
// Don't toggle if clicking inside the dropdown list
const target = e.target as Element;
if (target && target.closest(`.${style.list}`)) {
return;
}
e.stopPropagation();
setOpen(!open);
},
[open]
)}
>
<CogIcon />
<ul
class={classNames(style.list, { [style.open]: open })}
onClick={(e) => e.stopPropagation()}
>
<li>
<label onClick={(e) => handleLabelClick(e, 'auto')}>
<input
type="radio"
name="videoQuality"
value="auto"
checked={!currentLevel}
onChange={onRadioChange}
onClick={(e) => {
e.stopPropagation();
handleOptionClick('auto');
}}
/>
<span>AUTO</span>
</label>
</li>
{videoLevels.map((level, index) => (
<li key={level.id || index}>
<label onClick={(e) => handleLabelClick(e, index.toString())}>
<input
type="radio"
name="videoQuality"
value={index.toString()}
checked={currentLevel?.id === level.id}
onChange={onRadioChange}
onClick={(e) => {
e.stopPropagation();
handleOptionClick(index.toString());
}}
/>
<span>{formatLevelLabel(level)}</span>
</label>
</li>
))}
</ul>
</div>
);
}
Loading