feature (video): revamp the video player + chromecast integration

This commit is contained in:
Mickael Kerjean
2023-04-16 22:03:25 +10:00
parent 21e26aea85
commit ab893d9e9c
7 changed files with 515 additions and 69 deletions
+1
View File
@@ -38,6 +38,7 @@
- Manage your files from a browser
- Authentication middleware to connect to various source of user
- Flexible Share mechanism
- Chromecast support for images, music, and videos
- Video player
- Video transcoding (mov, mkv, avi, mpeg, and more)
- Image viewer
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+8 -1
View File
@@ -143,5 +143,12 @@ function setup_translation() {
}
function setup_chromecast() {
return Promise.resolve();
if (!CONFIG.enable_chromecast) {
return Promise.resolve();
} else if (!("chrome" in window)) {
return Promise.resolve();
} else if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
return Promise.resolve();
}
return Chromecast.init();
}
-2
View File
@@ -1,5 +1,3 @@
"use strict";
import { Session } from "./session";
import { currentShare, objectGet } from "../helpers/";
+366 -30
View File
@@ -1,38 +1,319 @@
import React, { useEffect, useRef } from "react";
import React, { useEffect, useState, useRef, useMemo } from "react";
import ReactCSSTransitionGroup from "react-addons-css-transition-group";
import filepath from "path";
import { Pager } from "./pager";
import { MenuBar } from "./menubar";
import { getMimeType } from "../../helpers/";
import videojs from "video.js";
import "videojs-contrib-hls";
import "video.js/dist/video-js.css";
import { Chromecast } from "../../model/"
import { getMimeType,settings_get, settings_put, notify } from "../../helpers/";
import { t } from "../../locales/";
import { Icon } from "../../components/";
import hls from "hls.js";
import "./videoplayer.scss";
export function VideoPlayer({ filename, data, path }) {
const $video = useRef();
if (!window.overrides["video-map-sources"]) {
window.overrides["video-map-sources"] = (s) => (s);
}
const $container = useRef();
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(settings_get("volume") === null ? 50 : settings_get("volume"));
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isChromecast, setIsChromecast] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isBuffering, setIsBuffering] = useState(false);
const [render, setRender] = useState(0);
const [hint, setHint] = useState(null);
const [videoSources, setVideoSources] = useState([]);
useEffect(() => {
const player = videojs($video.current, {
controls: true,
sources: window.overrides["video-map-sources"]([{
src: data,
type: getMimeType(data),
}]),
});
return () => {
player.dispose();
if (!$video.current) return;
const metadataHandler = () => {
$video.current.volume = volume / 100;
setDuration($video.current.duration);
setIsLoading(false);
};
}, [data]);
const finishHandler = () => {
$video.current.currentTime = 0;
setIsPlaying(false);
};
const errorHandler = (err) => {
console.error(err);
notify.send(t("Not supported"), "error");
setIsPlaying(false);
setIsLoading(false);
};
const waitingHandler = (e) => {
setIsBuffering(true);
}
const playingHandler = (e) => {
setIsBuffering(false);
}
if (!window.overrides["video-map-sources"]) {
window.overrides["video-map-sources"] = (s) => (s);
}
const sources = window.overrides["video-map-sources"]([{
src: data,
type: getMimeType(data),
}]);
setVideoSources(sources.map((source) => {
if (source.type !== "application/x-mpegURL" && source.type !== "application/vnd.apple.mpegurl") return source;
const h = new hls();
h.loadSource(source.src);
h.attachMedia($video.current);
return source;
}));
$video.current.addEventListener("loadeddata", metadataHandler);
$video.current.addEventListener("ended", finishHandler);
$video.current.addEventListener("error", errorHandler);
$video.current.addEventListener("waiting", waitingHandler);
$video.current.addEventListener("playing", playingHandler);
let $sources = $video.current.querySelectorAll("source")
for (let i=0; i<$sources.length; i++) {
$sources[i].addEventListener("error", errorHandler);
}
return () => {
$video.current.removeEventListener("loadeddata", metadataHandler);
$video.current.removeEventListener("ended", finishHandler);
$video.current.removeEventListener("error", errorHandler);
$video.current.removeEventListener("waiting", waitingHandler);
$video.current.removeEventListener("playing", playingHandler);
for (let i=0; i<$sources.length; i++) {
$sources[i].removeEventListener("error", errorHandler);
}
};
}, [$video, data]);
useEffect(() => {
const resizeHandler = () => setRender(render + 1);
const onKeyPressHandler = (e) => {
if(e.code !== "Space") {
return
}
isPlaying ? onPause(e) : onPlay(e);
};
window.addEventListener("resize", resizeHandler);
window.addEventListener("keypress", onKeyPressHandler);
return () => {
window.removeEventListener("resize", resizeHandler);
window.removeEventListener("keypress", onKeyPressHandler);
};
}, [render, isPlaying, isChromecast]);
useEffect(() => {
const context = Chromecast.context();
if (!context) return;
document.getElementById("chromecast-target").append(document.createElement("google-cast-launcher"));
const chromecastSetup = (event) => {
switch (event.sessionState) {
case cast.framework.SessionState.SESSION_STARTING:
setIsChromecast(true);
setIsLoading(true);
break;
case cast.framework.SessionState.SESSION_START_FAILED:
setIsChromecast(false);
setIsLoading(false);
break;
case cast.framework.SessionState.SESSION_STARTED:
chromecastLoader()
break;
case cast.framework.SessionState.SESSION_ENDING:
setIsChromecast(false);
$video.current.currentTime = _currentTime;
$video.current.muted = false;
if (isPlaying) $video.current.play();
break;
}
};
context.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
);
return () => {
const media = Chromecast.media();
if (media) media.removeUpdateListener(chromecastMediaHandler);
context.removeEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
);
};
}, []);
useEffect(() => {
const interval = setInterval(() => {
if (isLoading) return;
if (isChromecast) {
const media = Chromecast.media();
if (!media) return;
_currentTime = media.getEstimatedTime();
setIsBuffering(media.playerState === "BUFFERING");
} else _currentTime = $video.current.currentTime;
setCurrentTime(_currentTime)
}, 100);
return () => {
clearInterval(interval);
};
}, [data, isChromecast, isLoading]);
const onPlay = () => {
setIsPlaying(true);
if (isChromecast) {
const media = Chromecast.media();
if (media) media.play();
} else $video.current.play();
};
const onPause = () => {
setIsPlaying(false);
if (isChromecast) {
const media = Chromecast.media();
if (media) media.pause();
} else $video.current.pause();
};
const onSeek = (newTime) => {
if (isChromecast) {
const media = Chromecast.media();
if (!media) return;
setIsLoading(true);
const seekRequest = new chrome.cast.media.SeekRequest();
seekRequest.currentTime = parseInt(newTime);
media.seek(seekRequest);
setTimeout(() => setIsLoading(false), 1000);
} else $video.current.currentTime = newTime;
};
const onClickSeek = (e) => {
let $progress = e.target;
if (e.target.classList.contains("progress") == false) {
$progress = e.target.parentElement;
}
const rec = $progress.getBoundingClientRect();
e.persist();
let n = (e.clientX - rec.x) / rec.width;
if (n < 2/100) {
onPause();
n = 0;
}
onSeek(n * duration);
};
const onVolumeChange = (n) => {
settings_put("volume", n);
setVolume(n);
if (isChromecast) {
const session = Chromecast.session()
if (session) session.setVolume(n / 100);
else notify.send(t("Cannot establish a connection"), "error");
}
else $video.current.volume = n / 100;
};
const onProgressHover = (e) => {
const rec = e.target.getBoundingClientRect();
const width = e.clientX - rec.x;
const time = duration * width / rec.width;
let posX = width;
posX = Math.max(posX, 30) // min boundary
posX = Math.min(posX, e.target.clientWidth - 30);
setHint({ x: `${posX}px`, time })
};
const onRequestFullscreen = () => {
const session = Chromecast.session();
if (!session) {
document.querySelector(".video_screen").requestFullscreen();
requestAnimationFrame(() => setRender(render + 1));
} else {
chromecastLoader();
}
};
const isFullscreen = () => {
if (!$container.current) return false
return window.innerHeight === screen.height;
};
const renderBuffer = () => {
if (!$video.current) return null;
const calcWidth = (i) => {
return ($video.current.buffered.end(i) - $video.current.buffered.start(i)) / duration * 100;
};
const calcLeft = (i) => {
return $video.current.buffered.start(i) / duration * 100;
};
return (
<React.Fragment>
{
Array.apply(null, { length: $video.current.buffered.length }).map((_, i) => (
<div className="progress-buffer" key={i} style={{left: calcLeft(i) + "%", width: calcWidth(i) + "%" }} />
))
}
</React.Fragment>
);
};
const formatTimecode = (seconds) => {
return String(parseInt(seconds / 60)).padStart(2, "0") +
":"+
String(parseInt(seconds % 60)).padStart(2, "0");
};
const chromecastLoader = () => {
const link = Chromecast.createLink(data);
const media = new chrome.cast.media.MediaInfo(
link,
getMimeType(data),
);
media.metadata = new chrome.cast.media.MovieMediaMetadata()
media.metadata.title = filename.substr(0, filename.lastIndexOf(filepath.extname(filename)));
media.metadata.subtitle = CONFIG.name;
media.metadata.images = [
new chrome.cast.Image(origin + "/assets/icons/video.png"),
];
setIsChromecast(true);
setIsPlaying(true);
setIsLoading(false);
$video.current.muted = true;
$video.current.pause();
const session = Chromecast.session();
if (!session) return;
$video.current.pause();
setVolume(session.getVolume() * 100);
Chromecast.createRequest(media)
.then((req) => {
req.currentTime = parseInt($video.current.currentTime);
setCurrentTime($video.current.currentTime);
return session.loadMedia(req);
})
.then(() => {
const media = session.getMediaSession();
if (!media) return;
media.addUpdateListener(chromecastMediaHandler);
})
.catch((err) => {
console.error(err);
notify.send(t("Cannot establish a connection"), "error");
});
};
const chromecastMediaHandler = (isAlive) => {
if (isAlive) return;
const session = Chromecast.session();
if (session) {
session.endSession();
$video.current.muted = false;
setVolume($video.current.volume * 100);
setIsChromecast(false);
onSeek(0);
onPause();
}
};
return (
<div className="component_videoplayer">
<MenuBar title={filename} download={data} />
<div className="video_container">
<div className="video_container" ref={$container}>
<ReactCSSTransitionGroup
transitionName="video"
transitionAppear={true}
@@ -40,16 +321,69 @@ export function VideoPlayer({ filename, data, path }) {
transitionEnter={true}
transitionEnterTimeout={300}
transitionAppearTimeout={300}>
<div key={data} data-vjs-player>
<video
ref={$video}
className="video-js vjs-fill vjs-default-skin vjs-big-play-centered"
style={{
boxShadow: "rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, " +
"rgba(0, 0, 0, 0.12) 0px 1px 10px 0px, " +
"rgba(0, 0, 0, 0.2) 0px 2px 4px -1px",
}}>
</video>
<div className={"video_screen" + (isPlaying ? " video-state-play" : " video-state-pause")}>
<div className="video_wrapper" style={isFullscreen() ? {
maxHeight: "inherit",
height: "inherit",
} : {
maxHeight: (($container.current || {}).clientHeight - 100) || 0,
}}>
<video onClick={() => isPlaying ? onPause() : onPlay()} ref={$video}>
{
videoSources.map((d, i) => (
<source key={i} src={d.src} type={d.type} />
))
}
</video>
</div>
{
isLoading && (
<div className="loader no-select">
<Icon name="loading" />
</div>
)
}
{
duration > 0 && (
<div className="videoplayer_control no-select">
<div className="progress" onClick={onClickSeek} onMouseMove={onProgressHover} onMouseLeave={() => setHint(null)}>
{ isChromecast === false && renderBuffer() }
<div className="progress-active" style={{width: (currentTime * 100 / (duration || 1)) + "%"}}>
<div className="thumb" />
</div>
<div className="progress-placeholder"></div>
</div>
{
isLoading || isBuffering ? (
<Icon name="loading" />
) : isPlaying ? (
<Icon name="pause" onClick={onPause} />
) : (
<Icon name="play" onClick={onPlay} />
)
}
<Icon name="volume" onClick={() => onVolumeChange(0)} name={volume === 0 ? "volume_mute" : volume < 50 ? "volume_low" : "volume"}/>
<input type="range" onChange={(e) => onVolumeChange(Number(e.target.value))} value={volume} min="0" max="100" />
<span className="timecode">
{ formatTimecode(currentTime) }
&nbsp; / &nbsp;
{ formatTimecode(duration) }
{
hint && (
<div className="hint" style={{left: hint.x}}>{ formatTimecode(hint.time) }</div>
)
}
</span>
<div className="pull-right">
{
<React.Fragment>
<Icon name="fullscreen" onClick={onRequestFullscreen} />
</React.Fragment>
}
</div>
</div>
)
}
</div>
</ReactCSSTransitionGroup>
<Pager path={path} />
@@ -57,3 +391,5 @@ export function VideoPlayer({ filename, data, path }) {
</div>
);
}
let _currentTime = 0; // trick to avoid making too many call to the chromecast SDK
+139 -33
View File
@@ -1,54 +1,160 @@
.component_videoplayer{
.component_videoplayer {
background: #525659;
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
.video_container{
.video_container {
display: flex;
flex: 1;
text-align: center;
background: #525659;
overflow: hidden;
padding: 15px 10px 65px 10px;
height: 100%;
box-sizing: border-box;
> span{
height: 100%;
> span {
display: flex;
flex-direction: column;
max-width: 800px;
margin: 0 auto;
flex: 1;
text-align: center;
overflow: hidden;
height: 100%;
box-sizing: border-box;
}
.video_screen {
background: black;
box-shadow: rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px, rgba(0, 0, 0, 0.2) 0px 2px 4px -1px;
position: relative;
border-radius: 3px;
margin: auto;
position: relative;
width: 800px;
.video_wrapper { height: 450px; }
.loader {
position: absolute;
top: 30%;
width: 100%;
}
&.video-state-pause .videoplayer_control { opacity: 1; transition: 0.1s opacity ease; }
&.video-state-play:hover .videoplayer_control { opacity: 1; transition: 0.1s opacity ease; }
.videoplayer_control {
transition: 0.5s opacity ease;
opacity: 0;
display: flex;
text-align: left;
position: absolute;
bottom: 0;
width: 100%;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADCCAYAAACIaaiTAAAAAXNSR0IArs4c6QAAARJJREFUOE9lyNdHBQAAhfHb3nvvuu2997jNe29TJJEkkkgSSSSJJJJEEkkiifRH5jsP56Xz8PM5gcC/xfCIWBNHiXiTQIlEk0SJZJNCiVRIM+mUyDCZlMgy2ZTIMbmUyDP5lCgwhZQoMsWUKDGllCgz5ZSogEpTRYlqU0OJoKmlRJ2pp0SDaaREk2mmRItppUSbaadEh+mkRBd0mx5K9Jo+SvSbAUoMmiFKDJsRSoyaMUqMmwlKhMwkJabMNCVmYNbMUSJsIpSImnlKLJhFSiyZZWoFVmEN1mEDNmELtmEHdmEP9uEADuEIjuEETuEMzuECLuEKruEGbuEO7uEBHuEJnuEFXuEN3uEDPuELvuEHfv8AoRErEi7Uc8UAAAAASUVORK5CYII=);
background-repeat: repeat-x;
background-size: contain;
padding: 0 0 7px 0;
img {
cursor: pointer;
width: 25px;
filter: brightness(0) invert(1);
padding: 5px 5px 5px 10px;
}
input[type="range"] {
width: 60px;
cursor: pointer;
outline: none;
-webkit-appearance: none;
background: transparent;
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: 12px;
width: 12px;
border: none;
border-radius: 50%;
background: white;
margin-top: -5px;
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 2px;
background-color: rgba(255,255,255,0.7);
border-radius: 2px;
}
&::-moz-range-track {
width: 100%;
height: 2px;
background-color: rgba(255,255,255,0.7);
border-radius: 2px;
}
}
.timecode {
color: white;
margin: auto 0;
padding-left: 10px;
.hint {
position: absolute;
top: -40px;
margin-left: -23px;
font-size: 0.9rem;
background: #f1f1f155;
border-radius: 3px;
padding: 2px 5px;
background: var(--dark);
}
}
.progress {
position: absolute;
width: 100%;
top: -20px;
height: 20px;
cursor: pointer;
.progress-active, .progress-buffer, .progress-placeholder {
top: 10px;
position: absolute;
height: 4px;
}
.progress-active {
background: var(--primary);
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
.thumb {
display: none;
float: right;
width: 4px;
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 50%;
margin-top: 0px;
position: relative;
left: 3px;
}
}
.progress-buffer {
background: #e2e2e244;
}
.progress-placeholder {
background: #e2e2e222;
width: 100%;
}
}
.pull-right {
margin-left: auto;
padding-right: 10px;
}
}
}
video {
width: 100%;
> div{margin: auto;}
height: 100%;
}
}
}
/* SKIN FOR VIDEOJS: */
.video-js{
font-size: 14px; /* Chromium bug */
max-height: 500px;
.vjs-control-bar{
background: black;
background: linear-gradient(transparent 20%, #00000099);
}
.vjs-load-progress div{
background: var(--primary);
}
.vjs-big-play-button{
background-color: var(--primary)!important;
border-color: var(--emphasis-primary)!important;
border-width: 2px;
margin-top: -40px;
border-radius: 8px;
&:before{ font-size: 45px; color: var(--bg-color); }
}
}
.component_videoplayer .video_container .video_screen .videoplayer_control:hover .progress .progress-active .thumb { display: block; }
.video-enter, .video-appear{
+1 -3
View File
@@ -18,6 +18,7 @@
"codemirror": "^5.26.0",
"epubjs": "^0.3.93",
"exif-js": "^2.3.0",
"hls.js": "^1.4.0",
"little-loader": "^0.2.0",
"prop-types": "^15.5.10",
"react": "^16.5.0",
@@ -33,9 +34,6 @@
"react-selectable": "git+https://github.com/mickael-kerjean/react-selectable.git",
"react-sticky": "^6.0.2",
"rxjs": "^5.4.0",
"video.js": "^7.14.3",
"videojs-contrib-hls": "^5.14.1",
"videojs-sublime-skin": "^1.0.3",
"wavesurfer.js": "^1.4.0"
},
"devDependencies": {