feature (chromecast): image viewer chromecast support

This commit is contained in:
Mickael Kerjean 2023-04-13 23:03:50 +10:00
parent 149fbd9980
commit bb7840f27e
13 changed files with 268 additions and 144 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -10,7 +10,8 @@ import "./assets/css/reset.scss";
(function() {
Promise.all([
setup_dom(), setup_translation(), setup_xdg_open(), setup_cache(), Config.refresh(),
setup_dom(), setup_translation(), setup_xdg_open(), setup_cache(),
Config.refresh().then(setup_chromecast),
]).then(() => {
const timeSinceBoot = new Date() - window.initTime;
if (window.CONFIG.name) document.title = window.CONFIG.name;
@ -140,3 +141,26 @@ function setup_translation() {
window.LNG = d;
});
}
function setup_chromecast() {
if (!CONFIG.enable_chromecast) {
return Promise.resolve();
} else if (typeof window.chrome === undefined) {
return Promise.resolve();
} 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)
});
}

View file

@ -8,7 +8,7 @@ import { Files } from "../model/";
import {
BreadCrumb, Bundle, NgIf, Loader, EventReceiver, LoggedInOnly, ErrorPage,
} from "../components/";
import { opener, notify } from "../helpers/";
import { opener, notify, objectGet } from "../helpers/";
import { FileDownloader, ImageViewer, PDFViewer, FormViewer } from "./viewerpage/";
const VideoPlayer = (props) => (
@ -135,6 +135,15 @@ export function ViewerPageComponent({ error, subscribe, unsubscribe, match, loca
return history.listen(() => {})
}, [path]);
useEffect(() => {
return () => {
if (!objectGet(window.chrome, ["cast", "isAvailable"])) {
return
}
cast.framework.CastContext.getInstance().endCurrentSession();
};
}, [])
return (
<div className="component_page_viewerpage">
<BreadCrumb needSaving={state.needSaving} className="breadcrumb" path={path} />

View file

@ -1,10 +1,11 @@
import React, { createRef } from "react";
import path from "path";
import React, { useEffect, useReducer, useRef } from "react";
import filepath from "path";
import ReactCSSTransitionGroup from "react-addons-css-transition-group";
import { MenuBar } from "./menubar";
import { Bundle, Icon, NgIf, Loader, EventEmitter, EventReceiver } from "../../components/";
import { alert, randomString } from "../../helpers/";
import { alert, randomString, objectGet, notify, getMimeType, currentShare } from "../../helpers/";
import { Session } from "../../model/";
import { Pager } from "./pager";
import { t } from "../../locales/";
import "./imageviewer.scss";
@ -21,127 +22,177 @@ const LargeExif = (props) => (
</Bundle>
);
export class ImageViewerComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
preload: null,
_: null,
show_exif: false,
is_loaded: false,
draggable: true,
};
this.shortcut= (e) => {
if (e.keyCode === 27) this.setState({ show_exif: false });
else if (e.keyCode === 73) this.setState({ show_exif: !this.state.show_exif });
};
this.refresh = () => this.setState({ "_": randomString() });
this.$container = createRef();
}
componentDidMount() {
this.props.subscribe("media::preload", (preload) => {
this.setState({ preload: preload });
export function ImageViewerComponent({ filename, data, path, subscribe, unsubscribe }) {
const [state, setState] = useReducer((s, a) => {
return { ...s, ...a };
}, {
preload: null,
refresh: 0,
show_exif: false,
is_loaded: false,
draggable: false,
});
const $container = useRef();
const refresh = () => setState({ refresh: state.refresh + 1 });
const shortcut = (e) => {
if (e.keyCode === 27) setState({ show_exif: false });
else if (e.keyCode === 73) setState({ show_exif: !state.show_exif });
};
useEffect(() => {
setState({ is_loaded: false });
subscribe("media::preload", (preload) => {
setState({ preload: preload });
});
document.addEventListener("webkitfullscreenchange", this.refresh);
document.addEventListener("mozfullscreenchange", this.refresh);
document.addEventListener("fullscreenchange", this.refresh);
document.addEventListener("keydown", this.shortcut);
}
document.addEventListener("webkitfullscreenchange", refresh);
document.addEventListener("mozfullscreenchange", refresh);
document.addEventListener("fullscreenchange", refresh);
document.addEventListener("keydown", shortcut);
componentWillUnmount() {
this.props.unsubscribe("media::preload");
document.removeEventListener("webkitfullscreenchange", this.refresh);
document.removeEventListener("mozfullscreenchange", this.refresh);
document.removeEventListener("fullscreenchange", this.refresh);
document.removeEventListener("keydown", this.shortcut);
}
return () => {
unsubscribe("media::preload");
document.removeEventListener("webkitfullscreenchange", refresh);
document.removeEventListener("mozfullscreenchange", refresh);
document.removeEventListener("fullscreenchange", refresh);
document.removeEventListener("keydown", shortcut);
};
UNSAFE_componentWillReceiveProps(props) {
if (props.data !== this.props.data) {
this.setState({ is_loaded: false });
}, [data]);
const chromecastSetup = (event) => {
switch (event.sessionState) {
case cast.framework.SessionState.SESSION_STARTED:
chromecastHandler();
break;
}
}
};
toggleExif() {
const chromecastHandler = (event) => {
const cSession = cast.framework.CastContext.getInstance().getCurrentSession()
if (!cSession) 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 media = new chrome.cast.media.MediaInfo(
link,
getMimeType(filename),
);
media.metadata = new chrome.cast.media.PhotoMediaMetadata();
media.metadata.title = filename;
media.metadata.images = [
new chrome.cast.Image(origin + "/assets/icons/photo.png"),
];
return cSession.loadMedia(new chrome.cast.media.LoadRequest(media));
}).catch((err) => {
notify.send(err && err.message, "error");
});
};
useEffect(() => {
if (!objectGet(window.chrome, ["cast", "isAvailable"])) {
return;
}
const context = cast.framework.CastContext.getInstance();
document.getElementById("chromecast-target").append(document.createElement("google-cast-launcher"));
context.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
);
chromecastHandler();
return () => {
context.removeEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
);
};
}, []);
const hasExif = (fname) => {
const ext = filepath.extname(fname).toLowerCase().substring(1);
return ["jpg", "jpeg", "tiff", "tif"].indexOf(ext) !== -1;
};
const toggleExif = () => {
if (window.innerWidth < 580) {
alert.now(<SmallExif />);
} else {
this.setState({
show_exif: !this.state.show_exif,
setState({
show_exif: !state.show_exif,
});
}
}
requestFullScreen() {
};
const requestFullScreen = () => {
if ("webkitRequestFullscreen" in document.body) {
this.$container.current.webkitRequestFullscreen();
$container.current.webkitRequestFullscreen();
} else if ("mozRequestFullScreen" in document.body) {
this.$container.current.mozRequestFullScreen();
$container.current.mozRequestFullScreen();
}
}
};
render() {
const hasExif = (filename) => {
const ext = path.extname(filename).toLowerCase().substring(1);
return ["jpg", "jpeg", "tiff", "tif"].indexOf(ext) !== -1;
};
return (
<div className="component_imageviewer">
<MenuBar title={this.props.filename} download={this.props.data}>
<NgIf type="inline" cond={hasExif(this.props.filename)}>
<Icon name="info" onClick={this.toggleExif.bind(this)} />
</NgIf>
<NgIf
type="inline"
cond={("webkitRequestFullscreen" in document.body) ||
("mozRequestFullScreen" in document.body)}>
<Icon name="fullscreen" onClick={this.requestFullScreen.bind(this)} />
</NgIf>
</MenuBar>
<div
ref={this.$container}
className={
"component_image_container " +
(document.webkitIsFullScreen || document.mozFullScreen ? "fullscreen" : "")
}
>
<div className="images_wrapper">
<ImageFancy
draggable={this.state.draggable}
onLoad={() => this.setState({ is_loaded: true })}
url={this.props.data} />
</div>
<div className={"images_aside scroll-y"+(this.state.show_exif ? " open": "")}>
<div className="header">
<div>{ t("Info") }</div>
<div style={{ flex: 1 }}>
<Icon name="close" onClick={this.toggleExif.bind(this)} />
</div>
</div>
<div className="content">
<LargeExif
data={this.props.data}
show={this.state.show_exif}
ready={this.state.is_loaded} />
</div>
</div>
<Pager
type={["image"]}
path={this.props.path}
pageChange={(files) =>
this.setState({ draggable: files.length > 1 ? true : false })}
next={(e) => this.setState({ preload: e })} />
</div>
<NgIf cond={this.state.is_loaded}>
<Img style={{ display: "none" }} src={this.state.preload}/>
return (
<div className="component_imageviewer">
<MenuBar title={filename} download={data}>
<NgIf type="inline" cond={hasExif(filename)}>
<Icon name="info" onClick={toggleExif} />
</NgIf>
<NgIf
type="inline"
cond={("webkitRequestFullscreen" in document.body) ||
("mozRequestFullScreen" in document.body)}>
<Icon name="fullscreen" onClick={requestFullScreen} />
</NgIf>
</MenuBar>
<div
ref={$container}
className={
"component_image_container " +
(document.webkitIsFullScreen || document.mozFullScreen ? "fullscreen" : "")
}
>
<div className="images_wrapper">
<ImageFancy
draggable={state.draggable}
onLoad={() => setState({ is_loaded: true })}
url={data} />
</div>
<div className={"images_aside scroll-y"+(state.show_exif ? " open": "")}>
<div className="header">
<div>{ t("Info") }</div>
<div style={{ flex: 1 }}>
<Icon name="close" onClick={toggleExif} />
</div>
</div>
<div className="content">
<LargeExif
data={data}
show={state.show_exif}
ready={state.is_loaded} />
</div>
</div>
<Pager
type={["image"]}
path={path}
pageChange={(files) => setState({ draggable: files.length > 1 ? true : false })}
next={(e) => setState({ preload: e })} />
</div>
);
}
<NgIf cond={state.is_loaded}>
<Img style={{ display: "none" }} src={state.preload}/>
</NgIf>
</div>
);
}
export const ImageViewer = EventReceiver(EventEmitter(ImageViewerComponent));
@ -180,6 +231,7 @@ class ImageFancyComponent extends React.Component {
}
imageDragStart(e) {
if(!this.props.draggable) return;
const t = new Date();
if (e.touches) {
this.setState({

View file

@ -7,10 +7,6 @@
flex-direction: column;
}
.component_menubar{
.specific{padding-right: 0;}
}
.component_image_container{
&.fullscreen{
background: var(--dark);

View file

@ -16,6 +16,7 @@ export const MenuBar = (props) => {
<div className="titlebar" style={{ letterSpacing: "0.3px" }}>{props.title}</div>
<div className="action-item no-select">
<span className="specific">
<span id="chromecast-target"></span>
{props.children}
</span>
{

View file

@ -3,6 +3,22 @@
background: linear-gradient(#585b5d 94%, var(--dark));
}
// chromecast
:root {
--disconnected-color: #F2F2F2;
--connected-color: var(--primary);
}
google-cast-launcher{
display: inline-block;
height: 23px;
width: 23px;
cursor: pointer;
padding: 0 5px;
position: relative;
top: 1px;
cursor: pointer;
}
.component_menubar{
background: var(--dark);
color: #f1f1f1;
@ -26,7 +42,6 @@
}
.action-item{
.specific{ padding-right: 10px; }
.component_icon{
height: 19px;
width: 19px;

View file

@ -5,9 +5,10 @@ import (
)
type App struct {
Backend IBackend
Body map[string]interface{}
Session map[string]string
Share Share
Context context.Context
Backend IBackend
Body map[string]interface{}
Session map[string]string
Share Share
Context context.Context
Authorization string
}

View file

@ -103,6 +103,7 @@ func NewConfiguration() Configuration {
Title: "protection",
Elmnts: []FormElement{
FormElement{Name: "iframe", Type: "text", Default: "", Description: "list of domains who can use the application from an iframe. eg: https://www.filestash.app http://example.com"},
FormElement{Name: "enable_chromecast", Type: "boolean", Default: true, Description: "Enable users to stream content on a chromecast device. This feature requires the browser to access google's server to download the chromecast SDK."},
},
},
},
@ -362,6 +363,7 @@ func (this *Configuration) Export() interface{} {
FilePageDefaultView string `json:"default_view"`
AuthMiddleware interface{} `json:"auth"`
Thumbnailer []string `json:"thumbnailer"`
EnableChromecast bool `json:"enable_chromecast"`
}{
Editor: this.Get("general.editor").String(),
ForkButton: this.Get("general.fork_button").Bool(),
@ -394,6 +396,7 @@ func (this *Configuration) Export() interface{} {
}
return tArray
}(),
EnableChromecast: this.Get("features.protection.enable_chromecast").Bool(),
}
}

View file

@ -18,9 +18,10 @@ import (
)
type Session struct {
Home *string `json:"home,omitempty"`
IsAuth bool `json:"is_authenticated"`
Backend string `json:"backendID"`
Home *string `json:"home,omitempty"`
IsAuth bool `json:"is_authenticated"`
Backend string `json:"backendID"`
Authorization string `json:"authorization,omitempty"`
}
func SessionGet(ctx *App, res http.ResponseWriter, req *http.Request) {
@ -41,6 +42,9 @@ func SessionGet(ctx *App, res http.ResponseWriter, req *http.Request) {
r.IsAuth = true
r.Home = NewString(home)
r.Backend = GenerateID(ctx)
if ctx.Share.Id == "" && Config.Get("features.protection.enable_chromecast").Bool() {
r.Authorization = ctx.Authorization
}
SendSuccessResult(res, r)
}

View file

@ -47,6 +47,9 @@ func IndexHeaders(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx *A
cspHeader += "font-src 'self' data: blob:; "
cspHeader += "manifest-src 'self'; "
cspHeader += "script-src 'self' 'sha256-JNAde5CZQqXtYRLUk8CGgyJXo6C7Zs1lXPPClLM1YM4=' 'sha256-9/gQeQaAmVkFStl6tfCbHXn8mr6PgtxlH+hEp685lzY=' 'sha256-ER9LZCe8unYk8AJJ2qopE+rFh7OUv8QG5q3h6jZeoSk='; "
if Config.Get("features.protection.enable_chromecast").Bool() {
cspHeader += "script-src-elem 'self' 'unsafe-inline' https://www.gstatic.com http://www.gstatic.com; "
}
cspHeader += "img-src 'self' blob: data: https://maps.wikimedia.org; "
cspHeader += "connect-src 'self'; "
cspHeader += "object-src 'self'; "

View file

@ -53,10 +53,12 @@ func AdminOnly(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx *App,
func SessionStart(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx *App, res http.ResponseWriter, req *http.Request) {
return func(ctx *App, res http.ResponseWriter, req *http.Request) {
var err error
if ctx.Share, err = _extractShare(req); err != nil {
SendErrorResult(res, err)
return
}
ctx.Authorization = _extractAuthorization(req)
if ctx.Session, err = _extractSession(req, ctx); err != nil {
SendErrorResult(res, err)
return
@ -76,6 +78,7 @@ func SessionStart(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx *A
func SessionTry(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx *App, res http.ResponseWriter, req *http.Request) {
return func(ctx *App, res http.ResponseWriter, req *http.Request) {
ctx.Share, _ = _extractShare(req)
ctx.Authorization = _extractAuthorization(req)
ctx.Session, _ = _extractSession(req, ctx)
ctx.Backend, _ = _extractBackend(req, ctx)
fn(ctx, res, req)
@ -163,6 +166,33 @@ func CanManageShare(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx
}
}
func _extractAuthorization(req *http.Request) (token string) {
// strategy 1: split cookie
index := 0
for {
cookie, err := req.Cookie(CookieName(index))
if err != nil {
break
}
index++
token += cookie.Value
}
// strategy 2: Authorization header
if token == "" {
authHeader := req.Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
}
}
// strategy 3: Authorization query param
if token == "" {
if auth := req.URL.Query().Get("authorization"); auth != "" {
token = auth
}
}
return token
}
func _extractShareId(req *http.Request) string {
share := req.URL.Query().Get("share")
if share != "" {
@ -236,9 +266,11 @@ func _extractShare(req *http.Request) (Share, error) {
}
func _extractSession(req *http.Request, ctx *App) (map[string]string, error) {
var str string
var err error
var session map[string]string = make(map[string]string)
var (
str string
err error
session map[string]string = make(map[string]string)
)
if ctx.Share.Id != "" { // Shared link
str, err = DecryptString(SECRET_KEY_DERIVATE_FOR_USER, ctx.Share.Auth)
@ -265,26 +297,10 @@ func _extractSession(req *http.Request, ctx *App) (map[string]string, error) {
return session, err
}
str = ""
index := 0
for {
cookie, err := req.Cookie(CookieName(index))
if err != nil {
break
}
index++
str += cookie.Value
}
if str == "" {
authHeader := req.Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
str = strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
}
}
if str == "" {
if ctx.Authorization == "" {
return session, nil
}
str, err = DecryptString(SECRET_KEY_DERIVATE_FOR_USER, str)
str, err = DecryptString(SECRET_KEY_DERIVATE_FOR_USER, ctx.Authorization)
if err != nil {
// This typically happen when changing the secret key
Log.Debug("middleware::session decrypt error '%s'", err.Error())