diff --git a/packages/core/src/tech/BaseTech.ts b/packages/core/src/tech/BaseTech.ts index 3a1f964..a284b85 100644 --- a/packages/core/src/tech/BaseTech.ts +++ b/packages/core/src/tech/BaseTech.ts @@ -26,7 +26,7 @@ export enum PlaybackState { BUFFERING, BITRATE_CHANGE, - PLAYER_STOPPED + PLAYER_STOPPED, } export interface IVideoLevel { @@ -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; @@ -82,39 +82,39 @@ 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 @@ -122,7 +122,7 @@ export default class BaseTech extends EventEmitter { // @ts-ignore this.video.audioTracks.addEventListener( 'change', - (this.onAudioTrackChange = this.onAudioTrackChange.bind(this)) + (this.onAudioTrackChange = this.onAudioTrackChange.bind(this)), ); } } @@ -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) { @@ -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() { @@ -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; } @@ -348,7 +354,7 @@ export default class BaseTech extends EventEmitter { } return playPromise.then( () => true, - () => false + () => false, ); } @@ -386,7 +392,7 @@ export default class BaseTech extends EventEmitter { audioTracks: [], textTracks: [], volume: this.video.volume, - ...defaultState + ...defaultState, }); } diff --git a/packages/eyevinn-skin/src/EyevinnSkin.tsx b/packages/eyevinn-skin/src/EyevinnSkin.tsx index 8c16ab4..e188d5a 100644 --- a/packages/eyevinn-skin/src/EyevinnSkin.tsx +++ b/packages/eyevinn-skin/src/EyevinnSkin.tsx @@ -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'; @@ -29,7 +30,7 @@ export default function EyevinnSkin({ rootElement, }: { player: WebPlayer; - castAppId: string; + castAppId?: string; rootElement: HTMLElement; }) { const skinContainerRef = useRef(); @@ -40,6 +41,7 @@ export default function EyevinnSkin({ toggleMute, changeAudioTrack, changeTextTrack, + changeVideoLevel, seekByPercentage, seekByChange, seekToLive, @@ -188,8 +190,18 @@ export default function EyevinnSkin({ isSeekable={state?.isSeekable} /> )} -
- + {(!state?.isLive || state?.isSeekable) && ( + + )} + {castAppId != null && } {airplayAvailable && } {state?.isCasting === false && !!state?.textTracks.length && ( )} + {state?.isCasting === false && player.getVideoLevels().length > 0 && ( + + )}
- {(!state?.isLive || state?.isSeekable) && ( - - )} ); diff --git a/packages/eyevinn-skin/src/components/buttons/castButton/cast.module.css b/packages/eyevinn-skin/src/components/buttons/castButton/cast.module.css index 0e50918..39e4f73 100644 --- a/packages/eyevinn-skin/src/components/buttons/castButton/cast.module.css +++ b/packages/eyevinn-skin/src/components/buttons/castButton/cast.module.css @@ -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; } \ No newline at end of file diff --git a/packages/eyevinn-skin/src/components/buttons/videoQuality/VideoQualityButton.tsx b/packages/eyevinn-skin/src/components/buttons/videoQuality/VideoQualityButton.tsx new file mode 100644 index 0000000..186a51a --- /dev/null +++ b/packages/eyevinn-skin/src/components/buttons/videoQuality/VideoQualityButton.tsx @@ -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 ( + + + + + ); +} + +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 ( +
{ + // 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] + )} + > + +
    e.stopPropagation()} + > +
  • + +
  • + {videoLevels.map((level, index) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/packages/eyevinn-skin/src/components/buttons/videoQuality/videoQuality.module.css b/packages/eyevinn-skin/src/components/buttons/videoQuality/videoQuality.module.css new file mode 100644 index 0000000..645b95c --- /dev/null +++ b/packages/eyevinn-skin/src/components/buttons/videoQuality/videoQuality.module.css @@ -0,0 +1,64 @@ +.container { + position: relative; + + width: 1.5em; + height: 1.5em; + + cursor: pointer; +} +.container svg { + width: 100%; + height: 100%; + fill: rgba(255, 255, 255, 0.8); +} +.container svg:hover { + fill: rgba(255, 255, 255, 1); +} + +.list { + list-style: none; + position: absolute; + bottom: 1em; + left: 50%; + transform: translateX(-50%); + padding: 0.5em; + white-space: nowrap; + background: rgba(20, 20, 20, 0.6); + border: 1px solid rgba(255, 255, 255, 0.2); + + display: none; +} + +.list.open { + display: block; +} + +.list li { + margin: 0; +} + +.list input { + display: none; + margin-right: 0.25em; +} + +.list label { + display: block; + cursor: pointer; + padding: 0.25em 0.5em; + margin: 0 -0.5em; +} + +.list label span { + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + display: block; +} + +.list label:hover span { + color: rgb(100, 180, 255); +} + +.list :checked + span { + color: rgb(30, 129, 160); +} diff --git a/packages/eyevinn-skin/src/components/timeline/timeline.module.scss b/packages/eyevinn-skin/src/components/timeline/timeline.module.scss index f3e46a2..cd1e13d 100644 --- a/packages/eyevinn-skin/src/components/timeline/timeline.module.scss +++ b/packages/eyevinn-skin/src/components/timeline/timeline.module.scss @@ -2,11 +2,14 @@ position: relative; display: flex; align-items: center; + flex: 1; + min-width: 0; + width: 100%; & > * { - margin-right: 1em; + margin-right: 0.5em; - &*:last-child { + &:last-child { margin-right: 0; } } @@ -21,6 +24,8 @@ .time { color: #ffffff; white-space: nowrap; + font-size: 0.875em; + flex-shrink: 0; } .progressbarWrapper { @@ -28,8 +33,10 @@ justify-content: center; align-items: center; + flex: 1 1 auto; + min-width: 0; width: 100%; - padding: 0.5em 0; + padding: 0.25em 0; cursor: pointer; } diff --git a/packages/eyevinn-skin/src/skin.module.scss b/packages/eyevinn-skin/src/skin.module.scss index c76a67d..3b74ffa 100644 --- a/packages/eyevinn-skin/src/skin.module.scss +++ b/packages/eyevinn-skin/src/skin.module.scss @@ -36,6 +36,7 @@ .controls { display: flex; + align-items: center; margin-bottom: 0.5em; } diff --git a/packages/eyevinn-skin/src/util/hooks.ts b/packages/eyevinn-skin/src/util/hooks.ts index 877ec7d..abe8faa 100644 --- a/packages/eyevinn-skin/src/util/hooks.ts +++ b/packages/eyevinn-skin/src/util/hooks.ts @@ -141,6 +141,9 @@ export function usePlayer(webPlayer: WebPlayer, castAppId: string) { const changeTextTrack = useCallback((id) => player.setTextTrack(id), [ player, ]); + const changeVideoLevel = useCallback((level) => { + player.currentLevel = level; + }, [player]); const seekByPercentage = useCallback( (percentage: number) => player.seekTo({ percentage }), [player] @@ -166,6 +169,7 @@ export function usePlayer(webPlayer: WebPlayer, castAppId: string) { toggleMute, changeAudioTrack, changeTextTrack, + changeVideoLevel, seekByPercentage, seekByChange, seekToLive,