mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
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:
@@ -70,4 +70,10 @@ fragment SceneData on Scene {
|
||||
endpoint
|
||||
stash_id
|
||||
}
|
||||
|
||||
sceneStreams {
|
||||
url
|
||||
mime_type
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene
|
||||
filesize
|
||||
duration
|
||||
scenes {
|
||||
...SlimSceneData
|
||||
...SceneData
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,9 @@ query FindScene($id: ID!, $checksum: String) {
|
||||
findScene(id: $id, checksum: $checksum) {
|
||||
...SceneData
|
||||
}
|
||||
}
|
||||
|
||||
query FindSceneMarkerTags($id: ID!) {
|
||||
sceneMarkerTags(scene_id: $id) {
|
||||
tag {
|
||||
id
|
||||
@@ -66,9 +68,11 @@ query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!)
|
||||
}
|
||||
|
||||
query SceneStreams($id: ID!) {
|
||||
sceneStreams(id: $id) {
|
||||
findScene(id: $id) {
|
||||
sceneStreams {
|
||||
url
|
||||
mime_type
|
||||
label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ type Scene {
|
||||
tags: [Tag!]!
|
||||
performers: [Performer!]!
|
||||
stash_ids: [StashID!]!
|
||||
|
||||
"""Return valid stream paths"""
|
||||
sceneStreams: [SceneStreamEndpoint!]!
|
||||
}
|
||||
|
||||
input SceneMovieInput {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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/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) {
|
||||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||
config := manager.GetInstance().Config
|
||||
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
|
||||
builder.APIKey = config.GetInstance().GetAPIKey()
|
||||
builder.APIKey = config.GetAPIKey()
|
||||
screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt.Timestamp)
|
||||
previewPath := builder.GetStreamPreviewURL()
|
||||
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) {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// Note: These have the wrong mime type intentionally to allow jwplayer to selection between mp4/webm
|
||||
webmLabelFourK := "WEBM 4K (2160p)" // "FOUR_K"
|
||||
@@ -166,6 +159,13 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami
|
||||
|
||||
ret = append(ret, defaultStreams...)
|
||||
|
||||
hls := models.SceneStreamEndpoint{
|
||||
URL: directStreamURL + ".m3u8",
|
||||
MimeType: &mimeHLS,
|
||||
Label: &labelHLS,
|
||||
}
|
||||
ret = append(ret, &hls)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intl": "^5.10.16",
|
||||
"react-jw-player": "1.19.1",
|
||||
"react-markdown": "^7.1.0",
|
||||
"react-router-bootstrap": "^0.25.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
@@ -71,7 +70,11 @@
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
"thehandy": "^0.2.7",
|
||||
"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-tsconfig-paths": "^3.3.17",
|
||||
"ws": "^7.4.6",
|
||||
@@ -96,6 +99,8 @@
|
||||
"@types/react-router-bootstrap": "^0.24.5",
|
||||
"@types/react-router-dom": "5.1.7",
|
||||
"@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/parser": "^4.33.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
@@ -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
@@ -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}}]);
|
||||
@@ -2,6 +2,7 @@
|
||||
* Add python location in System Settings for script scrapers and plugins. ([#2409](https://github.com/stashapp/stash/pull/2409))
|
||||
|
||||
### 🎨 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))
|
||||
* 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))
|
||||
|
||||
119
ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts
Normal file
119
ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts
Normal 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;
|
||||
@@ -1,447 +1,344 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
import ReactJWPlayer from "react-jw-player";
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
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 { JWUtils, ScreenUtils } from "src/utils";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
||||
import { Interactive } from "../../utils/interactive";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { Interactive } from "src/utils/interactive";
|
||||
|
||||
/*
|
||||
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>
|
||||
`;
|
||||
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
|
||||
|
||||
interface IScenePlayerProps {
|
||||
className?: string;
|
||||
scene: GQL.SceneDataFragment;
|
||||
sceneStreams: GQL.SceneStreamEndpoint[];
|
||||
scene: GQL.SceneDataFragment | undefined | null;
|
||||
timestamp: number;
|
||||
autoplay?: boolean;
|
||||
onReady?: () => void;
|
||||
onSeeked?: () => void;
|
||||
onTime?: () => void;
|
||||
onComplete?: () => void;
|
||||
config?: GQL.ConfigInterfaceDataFragment;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
}
|
||||
interface IScenePlayerState {
|
||||
scrubberPosition: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
config: Record<string, any>;
|
||||
interactiveClient: Interactive;
|
||||
}
|
||||
export class ScenePlayerImpl extends React.Component<
|
||||
IScenePlayerProps,
|
||||
IScenePlayerState
|
||||
> {
|
||||
private static isDirectStream(src?: string) {
|
||||
if (!src) {
|
||||
|
||||
export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
className,
|
||||
autoplay,
|
||||
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);
|
||||
});
|
||||
}
|
||||
}, [timestamp]);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const url = new URL(src);
|
||||
return url.pathname.endsWith("/stream");
|
||||
}
|
||||
if (!scene) return;
|
||||
|
||||
// Typings for jwplayer are, unfortunately, very lacking
|
||||
private player: any;
|
||||
private playlist: any;
|
||||
private lastTime = 0;
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
constructor(props: IScenePlayerProps) {
|
||||
super(props);
|
||||
this.onReady = this.onReady.bind(this);
|
||||
this.onSeeked = this.onSeeked.bind(this);
|
||||
this.onTime = this.onTime.bind(this);
|
||||
const auto =
|
||||
autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0;
|
||||
if (!auto && scene.paths?.screenshot) player.poster(scene.paths.screenshot);
|
||||
else player.poster("");
|
||||
|
||||
this.onScrubberSeek = this.onScrubberSeek.bind(this);
|
||||
this.onScrubberScrolled = this.onScrubberScrolled.bind(this);
|
||||
this.state = {
|
||||
scrubberPosition: 0,
|
||||
config: this.makeJWPlayerConfig(props.scene),
|
||||
interactiveClient: new Interactive(
|
||||
this.props.config?.handyKey || "",
|
||||
this.props.config?.funscriptOffset || 0
|
||||
),
|
||||
};
|
||||
// clear the offset before loading anything new.
|
||||
// otherwise, the offset will be applied to the next file when
|
||||
// currentTime is called.
|
||||
(player as any).clearOffsetDuration();
|
||||
player.src(
|
||||
scene.sceneStreams.map((stream) => ({
|
||||
src: stream.url,
|
||||
type: stream.mime_type ?? undefined,
|
||||
label: stream.label ?? undefined,
|
||||
}))
|
||||
);
|
||||
player.currentTime(0);
|
||||
|
||||
// Default back to Direct Streaming
|
||||
localStorage.removeItem("jwplayer.qualityLabel");
|
||||
}
|
||||
public UNSAFE_componentWillReceiveProps(props: IScenePlayerProps) {
|
||||
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
|
||||
player.loop(
|
||||
!!scene.file.duration &&
|
||||
maxLoopDuration !== 0 &&
|
||||
scene.file.duration < maxLoopDuration
|
||||
);
|
||||
|
||||
// control bar icon
|
||||
const buttonContainer = playerContainer.querySelector(
|
||||
".jw-button-container"
|
||||
) 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);
|
||||
};
|
||||
player.on("loadstart", function (this: VideoJsPlayer) {
|
||||
// handle offset after loading so that we get the correct current source
|
||||
handleOffset(this);
|
||||
});
|
||||
}
|
||||
|
||||
private onReady() {
|
||||
this.player = JWUtils.getPlayer();
|
||||
this.addForwardButton();
|
||||
|
||||
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("play", function (this: VideoJsPlayer) {
|
||||
if (scene.interactive) {
|
||||
interactiveClient.play(this.currentTime());
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
this.player.on("meta", (metadata: any) => {
|
||||
if (
|
||||
metadata.metadataType === "media" &&
|
||||
!metadata.width &&
|
||||
!metadata.height
|
||||
) {
|
||||
player.on("pause", () => {
|
||||
if (scene.interactive) {
|
||||
interactiveClient.pause();
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
// 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.
|
||||
const currentFile = this.player.getPlaylistItem().file;
|
||||
const currentFile = player.currentSrc();
|
||||
if (currentFile != null && !currentFile.includes("m3u8")) {
|
||||
const state = this.player.getState();
|
||||
const play = state === "buffering" || state === "playing";
|
||||
this.handleError(play);
|
||||
// const play = !player.paused();
|
||||
// handleError(play);
|
||||
player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.player.on("firstFrame", () => {
|
||||
if (this.props.timestamp > 0) {
|
||||
this.player.seek(this.props.timestamp);
|
||||
player.load();
|
||||
|
||||
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", () => {
|
||||
if (this.props.scene.interactive) {
|
||||
this.state.interactiveClient.play(this.player.getPosition());
|
||||
}
|
||||
});
|
||||
return () => player.off("ended");
|
||||
}, [onComplete]);
|
||||
|
||||
this.player.on("pause", () => {
|
||||
if (this.props.scene.interactive) {
|
||||
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 onScrubberScrolled = () => {
|
||||
playerRef.current?.pause();
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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 onScrubberSeek = (seconds: number) => {
|
||||
playerRef.current?.currentTime(seconds);
|
||||
};
|
||||
|
||||
const seekHook = (seekToPosition: number, _videoTag: HTMLVideoElement) => {
|
||||
if (!_videoTag.src || _videoTag.src.endsWith(".m3u8")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
const isPortrait =
|
||||
scene &&
|
||||
scene.file.height &&
|
||||
scene.file.width &&
|
||||
scene.file.height > scene.file.width;
|
||||
|
||||
return (
|
||||
<div id="jwplayer-container" className={className}>
|
||||
<ReactJWPlayer
|
||||
playerId={JWUtils.playerID}
|
||||
playerScript="jwplayer/jwplayer.js"
|
||||
customProps={this.state.config}
|
||||
onReady={this.onReady}
|
||||
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 className={cx("VideoPlayer", { portrait: isPortrait })}>
|
||||
<div data-vjs-player className={cx("video-wrapper", className)}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
id={VIDEO_PLAYER_ID}
|
||||
className="video-js vjs-big-play-centered"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ScenePlayer: React.FC<IScenePlayerProps> = (
|
||||
props: IScenePlayerProps
|
||||
) => {
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
|
||||
return (
|
||||
<ScenePlayerImpl
|
||||
{...props}
|
||||
config={configuration ? configuration.interface : undefined}
|
||||
{scene && (
|
||||
<ScenePlayerScrubber
|
||||
scene={scene}
|
||||
position={time}
|
||||
onSeek={onScrubberSeek}
|
||||
onScrolled={onScrubberScrolled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getPlayerPosition = () =>
|
||||
VideoJS.getPlayer(VIDEO_PLAYER_ID).currentTime();
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { ScenePlayer } from "./ScenePlayer";
|
||||
export * from "./ScenePlayer";
|
||||
|
||||
76
ui/v2.5/src/components/ScenePlayer/live.ts
Normal file
76
ui/v2.5/src/components/ScenePlayer/live.ts
Normal 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;
|
||||
@@ -2,7 +2,7 @@ $scrubberHeight: 120px;
|
||||
$menuHeight: 4rem;
|
||||
$sceneTabWidth: 450px;
|
||||
|
||||
#jwplayer-container {
|
||||
.VideoPlayer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - #{$menuHeight});
|
||||
@@ -12,29 +12,143 @@ $sceneTabWidth: 450px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
& video:focus {
|
||||
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 {
|
||||
.video-js {
|
||||
height: 56.25vw;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.portrait .video-wrapper {
|
||||
&.portrait .video-js {
|
||||
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 {
|
||||
outline: none;
|
||||
}
|
||||
@@ -161,6 +275,7 @@ $sceneTabWidth: 450px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: relative;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
width: 96%;
|
||||
|
||||
&.dragging {
|
||||
@@ -255,3 +370,22 @@ $sceneTabWidth: 450px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export const QueueViewer: React.FC<IPlaylistViewer> = ({
|
||||
<img alt={scene.title ?? ""} src={scene.paths.screenshot ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="align-middle">
|
||||
<span className="align-middle text-break">
|
||||
{scene.title ?? TextUtils.fileNameFromPath(scene.path)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import React, { useEffect, useState, useMemo, useContext } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useParams, useLocation, useHistory, Link } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
mutateMetadataScan,
|
||||
@@ -12,19 +11,19 @@ import {
|
||||
useSceneIncrementO,
|
||||
useSceneDecrementO,
|
||||
useSceneResetO,
|
||||
useSceneStreams,
|
||||
useSceneGenerateScreenshot,
|
||||
useSceneUpdate,
|
||||
queryFindScenes,
|
||||
queryFindScenesByID,
|
||||
} from "src/core/StashService";
|
||||
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 { ScenePlayer } from "src/components/ScenePlayer";
|
||||
import { TextUtils, JWUtils } from "src/utils";
|
||||
import { SubmitStashBoxDraft } from "src/components/Dialogs/SubmitDraft";
|
||||
import { ScenePlayer, getPlayerPosition } from "src/components/ScenePlayer";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { TextUtils } from "src/utils";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { SceneQueue } from "src/models/sceneQueue";
|
||||
import { QueueViewer } from "./QueueViewer";
|
||||
import { SceneMarkersPanel } from "./SceneMarkersPanel";
|
||||
@@ -44,32 +43,50 @@ import { ConfigurationContext } from "src/hooks/Config";
|
||||
interface IProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
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 location = useLocation();
|
||||
const ScenePage: React.FC<IProps> = ({
|
||||
scene,
|
||||
refetch,
|
||||
setTimestamp,
|
||||
queueScenes,
|
||||
onQueueNext,
|
||||
onQueuePrevious,
|
||||
onQueueRandom,
|
||||
continuePlaylist,
|
||||
playScene,
|
||||
queueHasMoreScenes,
|
||||
onQueueMoreScenes,
|
||||
onQueueLessScenes,
|
||||
queueStart,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
setContinuePlaylist,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
const [updateScene] = useSceneUpdate();
|
||||
const [generateScreenshot] = useSceneGenerateScreenshot();
|
||||
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
|
||||
const [showScrubber, setShowScrubber] = useState(
|
||||
configuration?.interface.showScrubber ?? true
|
||||
);
|
||||
const [showDraftModal, setShowDraftModal] = useState(false);
|
||||
const boxes = configuration?.general?.stashBoxes ?? [];
|
||||
|
||||
const {
|
||||
data: sceneStreams,
|
||||
error: streamableError,
|
||||
loading: streamableLoading,
|
||||
} = useSceneStreams(scene.id);
|
||||
|
||||
const [incrementO] = useSceneIncrementO(scene.id);
|
||||
const [decrementO] = useSceneDecrementO(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 [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
|
||||
|
||||
const [sceneQueue, setSceneQueue] = useState<SceneQueue>(new SceneQueue());
|
||||
const [queueScenes, setQueueScenes] = useState<GQL.SlimSceneDataFragment[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
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);
|
||||
const onIncrementClick = async () => {
|
||||
try {
|
||||
await incrementO();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
async function getQueueScenes(sceneIDs: number[]) {
|
||||
const query = await queryFindScenesByID(sceneIDs);
|
||||
const { scenes, count } = query.data.findScenes;
|
||||
setQueueScenes(scenes);
|
||||
setQueueTotal(count);
|
||||
setQueueStart(1);
|
||||
const onDecrementClick = async () => {
|
||||
try {
|
||||
await decrementO();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
setContinuePlaylist(queryParams?.continue === "true");
|
||||
}, [queryParams]);
|
||||
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));
|
||||
|
||||
// HACK - jwplayer doesn't handle re-rendering when scene changes, so force
|
||||
// a rerender by not drawing it
|
||||
useEffect(() => {
|
||||
if (rerenderPlayer) {
|
||||
setRerenderPlayer(false);
|
||||
}
|
||||
}, [rerenderPlayer]);
|
||||
|
||||
useEffect(() => {
|
||||
setRerenderPlayer(true);
|
||||
}, [scene.id]);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
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(",");
|
||||
};
|
||||
});
|
||||
|
||||
const onOrganizedClick = async () => {
|
||||
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 () => {
|
||||
try {
|
||||
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) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
@@ -370,9 +257,7 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
|
||||
<Dropdown.Item
|
||||
key="generate-screenshot"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() =>
|
||||
onGenerateScreenshot(JWUtils.getPlayer().getPosition())
|
||||
}
|
||||
onClick={() => onGenerateScreenshot(getPlayerPosition())}
|
||||
>
|
||||
<FormattedMessage id="actions.generate_thumb_from_current" />
|
||||
</Dropdown.Item>
|
||||
@@ -502,7 +387,7 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
|
||||
scenes={queueScenes}
|
||||
currentID={scene.id}
|
||||
continue={continuePlaylist}
|
||||
setContinue={(v) => setContinuePlaylist(v)}
|
||||
setContinue={setContinuePlaylist}
|
||||
onSceneClicked={(sceneID) => playScene(sceneID)}
|
||||
onNext={onQueueNext}
|
||||
onPrevious={onQueuePrevious}
|
||||
@@ -515,7 +400,7 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scene-markers-panel">
|
||||
<SceneMarkersPanel
|
||||
scene={scene}
|
||||
sceneId={scene.id}
|
||||
onClickMarker={onClickMarker}
|
||||
isVisible={activeTabKey === "scene-markers-panel"}
|
||||
/>
|
||||
@@ -551,44 +436,12 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
|
||||
</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() {
|
||||
return collapsed ? ">" : "<";
|
||||
}
|
||||
|
||||
if (streamableLoading) return <LoadingIndicator />;
|
||||
if (streamableError) return <ErrorMessage error={streamableError.message} />;
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{scene.title ?? TextUtils.fileNameFromPath(scene.path)}</title>
|
||||
</Helmet>
|
||||
@@ -626,20 +479,6 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
|
||||
{getCollapseButtonText()}
|
||||
</Button>
|
||||
</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
|
||||
boxes={boxes}
|
||||
entity={scene}
|
||||
@@ -647,20 +486,233 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
|
||||
show={showDraftModal}
|
||||
onHide={() => setShowDraftModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SceneLoader: React.FC = () => {
|
||||
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 (!data?.findScene)
|
||||
if (!loading && !data?.findScene)
|
||||
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;
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
TagSelect,
|
||||
MarkerTitleSuggest,
|
||||
} from "src/components/Shared";
|
||||
import { getPlayerPosition } from "src/components/ScenePlayer";
|
||||
import { useToast } from "src/hooks";
|
||||
import { JWUtils } from "src/utils";
|
||||
|
||||
interface IFormFields {
|
||||
title: string;
|
||||
@@ -82,7 +82,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
||||
onReset={() =>
|
||||
fieldProps.form.setFieldValue(
|
||||
"seconds",
|
||||
Math.round(JWUtils.getPlayer()?.getPosition() ?? 0)
|
||||
Math.round(getPlayerPosition() ?? 0)
|
||||
)
|
||||
}
|
||||
numericValue={Number.parseInt(fieldProps.field.value ?? "0", 10)}
|
||||
@@ -117,8 +117,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
||||
const values: IFormFields = {
|
||||
title: editingMarker?.title ?? "",
|
||||
seconds: (
|
||||
editingMarker?.seconds ??
|
||||
Math.round(JWUtils.getPlayer()?.getPosition() ?? 0)
|
||||
editingMarker?.seconds ?? Math.round(getPlayerPosition() ?? 0)
|
||||
).toString(),
|
||||
primaryTagId: editingMarker?.primary_tag.id ?? "",
|
||||
tagIds: editingMarker?.tags.map((tag) => tag.id) ?? [],
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PrimaryTags } from "./PrimaryTags";
|
||||
import { SceneMarkerForm } from "./SceneMarkerForm";
|
||||
|
||||
interface ISceneMarkersPanelProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
sceneId: string;
|
||||
isVisible: boolean;
|
||||
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
|
||||
}
|
||||
@@ -16,6 +16,11 @@ interface ISceneMarkersPanelProps {
|
||||
export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
|
||||
props: ISceneMarkersPanelProps
|
||||
) => {
|
||||
const { data, loading } = GQL.useFindSceneMarkerTagsQuery({
|
||||
variables: {
|
||||
id: props.sceneId,
|
||||
},
|
||||
});
|
||||
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
|
||||
const [
|
||||
editingMarker,
|
||||
@@ -33,6 +38,8 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) {
|
||||
setIsEditorOpen(true);
|
||||
setEditingMarker(marker ?? undefined);
|
||||
@@ -50,12 +57,14 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
|
||||
if (isEditorOpen)
|
||||
return (
|
||||
<SceneMarkerForm
|
||||
sceneID={props.scene.id}
|
||||
sceneID={props.sceneId}
|
||||
editingMarker={editingMarker}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
);
|
||||
|
||||
const sceneMarkers = data?.sceneMarkerTags[0]?.scene_markers ?? [];
|
||||
|
||||
return (
|
||||
<div className="scene-markers-panel">
|
||||
<Button onClick={() => onOpenEditor()}>
|
||||
@@ -63,13 +72,13 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
|
||||
</Button>
|
||||
<div className="container">
|
||||
<PrimaryTags
|
||||
sceneMarkers={props.scene.scene_markers ?? []}
|
||||
sceneMarkers={sceneMarkers}
|
||||
onClickMarker={onClickMarker}
|
||||
onEdit={onOpenEditor}
|
||||
/>
|
||||
</div>
|
||||
<WallPanel
|
||||
sceneMarkers={props.scene.scene_markers}
|
||||
sceneMarkers={sceneMarkers}
|
||||
clickHandler={(marker) => {
|
||||
window.scrollTo(0, 0);
|
||||
onClickMarker(marker as GQL.SceneMarkerDataFragment);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
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";
|
||||
|
||||
interface ISceneVideoFilterPanelProps {
|
||||
@@ -109,10 +109,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
|
||||
);
|
||||
|
||||
function updateVideoStyle() {
|
||||
const playerId = JWUtils.playerID;
|
||||
const playerVideoElement = document
|
||||
.getElementById(playerId)
|
||||
?.getElementsByClassName("jw-video")[0];
|
||||
const playerVideoElement = document.getElementById(VIDEO_PLAYER_ID);
|
||||
|
||||
if (playerVideoElement != null) {
|
||||
let styleString = "filter:";
|
||||
@@ -510,11 +507,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
|
||||
const sceneAspectRatio = sceneWidth / sceneHeight;
|
||||
const sceneNewAspectRatio = sceneHeight / sceneWidth;
|
||||
|
||||
const playerId = JWUtils.playerID;
|
||||
const playerVideoElement = document
|
||||
.getElementById(playerId)
|
||||
?.getElementsByClassName("jw-video")[0];
|
||||
|
||||
const playerVideoElement = document.getElementById(VIDEO_PLAYER_ID);
|
||||
const playerWidth = playerVideoElement?.clientWidth ?? 1;
|
||||
const playerHeight = playerVideoElement?.clientHeight ?? 1;
|
||||
const playerAspectRation = playerWidth / playerHeight;
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
@import "src/components/Shared/styles.scss";
|
||||
@import "src/components/Tags/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/hooks/Lightbox/lightbox.scss";
|
||||
@import "src/components/Dialogs/IdentifyDialog/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 */
|
||||
#root {
|
||||
|
||||
@@ -117,16 +117,15 @@ export class SceneQueue {
|
||||
sceneID: string,
|
||||
options?: IPlaySceneOptions
|
||||
) {
|
||||
history.push(this.makeLink(sceneID, options));
|
||||
history.replace(this.makeLink(sceneID, options));
|
||||
}
|
||||
|
||||
public makeLink(sceneID: string, options?: IPlaySceneOptions) {
|
||||
const paramStr = this.makeQueryParameters(
|
||||
options?.sceneIndex,
|
||||
options?.newPage
|
||||
);
|
||||
const autoplayParam = options?.autoPlay ? "&autoplay=true" : "";
|
||||
const continueParam = options?.continue ? "&continue=true" : "";
|
||||
return `/scenes/${sceneID}?${paramStr}${autoplayParam}${continueParam}`;
|
||||
const params = [
|
||||
this.makeQueryParameters(options?.sceneIndex, options?.newPage),
|
||||
options?.autoPlay ? "autoplay=true" : "",
|
||||
options?.continue ? "continue=true" : "",
|
||||
].filter((param) => !!param);
|
||||
return `/scenes/${sceneID}${params.length ? "?" + params.join("&") : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export { default as TextUtils } from "./text";
|
||||
export { default as EditableTextUtils } from "./editabletext";
|
||||
export { default as FormUtils } from "./form";
|
||||
export { default as DurationUtils } from "./duration";
|
||||
export { default as JWUtils } from "./jwplayer";
|
||||
export { default as SessionUtils } from "./session";
|
||||
export { default as flattenMessages } from "./flattenMessages";
|
||||
export { default as getISOCountry } from "./country";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user