diff --git a/package-lock.json b/package-lock.json index eadd1820..32cef4fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10859,6 +10859,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "devOptional": true, "engines": { "node": ">=8" } @@ -11758,6 +11759,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", + "devOptional": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -17650,9 +17652,9 @@ } }, "node_modules/hls.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.2.0.tgz", - "integrity": "sha512-QIEQIUpBRhcpBMq3NA+/qozG8lbNfVekuX7kCMUlhiVu4382xFWsnwcuBe/CA4Gp/wB/pf2aRBaGRFlxh/FN8g==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.1.1.tgz", + "integrity": "sha512-2VEVzO/gr5LOD6K/DEmBkKEary6hr4YZ/SLb0PV81jOAM/Tl9DviL0sFMoUHDq05/j4OZxIj691Zo0p40u3Gtw==" }, "node_modules/hmac-drbg": { "version": "1.0.1", @@ -18739,6 +18741,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "devOptional": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -27117,6 +27120,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "devOptional": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -41678,7 +41682,8 @@ "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "devOptional": true }, "bindings": { "version": "1.5.0", @@ -42392,6 +42397,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", + "devOptional": true, "requires": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -47009,9 +47015,9 @@ } }, "hls.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.2.0.tgz", - "integrity": "sha512-QIEQIUpBRhcpBMq3NA+/qozG8lbNfVekuX7kCMUlhiVu4382xFWsnwcuBe/CA4Gp/wB/pf2aRBaGRFlxh/FN8g==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.1.1.tgz", + "integrity": "sha512-2VEVzO/gr5LOD6K/DEmBkKEary6hr4YZ/SLb0PV81jOAM/Tl9DviL0sFMoUHDq05/j4OZxIj691Zo0p40u3Gtw==" }, "hmac-drbg": { "version": "1.0.1", @@ -47840,6 +47846,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "devOptional": true, "requires": { "binary-extensions": "^2.0.0" } @@ -54334,6 +54341,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "devOptional": true, "requires": { "picomatch": "^2.2.1" } diff --git a/public/index.html b/public/index.html index 2f150420..8317367f 100644 --- a/public/index.html +++ b/public/index.html @@ -56,5 +56,8 @@ > <% } %> + + + diff --git a/src/features/ChromeCast/CastVideos.js b/src/features/ChromeCast/CastVideos.js new file mode 100644 index 00000000..eba967f1 --- /dev/null +++ b/src/features/ChromeCast/CastVideos.js @@ -0,0 +1,177 @@ +/* eslint-disable no-undef */ +/* eslint-disable max-classes-per-file */ +/** + * Constants of states for media playback + * @enum {string} + */ +const PLAYER_STATE = { + ERROR: 'ERROR', + IDLE: 'IDLE', + LOADED: 'LOADED', + LOADING: 'LOADING', + PAUSED: 'PAUSED', + PLAYING: 'PLAYING', + STOPPED: 'STOPPED', +} + +/** + * Cast player object + * Main variables: + * - PlayerHandler object for handling media playback + * - Cast player variables for controlling Cast mode media playback + * - Current media variables for transition between Cast and local modes + * @struct @constructor + */ +export class CastPlayer { + constructor(source) { + /** @type {PlayerHandler} Delegation proxy for media playback */ + this.playerHandler = new PlayerHandler(this) + + /** @type {PLAYER_STATE} A state for media playback */ + this.playerState = PLAYER_STATE.IDLE + + /* Cast player variables */ + /** @type {cast.framework.RemotePlayer} */ + this.remotePlayer = null + /** @type {cast.framework.RemotePlayerController} */ + this.remotePlayerController = null + + this.src = source + } + + initializeCastPlayer() { + const options = {} + + // Set the receiver application ID to your own (created in the + // Google Cast Developer Console), or optionally + // use the chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID + options.receiverApplicationId = chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID + + // Auto join policy can be one of the following three: + // ORIGIN_SCOPED - Auto connect from same appId and page origin + // TAB_AND_ORIGIN_SCOPED - Auto connect from same appId, page origin, and tab + // PAGE_SCOPED - No auto connect + options.autoJoinPolicy = chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED + options.androidReceiverCompatible = false + + cast.framework.CastContext.getInstance().setOptions(options) + + // const credentialsData = new chrome.cast.CredentialsData('{"userId": "abc"}') + // cast.framework.CastContext.getInstance().setLaunchCredentialsData(credentialsData) + + this.remotePlayer = new cast.framework.RemotePlayer() + this.remotePlayerController = new cast.framework.RemotePlayerController(this.remotePlayer) + this.remotePlayerController.addEventListener( + cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, + this.switchPlayer.bind(this), + ) + } + + /* + * PlayerHandler and setup functions + */ + switchPlayer() { + this.playerState = PLAYER_STATE.IDLE + if (cast && cast.framework && this.remotePlayer.isConnected) { + this.setupRemotePlayer() + } + } + + /** + * Set the PlayerHandler target to use the remote player + */ + setupRemotePlayer() { + const castSession = cast.framework.CastContext.getInstance().getCurrentSession() + + // This object will implement PlayerHandler callbacks with + // remotePlayerController, and makes necessary UI updates specific + // to remote playback + const playerTarget = {} + + playerTarget.play = function () { + if (this.remotePlayer.isPaused) { + this.remotePlayerController.playOrPause() + } + }.bind(this) + + playerTarget.pause = function () { + if (!this.remotePlayer.isPaused) { + this.remotePlayerController.playOrPause() + } + }.bind(this) + + playerTarget.stop = function () { + this.remotePlayerController.stop() + }.bind(this) + + playerTarget.load = function () { + const mediaInfo = new chrome.cast.media.MediaInfo(this.src, 'video/mp4') + + mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata() + mediaInfo.metadata.metadataType = chrome.cast.media.MetadataType.GENERIC + mediaInfo.metadata.title = 'title' + mediaInfo.metadata.images = [{ url: 'https://s3.eu-central-1.amazonaws.com/img.hromadske.ua/posts/224808/opng/medium.jpg' }] + const request = new chrome.cast.media.LoadRequest(mediaInfo) + request.credentials = 'user-credentials' + request.atvCredentials = 'atv-user-credentials' + castSession.loadMedia(request).then( + this.playerHandler.loaded.bind(this.playerHandler), + (errorCode) => { + this.playerState = PLAYER_STATE.ERROR + }, + ) + }.bind(this) + + this.playerHandler.setTarget(playerTarget) + + this.playerHandler.play() + } +} +/** + * PlayerHandler + */ +class PlayerHandler { + constructor(castPlayer) { + this.target = {} + + this.setTarget = function (target) { + this.target = target + } + + this.play = function () { + if (castPlayer.playerState !== PLAYER_STATE.PLAYING + && castPlayer.playerState !== PLAYER_STATE.PAUSED + && castPlayer.playerState !== PLAYER_STATE.LOADED) { + this.load(castPlayer.src) + return + } + + this.target.play() + castPlayer.playerState = PLAYER_STATE.PLAYING + } + + this.pause = function () { + if (castPlayer.playerState !== PLAYER_STATE.PLAYING) { + return + } + + this.target.pause() + castPlayer.playerState = PLAYER_STATE.PAUSED + } + + this.stop = function () { + this.pause() + castPlayer.playerState = PLAYER_STATE.STOPPED + } + + this.load = function (src) { + castPlayer.playerState = PLAYER_STATE.LOADING + + this.target.load(src) + } + + this.loaded = function () { + castPlayer.playerState = PLAYER_STATE.LOADED + } + } +} diff --git a/src/features/ChromeCast/index.tsx b/src/features/ChromeCast/index.tsx new file mode 100644 index 00000000..88a75802 --- /dev/null +++ b/src/features/ChromeCast/index.tsx @@ -0,0 +1,70 @@ +import { + Fragment, + memo, + useEffect, + useRef, + useState, +} from 'react' + +import { readToken } from 'helpers' + +import { API_ROOT } from 'config' +import { usePageParams } from 'hooks/usePageParams' + +import { Container } from './styled' +import { CastPlayer } from './CastVideos' + +type Props = { + src?: string, +} + +export const ChromeCast = memo(({ src } : Props) => { + const [isCastAvailable, setIsCastAvailable] = useState(false) + + const { profileId: matchId, sportType } = usePageParams() + + const containerRef = useRef(null) + const GoogleCastLauncher = (document as any).createElement('google-cast-launcher') + GoogleCastLauncher.setAttribute('id', 'castbutton') + + const baseUrl = src ?? `${API_ROOT}/video/chromecast/stream/${sportType}/${matchId}.m3u8` + const urlWithToken = (/\d.m3u8/.test(baseUrl)) ? `${baseUrl}?access_token=${readToken()}` : baseUrl + + useEffect(() => { + if ( + containerRef.current + && GoogleCastLauncher + && containerRef.current.childNodes.length < 1 + ) { + (containerRef.current as any).appendChild(GoogleCastLauncher) + } + }, [GoogleCastLauncher]) + + useEffect(() => { + const script = document.createElement('script') + + script.src = '//www.gstatic.com/cv/js/sender/v1/cast_sender.js' + script.async = true + + document.body.appendChild(script) + + const castPlayer = new CastPlayer(urlWithToken); + (window as any).__onGCastApiAvailable = (isAvailable: boolean) => { + if (isAvailable) { + castPlayer.initializeCastPlayer() + setIsCastAvailable(true) + } + } + + return () => { + document.body.removeChild(script) + setIsCastAvailable(false) + } + }, [urlWithToken]) + + return ( + + {isCastAvailable && } + + ) +}) diff --git a/src/features/ChromeCast/styled.tsx b/src/features/ChromeCast/styled.tsx new file mode 100644 index 00000000..7d088ccb --- /dev/null +++ b/src/features/ChromeCast/styled.tsx @@ -0,0 +1,19 @@ +import styled, { css } from 'styled-components' + +import { isMobileDevice } from 'config/userAgent' + +export const Container = styled.div` + display: flex; + align-items: center; + margin-left: 25px; + height: 24px; + width: 24px; + + & #castbutton:hover { + --disconnected-color: #FFFFFF; + } + + ${isMobileDevice ? css` + margin-left: 15px; + ` : ''} +` diff --git a/src/features/MultiSourcePlayer/index.tsx b/src/features/MultiSourcePlayer/index.tsx index bd46eb5d..6bee0cbb 100644 --- a/src/features/MultiSourcePlayer/index.tsx +++ b/src/features/MultiSourcePlayer/index.tsx @@ -194,6 +194,7 @@ export const MultiSourcePlayer = (props: Props) => { rewindBackward={rewindBackward} rewindForward={rewindForward} selectedQuality={selectedQuality} + src={firstPlayerActive ? activeSrc : nextSrc} togglePlaying={togglePlaying} videoQualities={videoQualities} volume={volume} diff --git a/src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx b/src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx index 8a60bebd..0bf043ce 100644 --- a/src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx +++ b/src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx @@ -1,5 +1,6 @@ import { T9n } from 'features/T9n' import { Settings } from 'features/MultiSourcePlayer/components/Settings' +import { ChromeCast } from 'features/ChromeCast' import { ControlsPropsExtended } from '../..' import { LiveBtn } from '../../../../styled' @@ -24,6 +25,7 @@ export const ControlsMobile = (controlsProps: {props: ControlsPropsExtended}) => playBackTime, progressBarElement, selectedQuality, + src, videoQualities, } = props @@ -42,6 +44,7 @@ export const ControlsMobile = (controlsProps: {props: ControlsPropsExtended}) => )} + rewindBackward, rewindForward, selectedQuality, + src, togglePlaying, videoQualities, volumeInPercent, @@ -98,6 +100,7 @@ export const ControlsWeb = (controlsProps: { props: ControlsPropsExtended }) => )} + void, rewindForward: () => void, selectedQuality: string, + src?: string, togglePlaying: () => void, videoQualities: Array, volume: number,