feature (chromecast): improve support for chromecast

This commit is contained in:
Mickael Kerjean
2023-04-14 08:20:04 +10:00
parent bb7840f27e
commit a10c457437
10 changed files with 260 additions and 75 deletions

View File

@ -2,7 +2,7 @@ import React from "react";
import ReactDOM from "react-dom";
import Router from "./router";
import { Config, Log } from "./model/";
import { Config, Log, Chromecast } from "./model/";
import { http_get, setup_cache } from "./helpers/";
import load from "little-loader";
@ -150,17 +150,5 @@ function setup_chromecast() {
} else if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
return Promise.resolve();
}
return new Promise((done) => {
const script = document.createElement("script");
script.src = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
script.onerror = () => done()
window["__onGCastApiAvailable"] = function(isAvailable) {
if (isAvailable) cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
done();
};
document.head.appendChild(script)
});
return Chromecast.init();
}

View File

@ -0,0 +1,73 @@
"use strict";
import { Session } from "./session";
import { currentShare, objectGet } from "../helpers/";
class ChromecastManager {
init() {
return new Promise((done) => {
const script = document.createElement("script");
script.src = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
script.onerror = () => done()
window["__onGCastApiAvailable"] = function(isAvailable) {
if (isAvailable) cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
done();
};
document.head.appendChild(script)
});
}
origin() {
return location.origin;
};
createLink(apiPath) {
const shareID = currentShare();
if (shareID) {
const target = new URL(this.origin() + apiPath);
target.searchParams.append("share", shareID);
return target.toString();
}
const target = new URL(this.origin() + apiPath)
return target.toString();
}
createRequest(mediaInfo) {
let prior = Promise.resolve();
if (!Session.authorization) prior = Session.currentUser();
return prior.then(() => {
if (!Session.authorization) throw new Error("Invalid account");
// TODO: it would be much much nicer to set the authorization from an HTTP header
// but this would require to create a custom web receiver app, setup accounts on
// google, etc,... Until that happens, we're setting the authorization within the
// url. Once we have that app, the authorisation will come from a customData field
// of a chrome.cast.media.LoadRequest
const target = new URL(mediaInfo.contentId);
target.searchParams.append("authorization", Session.authorization);
mediaInfo.contentId = target.toString();
return new chrome.cast.media.LoadRequest(mediaInfo);
});
}
context() {
if (!objectGet(window.chrome, ["cast", "isAvailable"])) {
return;
}
return cast.framework.CastContext.getInstance();
}
session() {
const context = this.context();
if (!context) return;
return context.getCurrentSession();
}
media() {
const session = this.session();
if (!session) return;
return session.getMediaSession();
}
}
export const Chromecast = new ChromecastManager();

View File

@ -6,3 +6,4 @@ export { Log } from "./log";
export { Admin } from "./admin";
export { Audit } from "./audit";
export { Tags } from "./tags";
export { Chromecast } from "./chromecast";

View File

@ -4,7 +4,10 @@ class SessionManager {
currentUser() {
const shareID = currentShare();
return http_get("/api/session" + (shareID && `?share=${shareID}`))
.then((data) => data.result)
.then((data) => {
this.authorization = data.result.authorization;
return data.result;
})
.catch((err) => {
if (err.code === "Unauthorized") {
if (location.pathname.indexOf("/files/") !== -1 || location.pathname.indexOf("/view/") !== -1) {
@ -41,6 +44,7 @@ class SessionManager {
logout() {
const url = "/api/session";
this.authorization = null;
return http_delete(url)
.then((data) => data.result);
}

View File

@ -4,7 +4,7 @@ import Path from "path";
import "./viewerpage.scss";
import "./error.scss";
import { Files } from "../model/";
import { Files, Chromecast } from "../model/";
import {
BreadCrumb, Bundle, NgIf, Loader, EventReceiver, LoggedInOnly, ErrorPage,
} from "../components/";
@ -137,12 +137,11 @@ export function ViewerPageComponent({ error, subscribe, unsubscribe, match, loca
useEffect(() => {
return () => {
if (!objectGet(window.chrome, ["cast", "isAvailable"])) {
return
}
cast.framework.CastContext.getInstance().endCurrentSession();
const context = Chromecast.context();
if (!context) return;
context.endCurrentSession();
};
}, [])
}, []);
return (
<div className="component_page_viewerpage">

View File

@ -1,9 +1,12 @@
import React, { useState, useEffect, useRef } from "react";
import WaveSurfer from "wavesurfer.js";
import filepath from "path";
import { MenuBar } from "./menubar";
import { NgIf, Icon } from "../../components/";
import { settings_get, settings_put } from "../../helpers/";
import { settings_get, settings_put, notify, getMimeType, basename } from "../../helpers/";
import { Chromecast } from "../../model/";
import { t } from "../../locales/";
import "./audioplayer.scss";
export function AudioPlayer({ filename, data }) {
@ -11,6 +14,7 @@ export function AudioPlayer({ filename, data }) {
const [isLoading, setIsLoading] = useState(true);
const [purcentLoading, setPurcentLoading] = useState(0);
const [volume, setVolume] = useState(settings_get("volume") === null ? 50 : settings_get("volume"));
const [isChromecast, setIsChromecast] = useState(false);
const [error, setError] = useState(null);
const wavesurfer = useRef(null);
@ -29,13 +33,14 @@ export function AudioPlayer({ filename, data }) {
let $currentTime = document.getElementById("currentTime");
let $totalDuration = document.getElementById("totalDuration");
wavesurfer.current.on("ready", () => {
setPurcentLoading(100);
setIsLoading(false);
wavesurfer.current.setVolume(volume / 100);
$totalDuration.innerHTML = formatTimecode(wavesurfer.current.getDuration());
});
wavesurfer.current.on("audioprocess", () => {
$currentTime.innerHTML = formatTimecode(wavesurfer.current.getCurrentTime());
})
});
wavesurfer.current.on("loading", (n) => {
setPurcentLoading(n);
});
@ -43,15 +48,113 @@ export function AudioPlayer({ filename, data }) {
setIsLoading(false);
setError(err);
});
wavesurfer.current.on("seek", (s) => {
const media = Chromecast.media();
if (!media) return;
const seekRequest = new chrome.cast.media.SeekRequest();
seekRequest.currentTime = parseInt(s*wavesurfer.current.getDuration());
media.seek(seekRequest);
});
return () => wavesurfer.current.destroy();
}, []);
useEffect(() => {
if(wavesurfer.current === null) return;
window.addEventListener("keypress", onKeyPressHandler);
return () => window.removeEventListener("keypress", onKeyPressHandler);
}, [isPlaying])
const chromecastSetup = (event) => {
switch (event.sessionState) {
case cast.framework.SessionState.SESSION_STARTING:
setIsChromecast(true);
setIsLoading(true);
break;
case cast.framework.SessionState.SESSION_START_FAILED:
notify.send(t("Cannot establish a connection"), "error");
setIsChromecast(false);
setIsLoading(false);
break;
case cast.framework.SessionState.SESSION_STARTED:
chromecastHandler()
break;
case cast.framework.SessionState.SESSION_ENDING:
wavesurfer.current.setMute(false);
setIsChromecast(false);
break;
}
};
const chromecastHandler = () => {
setIsLoading(true);
const link = Chromecast.createLink(data);
const media = new chrome.cast.media.MediaInfo(
link,
getMimeType(data),
);
media.metadata = new chrome.cast.media.MusicTrackMediaMetadata()
media.metadata.title = filename.substr(0, filename.lastIndexOf(filepath.extname(filename)));
media.metadata.subtitle = CONFIG.name;
media.metadata.albumName = CONFIG.name;
media.metadata.images = [
new chrome.cast.Image(origin + "/assets/icons/music.png"),
];
const session = Chromecast.session();
if (!session) return;
Chromecast.createRequest(media)
.then((req) => {
req.currentTime = parseInt(wavesurfer.current.getCurrentTime());
return session.loadMedia(req)
})
.then(() => {
setIsPlaying(true);
setIsLoading(false);
wavesurfer.current.play();
wavesurfer.current.setMute(true);
const media = Chromecast.media();
if (!media) return;
wavesurfer.current.seekTo(media.getEstimatedTime() / wavesurfer.current.getDuration());
media.addUpdateListener(chromecastAlive);
}).catch((err) => {
console.error(err);
notify.send(t("Cannot establish a connection"), "error");
setIsChromecast(false);
setIsLoading(false);
});
}
const chromecastAlive = (isAlive) => {
if (isAlive) return;
const session = Chromecast.session();
if (session) {
session.endSession();
wavesurfer.current.setMute(false);
}
};
useEffect(() => {
const context = Chromecast.context();
if (!context) return;
chromecastAlive(false);
document.getElementById("chromecast-target").append(document.createElement("google-cast-launcher"));
context.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
);
return () => {
context.removeEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
);
const media = Chromecast.media();
if (!media) return
media.removeUpdateListener(chromecastAlive);
chromecastAlive(false);
};
}, []);
const onKeyPressHandler = (e) => {
if(e.code !== "Space") {
return
@ -62,22 +165,33 @@ export function AudioPlayer({ filename, data }) {
const onPlay = (e) => {
e.preventDefault();
e.stopPropagation();
wavesurfer.current.play();
setIsPlaying(true);
if (wavesurfer.current) wavesurfer.current.play();
if (isChromecast) {
const media = Chromecast.media();
if (media) media.play();
}
};
const onPause = (e) => {
e.preventDefault();
e.stopPropagation();
wavesurfer.current.pause();
setIsPlaying(false);
if (wavesurfer.current) wavesurfer.current.pause();
if (isChromecast) {
const media = Chromecast.media();
if (media) media.pause();
}
};
const onVolumeChange = (e) => {
const v = Number(e.target.value);
settings_put("volume", v);
setVolume(v);
wavesurfer.current.setVolume(v / 100);
if (isChromecast) {
const session = Chromecast.session()
if (session) session.setVolume(v / 100);
} else wavesurfer.current.setVolume(v / 100);
};
const onVolumeClick = () => {
onVolumeChange({ target: { value: 0 }});
@ -95,13 +209,23 @@ export function AudioPlayer({ filename, data }) {
<NgIf cond={error === null}>
<div className="audioplayer_box">
<NgIf cond={isLoading}>
<div className="audioplayer_loader" style={{width: purcentLoading + "%"}}></div>
{
isChromecast ? (
<div className="chromecast_loader">
<Icon name="loading" />
</div>
) : (
<React.Fragment>
<div className="audioplayer_loader" style={{width: purcentLoading + "%"}}></div>
<span className="percent">{purcentLoading}%</span>
<Icon name="loading" />
</React.Fragment>
)
}
</NgIf>
<div id="waveform"></div>
<div className="audioplayer_control" style={{ opacity: isLoading? 0 : 1 }}>
<div className="buttons">
<div className="buttons no-select">
{
isPlaying ? (
<span onClick={onPause}>
@ -119,7 +243,7 @@ export function AudioPlayer({ filename, data }) {
<div className="timecode">
<span id="currentTime">00:00:00</span>
<span id="separator">/</span>
<span id="separator" className="no-select">/</span>
<span id="totalDuration">00:00:00</span>
</div>
</div>

View File

@ -90,16 +90,23 @@
top: 0;
left: 0;
}
.component_icon[alt="loading"] {
position: absolute;
margin: 50px 0px
}
.chromecast_loader .component_icon[alt="loading"] {
margin: 0;
right: 20px;
top: 20px;
width: 30px;
}
.percent {
position: absolute;
margin: 100px 0px;
width: 120px;
font-size: 1.4rem;
}
.component_icon[alt="loading"] {
position: absolute;
margin: 50px 0px
}
}
}
}

View File

@ -4,8 +4,8 @@ import ReactCSSTransitionGroup from "react-addons-css-transition-group";
import { MenuBar } from "./menubar";
import { Bundle, Icon, NgIf, Loader, EventEmitter, EventReceiver } from "../../components/";
import { alert, randomString, objectGet, notify, getMimeType, currentShare } from "../../helpers/";
import { Session } from "../../model/";
import { alert, randomString, notify, getMimeType, currentShare } from "../../helpers/";
import { Chromecast } from "../../model/";
import { Pager } from "./pager";
import { t } from "../../locales/";
import "./imageviewer.scss";
@ -67,25 +67,10 @@ export function ImageViewerComponent({ filename, data, path, subscribe, unsubscr
};
const chromecastHandler = (event) => {
const cSession = cast.framework.CastContext.getInstance().getCurrentSession()
if (!cSession) return;
const session = Chromecast.session();
if (!session) return;
const createLink = () => {
const shareID = currentShare();
const origin = location.origin;
if (shareID) {
const target = new URL(origin + data);
target.searchParams.append("share", shareID);
return Promise.resolve(target.toString());
}
return Session.currentUser().then(({ authorization }) => {
const target = new URL(origin + data);
target.searchParams.append("authorization", authorization);
return target.toString()
});
};
return createLink().then((link) => {
const link = Chromecast.createLink(data);
const media = new chrome.cast.media.MediaInfo(
link,
getMimeType(filename),
@ -93,19 +78,19 @@ export function ImageViewerComponent({ filename, data, path, subscribe, unsubscr
media.metadata = new chrome.cast.media.PhotoMediaMetadata();
media.metadata.title = filename;
media.metadata.images = [
new chrome.cast.Image(origin + "/assets/icons/photo.png"),
new chrome.cast.Image(Chromecast.origin() + "/assets/icons/photo.png"),
];
return cSession.loadMedia(new chrome.cast.media.LoadRequest(media));
}).catch((err) => {
notify.send(err && err.message, "error");
Chromecast.createRequest(media)
.then((req) => session.loadMedia(req))
.catch((err) => {
console.error(err)
notify.send(t("Cannot establish a connection"), "error");
});
};
useEffect(() => {
if (!objectGet(window.chrome, ["cast", "isAvailable"])) {
return;
}
const context = cast.framework.CastContext.getInstance();
const context = Chromecast.context();
if (!context) return;
document.getElementById("chromecast-target").append(document.createElement("google-cast-launcher"));
context.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
@ -158,6 +143,7 @@ export function ImageViewerComponent({ filename, data, path, subscribe, unsubscr
ref={$container}
className={
"component_image_container" +
(state.draggable ? "" : " component_image_no_pager") +
(document.webkitIsFullScreen || document.mozFullScreen ? " fullscreen" : "")
}
>

View File

@ -26,8 +26,10 @@
background: #525659;
overflow: hidden;
padding: 15px 10px 65px 10px;
&.component_image_no_pager { padding-bottom: 15px; }
@media screen and (max-height: 410px) {
padding: 5px 0px 40px 10px;
&.component_image_no_pager { padding-bottom: 5px; }
.component_pager .wrapper{
> span{padding: 2px 5px;}
padding: 5px 0;

View File

@ -34,7 +34,8 @@
max-height: 500px;
.vjs-control-bar{
background: rgba(255,255,255,0.2);
background: black;
background: linear-gradient(transparent 20%, #00000099);
}
.vjs-load-progress div{
background: var(--primary);