Replace JW Player with video.js (#2100)

* Replace JW Player with video.js
* Move HLS stream to bottom of list

HLS doesn't work very well on non-ios devices.

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
InfiniteTF
2022-03-28 22:17:19 +02:00
committed by GitHub
parent 02ee791796
commit f3355f3da8
34 changed files with 1576 additions and 1986 deletions

View File

@@ -70,4 +70,10 @@ fragment SceneData on Scene {
endpoint endpoint
stash_id stash_id
} }
sceneStreams {
url
mime_type
label
}
} }

View File

@@ -4,7 +4,7 @@ query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene
filesize filesize
duration duration
scenes { scenes {
...SlimSceneData ...SceneData
} }
} }
} }
@@ -30,7 +30,9 @@ query FindScene($id: ID!, $checksum: String) {
findScene(id: $id, checksum: $checksum) { findScene(id: $id, checksum: $checksum) {
...SceneData ...SceneData
} }
}
query FindSceneMarkerTags($id: ID!) {
sceneMarkerTags(scene_id: $id) { sceneMarkerTags(scene_id: $id) {
tag { tag {
id id
@@ -66,9 +68,11 @@ query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!)
} }
query SceneStreams($id: ID!) { query SceneStreams($id: ID!) {
sceneStreams(id: $id) { findScene(id: $id) {
sceneStreams {
url url
mime_type mime_type
label label
} }
} }
}

View File

@@ -55,6 +55,9 @@ type Scene {
tags: [Tag!]! tags: [Tag!]!
performers: [Performer!]! performers: [Performer!]!
stash_ids: [StashID!]! stash_ids: [StashID!]!
"""Return valid stream paths"""
sceneStreams: [SceneStreamEndpoint!]!
} }
input SceneMovieInput { input SceneMovieInput {

View File

@@ -5,7 +5,7 @@ import (
"time" "time"
"github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
@@ -87,8 +87,9 @@ func (r *sceneResolver) File(ctx context.Context, obj *models.Scene) (*models.Sc
func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.ScenePathsType, error) { func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.ScenePathsType, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
builder.APIKey = config.GetInstance().GetAPIKey() builder.APIKey = config.GetAPIKey()
screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt.Timestamp) screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt.Timestamp)
previewPath := builder.GetStreamPreviewURL() previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL() streamPath := builder.GetStreamURL()
@@ -237,3 +238,12 @@ func (r *sceneResolver) UpdatedAt(ctx context.Context, obj *models.Scene) (*time
func (r *sceneResolver) FileModTime(ctx context.Context, obj *models.Scene) (*time.Time, error) { func (r *sceneResolver) FileModTime(ctx context.Context, obj *models.Scene) (*time.Time, error) {
return &obj.FileModTime.Timestamp, nil return &obj.FileModTime.Timestamp, nil
} }
func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]*models.SceneStreamEndpoint, error) {
config := manager.GetInstance().Config
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(), config.GetMaxStreamingTranscodeSize())
}

View File

@@ -100,13 +100,6 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami
}) })
} }
hls := models.SceneStreamEndpoint{
URL: directStreamURL + ".m3u8",
MimeType: &mimeHLS,
Label: &labelHLS,
}
ret = append(ret, &hls)
// WEBM quality transcoding options // WEBM quality transcoding options
// Note: These have the wrong mime type intentionally to allow jwplayer to selection between mp4/webm // Note: These have the wrong mime type intentionally to allow jwplayer to selection between mp4/webm
webmLabelFourK := "WEBM 4K (2160p)" // "FOUR_K" webmLabelFourK := "WEBM 4K (2160p)" // "FOUR_K"
@@ -166,6 +159,13 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami
ret = append(ret, defaultStreams...) ret = append(ret, defaultStreams...)
hls := models.SceneStreamEndpoint{
URL: directStreamURL + ".m3u8",
MimeType: &mimeHLS,
Label: &labelHLS,
}
ret = append(ret, &hls)
return ret, nil return ret, nil
} }

View File

@@ -59,7 +59,6 @@
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-intl": "^5.10.16", "react-intl": "^5.10.16",
"react-jw-player": "1.19.1",
"react-markdown": "^7.1.0", "react-markdown": "^7.1.0",
"react-router-bootstrap": "^0.25.0", "react-router-bootstrap": "^0.25.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
@@ -71,7 +70,11 @@
"subscriptions-transport-ws": "^0.9.18", "subscriptions-transport-ws": "^0.9.18",
"thehandy": "^0.2.7", "thehandy": "^0.2.7",
"universal-cookie": "^4.0.4", "universal-cookie": "^4.0.4",
"vite": "^2.6.11", "video.js": "^7.17.0",
"videojs-landscape-fullscreen": "^11.33.0",
"videojs-seek-buttons": "^2.2.0",
"videojs-vtt-thumbnails-freetube": "^0.0.15",
"vite": "^2.7.1",
"vite-plugin-compression": "^0.3.5", "vite-plugin-compression": "^0.3.5",
"vite-tsconfig-paths": "^3.3.17", "vite-tsconfig-paths": "^3.3.17",
"ws": "^7.4.6", "ws": "^7.4.6",
@@ -96,6 +99,8 @@
"@types/react-router-bootstrap": "^0.24.5", "@types/react-router-bootstrap": "^0.24.5",
"@types/react-router-dom": "5.1.7", "@types/react-router-dom": "5.1.7",
"@types/react-router-hash-link": "^1.2.1", "@types/react-router-hash-link": "^1.2.1",
"@types/video.js": "^7.3.28",
"@types/videojs-seek-buttons": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0", "@typescript-eslint/parser": "^4.33.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,92 +0,0 @@
JW Player version 8.11.5
Copyright (c) 2020, JW Player, All Rights Reserved
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
https://www.jwplayer.com/tos/
This product includes portions of other software. For the full text of licenses, see below:
JW Player Third Party Software Notices and/or Additional Terms and Conditions
**************************************************************************************************
The following software is used under Apache License 2.0
**************************************************************************************************
vtt.js v0.13.0
Copyright (c) 2020 Mozilla (http://mozilla.org)
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * *
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**************************************************************************************************
The following software is used under MIT license
**************************************************************************************************
Underscore.js v1.6.0
Copyright (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative
https://github.com/jashkenas/underscore/blob/1.6.0/LICENSE
Backbone backbone.events.js v1.1.2
Copyright (c) 2010-2014 Jeremy Ashkenas, DocumentCloud
https://github.com/jashkenas/backbone/blob/1.1.2/LICENSE
Promise Polyfill v7.1.1
Copyright (c) 2014 Taylor Hakes and Forbes Lindesay
https://github.com/taylorhakes/promise-polyfill/blob/v7.1.1/LICENSE
can-autoplay.js v3.0.0
Copyright (c) 2017 video-dev
https://github.com/video-dev/can-autoplay/blob/v3.0.0/LICENSE
* * *
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**************************************************************************************************
The following software is used under W3C license
**************************************************************************************************
Intersection Observer v0.5.0
Copyright (c) 2016 Google Inc. (http://google.com)
https://github.com/w3c/IntersectionObserver/blob/v0.5.0/LICENSE.md
* * *
W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE
Status: This license takes effect 13 May, 2015.
This work is being provided by the copyright holders under the following license.
License
By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions.
Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications:
The full text of this NOTICE in a location viewable to users of the redistributed or derivative work.
Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included.
Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)."
Disclaimers
THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT.
The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,95 +0,0 @@
/*!
JW Player version 8.11.5
Copyright (c) 2020, JW Player, All Rights Reserved
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
https://www.jwplayer.com/tos/
This product includes portions of other software. For the full text of licenses, see below:
JW Player Third Party Software Notices and/or Additional Terms and Conditions
**************************************************************************************************
The following software is used under Apache License 2.0
**************************************************************************************************
vtt.js v0.13.0
Copyright (c) 2020 Mozilla (http://mozilla.org)
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * *
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**************************************************************************************************
The following software is used under MIT license
**************************************************************************************************
Underscore.js v1.6.0
Copyright (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative
https://github.com/jashkenas/underscore/blob/1.6.0/LICENSE
Backbone backbone.events.js v1.1.2
Copyright (c) 2010-2014 Jeremy Ashkenas, DocumentCloud
https://github.com/jashkenas/backbone/blob/1.1.2/LICENSE
Promise Polyfill v7.1.1
Copyright (c) 2014 Taylor Hakes and Forbes Lindesay
https://github.com/taylorhakes/promise-polyfill/blob/v7.1.1/LICENSE
can-autoplay.js v3.0.0
Copyright (c) 2017 video-dev
https://github.com/video-dev/can-autoplay/blob/v3.0.0/LICENSE
* * *
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**************************************************************************************************
The following software is used under W3C license
**************************************************************************************************
Intersection Observer v0.5.0
Copyright (c) 2016 Google Inc. (http://google.com)
https://github.com/w3c/IntersectionObserver/blob/v0.5.0/LICENSE.md
* * *
W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE
Status: This license takes effect 13 May, 2015.
This work is being provided by the copyright holders under the following license.
License
By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions.
Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications:
The full text of this NOTICE in a location viewable to users of the redistributed or derivative work.
Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included.
Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)."
Disclaimers
THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT.
The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders.
*/
(window.webpackJsonpjwplayer=window.webpackJsonpjwplayer||[]).push([[10],{97:function(t,e,r){"use strict";r.r(e);var n=r(42),i=r(67),s=/^(\d+):(\d{2})(:\d{2})?\.(\d{3})/,a=/^-?\d+$/,u=/\r\n|\n/,o=/^NOTE($|[ \t])/,c=/^[^\sa-zA-Z-]+/,l=/:/,f=/\s/,h=/^\s+/,g=/-->/,d=/^WEBVTT([ \t].*)?$/,p=function(t,e){this.window=t,this.state="INITIAL",this.buffer="",this.decoder=e||new b,this.regionList=[],this.maxCueBatch=1e3};function b(){return{decode:function(t){if(!t)return"";if("string"!=typeof t)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(t))}}}function v(){this.values=Object.create(null)}v.prototype={set:function(t,e){this.get(t)||""===e||(this.values[t]=e)},get:function(t,e,r){return r?this.has(t)?this.values[t]:e[r]:this.has(t)?this.values[t]:e},has:function(t){return t in this.values},alt:function(t,e,r){for(var n=0;n<r.length;++n)if(e===r[n]){this.set(t,e);break}},integer:function(t,e){a.test(e)&&this.set(t,parseInt(e,10))},percent:function(t,e){return(e=parseFloat(e))>=0&&e<=100&&(this.set(t,e),!0)}};var E=new i.a(0,0,0),w="middle"===E.align?"middle":"center";function T(t,e,r){var n=t;function i(){var e=function(t){function e(t,e,r,n){return 3600*(0|t)+60*(0|e)+(0|r)+(0|n)/1e3}var r=t.match(s);return r?r[3]?e(r[1],r[2],r[3].replace(":",""),r[4]):r[1]>59?e(r[1],r[2],0,r[4]):e(0,r[1],r[2],r[4]):null}(t);if(null===e)throw new Error("Malformed timestamp: "+n);return t=t.replace(c,""),e}function a(){t=t.replace(h,"")}if(a(),e.startTime=i(),a(),"--\x3e"!==t.substr(0,3))throw new Error("Malformed time stamp (time stamps must be separated by '--\x3e'): "+n);t=t.substr(3),a(),e.endTime=i(),a(),function(t,e){var n=new v;!function(t,e,r,n){for(var i=n?t.split(n):[t],s=0;s<=i.length;s+=1)if("string"==typeof i[s]){var a=i[s].split(r);if(2===a.length)e(a[0],a[1])}}(t,(function(t,e){switch(t){case"region":for(var i=r.length-1;i>=0;i--)if(r[i].id===e){n.set(t,r[i].region);break}break;case"vertical":n.alt(t,e,["rl","lr"]);break;case"line":var s=e.split(","),a=s[0];n.integer(t,a),n.percent(t,a)&&n.set("snapToLines",!1),n.alt(t,a,["auto"]),2===s.length&&n.alt("lineAlign",s[1],["start",w,"end"]);break;case"position":var u=e.split(",");n.percent(t,u[0]),2===u.length&&n.alt("positionAlign",u[1],["start",w,"end","line-left","line-right","auto"]);break;case"size":n.percent(t,e);break;case"align":n.alt(t,e,["start",w,"end","left","right"])}}),l,f),e.region=n.get("region",null),e.vertical=n.get("vertical","");var i=n.get("line","auto");"auto"===i&&-1===E.line&&(i=-1),e.line=i,e.lineAlign=n.get("lineAlign","start"),e.snapToLines=n.get("snapToLines",!0),e.size=n.get("size",100),e.align=n.get("align",w);var s=n.get("position","auto");"auto"===s&&50===E.position&&(s="start"===e.align||"left"===e.align?0:"end"===e.align||"right"===e.align?100:50),e.position=s}(t,e)}p.prototype={parse:function(t,e){var r,s=this;function a(){for(var t=s.buffer,e=0;e<t.length&&"\r"!==t[e]&&"\n"!==t[e];)++e;var r=t.substr(0,e);return"\r"===t[e]&&++e,"\n"===t[e]&&++e,s.buffer=t.substr(e),r}function c(){"CUETEXT"===s.state&&s.cue&&s.oncue&&s.oncue(s.cue),s.cue=null,s.state="INITIAL"===s.state?"BADWEBVTT":"BADCUE"}t&&(s.buffer+=s.decoder.decode(t,{stream:!0}));try{if("INITIAL"===s.state){if(!u.test(s.buffer))return this;var f=(r=a()).match(d);if(!f||!f[0])throw new Error("Malformed WebVTT signature.");s.state="HEADER"}}catch(t){return c(),this}var h=!1,p=0;!function t(){try{for(;s.buffer&&p<=s.maxCueBatch;){if(!u.test(s.buffer))return s.flush(),this;switch(h?h=!1:r=a(),s.state){case"HEADER":l.test(r)||r||(s.state="ID");break;case"NOTE":r||(s.state="ID");break;case"ID":if(o.test(r)){s.state="NOTE";break}if(!r)break;if(s.cue=new i.a(0,0,""),s.state="CUE",!g.test(r)){s.cue.id=r;break}case"CUE":try{T(r,s.cue,s.regionList)}catch(t){s.cue=null,s.state="BADCUE";break}s.state="CUETEXT";break;case"CUETEXT":var f=g.test(r);if(!r||f&&(h=!0)){s.oncue&&(p+=1,s.oncue(s.cue)),s.cue=null,s.state="ID";break}s.cue.text&&(s.cue.text+="\n"),s.cue.text+=r;break;case"BADCUE":r||(s.state="ID")}}if(p=0,s.buffer)Object(n.b)(t);else if(!e)return s.flush(),this}catch(t){return c(),this}}()},flush:function(){try{if(this.buffer+=this.decoder.decode(),(this.cue||"HEADER"===this.state)&&(this.buffer+="\n\n",this.parse(void 0,!0)),"INITIAL"===this.state)throw new Error("Malformed WebVTT signature.")}catch(t){throw t}return this.onflush&&this.onflush(),this}},e.default=p}}]);

View File

@@ -2,6 +2,7 @@
* Add python location in System Settings for script scrapers and plugins. ([#2409](https://github.com/stashapp/stash/pull/2409)) * Add python location in System Settings for script scrapers and plugins. ([#2409](https://github.com/stashapp/stash/pull/2409))
### 🎨 Improvements ### 🎨 Improvements
* Changed video player to videojs. ([#2100](https://github.com/stashapp/stash/pull/2100))
* Maintain lightbox settings and add lightbox settings to Interface settings page. ([#2406](https://github.com/stashapp/stash/pull/2406)) * Maintain lightbox settings and add lightbox settings to Interface settings page. ([#2406](https://github.com/stashapp/stash/pull/2406))
* Image lightbox now transitions to next/previous image when scrolling in pan-Y mode. ([#2403](https://github.com/stashapp/stash/pull/2403)) * Image lightbox now transitions to next/previous image when scrolling in pan-Y mode. ([#2403](https://github.com/stashapp/stash/pull/2403))
* Allow customisation of UI theme color using `theme_color` property in `config.yml` ([#2365](https://github.com/stashapp/stash/pull/2365)) * Allow customisation of UI theme color using `theme_color` property in `config.yml` ([#2365](https://github.com/stashapp/stash/pull/2365))

View File

@@ -0,0 +1,119 @@
/* eslint-disable @typescript-eslint/naming-convention */
import VideoJS, { VideoJsPlayer } from "video.js";
const Button = VideoJS.getComponent("Button");
interface ControlOptions extends VideoJS.ComponentOptions {
direction: "forward" | "back";
parent: SkipButtonPlugin;
}
/**
* A video.js plugin.
*
* In the plugin function, the value of `this` is a video.js `Player`
* instance. You cannot rely on the player being in a "ready" state here,
* depending on how the plugin is invoked. This may or may not be important
* to you; if not, remove the wait for "ready"!
*
* @function skipButtons
* @param {Object} [options={}]
* An object of options left to the plugin author to define.
*/
class SkipButtonPlugin extends VideoJS.getPlugin("plugin") {
onNext?: () => void | undefined;
onPrevious?: () => void | undefined;
constructor(player: VideoJsPlayer) {
super(player);
player.ready(() => {
this.ready();
});
}
public setForwardHandler(handler?: () => void) {
this.onNext = handler;
if (handler !== undefined) this.player.addClass("vjs-skip-buttons-next");
else this.player.removeClass("vjs-skip-buttons-next");
}
public setBackwardHandler(handler?: () => void) {
this.onPrevious = handler;
if (handler !== undefined) this.player.addClass("vjs-skip-buttons-prev");
else this.player.removeClass("vjs-skip-buttons-prev");
}
handleForward() {
this.onNext?.();
}
handleBackward() {
this.onPrevious?.();
}
ready() {
this.player.addClass("vjs-skip-buttons");
this.player.controlBar.addChild(
"skipButton",
{
direction: "forward",
parent: this,
},
1
);
this.player.controlBar.addChild(
"skipButton",
{
direction: "back",
parent: this,
},
0
);
}
}
class SkipButton extends Button {
private parentPlugin: SkipButtonPlugin;
private direction: "forward" | "back";
constructor(player: VideoJsPlayer, options: ControlOptions) {
super(player, options);
this.parentPlugin = options.parent;
this.direction = options.direction;
if (options.direction === "forward") {
this.controlText(this.localize("Skip to next video"));
this.addClass(`vjs-icon-next-item`);
} else if (options.direction === "back") {
this.controlText(this.localize("Skip to previous video"));
this.addClass(`vjs-icon-previous-item`);
}
}
/**
* Return button class names
*/
buildCSSClass() {
return `vjs-skip-button ${super.buildCSSClass()}`;
}
/**
* Seek with the button's configured offset
*/
handleClick() {
if (this.direction === "forward") this.parentPlugin.handleForward();
else this.parentPlugin.handleBackward();
}
}
VideoJS.registerComponent("SkipButton", SkipButton);
VideoJS.registerPlugin("skipButtons", SkipButtonPlugin);
declare module "video.js" {
interface VideoJsPlayer {
skipButtons: () => void | SkipButtonPlugin;
}
}
export default SkipButtonPlugin;

View File

@@ -1,447 +1,344 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react"; import React, { useContext, useEffect, useRef, useState } from "react";
import ReactJWPlayer from "react-jw-player"; import VideoJS, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js";
import "videojs-vtt-thumbnails-freetube";
import "videojs-seek-buttons";
import "videojs-landscape-fullscreen";
import "./live";
import "./PlaylistButtons";
import cx from "classnames";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { JWUtils, ScreenUtils } from "src/utils";
import { ConfigurationContext } from "src/hooks/Config";
import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
import { Interactive } from "../../utils/interactive"; import { ConfigurationContext } from "src/hooks/Config";
import { Interactive } from "src/utils/interactive";
/* export const VIDEO_PLAYER_ID = "VideoJsPlayer";
fast-forward svg derived from https://github.com/jwplayer/jwplayer/blob/master/src/assets/SVG/rewind-10.svg
Flipped horizontally, then flipped '10' numerals horizontally.
Creative Common License: https://github.com/jwplayer/jwplayer/blob/master/LICENSE
*/
const ffSVG = `
<svg xmlns="http://www.w3.org/2000/svg" class="jw-svg-icon jw-svg-icon-rewind" viewBox="0 0 240 240" focusable="false">
<path d="M185,135.6c-3.7-6.3-10.4-10.3-17.7-10.6c-7.3,0.3-14,4.3-17.7,10.6c-8.6,14.2-8.6,32.1,0,46.3c3.7,6.3,10.4,10.3,17.7,10.6
c7.3-0.3,14-4.3,17.7-10.6C193.6,167.6,193.6,149.8,185,135.6z M167.3,182.8c-7.8,0-14.4-11-14.4-24.1s6.6-24.1,14.4-24.1
s14.4,11,14.4,24.1S175.2,182.8,167.3,182.8z M123.9,192.5v-51l-4.8,4.8l-6.8-6.8l13-13c1.9-1.9,4.9-1.9,6.8,0
c0.9,0.9,1.4,2.1,1.4,3.4v62.7L123.9,192.5z M22.7,57.4h130.1V38.1c0-5.3,3.6-7.2,8-4.3l41.8,27.9c1.2,0.6,2.1,1.5,2.7,2.7
c1.4,3,0.2,6.5-2.7,8l-41.8,27.9c-4.4,2.9-8,1-8-4.3V76.7H37.1v96.4h48.2v19.3H22.6c-2.6,0-4.8-2.2-4.8-4.8V62.3
C17.8,59.6,20,57.4,22.7,57.4z">
</path>
</svg>
`;
interface IScenePlayerProps { interface IScenePlayerProps {
className?: string; className?: string;
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment | undefined | null;
sceneStreams: GQL.SceneStreamEndpoint[];
timestamp: number; timestamp: number;
autoplay?: boolean; autoplay?: boolean;
onReady?: () => void;
onSeeked?: () => void;
onTime?: () => void;
onComplete?: () => void; onComplete?: () => void;
config?: GQL.ConfigInterfaceDataFragment; onNext?: () => void;
onPrevious?: () => void;
} }
interface IScenePlayerState {
scrubberPosition: number; export const ScenePlayer: React.FC<IScenePlayerProps> = ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any className,
config: Record<string, any>; autoplay,
interactiveClient: Interactive; scene,
timestamp,
onComplete,
onNext,
onPrevious,
}) => {
const { configuration } = useContext(ConfigurationContext);
const config = configuration?.interface;
const videoRef = useRef<HTMLVideoElement>(null);
const playerRef = useRef<VideoJsPlayer | undefined>();
const skipButtonsRef = useRef<any>();
const [time, setTime] = useState(0);
const [interactiveClient] = useState(
new Interactive(config?.handyKey || "", config?.funscriptOffset || 0)
);
const [initialTimestamp] = useState(timestamp);
const maxLoopDuration = config?.maximumLoopDuration ?? 0;
useEffect(() => {
if (playerRef.current && timestamp >= 0) {
const player = playerRef.current;
player.play()?.then(() => {
player.currentTime(timestamp);
});
} }
export class ScenePlayerImpl extends React.Component< }, [timestamp]);
IScenePlayerProps,
IScenePlayerState useEffect(() => {
> { const videoElement = videoRef.current;
private static isDirectStream(src?: string) { if (!videoElement) return;
if (!src) {
const options: VideoJsPlayerOptions = {
controls: true,
controlBar: {
pictureInPictureToggle: false,
volumePanel: {
inline: false,
},
},
nativeControlsForTouch: false,
playbackRates: [0.75, 1, 1.5, 2, 3, 4],
inactivityTimeout: 2000,
preload: "none",
userActions: {
hotkeys: true,
},
};
const player = VideoJS(videoElement, options);
(player as any).landscapeFullscreen({
fullscreen: {
enterOnRotate: true,
exitOnRotate: true,
alwaysInLandscapeMode: true,
iOS: true,
},
});
(player as any).offset();
player.focus();
playerRef.current = player;
}, []);
useEffect(() => {
if (scene?.interactive) {
interactiveClient.uploadScript(scene.paths.funscript || "");
}
}, [interactiveClient, scene?.interactive, scene?.paths.funscript]);
useEffect(() => {
if (skipButtonsRef.current) {
skipButtonsRef.current.setForwardHandler(onNext);
skipButtonsRef.current.setBackwardHandler(onPrevious);
}
}, [onNext, onPrevious]);
useEffect(() => {
const player = playerRef.current;
if (player) {
player.seekButtons({
forward: 10,
back: 10,
});
skipButtonsRef.current = player.skipButtons() ?? undefined;
player.focus();
}
// Video player destructor
return () => {
if (playerRef.current) {
playerRef.current.dispose();
playerRef.current = undefined;
}
};
}, []);
useEffect(() => {
function handleOffset(player: VideoJsPlayer) {
if (!scene) return;
const currentSrc = player.currentSrc();
const isDirect =
currentSrc.endsWith("/stream") || currentSrc.endsWith("/stream.m3u8");
if (!isDirect) {
(player as any).setOffsetDuration(scene.file.duration);
} else {
(player as any).clearOffsetDuration();
}
}
function handleError(play: boolean) {
const player = playerRef.current;
if (!player) return;
const currentFile = player.currentSource();
if (currentFile) {
// eslint-disable-next-line no-console
console.log(`Source failed: ${currentFile.src}`);
player.focus();
}
if (tryNextStream()) {
// eslint-disable-next-line no-console
console.log(`Trying next source in playlist: ${player.currentSrc()}`);
player.load();
if (play) {
player.play();
}
} else {
// eslint-disable-next-line no-console
console.log("No more sources in playlist.");
}
}
function tryNextStream() {
const player = playerRef.current;
if (!player) return;
const sources = player.currentSources();
if (sources.length > 1) {
sources.shift();
player.src(sources);
return true;
}
return false; return false;
} }
const url = new URL(src); if (!scene) return;
return url.pathname.endsWith("/stream");
}
// Typings for jwplayer are, unfortunately, very lacking const player = playerRef.current;
private player: any; if (!player) return;
private playlist: any;
private lastTime = 0;
constructor(props: IScenePlayerProps) { const auto =
super(props); autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0;
this.onReady = this.onReady.bind(this); if (!auto && scene.paths?.screenshot) player.poster(scene.paths.screenshot);
this.onSeeked = this.onSeeked.bind(this); else player.poster("");
this.onTime = this.onTime.bind(this);
this.onScrubberSeek = this.onScrubberSeek.bind(this); // clear the offset before loading anything new.
this.onScrubberScrolled = this.onScrubberScrolled.bind(this); // otherwise, the offset will be applied to the next file when
this.state = { // currentTime is called.
scrubberPosition: 0, (player as any).clearOffsetDuration();
config: this.makeJWPlayerConfig(props.scene), player.src(
interactiveClient: new Interactive( scene.sceneStreams.map((stream) => ({
this.props.config?.handyKey || "", src: stream.url,
this.props.config?.funscriptOffset || 0 type: stream.mime_type ?? undefined,
), label: stream.label ?? undefined,
}; }))
);
player.currentTime(0);
// Default back to Direct Streaming player.loop(
localStorage.removeItem("jwplayer.qualityLabel"); !!scene.file.duration &&
} maxLoopDuration !== 0 &&
public UNSAFE_componentWillReceiveProps(props: IScenePlayerProps) { scene.file.duration < maxLoopDuration
if (props.scene !== this.props.scene) {
this.setState((state) => ({
...state,
config: this.makeJWPlayerConfig(this.props.scene),
}));
}
}
public componentDidUpdate(prevProps: IScenePlayerProps) {
if (prevProps.timestamp !== this.props.timestamp) {
this.player.seek(this.props.timestamp);
}
}
onIncrease() {
const currentPlaybackRate = this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate + 0.5);
}
onDecrease() {
const currentPlaybackRate = this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate - 0.5);
}
onReset() {
this.player.setPlaybackRate(1);
}
onPause() {
if (this.player.getState().paused) {
this.player.play();
} else {
this.player.pause();
}
}
private addForwardButton() {
// add forward button: https://github.com/jwplayer/jwplayer/issues/3894
const playerContainer = document.querySelector(
`#${JWUtils.playerID}`
) as HTMLElement;
// display icon
const rewindContainer = playerContainer.querySelector(
".jw-display-icon-rewind"
) as HTMLElement;
const forwardContainer = rewindContainer.cloneNode(true) as HTMLElement;
const forwardDisplayButton = forwardContainer.querySelector(
".jw-icon-rewind"
) as HTMLElement;
forwardDisplayButton.innerHTML = ffSVG;
forwardDisplayButton.ariaLabel = "Forward 10 Seconds";
const nextContainer = playerContainer.querySelector(
".jw-display-icon-next"
) as HTMLElement;
(nextContainer.parentNode as HTMLElement).insertBefore(
forwardContainer,
nextContainer
); );
// control bar icon player.on("loadstart", function (this: VideoJsPlayer) {
const buttonContainer = playerContainer.querySelector( // handle offset after loading so that we get the correct current source
".jw-button-container" handleOffset(this);
) as HTMLElement;
const rewindControlBarButton = buttonContainer.querySelector(
".jw-icon-rewind"
) as HTMLElement;
const forwardControlBarButton = rewindControlBarButton.cloneNode(
true
) as HTMLElement;
forwardControlBarButton.innerHTML = ffSVG;
forwardControlBarButton.ariaLabel = "Forward 10 Seconds";
(rewindControlBarButton.parentNode as HTMLElement).insertBefore(
forwardControlBarButton,
rewindControlBarButton.nextElementSibling
);
// add onclick handlers
[forwardDisplayButton, forwardControlBarButton].forEach((button) => {
button.onclick = () => {
this.player.seek(this.player.getPosition() + 10);
};
}); });
}
private onReady() { player.on("play", function (this: VideoJsPlayer) {
this.player = JWUtils.getPlayer(); if (scene.interactive) {
this.addForwardButton(); interactiveClient.play(this.currentTime());
this.player.on("error", (err: any) => {
if (err && err.code === 224003) {
// When jwplayer has been requested to play but the browser doesn't support the video format.
this.handleError(true);
} }
}); });
// player.on("pause", () => {
this.player.on("meta", (metadata: any) => { if (scene.interactive) {
if ( interactiveClient.pause();
metadata.metadataType === "media" && }
!metadata.width && });
!metadata.height
) { player.on("timeupdate", function (this: VideoJsPlayer) {
if (scene.interactive) {
interactiveClient.ensurePlaying(this.currentTime());
}
setTime(this.currentTime());
});
player.on("seeking", function (this: VideoJsPlayer) {
// backwards compatibility - may want to remove this in future
this.play();
});
player.on("error", () => {
handleError(true);
});
player.on("loadedmetadata", () => {
if (!player.videoWidth() && !player.videoHeight()) {
// Occurs during preload when videos with supported audio/unsupported video are preloaded. // Occurs during preload when videos with supported audio/unsupported video are preloaded.
// Treat this as a decoding error and try the next source without playing. // Treat this as a decoding error and try the next source without playing.
// However on Safari we get an media event when m3u8 is loaded which needs to be ignored. // However on Safari we get an media event when m3u8 is loaded which needs to be ignored.
const currentFile = this.player.getPlaylistItem().file; const currentFile = player.currentSrc();
if (currentFile != null && !currentFile.includes("m3u8")) { if (currentFile != null && !currentFile.includes("m3u8")) {
const state = this.player.getState(); // const play = !player.paused();
const play = state === "buffering" || state === "playing"; // handleError(play);
this.handleError(play); player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
} }
} }
}); });
this.player.on("firstFrame", () => { player.load();
if (this.props.timestamp > 0) {
this.player.seek(this.props.timestamp); if (auto) {
player
.play()
?.then(() => {
if (initialTimestamp > 0) {
player.currentTime(initialTimestamp);
} }
})
.catch(() => {
if (scene.paths.screenshot) player.poster(scene.paths.screenshot);
});
}
if ((player as any).vttThumbnails?.src)
(player as any).vttThumbnails?.src(scene?.paths.vtt);
else
(player as any).vttThumbnails({
src: scene?.paths.vtt,
showTimestamp: true,
});
}, [
scene,
config?.autostartVideo,
maxLoopDuration,
initialTimestamp,
autoplay,
interactiveClient,
]);
useEffect(() => {
// Attach handler for onComplete event
const player = playerRef.current;
if (!player) return;
player.on("ended", () => {
onComplete?.();
}); });
this.player.on("play", () => { return () => player.off("ended");
if (this.props.scene.interactive) { }, [onComplete]);
this.state.interactiveClient.play(this.player.getPosition());
}
});
this.player.on("pause", () => { const onScrubberScrolled = () => {
if (this.props.scene.interactive) { playerRef.current?.pause();
this.state.interactiveClient.pause();
}
});
if (this.props.scene.interactive) {
this.state.interactiveClient.uploadScript(
this.props.scene.paths.funscript || ""
);
}
this.player.getContainer().focus();
}
private onSeeked() {
const position = this.player.getPosition();
this.setState({ scrubberPosition: position });
this.player.play();
}
private onTime() {
const position = this.player.getPosition();
const difference = Math.abs(position - this.lastTime);
if (difference > 1) {
this.lastTime = position;
this.setState({ scrubberPosition: position });
if (this.props.scene.interactive) {
this.state.interactiveClient.ensurePlaying(position);
}
}
}
private onComplete() {
if (this.props?.onComplete) {
this.props.onComplete();
}
}
private onScrubberSeek(seconds: number) {
this.player.seek(seconds);
}
private onScrubberScrolled() {
this.player.pause();
}
private handleError(play: boolean) {
const currentFile = this.player.getPlaylistItem();
if (currentFile) {
// eslint-disable-next-line no-console
console.log(`Source failed: ${currentFile.file}`);
}
if (this.tryNextStream()) {
// eslint-disable-next-line no-console
console.log(
`Trying next source in playlist: ${this.playlist.sources[0].file}`
);
this.player.load(this.playlist);
if (play) {
this.player.play();
}
}
}
private shouldRepeat(scene: GQL.SceneDataFragment) {
const maxLoopDuration = this.props?.config?.maximumLoopDuration ?? 0;
return (
!!scene.file.duration &&
!!maxLoopDuration &&
scene.file.duration < maxLoopDuration
);
}
private tryNextStream() {
if (this.playlist.sources.length > 1) {
this.playlist.sources.shift();
return true;
}
return false;
}
private makePlaylist() {
const { scene } = this.props;
return {
image: scene.paths.screenshot,
tracks: [
{
file: scene.paths.vtt,
kind: "thumbnails",
},
{
file: scene.paths.chapters_vtt,
kind: "chapters",
},
],
sources: this.props.sceneStreams.map((s) => {
return {
file: s.url,
type: s.mime_type,
label: s.label,
}; };
}), const onScrubberSeek = (seconds: number) => {
}; playerRef.current?.currentTime(seconds);
}
private makeJWPlayerConfig(scene: GQL.SceneDataFragment) {
if (!scene.paths.stream) {
return {};
}
const repeat = this.shouldRepeat(scene);
const getDurationHook = () => {
return this.props.scene.file.duration ?? null;
}; };
const seekHook = (seekToPosition: number, _videoTag: HTMLVideoElement) => { const isPortrait =
if (!_videoTag.src || _videoTag.src.endsWith(".m3u8")) { scene &&
return false; scene.file.height &&
} scene.file.width &&
scene.file.height > scene.file.width;
if (ScenePlayerImpl.isDirectStream(_videoTag.src)) {
if (_videoTag.dataset.start) {
/* eslint-disable-next-line no-param-reassign */
_videoTag.dataset.start = "0";
}
// direct stream - fall back to default
return false;
}
// remove the start parameter
const srcUrl = new URL(_videoTag.src);
srcUrl.searchParams.delete("start");
/* eslint-disable no-param-reassign */
_videoTag.dataset.start = seekToPosition.toString();
srcUrl.searchParams.append("start", seekToPosition.toString());
_videoTag.src = srcUrl.toString();
/* eslint-enable no-param-reassign */
_videoTag.play();
// return true to indicate not to fall through to default
return true;
};
const getCurrentTimeHook = (_videoTag: HTMLVideoElement) => {
const start = Number.parseFloat(_videoTag.dataset?.start ?? "0");
return _videoTag.currentTime + start;
};
this.playlist = this.makePlaylist();
// TODO: leverage the floating.mode option after upgrading JWPlayer
const extras: any = {};
if (!ScreenUtils.isMobile()) {
extras.floating = {
dismissible: true,
};
}
const ret = {
playlist: this.playlist,
image: scene.paths.screenshot,
width: "100%",
height: "100%",
cast: {},
primary: "html5",
preload: "none",
autostart:
this.props.autoplay ||
(this.props.config ? this.props.config.autostartVideo : false) ||
this.props.timestamp > 0,
repeat,
playbackRateControls: true,
playbackRates: [0.75, 1, 1.5, 2, 3, 4],
getDurationHook,
seekHook,
getCurrentTimeHook,
...extras,
};
return ret;
}
public render() {
let className =
this.props.className ?? "w-100 col-sm-9 m-sm-auto no-gutter";
const sceneFile = this.props.scene.file;
if (
sceneFile.height &&
sceneFile.width &&
sceneFile.height > sceneFile.width
) {
className += " portrait";
}
return ( return (
<div id="jwplayer-container" className={className}> <div className={cx("VideoPlayer", { portrait: isPortrait })}>
<ReactJWPlayer <div data-vjs-player className={cx("video-wrapper", className)}>
playerId={JWUtils.playerID} <video
playerScript="jwplayer/jwplayer.js" ref={videoRef}
customProps={this.state.config} id={VIDEO_PLAYER_ID}
onReady={this.onReady} className="video-js vjs-big-play-centered"
onSeeked={this.onSeeked}
onTime={this.onTime}
onOneHundredPercent={() => this.onComplete()}
className="video-wrapper"
/>
<ScenePlayerScrubber
scene={this.props.scene}
position={this.state.scrubberPosition}
onSeek={this.onScrubberSeek}
onScrolled={this.onScrubberScrolled}
/> />
</div> </div>
); {scene && (
} <ScenePlayerScrubber
} scene={scene}
position={time}
export const ScenePlayer: React.FC<IScenePlayerProps> = ( onSeek={onScrubberSeek}
props: IScenePlayerProps onScrolled={onScrubberScrolled}
) => {
const { configuration } = React.useContext(ConfigurationContext);
return (
<ScenePlayerImpl
{...props}
config={configuration ? configuration.interface : undefined}
/> />
)}
</div>
); );
}; };
export const getPlayerPosition = () =>
VideoJS.getPlayer(VIDEO_PLAYER_ID).currentTime();

View File

@@ -1 +1 @@
export { ScenePlayer } from "./ScenePlayer"; export * from "./ScenePlayer";

View File

@@ -0,0 +1,76 @@
import videojs, { VideoJsPlayer } from "video.js";
const offset = function (this: VideoJsPlayer) {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const Player = this.constructor as any;
if (!Player.__super__ || !Player.__super__.__offsetInit) {
Player.__super__ = {
__offsetInit: true,
duration: Player.prototype.duration,
currentTime: Player.prototype.currentTime,
remainingTime: Player.prototype.remainingTime,
getCache: Player.prototype.getCache,
};
Player.prototype.clearOffsetDuration = function () {
this._offsetDuration = undefined;
this._offsetStart = undefined;
};
Player.prototype.setOffsetDuration = function (duration: number) {
this._offsetDuration = duration;
};
Player.prototype.duration = function () {
if (this._offsetDuration !== undefined) {
return this._offsetDuration;
}
return Player.__super__.duration.apply(this, arguments);
};
Player.prototype.currentTime = function (seconds: number) {
if (seconds !== undefined && this._offsetDuration !== undefined) {
this._offsetStart = seconds;
const srcUrl = new URL(this.src());
srcUrl.searchParams.delete("start");
srcUrl.searchParams.append("start", seconds.toString());
this.src({
src: srcUrl.toString(),
type: "video/webm",
});
this.play();
return seconds;
}
return (
(this._offsetStart ?? 0) +
Player.__super__.currentTime.apply(this, arguments)
);
};
Player.prototype.getCache = function () {
const cache = Player.__super__.getCache.apply(this);
if (this._offsetDuration !== undefined)
return {
...cache,
currentTime:
(this._offsetStart ?? 0) + Player.__super__.currentTime.apply(this),
};
return cache;
};
Player.prototype.remainingTime = function () {
if (this._offsetDuration !== undefined) {
return this._offsetDuration - this.currentTime();
}
return this.duration() - this.currentTime();
};
}
};
// Register the plugin with video.js.
videojs.registerPlugin("offset", offset);
export default offset;

View File

@@ -2,7 +2,7 @@ $scrubberHeight: 120px;
$menuHeight: 4rem; $menuHeight: 4rem;
$sceneTabWidth: 450px; $sceneTabWidth: 450px;
#jwplayer-container { .VideoPlayer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: calc(100vh - #{$menuHeight}); max-height: calc(100vh - #{$menuHeight});
@@ -12,29 +12,143 @@ $sceneTabWidth: 450px;
height: 100vh; height: 100vh;
} }
& video:focus { .video-js {
outline: 0;
}
/* stylelint-disable */
.jw-video {
// #1764 - jwplayer sets object-fit: fit in the style. Need to override it.
object-fit: contain !important;
}
/* stylelint-enable */
.video-wrapper {
height: 56.25vw; height: 56.25vw;
width: 100%;
@media (min-width: 1200px) { @media (min-width: 1200px) {
height: 100%; height: 100%;
} }
} }
&.portrait .video-wrapper { &.portrait .video-js {
height: 177.78vw; height: 177.78vw;
} }
.vjs-button {
outline: none;
}
.vjs-vtt-thumbnail-display {
// default opacity to 0, it gets set to 1 when moused-over.
// prevents the border from showing up when initially loaded
opacity: 0;
}
.vjs-touch-enabled {
margin: 0 -15px;
width: 100vw;
&:hover.vjs-user-active {
.vjs-button {
pointer-events: auto;
}
}
// make controls a little more compact on smaller screens
@media (max-width: 576px) {
.vjs-control-bar {
height: 2.5em;
}
.vjs-control-bar .vjs-control:not(.vjs-progress-control) {
width: 2.5em;
}
.vjs-time-control {
font-size: 12px;
line-height: 4em;
}
.vjs-button > .vjs-icon-placeholder::before,
.vjs-skip-button::before {
font-size: 1.5em;
line-height: 2;
}
}
.vjs-current-time {
margin-left: 1em;
}
.vjs-vtt-thumbnail-display {
bottom: 40px;
}
}
}
.video-js {
.vjs-control-bar {
background: none;
/* Scales control size */
font-size: 15px;
opacity: 0;
transition: opacity 0.25s cubic-bezier(0, 0, 0.2, 1);
&::before {
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0) 100%
);
bottom: 0;
content: "";
height: 10rem;
position: absolute;
width: 100%;
}
}
.vjs-time-control {
display: block;
min-width: 0;
padding: 0 4px;
pointer-events: none;
.vjs-control-text {
display: none;
}
}
.vjs-duration {
margin-right: auto;
}
.vjs-remaining-time {
display: none;
}
&:hover,
&.vjs-paused {
.vjs-control-bar {
opacity: 1;
}
}
.vjs-progress-control {
bottom: 3rem;
margin-left: 1%;
position: absolute;
width: 98%;
}
.vjs-vtt-thumbnail-display {
border: 2px solid white;
border-radius: 2px;
bottom: 90px;
position: absolute;
}
.vjs-big-play-button,
.vjs-big-play-button:hover,
.vjs-big-play-button:focus,
&:hover .vjs-big-play-button {
background: none;
border: none;
font-size: 10em;
}
.jwplayer { .jwplayer {
outline: none; outline: none;
} }
@@ -161,6 +275,7 @@ $sceneTabWidth: 450px;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
position: relative; position: relative;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none;
width: 96%; width: 96%;
&.dragging { &.dragging {
@@ -255,3 +370,22 @@ $sceneTabWidth: 450px;
width: 100%; width: 100%;
} }
} }
.vjs-skip-button {
&::before {
font-size: 1.8em;
line-height: 1.67;
}
}
.vjs-skip-buttons {
.vjs-icon-next-item,
.vjs-icon-previous-item {
display: none;
}
&-prev .vjs-icon-previous-item,
&-next .vjs-icon-next-item {
display: inline-block;
}
}

View File

@@ -84,7 +84,7 @@ export const QueueViewer: React.FC<IPlaylistViewer> = ({
<img alt={scene.title ?? ""} src={scene.paths.screenshot ?? ""} /> <img alt={scene.title ?? ""} src={scene.paths.screenshot ?? ""} />
</div> </div>
<div> <div>
<span className="align-middle"> <span className="align-middle text-break">
{scene.title ?? TextUtils.fileNameFromPath(scene.path)} {scene.title ?? TextUtils.fileNameFromPath(scene.path)}
</span> </span>
</div> </div>

View File

@@ -4,7 +4,6 @@ import React, { useEffect, useState, useMemo, useContext } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useParams, useLocation, useHistory, Link } from "react-router-dom"; import { useParams, useLocation, useHistory, Link } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
mutateMetadataScan, mutateMetadataScan,
@@ -12,19 +11,19 @@ import {
useSceneIncrementO, useSceneIncrementO,
useSceneDecrementO, useSceneDecrementO,
useSceneResetO, useSceneResetO,
useSceneStreams,
useSceneGenerateScreenshot, useSceneGenerateScreenshot,
useSceneUpdate, useSceneUpdate,
queryFindScenes, queryFindScenes,
queryFindScenesByID, queryFindScenesByID,
} from "src/core/StashService"; } from "src/core/StashService";
import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { ScenePlayer } from "src/components/ScenePlayer";
import { TextUtils, JWUtils } from "src/utils";
import { SubmitStashBoxDraft } from "src/components/Dialogs/SubmitDraft"; import { SubmitStashBoxDraft } from "src/components/Dialogs/SubmitDraft";
import { ScenePlayer, getPlayerPosition } from "src/components/ScenePlayer";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { TextUtils } from "src/utils";
import Mousetrap from "mousetrap";
import { SceneQueue } from "src/models/sceneQueue"; import { SceneQueue } from "src/models/sceneQueue";
import { QueueViewer } from "./QueueViewer"; import { QueueViewer } from "./QueueViewer";
import { SceneMarkersPanel } from "./SceneMarkersPanel"; import { SceneMarkersPanel } from "./SceneMarkersPanel";
@@ -44,32 +43,50 @@ import { ConfigurationContext } from "src/hooks/Config";
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
refetch: () => void; refetch: () => void;
setTimestamp: (num: number) => void;
queueScenes: GQL.SceneDataFragment[];
onQueueNext: () => void;
onQueuePrevious: () => void;
onQueueRandom: () => void;
continuePlaylist: boolean;
playScene: (sceneID: string, page?: number) => void;
queueHasMoreScenes: () => boolean;
onQueueMoreScenes: () => void;
onQueueLessScenes: () => void;
queueStart: number;
collapsed: boolean;
setCollapsed: (state: boolean) => void;
setContinuePlaylist: (value: boolean) => void;
} }
const ScenePage: React.FC<IProps> = ({ scene, refetch }) => { const ScenePage: React.FC<IProps> = ({
const location = useLocation(); scene,
refetch,
setTimestamp,
queueScenes,
onQueueNext,
onQueuePrevious,
onQueueRandom,
continuePlaylist,
playScene,
queueHasMoreScenes,
onQueueMoreScenes,
onQueueLessScenes,
queueStart,
collapsed,
setCollapsed,
setContinuePlaylist,
}) => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const [updateScene] = useSceneUpdate(); const [updateScene] = useSceneUpdate();
const [generateScreenshot] = useSceneGenerateScreenshot(); const [generateScreenshot] = useSceneGenerateScreenshot();
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
const [collapsed, setCollapsed] = useState(false);
const { configuration } = useContext(ConfigurationContext); const { configuration } = useContext(ConfigurationContext);
const [showScrubber, setShowScrubber] = useState(
configuration?.interface.showScrubber ?? true
);
const [showDraftModal, setShowDraftModal] = useState(false); const [showDraftModal, setShowDraftModal] = useState(false);
const boxes = configuration?.general?.stashBoxes ?? []; const boxes = configuration?.general?.stashBoxes ?? [];
const {
data: sceneStreams,
error: streamableError,
loading: streamableLoading,
} = useSceneStreams(scene.id);
const [incrementO] = useSceneIncrementO(scene.id); const [incrementO] = useSceneIncrementO(scene.id);
const [decrementO] = useSceneDecrementO(scene.id); const [decrementO] = useSceneDecrementO(scene.id);
const [resetO] = useSceneResetO(scene.id); const [resetO] = useSceneResetO(scene.id);
@@ -81,75 +98,48 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
const [sceneQueue, setSceneQueue] = useState<SceneQueue>(new SceneQueue()); const onIncrementClick = async () => {
const [queueScenes, setQueueScenes] = useState<GQL.SlimSceneDataFragment[]>( try {
[] await incrementO();
); } catch (e) {
Toast.error(e);
const [queueTotal, setQueueTotal] = useState(0);
const [queueStart, setQueueStart] = useState(1);
const [continuePlaylist, setContinuePlaylist] = useState(false);
const [rerenderPlayer, setRerenderPlayer] = useState(false);
const queryParams = useMemo(() => queryString.parse(location.search), [
location.search,
]);
const autoplay = queryParams?.autoplay === "true";
const currentQueueIndex = queueScenes.findIndex((s) => s.id === scene.id);
async function getQueueFilterScenes(filter: ListFilterModel) {
const query = await queryFindScenes(filter);
const { scenes, count } = query.data.findScenes;
setQueueScenes(scenes);
setQueueTotal(count);
setQueueStart((filter.currentPage - 1) * filter.itemsPerPage + 1);
} }
};
async function getQueueScenes(sceneIDs: number[]) { const onDecrementClick = async () => {
const query = await queryFindScenesByID(sceneIDs); try {
const { scenes, count } = query.data.findScenes; await decrementO();
setQueueScenes(scenes); } catch (e) {
setQueueTotal(count); Toast.error(e);
setQueueStart(1);
} }
};
// set up hotkeys
useEffect(() => { useEffect(() => {
setContinuePlaylist(queryParams?.continue === "true"); Mousetrap.bind("a", () => setActiveTabKey("scene-details-panel"));
}, [queryParams]); Mousetrap.bind("q", () => setActiveTabKey("scene-queue-panel"));
Mousetrap.bind("e", () => setActiveTabKey("scene-edit-panel"));
Mousetrap.bind("k", () => setActiveTabKey("scene-markers-panel"));
Mousetrap.bind("i", () => setActiveTabKey("scene-file-info-panel"));
Mousetrap.bind("o", () => onIncrementClick());
Mousetrap.bind("p n", () => onQueueNext());
Mousetrap.bind("p p", () => onQueuePrevious());
Mousetrap.bind("p r", () => onQueueRandom());
Mousetrap.bind(",", () => setCollapsed(!collapsed));
// HACK - jwplayer doesn't handle re-rendering when scene changes, so force return () => {
// a rerender by not drawing it Mousetrap.unbind("a");
useEffect(() => { Mousetrap.unbind("q");
if (rerenderPlayer) { Mousetrap.unbind("e");
setRerenderPlayer(false); Mousetrap.unbind("k");
} Mousetrap.unbind("i");
}, [rerenderPlayer]); Mousetrap.unbind("o");
Mousetrap.unbind("p n");
useEffect(() => { Mousetrap.unbind("p p");
setRerenderPlayer(true); Mousetrap.unbind("p r");
}, [scene.id]); Mousetrap.unbind(",");
};
useEffect(() => { });
setSceneQueue(SceneQueue.fromQueryParameters(location.search));
}, [location.search]);
useEffect(() => {
if (sceneQueue.query) {
getQueueFilterScenes(sceneQueue.query);
} else if (sceneQueue.sceneIDs) {
getQueueScenes(sceneQueue.sceneIDs);
}
}, [sceneQueue]);
function getInitialTimestamp() {
const params = queryString.parse(location.search);
const initialTimestamp = params?.t ?? "0";
return Number.parseInt(
Array.isArray(initialTimestamp) ? initialTimestamp[0] : initialTimestamp,
10
);
}
const onOrganizedClick = async () => { const onOrganizedClick = async () => {
try { try {
@@ -169,22 +159,6 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
} }
}; };
const onIncrementClick = async () => {
try {
await incrementO();
} catch (e) {
Toast.error(e);
}
};
const onDecrementClick = async () => {
try {
await decrementO();
} catch (e) {
Toast.error(e);
}
};
const onResetClick = async () => { const onResetClick = async () => {
try { try {
await resetO(); await resetO();
@@ -227,93 +201,6 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
}); });
} }
async function onQueueLessScenes() {
if (!sceneQueue.query || queueStart <= 1) {
return;
}
const filterCopy = sceneQueue.query.clone();
const newStart = queueStart - filterCopy.itemsPerPage;
filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage);
const query = await queryFindScenes(filterCopy);
const { scenes } = query.data.findScenes;
// prepend scenes to scene list
const newScenes = scenes.concat(queueScenes);
setQueueScenes(newScenes);
setQueueStart(newStart);
}
function queueHasMoreScenes() {
return queueStart + queueScenes.length - 1 < queueTotal;
}
async function onQueueMoreScenes() {
if (!sceneQueue.query || !queueHasMoreScenes()) {
return;
}
const filterCopy = sceneQueue.query.clone();
const newStart = queueStart + queueScenes.length;
filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage);
const query = await queryFindScenes(filterCopy);
const { scenes } = query.data.findScenes;
// append scenes to scene list
const newScenes = scenes.concat(queueScenes);
setQueueScenes(newScenes);
// don't change queue start
}
function playScene(sceneID: string, page?: number) {
sceneQueue.playScene(history, sceneID, {
newPage: page,
autoPlay: true,
continue: continuePlaylist,
});
}
function onQueueNext() {
if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) {
playScene(queueScenes[currentQueueIndex + 1].id);
}
}
function onQueuePrevious() {
if (currentQueueIndex > 0) {
playScene(queueScenes[currentQueueIndex - 1].id);
}
}
async function onQueueRandom() {
if (sceneQueue.query) {
const { query } = sceneQueue;
const pages = Math.ceil(queueTotal / query.itemsPerPage);
const page = Math.floor(Math.random() * pages) + 1;
const index = Math.floor(
Math.random() * Math.min(query.itemsPerPage, queueTotal)
);
const filterCopy = sceneQueue.query.clone();
filterCopy.currentPage = page;
const queryResults = await queryFindScenes(filterCopy);
if (queryResults.data.findScenes.scenes.length > index) {
const { id: sceneID } = queryResults!.data!.findScenes!.scenes[index];
// navigate to the image player page
playScene(sceneID, page);
}
} else {
const index = Math.floor(Math.random() * queueTotal);
playScene(queueScenes[index].id);
}
}
function onComplete() {
// load the next scene if we're autoplaying
if (continuePlaylist) {
onQueueNext();
}
}
function onDeleteDialogClosed(deleted: boolean) { function onDeleteDialogClosed(deleted: boolean) {
setIsDeleteAlertOpen(false); setIsDeleteAlertOpen(false);
if (deleted) { if (deleted) {
@@ -370,9 +257,7 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
<Dropdown.Item <Dropdown.Item
key="generate-screenshot" key="generate-screenshot"
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => onClick={() => onGenerateScreenshot(getPlayerPosition())}
onGenerateScreenshot(JWUtils.getPlayer().getPosition())
}
> >
<FormattedMessage id="actions.generate_thumb_from_current" /> <FormattedMessage id="actions.generate_thumb_from_current" />
</Dropdown.Item> </Dropdown.Item>
@@ -502,7 +387,7 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
scenes={queueScenes} scenes={queueScenes}
currentID={scene.id} currentID={scene.id}
continue={continuePlaylist} continue={continuePlaylist}
setContinue={(v) => setContinuePlaylist(v)} setContinue={setContinuePlaylist}
onSceneClicked={(sceneID) => playScene(sceneID)} onSceneClicked={(sceneID) => playScene(sceneID)}
onNext={onQueueNext} onNext={onQueueNext}
onPrevious={onQueuePrevious} onPrevious={onQueuePrevious}
@@ -515,7 +400,7 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
</Tab.Pane> </Tab.Pane>
<Tab.Pane eventKey="scene-markers-panel"> <Tab.Pane eventKey="scene-markers-panel">
<SceneMarkersPanel <SceneMarkersPanel
scene={scene} sceneId={scene.id}
onClickMarker={onClickMarker} onClickMarker={onClickMarker}
isVisible={activeTabKey === "scene-markers-panel"} isVisible={activeTabKey === "scene-markers-panel"}
/> />
@@ -551,44 +436,12 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
</Tab.Container> </Tab.Container>
); );
// set up hotkeys
useEffect(() => {
Mousetrap.bind("a", () => setActiveTabKey("scene-details-panel"));
Mousetrap.bind("q", () => setActiveTabKey("scene-queue-panel"));
Mousetrap.bind("e", () => setActiveTabKey("scene-edit-panel"));
Mousetrap.bind("k", () => setActiveTabKey("scene-markers-panel"));
Mousetrap.bind("i", () => setActiveTabKey("scene-file-info-panel"));
Mousetrap.bind("o", () => onIncrementClick());
Mousetrap.bind("p n", () => onQueueNext());
Mousetrap.bind("p p", () => onQueuePrevious());
Mousetrap.bind("p r", () => onQueueRandom());
Mousetrap.bind(",", () => setCollapsed(!collapsed));
Mousetrap.bind(".", () => setShowScrubber(!showScrubber));
return () => {
Mousetrap.unbind("a");
Mousetrap.unbind("q");
Mousetrap.unbind("e");
Mousetrap.unbind("k");
Mousetrap.unbind("i");
Mousetrap.unbind("o");
Mousetrap.unbind("p n");
Mousetrap.unbind("p p");
Mousetrap.unbind("p r");
Mousetrap.unbind(",");
Mousetrap.unbind(".");
};
});
function getCollapseButtonText() { function getCollapseButtonText() {
return collapsed ? ">" : "<"; return collapsed ? ">" : "<";
} }
if (streamableLoading) return <LoadingIndicator />;
if (streamableError) return <ErrorMessage error={streamableError.message} />;
return ( return (
<div className="row"> <>
<Helmet> <Helmet>
<title>{scene.title ?? TextUtils.fileNameFromPath(scene.path)}</title> <title>{scene.title ?? TextUtils.fileNameFromPath(scene.path)}</title>
</Helmet> </Helmet>
@@ -626,20 +479,6 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
{getCollapseButtonText()} {getCollapseButtonText()}
</Button> </Button>
</div> </div>
<div className={`scene-player-container ${collapsed ? "expanded" : ""}`}>
{!rerenderPlayer ? (
<ScenePlayer
className={`w-100 m-sm-auto no-gutter ${
!showScrubber ? "hide-scrubber" : ""
}`}
scene={scene}
timestamp={timestamp}
autoplay={autoplay}
sceneStreams={sceneStreams?.sceneStreams ?? []}
onComplete={onComplete}
/>
) : undefined}
</div>
<SubmitStashBoxDraft <SubmitStashBoxDraft
boxes={boxes} boxes={boxes}
entity={scene} entity={scene}
@@ -647,20 +486,233 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
show={showDraftModal} show={showDraftModal}
onHide={() => setShowDraftModal(false)} onHide={() => setShowDraftModal(false)}
/> />
</div> </>
); );
}; };
const SceneLoader: React.FC = () => { const SceneLoader: React.FC = () => {
const { id } = useParams<{ id?: string }>(); const { id } = useParams<{ id?: string }>();
const { data, loading, error, refetch } = useFindScene(id ?? ""); const location = useLocation();
const history = useHistory();
const { configuration } = useContext(ConfigurationContext);
const { data, loading, refetch } = useFindScene(id ?? "");
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
const [collapsed, setCollapsed] = useState(false);
const [continuePlaylist, setContinuePlaylist] = useState(false);
const [showScrubber, setShowScrubber] = useState(
configuration?.interface.showScrubber ?? true
);
if (loading) return <LoadingIndicator />; const sceneQueue = useMemo(
() => SceneQueue.fromQueryParameters(location.search),
[location.search]
);
const [queueScenes, setQueueScenes] = useState<GQL.SceneDataFragment[]>([]);
const [queueTotal, setQueueTotal] = useState(0);
const [queueStart, setQueueStart] = useState(1);
const queryParams = useMemo(() => queryString.parse(location.search), [
location.search,
]);
function getInitialTimestamp() {
const params = queryString.parse(location.search);
const initialTimestamp = params?.t ?? "0";
return Number.parseInt(
Array.isArray(initialTimestamp) ? initialTimestamp[0] : initialTimestamp,
10
);
}
const autoplay = queryParams?.autoplay === "true";
const currentQueueIndex = queueScenes
? queueScenes.findIndex((s) => s.id === id)
: -1;
// set up hotkeys
useEffect(() => {
Mousetrap.bind(".", () => setShowScrubber(!showScrubber));
return () => {
Mousetrap.unbind(".");
};
});
useEffect(() => {
// reset timestamp after notifying player
if (timestamp !== -1) setTimestamp(-1);
}, [timestamp]);
async function getQueueFilterScenes(filter: ListFilterModel) {
const query = await queryFindScenes(filter);
const { scenes, count } = query.data.findScenes;
setQueueScenes(scenes);
setQueueTotal(count);
setQueueStart((filter.currentPage - 1) * filter.itemsPerPage + 1);
}
async function getQueueScenes(sceneIDs: number[]) {
const query = await queryFindScenesByID(sceneIDs);
const { scenes, count } = query.data.findScenes;
setQueueScenes(scenes);
setQueueTotal(count);
setQueueStart(1);
}
useEffect(() => {
if (sceneQueue.query) {
getQueueFilterScenes(sceneQueue.query);
} else if (sceneQueue.sceneIDs) {
getQueueScenes(sceneQueue.sceneIDs);
}
}, [sceneQueue]);
async function onQueueLessScenes() {
if (!sceneQueue.query || queueStart <= 1) {
return;
}
const filterCopy = sceneQueue.query.clone();
const newStart = queueStart - filterCopy.itemsPerPage;
filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage);
const query = await queryFindScenes(filterCopy);
const { scenes } = query.data.findScenes;
// prepend scenes to scene list
const newScenes = scenes.concat(queueScenes);
setQueueScenes(newScenes);
setQueueStart(newStart);
}
function queueHasMoreScenes() {
return queueStart + queueScenes.length - 1 < queueTotal;
}
async function onQueueMoreScenes() {
if (!sceneQueue.query || !queueHasMoreScenes()) {
return;
}
const filterCopy = sceneQueue.query.clone();
const newStart = queueStart + queueScenes.length;
filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage);
const query = await queryFindScenes(filterCopy);
const { scenes } = query.data.findScenes;
// append scenes to scene list
const newScenes = scenes.concat(queueScenes);
setQueueScenes(newScenes);
// don't change queue start
}
function playScene(sceneID: string, newPage?: number) {
sceneQueue.playScene(history, sceneID, {
newPage,
autoPlay: true,
continue: continuePlaylist,
});
}
function onQueueNext() {
if (!queueScenes) return;
if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) {
playScene(queueScenes[currentQueueIndex + 1].id);
}
}
function onQueuePrevious() {
if (!queueScenes) return;
if (currentQueueIndex > 0) {
playScene(queueScenes[currentQueueIndex - 1].id);
}
}
async function onQueueRandom() {
if (!queueScenes) return;
if (sceneQueue.query) {
const { query } = sceneQueue;
const pages = Math.ceil(queueTotal / query.itemsPerPage);
const page = Math.floor(Math.random() * pages) + 1;
const index = Math.floor(
Math.random() * Math.min(query.itemsPerPage, queueTotal)
);
const filterCopy = sceneQueue.query.clone();
filterCopy.currentPage = page;
const queryResults = await queryFindScenes(filterCopy);
if (queryResults.data.findScenes.scenes.length > index) {
const { id: sceneID } = queryResults!.data!.findScenes!.scenes[index];
// navigate to the image player page
playScene(sceneID, page);
}
} else {
const index = Math.floor(Math.random() * queueTotal);
playScene(queueScenes[index].id);
}
}
function onComplete() {
// load the next scene if we're autoplaying
if (continuePlaylist) {
onQueueNext();
}
}
/*
if (error) return <ErrorMessage error={error.message} />; if (error) return <ErrorMessage error={error.message} />;
if (!data?.findScene) if (!loading && !data?.findScene)
return <ErrorMessage error={`No scene found with id ${id}.`} />; return <ErrorMessage error={`No scene found with id ${id}.`} />;
*/
return <ScenePage scene={data.findScene} refetch={refetch} />; const scene = data?.findScene;
return (
<div className="row">
{!loading && scene ? (
<ScenePage
scene={scene}
refetch={refetch}
setTimestamp={setTimestamp}
queueScenes={queueScenes ?? []}
queueStart={queueStart}
onQueueNext={onQueueNext}
onQueuePrevious={onQueuePrevious}
onQueueRandom={onQueueRandom}
continuePlaylist={continuePlaylist}
playScene={playScene}
queueHasMoreScenes={queueHasMoreScenes}
onQueueLessScenes={onQueueLessScenes}
onQueueMoreScenes={onQueueMoreScenes}
collapsed={collapsed}
setCollapsed={setCollapsed}
setContinuePlaylist={setContinuePlaylist}
/>
) : (
<div className="scene-tabs" />
)}
<div
className={`scene-player-container ${collapsed ? "expanded" : ""} ${
!showScrubber ? "hide-scrubber" : ""
}`}
>
<ScenePlayer
key="ScenePlayer"
className="w-100 m-sm-auto no-gutter"
scene={scene}
timestamp={timestamp}
autoplay={autoplay}
onComplete={onComplete}
onNext={
currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1
? onQueueNext
: undefined
}
onPrevious={currentQueueIndex > 0 ? onQueuePrevious : undefined}
/>
</div>
</div>
);
}; };
export default SceneLoader; export default SceneLoader;

View File

@@ -13,8 +13,8 @@ import {
TagSelect, TagSelect,
MarkerTitleSuggest, MarkerTitleSuggest,
} from "src/components/Shared"; } from "src/components/Shared";
import { getPlayerPosition } from "src/components/ScenePlayer";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { JWUtils } from "src/utils";
interface IFormFields { interface IFormFields {
title: string; title: string;
@@ -82,7 +82,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
onReset={() => onReset={() =>
fieldProps.form.setFieldValue( fieldProps.form.setFieldValue(
"seconds", "seconds",
Math.round(JWUtils.getPlayer()?.getPosition() ?? 0) Math.round(getPlayerPosition() ?? 0)
) )
} }
numericValue={Number.parseInt(fieldProps.field.value ?? "0", 10)} numericValue={Number.parseInt(fieldProps.field.value ?? "0", 10)}
@@ -117,8 +117,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
const values: IFormFields = { const values: IFormFields = {
title: editingMarker?.title ?? "", title: editingMarker?.title ?? "",
seconds: ( seconds: (
editingMarker?.seconds ?? editingMarker?.seconds ?? Math.round(getPlayerPosition() ?? 0)
Math.round(JWUtils.getPlayer()?.getPosition() ?? 0)
).toString(), ).toString(),
primaryTagId: editingMarker?.primary_tag.id ?? "", primaryTagId: editingMarker?.primary_tag.id ?? "",
tagIds: editingMarker?.tags.map((tag) => tag.id) ?? [], tagIds: editingMarker?.tags.map((tag) => tag.id) ?? [],

View File

@@ -8,7 +8,7 @@ import { PrimaryTags } from "./PrimaryTags";
import { SceneMarkerForm } from "./SceneMarkerForm"; import { SceneMarkerForm } from "./SceneMarkerForm";
interface ISceneMarkersPanelProps { interface ISceneMarkersPanelProps {
scene: GQL.SceneDataFragment; sceneId: string;
isVisible: boolean; isVisible: boolean;
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
} }
@@ -16,6 +16,11 @@ interface ISceneMarkersPanelProps {
export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = ( export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
props: ISceneMarkersPanelProps props: ISceneMarkersPanelProps
) => { ) => {
const { data, loading } = GQL.useFindSceneMarkerTagsQuery({
variables: {
id: props.sceneId,
},
});
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false); const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [ const [
editingMarker, editingMarker,
@@ -33,6 +38,8 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
} }
}); });
if (loading) return null;
function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) { function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) {
setIsEditorOpen(true); setIsEditorOpen(true);
setEditingMarker(marker ?? undefined); setEditingMarker(marker ?? undefined);
@@ -50,12 +57,14 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
if (isEditorOpen) if (isEditorOpen)
return ( return (
<SceneMarkerForm <SceneMarkerForm
sceneID={props.scene.id} sceneID={props.sceneId}
editingMarker={editingMarker} editingMarker={editingMarker}
onClose={closeEditor} onClose={closeEditor}
/> />
); );
const sceneMarkers = data?.sceneMarkerTags[0]?.scene_markers ?? [];
return ( return (
<div className="scene-markers-panel"> <div className="scene-markers-panel">
<Button onClick={() => onOpenEditor()}> <Button onClick={() => onOpenEditor()}>
@@ -63,13 +72,13 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
</Button> </Button>
<div className="container"> <div className="container">
<PrimaryTags <PrimaryTags
sceneMarkers={props.scene.scene_markers ?? []} sceneMarkers={sceneMarkers}
onClickMarker={onClickMarker} onClickMarker={onClickMarker}
onEdit={onOpenEditor} onEdit={onOpenEditor}
/> />
</div> </div>
<WallPanel <WallPanel
sceneMarkers={props.scene.scene_markers} sceneMarkers={sceneMarkers}
clickHandler={(marker) => { clickHandler={(marker) => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
onClickMarker(marker as GQL.SceneMarkerDataFragment); onClickMarker(marker as GQL.SceneMarkerDataFragment);

View File

@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { TruncatedText } from "src/components/Shared"; import { TruncatedText } from "src/components/Shared";
import { JWUtils } from "src/utils"; import { VIDEO_PLAYER_ID } from "src/components/ScenePlayer";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
interface ISceneVideoFilterPanelProps { interface ISceneVideoFilterPanelProps {
@@ -109,10 +109,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
); );
function updateVideoStyle() { function updateVideoStyle() {
const playerId = JWUtils.playerID; const playerVideoElement = document.getElementById(VIDEO_PLAYER_ID);
const playerVideoElement = document
.getElementById(playerId)
?.getElementsByClassName("jw-video")[0];
if (playerVideoElement != null) { if (playerVideoElement != null) {
let styleString = "filter:"; let styleString = "filter:";
@@ -510,11 +507,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
const sceneAspectRatio = sceneWidth / sceneHeight; const sceneAspectRatio = sceneWidth / sceneHeight;
const sceneNewAspectRatio = sceneHeight / sceneWidth; const sceneNewAspectRatio = sceneHeight / sceneWidth;
const playerId = JWUtils.playerID; const playerVideoElement = document.getElementById(VIDEO_PLAYER_ID);
const playerVideoElement = document
.getElementById(playerId)
?.getElementsByClassName("jw-video")[0];
const playerWidth = playerVideoElement?.clientWidth ?? 1; const playerWidth = playerVideoElement?.clientWidth ?? 1;
const playerHeight = playerVideoElement?.clientHeight ?? 1; const playerHeight = playerVideoElement?.clientHeight ?? 1;
const playerAspectRation = playerWidth / playerHeight; const playerAspectRation = playerWidth / playerHeight;

View File

@@ -17,11 +17,13 @@
@import "src/components/Shared/styles.scss"; @import "src/components/Shared/styles.scss";
@import "src/components/Tags/styles.scss"; @import "src/components/Tags/styles.scss";
@import "src/components/Wall/styles.scss"; @import "src/components/Wall/styles.scss";
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
@import "src/components/Tagger/styles.scss"; @import "src/components/Tagger/styles.scss";
@import "src/hooks/Lightbox/lightbox.scss"; @import "src/hooks/Lightbox/lightbox.scss";
@import "src/components/Dialogs/IdentifyDialog/styles.scss"; @import "src/components/Dialogs/IdentifyDialog/styles.scss";
@import "src/components/Dialogs/styles.scss"; @import "src/components/Dialogs/styles.scss";
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
@import "video.js/dist/video-js.css";
@import "videojs-seek-buttons/dist/videojs-seek-buttons.css";
/* stylelint-disable */ /* stylelint-disable */
#root { #root {

View File

@@ -117,16 +117,15 @@ export class SceneQueue {
sceneID: string, sceneID: string,
options?: IPlaySceneOptions options?: IPlaySceneOptions
) { ) {
history.push(this.makeLink(sceneID, options)); history.replace(this.makeLink(sceneID, options));
} }
public makeLink(sceneID: string, options?: IPlaySceneOptions) { public makeLink(sceneID: string, options?: IPlaySceneOptions) {
const paramStr = this.makeQueryParameters( const params = [
options?.sceneIndex, this.makeQueryParameters(options?.sceneIndex, options?.newPage),
options?.newPage options?.autoPlay ? "autoplay=true" : "",
); options?.continue ? "continue=true" : "",
const autoplayParam = options?.autoPlay ? "&autoplay=true" : ""; ].filter((param) => !!param);
const continueParam = options?.continue ? "&continue=true" : ""; return `/scenes/${sceneID}${params.length ? "?" + params.join("&") : ""}`;
return `/scenes/${sceneID}?${paramStr}${autoplayParam}${continueParam}`;
} }
} }

View File

@@ -6,7 +6,6 @@ export { default as TextUtils } from "./text";
export { default as EditableTextUtils } from "./editabletext"; export { default as EditableTextUtils } from "./editabletext";
export { default as FormUtils } from "./form"; export { default as FormUtils } from "./form";
export { default as DurationUtils } from "./duration"; export { default as DurationUtils } from "./duration";
export { default as JWUtils } from "./jwplayer";
export { default as SessionUtils } from "./session"; export { default as SessionUtils } from "./session";
export { default as flattenMessages } from "./flattenMessages"; export { default as flattenMessages } from "./flattenMessages";
export { default as getISOCountry } from "./country"; export { default as getISOCountry } from "./country";

View File

@@ -1,10 +0,0 @@
const playerID = "main-jwplayer";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getPlayer = () => (window as any).jwplayer(playerID);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default {
playerID,
getPlayer,
};

File diff suppressed because it is too large Load Diff