diff --git a/package-lock.json b/package-lock.json index 61cc8dc0a..612850491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3322,9 +3322,10 @@ } }, "node_modules/@dyteinternals/utils": { - "version": "1.11.0", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dyteinternals/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-alEjgR9i4qNk9ZiTtILKmdYwGD1hpDeg5IShPG2zc4UMOCa1z/btqp5tmCeTLy+f3qKm2LO7ouaB6VMzac3ldA==", "dev": true, - "license": "UNLICENSED", "dependencies": { "axios": "^0.25.0", "lodash-es": "^4.17.21" @@ -3347,9 +3348,9 @@ "link": true }, "node_modules/@dytesdk/web-core": { - "version": "2.1.10-staging.4", - "resolved": "https://registry.npmjs.org/@dytesdk/web-core/-/web-core-2.1.10-staging.4.tgz", - "integrity": "sha512-Vc6kSbdbZ/l+QJcZCb8zwu0p/rH0S8vnlA+Qq4knPM2mrTWZ//00BQjivOWxFzik5SZY7+Mvo2340A4l8JoUwA==", + "version": "2.2.0-staging.2", + "resolved": "https://registry.npmjs.org/@dytesdk/web-core/-/web-core-2.2.0-staging.2.tgz", + "integrity": "sha512-E6pmua3tCdrVD1wqxtPDGvI2j7pD2ByaTDlIa5uWAfskNXAwJvueeDcrdt4oU0cuaY5gktTb6WxKTD9FaL5l7g==", "dev": true, "dependencies": { "@protobuf-ts/runtime": "^2.7.0", @@ -12510,6 +12511,11 @@ "dev": true, "license": "MIT" }, + "node_modules/hls.js": { + "version": "1.5.17", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.17.tgz", + "integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==" + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "dev": true, @@ -26156,11 +26162,12 @@ "@floating-ui/dom": "^1.1.0", "@stencil/core": "2.20.0", "hark": "^1.2.3", + "hls.js": "^1.5.17", "lodash-es": "^4.17.21", "resize-observer-polyfill": "^1.5.1" }, "devDependencies": { - "@dyteinternals/utils": "^1.7.10", + "@dyteinternals/utils": "^3.1.0", "@dytesdk/web-core": "staging", "@rollup/plugin-commonjs": "24.0.1", "@rollup/plugin-node-resolve": "15.0.1", diff --git a/packages/core/package.json b/packages/core/package.json index 817f2e50a..e8d53d7dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,11 +46,12 @@ "@floating-ui/dom": "^1.1.0", "@stencil/core": "2.20.0", "hark": "^1.2.3", + "hls.js": "^1.5.17", "lodash-es": "^4.17.21", "resize-observer-polyfill": "^1.5.1" }, "devDependencies": { - "@dyteinternals/utils": "^1.7.10", + "@dyteinternals/utils": "^3.1.0", "@dytesdk/web-core": "staging", "@rollup/plugin-commonjs": "24.0.1", "@rollup/plugin-node-resolve": "15.0.1", diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 030728701..b4d50ac8b 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -3088,7 +3088,7 @@ export namespace Components { /** * Icon Pack */ - "iconPack": { people: string; people_checked: string; chat: string; poll: string; participants: string; rocket: string; call_end: string; share: string; mic_on: string; mic_off: string; video_on: string; video_off: string; share_screen_start: string; share_screen_stop: string; share_screen_person: string; clock: string; dismiss: string; send: string; search: string; more_vertical: string; chevron_down: string; chevron_up: string; chevron_left: string; chevron_right: string; settings: string; wifi: string; speaker: string; speaker_off: string; download: string; full_screen_maximize: string; full_screen_minimize: string; copy: string; attach: string; image: string; emoji_multiple: string; image_off: string; disconnected: string; wand: string; recording: string; subtract: string; stop_recording: string; warning: string; pin: string; pin_off: string; spinner: string; breakout_rooms: string; add: string; shuffle: string; edit: string; delete: string; back: string; save: string; web: string; checkmark: string; spotlight: string; join_stage: string; leave_stage: string; pip_off: string; pip_on: string; signal_1: string; signal_2: string; signal_3: string; signal_4: string; signal_5: string; start_livestream: string; stop_livestream: string; viewers: string; debug: string; info: string; devices: string; horizontal_dots: string; ai_sparkle: string; meeting_ai: string; create_channel: string; create_channel_illustration: string; captionsOn: string; captionsOff: string; }; + "iconPack": { people: string; people_checked: string; chat: string; poll: string; participants: string; rocket: string; call_end: string; share: string; mic_on: string; mic_off: string; video_on: string; video_off: string; share_screen_start: string; share_screen_stop: string; share_screen_person: string; clock: string; dismiss: string; send: string; search: string; more_vertical: string; chevron_down: string; chevron_up: string; chevron_left: string; chevron_right: string; settings: string; wifi: string; speaker: string; speaker_off: string; download: string; full_screen_maximize: string; full_screen_minimize: string; copy: string; attach: string; image: string; emoji_multiple: string; image_off: string; disconnected: string; wand: string; recording: string; subtract: string; stop_recording: string; warning: string; pin: string; pin_off: string; spinner: string; breakout_rooms: string; add: string; shuffle: string; edit: string; delete: string; back: string; save: string; web: string; checkmark: string; spotlight: string; join_stage: string; leave_stage: string; pip_off: string; pip_on: string; signal_1: string; signal_2: string; signal_3: string; signal_4: string; signal_5: string; start_livestream: string; stop_livestream: string; viewers: string; debug: string; info: string; devices: string; horizontal_dots: string; ai_sparkle: string; meeting_ai: string; create_channel: string; create_channel_illustration: string; captionsOn: string; captionsOff: string; play: string; pause: string; fastForward: string; }; /** * Language */ @@ -8370,7 +8370,7 @@ declare namespace LocalJSX { /** * Icon Pack */ - "iconPack"?: { people: string; people_checked: string; chat: string; poll: string; participants: string; rocket: string; call_end: string; share: string; mic_on: string; mic_off: string; video_on: string; video_off: string; share_screen_start: string; share_screen_stop: string; share_screen_person: string; clock: string; dismiss: string; send: string; search: string; more_vertical: string; chevron_down: string; chevron_up: string; chevron_left: string; chevron_right: string; settings: string; wifi: string; speaker: string; speaker_off: string; download: string; full_screen_maximize: string; full_screen_minimize: string; copy: string; attach: string; image: string; emoji_multiple: string; image_off: string; disconnected: string; wand: string; recording: string; subtract: string; stop_recording: string; warning: string; pin: string; pin_off: string; spinner: string; breakout_rooms: string; add: string; shuffle: string; edit: string; delete: string; back: string; save: string; web: string; checkmark: string; spotlight: string; join_stage: string; leave_stage: string; pip_off: string; pip_on: string; signal_1: string; signal_2: string; signal_3: string; signal_4: string; signal_5: string; start_livestream: string; stop_livestream: string; viewers: string; debug: string; info: string; devices: string; horizontal_dots: string; ai_sparkle: string; meeting_ai: string; create_channel: string; create_channel_illustration: string; captionsOn: string; captionsOff: string; }; + "iconPack"?: { people: string; people_checked: string; chat: string; poll: string; participants: string; rocket: string; call_end: string; share: string; mic_on: string; mic_off: string; video_on: string; video_off: string; share_screen_start: string; share_screen_stop: string; share_screen_person: string; clock: string; dismiss: string; send: string; search: string; more_vertical: string; chevron_down: string; chevron_up: string; chevron_left: string; chevron_right: string; settings: string; wifi: string; speaker: string; speaker_off: string; download: string; full_screen_maximize: string; full_screen_minimize: string; copy: string; attach: string; image: string; emoji_multiple: string; image_off: string; disconnected: string; wand: string; recording: string; subtract: string; stop_recording: string; warning: string; pin: string; pin_off: string; spinner: string; breakout_rooms: string; add: string; shuffle: string; edit: string; delete: string; back: string; save: string; web: string; checkmark: string; spotlight: string; join_stage: string; leave_stage: string; pip_off: string; pip_on: string; signal_1: string; signal_2: string; signal_3: string; signal_4: string; signal_5: string; start_livestream: string; stop_livestream: string; viewers: string; debug: string; info: string; devices: string; horizontal_dots: string; ai_sparkle: string; meeting_ai: string; create_channel: string; create_channel_illustration: string; captionsOn: string; captionsOff: string; play: string; pause: string; fastForward: string; }; /** * Tab change event */ diff --git a/packages/core/src/components/dyte-livestream-player/dyte-livestream-player.css b/packages/core/src/components/dyte-livestream-player/dyte-livestream-player.css index fe26617a6..ab433b635 100644 --- a/packages/core/src/components/dyte-livestream-player/dyte-livestream-player.css +++ b/packages/core/src/components/dyte-livestream-player/dyte-livestream-player.css @@ -1,15 +1,11 @@ @import '../../styles/reset.css'; :host { - @apply bg-background-900 mx-2 flex h-full w-full rounded-md; + @apply bg-background-900 flex h-full max-h-full min-h-full w-full min-w-full max-w-full rounded-md; } .player-container { - @apply relative m-4 flex grow items-center justify-center overflow-hidden rounded-md; -} - -.player { - @apply bg-background-1000 rounded-md left-0 h-full w-auto aspect-video z-20 border-[0px];; + @apply relative m-4 flex grow items-center justify-center rounded-md; } .loader { @@ -21,13 +17,6 @@ p { @apply text-text-700 text-text-lg my-1; } -.latency-controls { - @apply absolute bottom-4 right-4 z-20 flex flex-row items-center; -} - -.sync-live-stream { - @apply text-text-sm bg-brand-500 cursor-pointer rounded-sm px-2 py-1; -} .unmute-popup { @apply bg-background-800 absolute !z-30 flex w-72 flex-col rounded-md p-4 text-center; @@ -38,4 +27,81 @@ p { p { @apply text-text-md my-3; } +} + +.control-bar { + position: absolute; + bottom: 0; + display: flex; + height: auto; + justify-content: space-between; + align-items: center; + padding: 10px 10px 0px 10px; + z-index: 10; /* Ensures the control bar is above the video */ +} + +.timings { + @apply text-text-on-brand; +} + +.control-btn { + border: none; + @apply bg-brand-500 text-text-on-brand rounded-sm mr-2; + cursor: pointer; + font-size: 24px; + height: 30px; + width: 30px; +} + +.fullscreen-btn { + margin-right: 20px; + height: 30px; +} + +.control-btn:hover { + opacity: 0.8; +} + +.control-btn:focus { + outline: none; +} + + +.control-groups{ + display: flex; + align-items: center; + justify-content: space-around; +} + +#livestream-video { + aspect-ratio: 16/9; + max-width: 100%; + min-width: 100%; + @apply rounded-md left-0 w-auto aspect-video z-20 border-[0px];; +} + + +.level-select { + @apply bg-brand-500 text-text-on-brand rounded-sm; + border: none; + padding: 5px 10px; + font-size: 14px; + height: 30px; + cursor: pointer; + border-radius: 5px; + margin-right: 10px; +} + +.level-select:focus { + outline: none; +} + +.level-select option { + @apply bg-brand-500 text-text-on-brand rounded-sm; +} + +.volume-control-holder{ + display: flex; + justify-content: center; + align-items: center; } \ No newline at end of file diff --git a/packages/core/src/components/dyte-livestream-player/dyte-livestream-player.tsx b/packages/core/src/components/dyte-livestream-player/dyte-livestream-player.tsx index 8d56a75ce..fd131d8c1 100644 --- a/packages/core/src/components/dyte-livestream-player/dyte-livestream-player.tsx +++ b/packages/core/src/components/dyte-livestream-player/dyte-livestream-player.tsx @@ -1,9 +1,26 @@ import type { LivestreamState } from '@dytesdk/web-core'; -import { Component, h, Host, Prop, State, Watch, Event, EventEmitter } from '@stencil/core'; +import Hls from 'hls.js'; +import { + Component, + h, + Host, + Prop, + Element, + State, + Watch, + Event, + EventEmitter, +} from '@stencil/core'; import { Size, DyteI18n, IconPack, defaultIconPack } from '../../exports'; import { useLanguage } from '../../lib/lang'; import { Meeting } from '../../types/dyte-client'; -import { showLivestream, PlayerEventType, PlayerState } from '../../utils/livestream'; +import { + showLivestream, + PlayerEventType, + PlayerState, + getLivestreamViewerAllowedQualityLevels, +} from '../../utils/livestream'; +import { formatSecondsToHHMMSS } from '../../utils/time'; @Component({ tag: 'dyte-livestream-player', @@ -11,7 +28,15 @@ import { showLivestream, PlayerEventType, PlayerState } from '../../utils/livest shadow: true, }) export class DyteLivestreamPlayer { - private player: HTMLVideoElement; + private videoRef: HTMLVideoElement; + + private videoContainerRef: HTMLDivElement; + + @Element() el: HTMLDyteLivestreamPlayerElement; + + private hls: Hls; + + private statsIntervalTimer = null; /** Meeting object */ @Prop() meeting!: Meeting; @@ -35,12 +60,18 @@ export class DyteLivestreamPlayer { @State() playerError: any; - @State() latency: number = 0; - @State() livestreamId: string = null; @State() audioPlaybackError: boolean = false; + @State() qualityLevels: Array<{ level: number; resolution: string }> = []; + + @State() selectedQuality: number = -1; // -1 for auto + + @State() currentTime: number = 0; + + @State() duration: number = 0; + /** * Emit API error events */ @@ -50,19 +81,29 @@ export class DyteLivestreamPlayer { }>; private livestreamUpdateListener = (state: LivestreamState) => { - this.livestreamState = state; this.playbackUrl = this.meeting.livestream.playbackUrl; + this.livestreamState = state; + }; + + private updateProgress = () => { + this.currentTime = this.videoRef.currentTime; + }; + + private updateHlsStatsPeriodically = () => { + // Total duration is where video is + the latency that is there + this.duration = (this.videoRef?.currentTime || 0) + (this.hls?.latency || 0); + }; + + private fastForwardToLatest = () => { + this.videoRef.currentTime = this.duration - 1; // Move to the latest time }; @Watch('livestreamState') // @ts-ignore - private updateLivestreamId() { + private async updateLivestreamId() { const url = this.meeting.livestream.playbackUrl; if (!url || this.livestreamState !== 'LIVESTREAMING') { this.livestreamId = null; - this.player = null; - // @ts-ignore - window.dyteLivestreamPlayerElement = null; return; } @@ -70,8 +111,32 @@ export class DyteLivestreamPlayer { const manifestIndex = parts.findIndex((part) => part === 'manifest'); const streamId = parts[manifestIndex - 1]; this.livestreamId = streamId; + await this.conditionallyStartLivestreamViewer(); + } + + private async conditionallyStartLivestreamViewer() { + if (this.videoRef && this.playbackUrl && !this.hls) { + await this.initialiseAndPlayStream(); + } } + private togglePlay = () => { + if (this.videoRef.paused) { + this.videoRef.play(); + this.playerState = PlayerState.PLAYING; + } else { + this.videoRef.pause(); + this.playerState = PlayerState.IDLE; + } + }; + + private changeQuality = (level: number) => { + this.selectedQuality = level; + if (this.hls) { + this.hls.currentLevel = level; + } + }; + private getLoadingState = () => { let loadingMessage = ''; let isLoading = false; @@ -93,7 +158,7 @@ export class DyteLivestreamPlayer { showIcon = true; break; case 'LIVESTREAMING': - if (this.playerState !== PlayerState.PLAYING) { + if (this.playerState !== PlayerState.PLAYING && this.playerState !== PlayerState.PAUSED) { loadingMessage = this.t('livestream.starting'); showIcon = true; isLoading = true; @@ -131,82 +196,96 @@ export class DyteLivestreamPlayer { return { isError, errorMessage }; }; - private isScriptWithSrcPresent(srcUrl) { - const scripts = document.querySelectorAll('script'); - for (let script of scripts) { - if (script.src === srcUrl) { - return true; - } - } - return false; - } - - /** - * Make sure to call loadLivestreamPlayer before startLivestreamPlayer. - */ - private startLivestreamPlayer = async () => { + private initialiseAndPlayStream = async () => { try { this.meeting.__internals__.logger.info( - 'dyte-livestream-player:: Initialising player element.' - ); - // @ts-ignore - await window.__stream.initElement(this.player); - this.meeting.__internals__.logger.info('dyte-livestream-player:: About to start player.'); - // @ts-ignore - await window.dyte_hls.play(); - this.playerState = PlayerState.PLAYING; - this.audioPlaybackError = false; - this.meeting.__internals__.logger.info( - 'dyte-livestream-player:: Player has started playing.' + `dyte-livestream-player:: About to initialise HLS. VideoRef? ${!!this + .videoRef} playbackUrl: ${this.playbackUrl}` ); - } catch (error) { - this.meeting.__internals__.logger.error(`dyte-livestream-player:: Player couldn't start.`, { - error, - }); - // Retry with user gesture - this.audioPlaybackError = true; - } - }; + if (Hls.isSupported()) { + this.meeting.__internals__.logger.info( + `dyte-livestream-player:: Initialising HLS. HLS is Supported` + ); - private loadLivestreamPlayer = async () => { - const playerSrc = `https://cdn.dyte.in/streams/script.js`; - if (!(window as any).__stream && this.isScriptWithSrcPresent(playerSrc)) { - // Script loading is ongoing; Do Nothing - return false; - } + this.hls = new Hls({ + lowLatencyMode: false, + }); - if ((window as any).__stream) { - return true; - } + (window as any).dyte_hls = this.hls; + + this.meeting.__internals__.logger.info(`dyte-livestream-player:: Loading source`); + this.hls.loadSource(this.playbackUrl); + this.meeting.__internals__.logger.info( + `dyte-livestream-player:: Attaching video element to HLS` + ); + this.hls.attachMedia(this.videoRef); + + this.meeting.__internals__.logger.info( + `dyte-livestream-player:: Waiting async for HLS manifest parsing` + ); + + this.hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + this.meeting.__internals__.logger.error('dyte-livestream-player:: fatal error:', data); + } else { + this.meeting.__internals__.logger.warn( + 'dyte-livestream-player:: non-fatal error:', + data + ); + } + }); + + // Listen for manifest parsed to populate quality levels + this.hls.on(Hls.Events.MANIFEST_PARSED, async (_, data) => { + this.meeting.__internals__.logger.info(`dyte-livestream-player:: HLS manifest parsed`); + const { levels: levelsToUse, autoLevelChangeAllowed } = + getLivestreamViewerAllowedQualityLevels({ + meeting: this.meeting, + hlsLevels: data.levels, + }); + + this.qualityLevels = levelsToUse.map((level, index) => ({ + level: index, + resolution: level.height ? `${level.height}p` : 'auto', + })); + if (autoLevelChangeAllowed) { + this.qualityLevels = [{ level: -1, resolution: 'Auto' }, ...this.qualityLevels]; + } + // Set a reasonable starting quality + this.hls.currentLevel = this.qualityLevels[0].level; - // Since script is not there, let's add script first - return new Promise((resolve) => { - const script = document.createElement('script'); - script.src = playerSrc; - script.onload = () => { - setTimeout(() => { - if ((window as any).__stream) { + try { this.meeting.__internals__.logger.info( - `dyte-livestream-player:: Finished script load. Added window._stream.` + 'dyte-livestream-player:: About to start video.' + ); + await this.videoRef.play(); // Starts playing the video after it is ready + this.meeting.__internals__.logger.info( + 'dyte-livestream-player:: Video has started playing.' + ); + this.playerState = PlayerState.PLAYING; + } catch (error) { + this.audioPlaybackError = true; + this.meeting.__internals__.logger.error( + `dyte-livestream-player:: Video couldn't start. Trying with user gesture again.`, + { + error, + } ); - resolve(true); - return; } - this.meeting.__internals__.logger.error( - `dyte-livestream-player:: onLoad didn't add window._stream in time.` - ); - resolve(false); - }, 1000); - }; - script.onerror = (error: any) => { - this.meeting.__internals__.logger.error( - `dyte-livestream-player:: CDN script didn't load.`, - { error } - ); - resolve(false); - }; - document.head.appendChild(script); - }); + }); + + // Setup listeners to show current time and total duration + this.videoRef.addEventListener('timeupdate', this.updateProgress); + this.statsIntervalTimer = setInterval(this.updateHlsStatsPeriodically, 1000); + } else { + this.isSupported = false; + } + } catch (error) { + this.meeting.__internals__.logger.error(`dyte-livestream-player:: HLS couldn't initialise.`, { + error, + }); + // Retry with user gesture + } }; async connectedCallback() { @@ -215,16 +294,20 @@ export class DyteLivestreamPlayer { disconnectedCallback() { this.meeting.livestream.removeListener('livestreamUpdate', this.livestreamUpdateListener); - this.player = null; - // @ts-ignore - window.dyteLivestreamPlayerElement = null; + this.videoRef.removeEventListener('timeupdate', this.updateProgress); + clearInterval(this.statsIntervalTimer); + this.videoRef = null; + if (this.hls) { + this.hls.destroy(); + } + (window as any).dyte_hls = null; } @Watch('meeting') meetingChanged(meeting) { if (meeting == null) return; - this.livestreamState = this.meeting.livestream.state; this.playbackUrl = this.meeting.livestream.playbackUrl; + this.livestreamState = this.meeting.livestream.state; this.meeting.livestream.on('livestreamUpdate', this.livestreamUpdateListener); } @@ -235,31 +318,101 @@ export class DyteLivestreamPlayer { return ( -
- {this.livestreamState === 'LIVESTREAMING' && this.livestreamId && ( -
- { - this.player = self; - // Add player instance on window to satisfy cdn script - // @ts-ignore - window.dyteLivestreamPlayerElement = self; - const isPlayerLoaded = await this.loadLivestreamPlayer(); - if (isPlayerLoaded) { - await this.startLivestreamPlayer(); - } - }} - cmcd - autoplay - force-flavor="llhls" - customer-domain-prefix="customer-s8oj0c1n5ek8ah1e" - > -
- )} +
+
{ + this.videoContainerRef = el; + }} + class="video-container relative flex h-full w-full flex-col items-center justify-center pb-20" + > + + {this.playerState !== PlayerState.IDLE && ( + // +
+
+ {/* */} + + + + + {formatSecondsToHHMMSS(this.currentTime)} /{' '} + {formatSecondsToHHMMSS(this.duration)} + +
+ +
+ {/* */} + + + {/* */} + { + // Create a