diff --git a/package.json b/package.json
index 467ade17..d51935b5 100755
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
- "version": "4.20.61",
+ "version": "4.20.62",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"license": "Apache-2.0",
"scripts": {
diff --git a/src/entry.js b/src/entry.js
index a1a794ae..affcf4e1 100644
--- a/src/entry.js
+++ b/src/entry.js
@@ -2,11 +2,13 @@
'use strict';
(() => {
// Don't run on certain sub-domains.
- if ( /^(?:localhost\.rig|blog|player|im|chatdepot|tmi|api|brand|dev)\./.test(location.hostname) )
+ if ( /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev)\./.test(location.hostname) )
return;
- const DEBUG = localStorage.ffzDebugMode == 'true' && document.body.classList.contains('ffz-dev') && ! window.Ember,
- FLAVOR = location.pathname === '/p/ffz_bridge/' ? 'bridge' : 'avalon',
+ const DEBUG = localStorage.ffzDebugMode == 'true' && document.body.classList.contains('ffz-dev'),
+ FLAVOR =
+ location.hostname.includes('player') ? 'player' :
+ (location.pathname === '/p/ffz_bridge/' ? 'bridge' : 'avalon'),
SERVER = DEBUG ? '//localhost:8000' : '//cdn.frankerfacez.com',
CLIPS = /clips\.twitch\.tv/.test(location.hostname) ? 'clips/' : '',
diff --git a/src/experiments.js b/src/experiments.js
index a907133a..d3793644 100644
--- a/src/experiments.js
+++ b/src/experiments.js
@@ -261,7 +261,7 @@ export default class ExperimentManager extends Module {
// Twitch Experiments
getTwitchType(type) {
- const core = this.resolve('site')?.getCore();
+ const core = this.resolve('site')?.getCore?.();
if ( core?.experiments?.getExperimentType )
return core.experiments.getExperimentType(type);
@@ -275,7 +275,7 @@ export default class ExperimentManager extends Module {
}
getTwitchTypeByKey(key) {
- const core = this.resolve('site')?.getCore(),
+ const core = this.resolve('site')?.getCore?.(),
exps = core && core.experiments,
exp = exps?.experiments?.[key];
@@ -289,13 +289,13 @@ export default class ExperimentManager extends Module {
if ( window.__twilightSettings )
return window.__twilightSettings.experiments;
- const core = this.resolve('site')?.getCore();
+ const core = this.resolve('site')?.getCore?.();
return core && core.experiments.experiments;
}
usingTwitchExperiment(key) {
- const core = this.resolve('site')?.getCore();
+ const core = this.resolve('site')?.getCore?.();
return core && has(core.experiments.assignments, key)
}
@@ -305,7 +305,7 @@ export default class ExperimentManager extends Module {
overrides[key] = value;
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
- const core = this.resolve('site')?.getCore();
+ const core = this.resolve('site')?.getCore?.();
if ( core )
core.experiments.overrides[key] = value;
@@ -321,7 +321,7 @@ export default class ExperimentManager extends Module {
delete overrides[key];
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
- const core = this.resolve('site')?.getCore();
+ const core = this.resolve('site')?.getCore?.();
if ( core )
delete core.experiments.overrides[key];
@@ -334,7 +334,7 @@ export default class ExperimentManager extends Module {
}
getTwitchAssignment(key, channel = null) {
- const core = this.resolve('site')?.getCore(),
+ const core = this.resolve('site')?.getCore?.(),
exps = core && core.experiments;
if ( ! exps )
@@ -378,7 +378,7 @@ export default class ExperimentManager extends Module {
}
_rebuildTwitchKey(key, is_set, new_val) {
- const core = this.resolve('site')?.getCore(),
+ const core = this.resolve('site')?.getCore?.(),
exps = core.experiments,
old_val = has(exps.assignments, key) ?
diff --git a/src/modules/chat/rich_providers.js b/src/modules/chat/rich_providers.js
index 35db2087..b0138fca 100644
--- a/src/modules/chat/rich_providers.js
+++ b/src/modules/chat/rich_providers.js
@@ -4,8 +4,10 @@
// Rich Content Providers
// ============================================================================
-const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
-const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\w+)/;
+//const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
+//const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\w+)/;
+const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/([a-z0-9-]+)(?:\/)?(\w+)?(?:\/edit)?/i;
+const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/([a-z0-9-]+)/i;
const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
const USER_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([^/]+)$/;
@@ -258,6 +260,7 @@ export const Clips = {
return {
url: token.url,
+ accent: '#6441a4',
short: {
type: 'header',
diff --git a/src/modules/main_menu/components/menu-page.vue b/src/modules/main_menu/components/menu-page.vue
index 6ca684e7..054f30d0 100644
--- a/src/modules/main_menu/components/menu-page.vue
+++ b/src/modules/main_menu/components/menu-page.vue
@@ -117,12 +117,13 @@ export default {
},
onBeforeChange(current, new_item) {
- for(const child of this.$refs.children)
- if ( child && child.onBeforeChange ) {
- const res = child.onBeforeChange(current, new_item);
- if ( res !== undefined )
- return res;
- }
+ if ( this.$refs.children )
+ for(const child of this.$refs.children)
+ if ( child && child.onBeforeChange ) {
+ const res = child.onBeforeChange(current, new_item);
+ if ( res !== undefined )
+ return res;
+ }
}
}
}
diff --git a/src/modules/metadata.jsx b/src/modules/metadata.jsx
index 6c5c954b..59e50d18 100644
--- a/src/modules/metadata.jsx
+++ b/src/modules/metadata.jsx
@@ -1,4 +1,4 @@
- 'use strict';
+'use strict';
// ============================================================================
// Channel Metadata
diff --git a/src/player.js b/src/player.js
index c1158d32..e2471559 100644
--- a/src/player.js
+++ b/src/player.js
@@ -1,16 +1,18 @@
'use strict';
+import dayjs from 'dayjs';
import RavenLogger from './raven';
import Logger from 'utilities/logging';
import Module from 'utilities/module';
-import { timeout } from 'utilities/object';
import {DEBUG} from 'utilities/constants';
+import {timeout} from 'utilities/object';
import SettingsManager from './settings/index';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
+import Site from './sites/player';
class FFZPlayer extends Module {
constructor() {
@@ -46,7 +48,7 @@ class FFZPlayer extends Module {
this.inject('settings', SettingsManager);
this.inject('experiments', ExperimentManager);
this.inject('i18n', TranslationManager);
-
+ this.inject('site', Site);
// ========================================================================
// Startup
@@ -106,11 +108,6 @@ class FFZPlayer extends Module {
-------------------------------------------------------------------------------
${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n');
}
-
- async onEnable() {
-
- }
-
}
@@ -127,11 +124,11 @@ const VER = FFZPlayer.version_info = {
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}`
}
-FFZPlayer.utilities = {
+// We don't support addons in the player right now, so
+/*FFZPlayer.utilities = {
addon: require('utilities/addon'),
color: require('utilities/color'),
constants: require('utilities/constants'),
- dialog: require('utilities/dialog'),
dom: require('utilities/dom'),
events: require('utilities/events'),
fontAwesome: require('utilities/font-awesome'),
@@ -144,8 +141,8 @@ FFZPlayer.utilities = {
i18n: require('utilities/translation-core'),
dayjs: require('dayjs'),
popper: require('popper.js').default
-}
+}*/
window.FFZPlayer = FFZPlayer;
-window.ffz_player = new FFZPlayer();
\ No newline at end of file
+window.ffz = new FFZPlayer();
\ No newline at end of file
diff --git a/src/raven.js b/src/raven.js
index 040c5d24..1607640b 100644
--- a/src/raven.js
+++ b/src/raven.js
@@ -131,7 +131,7 @@ export default class RavenLogger extends Module {
if ( munch )
munch.getRequire().then(() => {
const site = this.resolve('site'),
- core = site.getCore(),
+ core = site?.getCore?.(),
logger = core?.logger;
if ( logger && ! logger.rootLogger ) {
@@ -151,7 +151,7 @@ export default class RavenLogger extends Module {
autoBreadcrumbs: {
console: false
},
- release: (window.FrankerFaceZ || window.FFZBridge).version_info.toString(),
+ release: (window.FrankerFaceZ || window.FFZPlayer || window.FFZBridge).version_info.toString(),
environment: DEBUG ? 'development' : 'production',
captureUnhandledRejections: false,
ignoreErrors: [
@@ -380,7 +380,7 @@ export default class RavenLogger extends Module {
buildTags() {
- const core = this.resolve('site')?.getCore(),
+ const core = this.resolve('site')?.getCore?.(),
out = {};
out.flavor = this.site?.constructor.name;
diff --git a/src/settings/components/page.vue b/src/settings/components/page.vue
index fa4d1a88..248a3ffc 100644
--- a/src/settings/components/page.vue
+++ b/src/settings/components/page.vue
@@ -86,7 +86,7 @@ export default {
}
try {
- return decodeURI(new URL(this.route.url(parts), location));
+ return decodeURI(new URL(this.route.url(parts), this.route.domain ? `https://${this.route.domain}` : location));
} catch(err) {
return '(unable to render url)';
}
diff --git a/src/sites/base.js b/src/sites/base.js
index 5a805373..9edafd13 100644
--- a/src/sites/base.js
+++ b/src/sites/base.js
@@ -15,12 +15,6 @@ export default class BaseSite extends Module {
this.log.info(`Using: ${this.constructor.name}`);
}
- async populateModules() {
- const ctx = await require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/);
- const modules = await this.populate(ctx, this.log);
- this.log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
- }
-
// ========================================================================
// DOM Manipulation
diff --git a/src/sites/player/css_tweaks/index.js b/src/sites/player/css_tweaks/index.js
new file mode 100644
index 00000000..15644542
--- /dev/null
+++ b/src/sites/player/css_tweaks/index.js
@@ -0,0 +1,87 @@
+'use strict';
+
+// ============================================================================
+// CSS Tweaks for Twitch Twilight
+// ============================================================================
+
+import Module from 'utilities/module';
+import {ManagedStyle} from 'utilities/dom';
+import {has} from 'utilities/object';
+
+
+const CLASSES = {
+ 'player-ext': '.video-player .extension-taskbar,.video-player .extension-container,.video-player .extensions-dock__layout,.video-player .extensions-notifications,.video-player .extensions-video-overlay-size-container,.video-player .extensions-dock__layout',
+ 'player-ext-hover': '.video-player__overlay[data-controls="false"] .extension-taskbar,.video-player__overlay[data-controls="false"] .extension-container,.video-player__overlay[data-controls="false"] .extensions-dock__layout,.video-player__overlay[data-controls="false"] .extensions-notifications,.video-player__overlay[data-controls="false"] .extensions-video-overlay-size-container',
+};
+
+
+export default class CSSTweaks extends Module {
+ constructor(...args) {
+ super(...args);
+
+ this.should_enable = true;
+
+ this.inject('settings');
+
+ this.style = new ManagedStyle;
+ this.chunks = {};
+ this.chunks_loaded = false;
+ }
+
+
+ toggleHide(key, val) {
+ const k = `hide--${key}`;
+ if ( ! val ) {
+ this.style.delete(k);
+ return;
+ }
+
+ if ( ! has(CLASSES, key) )
+ throw new Error(`cannot find class for "${key}"`);
+
+ this.style.set(k, `${CLASSES[key]} { display: none !important }`);
+ }
+
+
+ async toggle(key, val) {
+ if ( ! val ) {
+ this.style.delete(key);
+ return;
+ }
+
+ if ( ! this.chunks_loaded )
+ await this.populate();
+
+ if ( ! has(this.chunks, key) )
+ throw new Error(`cannot find chunk "${key}"`);
+
+ this.style.set(key, this.chunks[key]);
+ }
+
+
+ set(key, val) { return this.style.set(key, val) }
+ delete(key) { return this.style.delete(key) }
+
+ setVariable(key, val, scope = 'body') {
+ this.style.set(`var--${key}`, `${scope} { --ffz-${key}: ${val}; }`);
+ }
+
+ deleteVariable(key) { this.style.delete(`var--${key}`) }
+
+
+ populate() {
+ if ( this.chunks_loaded )
+ return;
+
+ return new Promise(async r => {
+ const raw = (await import(/* webpackChunkName: "player-css-tweaks" */ './styles.js')).default;
+ for(const key of raw.keys()) {
+ const k = key.slice(2, key.length - (key.endsWith('.scss') ? 5 : 4));
+ this.chunks[k] = raw(key).default;
+ }
+
+ this.chunks_loaded = true;
+ r();
+ })
+ }
+}
diff --git a/src/sites/player/css_tweaks/styles.js b/src/sites/player/css_tweaks/styles.js
new file mode 100644
index 00000000..dd6cb65f
--- /dev/null
+++ b/src/sites/player/css_tweaks/styles.js
@@ -0,0 +1,3 @@
+'use strict';
+
+export default require.context('!raw-loader!sass-loader!./styles', false, /\.s?css$/);
\ No newline at end of file
diff --git a/src/sites/player/css_tweaks/styles/player-ext-mouse.scss b/src/sites/player/css_tweaks/styles/player-ext-mouse.scss
new file mode 100644
index 00000000..2a82a7b9
--- /dev/null
+++ b/src/sites/player/css_tweaks/styles/player-ext-mouse.scss
@@ -0,0 +1,5 @@
+.video-player .extension-overlay__iframe,
+.video-player .extension-overlay,
+.video-player .extension-view__iframe {
+ pointer-events: none !important;
+}
\ No newline at end of file
diff --git a/src/sites/player/css_tweaks/styles/player-hide-mouse.scss b/src/sites/player/css_tweaks/styles/player-hide-mouse.scss
new file mode 100644
index 00000000..896487f7
--- /dev/null
+++ b/src/sites/player/css_tweaks/styles/player-hide-mouse.scss
@@ -0,0 +1,3 @@
+.video-player__overlay[data-controls="false"][data-paused="false"][data-ended="false"] {
+ cursor: none;
+}
\ No newline at end of file
diff --git a/src/sites/player/css_tweaks/styles/player-volume.scss b/src/sites/player/css_tweaks/styles/player-volume.scss
new file mode 100644
index 00000000..29965f76
--- /dev/null
+++ b/src/sites/player/css_tweaks/styles/player-volume.scss
@@ -0,0 +1,3 @@
+.video-player .volume-slider__slider-container {
+ opacity: 1 !important;
+}
\ No newline at end of file
diff --git a/src/sites/player/index.jsx b/src/sites/player/index.jsx
new file mode 100644
index 00000000..99d10d67
--- /dev/null
+++ b/src/sites/player/index.jsx
@@ -0,0 +1,157 @@
+'use strict';
+
+// ============================================================================
+// Standalone Player
+// ============================================================================
+
+import {createElement} from 'utilities/dom';
+
+import BaseSite from '../base';
+
+import Fine from 'utilities/compat/fine';
+import Player from './player';
+import CSSTweaks from './css_tweaks';
+import Tooltips from 'src/modules/tooltips';
+
+import MAIN_URL from './styles/player-main.scss';
+
+// ============================================================================
+// The Site
+// ============================================================================
+
+export default class PlayerSite extends BaseSite {
+ constructor(...args) {
+ super(...args);
+
+ this.inject('i18n');
+ this.inject(Fine);
+ this.inject(Player);
+ this.inject('tooltips', Tooltips);
+ this.inject('css_tweaks', CSSTweaks);
+
+ this.DataSource = this.fine.define(
+ 'data-source',
+ n => n.consentMetadata && n.onPlaying && n.props && n.props.data
+ );
+
+ this.PlayerMenu = this.fine.define(
+ 'player-menu',
+ n => n.closeSettingsMenu && n.state && n.state.activeMenu && n.getMaxMenuHeight
+ );
+ }
+
+ onEnable() {
+ this.settings = this.resolve('settings');
+
+ this.DataSource.on('mount', this.updateData, this);
+ this.DataSource.on('update', this.updateData, this);
+ this.DataSource.ready((cls, instances) => {
+ for(const inst of instances)
+ this.updateData(inst);
+ });
+
+ this.PlayerMenu.on('mount', this.updateMenu, this);
+ this.PlayerMenu.on('update', this.updateMenu, this);
+ this.PlayerMenu.ready((cls, instances) => {
+ for(const inst of instances)
+ this.updateMenu(inst);
+ });
+
+ this.on('i18n:update', () => {
+ for(const inst of this.PlayerMenu.instances)
+ this.updateMenu(inst);
+ });
+
+ // Window Size
+ const update_size = () => this.settings.updateContext({
+ size: {
+ height: window.innerHeight,
+ width: window.innerWidth
+ }
+ });
+
+ window.addEventListener('resize', update_size);
+ update_size();
+
+ this.settings.updateContext({
+ route: {
+ name: 'popout-player',
+ parts: ['/'],
+ domain: 'player.twitch.tv'
+ },
+ route_data: ['/']
+ });
+
+ document.head.appendChild(createElement('link', {
+ href: MAIN_URL,
+ rel: 'stylesheet',
+ type: 'text/css',
+ crossOrigin: 'anonymous'
+ }));
+ }
+
+ get data() {
+ return this.DataSource.first;
+ }
+
+ updateData(inst) {
+ const user = inst?.props?.data?.user,
+
+ bcast = user?.broadcastSettings,
+ game = user?.stream?.game;
+
+ this.settings.updateContext({
+ title: bcast?.title,
+ channelID: user?.id,
+ category: game?.name,
+ categoryID: game?.id
+ });
+ }
+
+ updateMenu(inst) {
+ const outer = this.fine.getChildNode(inst),
+ container = outer && outer.querySelector('div[data-a-target="player-settings-menu"]');
+
+ if ( ! container )
+ return;
+
+ const should_render = inst.state.activeMenu === 'settings-menu__main';
+
+ let lbl, cont = container.querySelector('.ffz--cc-button');
+ if ( ! cont ) {
+ if ( ! should_render )
+ return;
+
+ const handler = () => {
+ const win = window.open(
+ 'https://twitch.tv/popout/frankerfacez/chat?ffz-settings=player',
+ '_blank',
+ 'resizable=yes,scrollbars=yes,width=850,height=600'
+ );
+
+ if ( win )
+ win.focus();
+ }
+
+ cont = (
);
+
+ container.appendChild(cont);
+
+ } else if ( ! should_render ) {
+ cont.remove();
+ return;
+ } else
+ lbl = cont.querySelector('button > div > div');
+
+ lbl.textContent = this.i18n.t('site.menu_button', 'FrankerFaceZ Control Center');
+ }
+}
\ No newline at end of file
diff --git a/src/sites/player/metadata.jsx b/src/sites/player/metadata.jsx
new file mode 100644
index 00000000..b23c5a89
--- /dev/null
+++ b/src/sites/player/metadata.jsx
@@ -0,0 +1,667 @@
+'use strict';
+
+// ============================================================================
+// Player Metadata
+// ============================================================================
+
+import {createElement, ClickOutside, setChildren} from 'utilities/dom';
+import {maybe_call} from 'utilities/object';
+
+import {duration_to_string, durationForURL} from 'utilities/time';
+
+import Module from 'utilities/module';
+import Tooltip from 'utilities/tooltip';
+
+export default class Metadata extends Module {
+ constructor(...args) {
+ super(...args);
+
+ this.inject('settings');
+ this.inject('i18n');
+ this.inject('site.tooltips');
+
+ this.definitions = {};
+
+ this.settings.add('metadata.player-stats', {
+ default: false,
+ changed: () => this.updateMetadata('player-stats')
+ });
+
+ this.settings.add('metadata.stream-delay-warning', {
+ default: 0
+ });
+
+ this.settings.add('metadata.uptime', {
+ default: 1,
+ changed: () => this.updateMetadata('uptime')
+ });
+
+ this.definitions.uptime = {
+ inherit: true,
+ no_arrow: true,
+
+ refresh() { return this.settings.get('metadata.uptime') > 0 },
+
+ setup(data) {
+ let created = data?.channel?.live_since;
+ if ( ! created )
+ return {};
+
+ if ( !(created instanceof Date) )
+ created = new Date(created);
+
+ const now = Date.now();
+
+ return {
+ created,
+ uptime: created ? Math.floor((now - created.getTime()) / 1000) : -1,
+ getBroadcastID: data.getBroadcastID
+ }
+ },
+
+ order: 2,
+ icon: 'ffz-i-clock',
+
+ label(data) {
+ const setting = this.settings.get('metadata.uptime');
+ if ( ! setting || ! data.created )
+ return null;
+
+ return duration_to_string(data.uptime, false, false, false, setting !== 2);
+ },
+
+ subtitle: () => this.i18n.t('metadata.uptime.subtitle', 'Uptime'),
+
+ tooltip(data) {
+ if ( ! data.created )
+ return null;
+
+ return [
+ this.i18n.t(
+ 'metadata.uptime.tooltip',
+ 'Stream Uptime'
+ ),
+
+ {this.i18n.t(
+ 'metadata.uptime.since',
+ '(since {since,datetime})',
+ {since: data.created}
+ )}
+
+ ];
+ }
+ }
+
+ this.definitions['player-stats'] = {
+ button: true,
+ inherit: true,
+ modview: true,
+
+ refresh() {
+ return this.settings.get('metadata.player-stats')
+ },
+
+ setup() {
+ const Player = this.resolve('site.player'),
+ player = Player.current;
+
+ let stats;
+
+ if ( ! player )
+ stats = null;
+
+ else if ( typeof player.getPlaybackStats === 'function' ) {
+ stats = player.getPlaybackStats();
+
+ } else if ( typeof player.getVideoInfo === 'function' ) {
+ const temp = player.getVideoInfo();
+ stats = {
+ backendVersion: maybe_call(player.getVersion, player),
+ bufferSize: temp.video_buffer_size,
+ displayResolution: `${temp.vid_display_width}x${temp.vid_display_height}`,
+ fps: temp.current_fps,
+ hlsLatencyBroadcaster: temp.hls_latency_broadcaster / 1000,
+ hlsLatencyEncoder: temp.hls_latency_encoder / 1000,
+ memoryUsage: `${temp.totalMemoryNumber} MB`,
+ rate: maybe_call(player.getPlaybackRate, player) || 1,
+ playbackRate: temp.current_bitrate,
+ skippedFrames: temp.dropped_frames,
+ videoResolution: `${temp.vid_width}x${temp.vid_height}`
+ }
+ } else {
+ const videoHeight = maybe_call(player.getVideoHeight, player) || 0,
+ videoWidth = maybe_call(player.getVideoWidth, player) || 0,
+ displayHeight = maybe_call(player.getDisplayHeight, player) || 0,
+ displayWidth = maybe_call(player.getDisplayWidth, player) || 0;
+
+ stats = {
+ backendVersion: maybe_call(player.getVersion, player),
+ bufferSize: maybe_call(player.getBufferDuration, player),
+ displayResolution: `${displayWidth}x${displayHeight}`,
+ videoResolution: `${videoWidth}x${videoHeight}`,
+ videoHeight,
+ videoWidth,
+ displayHeight,
+ displayWidth,
+ rate: maybe_call(player.getPlaybackRate, player),
+ fps: Math.floor(maybe_call(player.getVideoFrameRate, player) || 0),
+ hlsLatencyBroadcaster: maybe_call(player.getLiveLatency, player) || 0,
+ //hlsLatencyBroadcaster: player.stats?.broadcasterLatency || player.core?.stats?.broadcasterLatency,
+ //hlsLatencyEncoder: player.stats?.transcoderLatency || player.core?.stats?.transcoderLatency,
+ playbackRate: Math.floor((maybe_call(player.getVideoBitRate, player) || 0) / 1000),
+ skippedFrames: maybe_call(player.getDroppedFrames, player),
+ }
+ }
+
+ let tampered = false;
+ try {
+ const url = player.core.state.path;
+ if ( url.includes('/api/channel/hls/') ) {
+ const data = JSON.parse(new URL(url).searchParams.get('token'));
+ tampered = data && data.player_type && (data.player_type !== 'site' && data.player_type !== 'popout' && data.player_type !== 'embed') ? data.player_type : false;
+ }
+ } catch(err) { /* no op */ }
+
+
+ if ( ! stats || stats.hlsLatencyBroadcaster < -100 )
+ return {stats};
+
+ return {
+ stats,
+ drift: 0,
+ rate: stats.rate == null ? 1 : stats.rate,
+ delay: stats.hlsLatencyBroadcaster,
+ old: stats.hlsLatencyBroadcaster > 180,
+ tampered
+ }
+ },
+
+ order: 3,
+
+ icon(data) {
+ if ( data.rate > 1 )
+ return 'ffz-i-fast-fw';
+
+ return 'ffz-i-gauge'
+ },
+
+ subtitle: () => this.i18n.t('metadata.player-stats.subtitle', 'Latency'),
+
+ label(data) {
+ if ( ! this.settings.get('metadata.player-stats') || ! data.delay )
+ return null;
+
+ const delayed = data.drift > 5000 ? '(!) ' : '';
+
+ if ( data.old )
+ return `${delayed}${data.delay.toFixed(2)}s old`;
+ else
+ return `${delayed}${data.delay.toFixed(2)}s`;
+ },
+
+ click() {
+ const Player = this.resolve('site.player'),
+ ui = Player.playerUI;
+
+ if ( ! ui )
+ return;
+
+ ui.setStatsOverlay(ui.statsOverlay === 1 ? 0 : 1);
+ },
+
+ color(data) {
+ const setting = this.settings.get('metadata.stream-delay-warning');
+ if ( setting === 0 || ! data.delay || data.old )
+ return;
+
+ if ( data.delay > (setting * 2) )
+ return '#f9b6b6'; //'#ec1313';
+
+ else if ( data.delay > setting )
+ return '#fcb896'; //'#fc7835';
+ },
+
+ tooltip(data) {
+ const tampered = data.tampered ? (
+ {this.i18n.t(
+ 'metadata.player-stats.tampered',
+ 'Your player has an unexpected player type ({type}), which may affect your viewing experience.',
+ {
+ type: data.tampered
+ }
+ )}
+
) : null;
+
+ const delayed = data.drift > 5000 && (
+ {this.i18n.t(
+ 'metadata.player-stats.delay-warning',
+ 'Your local clock seems to be off by roughly {count,number} seconds, which could make this inaccurate.',
+ Math.round(data.drift / 10) / 100
+ )}
+
);
+
+ const ff = data.rate > 1 && (
+ {this.i18n.t(
+ 'metadata.player-stats.rate-warning',
+ 'Playing at {rate,number}x speed to reduce delay.',
+ {rate: data.rate.toFixed(2)}
+ )}
+
);
+
+ if ( ! data.stats || ! data.delay )
+ return [
+ delayed,
+ ff,
+ this.i18n.t('metadata.player-stats.latency-tip', 'Stream Latency'),
+ tampered
+ ];
+
+ const stats = data.stats,
+ video_info = this.i18n.t(
+ 'metadata.player-stats.video-info',
+ 'Video: {videoResolution}p{fps}\nPlayback Rate: {playbackRate,number} Kbps\nDropped Frames:{skippedFrames,number}',
+ stats
+ );
+
+ if ( data.old )
+ return [
+ delayed,
+ this.i18n.t(
+ 'metadata.player-stats.video-tip',
+ 'Video Information'
+ ),
+
+ {this.i18n.t(
+ 'metadata.player-stats.broadcast-ago',
+ 'Broadcast {count,number}s Ago',
+ data.delay
+ )}
+
,
+
+ {video_info}
+
,
+ tampered
+ ];
+
+ return [
+ delayed, ff,
+ this.i18n.t(
+ 'metadata.player-stats.latency-tip',
+ 'Stream Latency'
+ ),
+
+ {video_info}
+
,
+ tampered
+ ];
+ }
+ }
+ }
+
+ onEnable() {
+ const md = this.tooltips.types.metadata = target => {
+ let el = target;
+ if ( el._ffz_stat )
+ el = el._ffz_stat;
+ else if ( ! el.classList.contains('ffz-stat') ) {
+ el = target.closest('.ffz-stat');
+ target._ffz_stat = el;
+ }
+
+ if ( ! el )
+ return;
+
+ const key = el.dataset.key,
+ def = this.definitions[key];
+
+ return maybe_call(def.tooltip, this, el._ffz_data)
+ };
+
+ md.onShow = (target, tip) => {
+ const el = target._ffz_stat || target;
+ el.tip = tip;
+ };
+
+ md.onHide = target => {
+ const el = target._ffz_stat || target;
+ el.tip = null;
+ el.tip_content = null;
+ }
+
+ md.popperConfig = (target, tip, opts) => {
+ opts.placement = 'bottom';
+ opts.modifiers.flip = {behavior: ['bottom','top']};
+ return opts;
+ }
+ }
+
+ get keys() {
+ return Object.keys(this.definitions);
+ }
+
+ define(key, definition) {
+ this.definitions[key] = definition;
+ this.updateMetadata(key);
+ }
+
+ updateMetadata(keys) {
+ const player = this.resolve('site.player');
+ if ( player )
+ for(const inst of player.Player.instances)
+ player.updateMetadata(inst, keys);
+ }
+
+ async render(key, data, container, timers, refresh_fn) {
+ if ( timers[key] )
+ clearTimeout(timers[key]);
+
+ let el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
+
+ const def = this.definitions[key],
+ destroy = () => {
+ if ( el ) {
+ if ( el.tooltip )
+ el.tooltip.destroy();
+
+ if ( el.popper )
+ el.popper.destroy();
+
+ if ( el._ffz_destroy )
+ el._ffz_destroy();
+
+ el._ffz_destroy = el.tooltip = el.popper = null;
+ el.remove();
+ }
+ };
+
+ if ( ! def || (data._mt || 'channel') !== (def.type || 'channel') )
+ return destroy();
+
+ try {
+ const ref_fn = () => refresh_fn(key);
+ data = {
+ ...data,
+ refresh: ref_fn
+ };
+
+ // Process the data if a setup method is defined.
+ if ( def.setup )
+ data = await def.setup.call(this, data);
+
+ // Let's get refresh logic out of the way now.
+ const refresh = maybe_call(def.refresh, this, data);
+ if ( refresh )
+ timers[key] = setTimeout(
+ ref_fn,
+ typeof refresh === 'number' ? refresh : 1000
+ );
+
+
+ // Grab the element again in case it changed, somehow.
+ el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
+
+ let stat, old_color, old_icon;
+
+ const label = maybe_call(def.label, this, data);
+
+ if ( ! label )
+ return destroy();
+
+ const order = maybe_call(def.order, this, data),
+ color = maybe_call(def.color, this, data) || '';
+
+ if ( ! el ) {
+ let icon = old_icon = maybe_call(def.icon, this, data);
+ let button = false;
+
+ if ( def.button !== false && (def.popup || def.click) ) {
+ button = true;
+
+ let btn, popup;
+ const border = maybe_call(def.border, this, data),
+ inherit = maybe_call(def.inherit, this, data);
+
+ if ( typeof icon === 'string' )
+ icon = (
+
+ );
+
+ if ( def.popup && def.click ) {
+ el = (
+ {btn = (
+
+ {icon}
+ {stat = ( )}
+
+ )}
+ {popup = (
+
+
+
+
+
+ )}
+
);
+
+ } else
+ btn = popup = el = (
+
+ {icon}
+ {stat = ( )}
+ {def.popup && ! def.no_arrow &&
+
+ }
+
+ );
+
+ if ( def.click )
+ btn.addEventListener('click', e => {
+ if ( el._ffz_fading || btn.disabled || btn.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
+ return false;
+
+ def.click.call(this, el._ffz_data, e, () => refresh_fn(key));
+ });
+
+ if ( def.popup )
+ popup.addEventListener('click', () => {
+ if ( popup.disabled || popup.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
+ return false;
+
+ if ( el._ffz_popup )
+ return el._ffz_destroy();
+
+ const listeners = [],
+ add_close_listener = cb => listeners.push(cb);
+
+ const destroy = el._ffz_destroy = () => {
+ for(const cb of listeners) {
+ try {
+ cb();
+ } catch(err) {
+ this.log.capture(err, {
+ tags: {
+ metadata: key
+ }
+ });
+ this.log.error('Error when running a callback for pop-up destruction for metadata:', key, err);
+ }
+ }
+
+ if ( el._ffz_outside )
+ el._ffz_outside.destroy();
+
+ if ( el._ffz_popup ) {
+ const fp = el._ffz_popup;
+ el._ffz_popup = null;
+ fp.destroy();
+ }
+
+ el._ffz_destroy = el._ffz_outside = null;
+ };
+
+ const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body,
+ tt = el._ffz_popup = new Tooltip(parent, el, {
+ logger: this.log,
+ i18n: this.i18n,
+ manual: true,
+ live: false,
+ html: true,
+
+ tooltipClass: 'ffz-metadata-balloon ffz-balloon tw-block tw-border tw-elevation-1 tw-border-radius-small tw-c-background-base',
+ // Hide the arrow for now, until we re-do our CSS to make it render correctly.
+ arrowClass: 'ffz-balloon__tail tw-overflow-hidden tw-absolute',
+ arrowInner: 'ffz-balloon__tail-symbol tw-border-t tw-border-r tw-border-b tw-border-l tw-border-radius-small tw-c-background-base tw-absolute',
+ innerClass: 'tw-pd-1',
+
+ popper: {
+ placement: 'top-end',
+ modifiers: {
+ preventOverflow: {
+ boundariesElement: parent
+ },
+ flip: {
+ behavior: ['top', 'bottom', 'left', 'right']
+ }
+ }
+ },
+ content: (t, tip) => def.popup.call(this, el._ffz_data, tip, () => refresh_fn(key), add_close_listener),
+ onShow: (t, tip) =>
+ setTimeout(() => {
+ el._ffz_outside = new ClickOutside(tip.outer, destroy);
+ }),
+ onHide: destroy
+ });
+
+ tt._enter(el);
+ });
+
+ } else {
+ if ( typeof icon === 'string' )
+ icon = ( );
+
+ el = (
+ {icon}
+ {stat = }
+
);
+
+ if ( def.click )
+ el.addEventListener('click', e => {
+ if ( el._ffz_fading || el.disabled || el.classList.contains('disabled') )
+ return false;
+
+ def.click.call(this, el._ffz_data, e, () => refresh_fn(key));
+ });
+ }
+
+ el._ffz_order = order;
+
+ if ( order != null )
+ el.style.order = order;
+
+ let subcontainer = container;
+
+ /*if ( button )
+ subcontainer = container.querySelector('.tw-flex:last-child') || container;
+ else
+ subcontainer = container.querySelector('.tw-flex:first-child') || container;*/
+
+ subcontainer.appendChild(el);
+
+ /*if ( def.tooltip ) {
+ const parent = document.body.querySelector('#root>div') || document.body;
+ el.tooltip = new Tooltip(parent, el, {
+ logger: this.log,
+ live: false,
+ html: true,
+ content: () => maybe_call(def.tooltip, this, el._ffz_data),
+ onShow: (t, tip) => el.tip = tip,
+ onHide: () => {
+ el.tip = null;
+ el.tip_content = null;
+ },
+ popper: {
+ placement: 'bottom',
+ modifiers: {
+ flip: {
+ behavior: ['bottom', 'top']
+ },
+ preventOverflow: {
+ boundariesElement: parent
+ }
+ }
+ }
+ });
+ }*/
+
+ } else {
+ stat = el.querySelector('.ffz-stat-text');
+ if ( ! stat )
+ return destroy();
+
+ old_icon = el.dataset.icon || '';
+ old_color = el.dataset.color || '';
+
+ if ( el._ffz_order !== order )
+ el.style.order = el._ffz_order = order;
+
+ if ( el.tip ) {
+ const tooltip = maybe_call(def.tooltip, this, data);
+ if ( el.tip_content !== tooltip ) {
+ el.tip_content = tooltip;
+ setChildren(el.tip.element, tooltip);
+ }
+ }
+ }
+
+ if ( typeof def.icon === 'function' ) {
+ const icon = maybe_call(def.icon, this, data);
+ if ( typeof icon === 'string' && icon !== old_icon ) {
+ el.dataset.icon = icon;
+ const figure = el.querySelector('figure');
+ if ( figure )
+ figure.className = icon;
+ }
+ }
+
+ if ( old_color !== color ) {
+ el.dataset.color = color;
+ el.style.setProperty('color', color, 'important');
+ }
+
+ el._ffz_data = data;
+ stat.innerHTML = label;
+
+ if ( def.disabled !== undefined )
+ el.disabled = maybe_call(def.disabled, this, data);
+
+ } catch(err) {
+ this.log.capture(err, {
+ tags: {
+ metadata: key
+ }
+ });
+ this.log.error(`Error rendering metadata for ${key}`, err);
+ return destroy();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/sites/player/player.jsx b/src/sites/player/player.jsx
new file mode 100644
index 00000000..00ceec02
--- /dev/null
+++ b/src/sites/player/player.jsx
@@ -0,0 +1,1302 @@
+'use strict';
+
+// ============================================================================
+// Twitch Player
+// ============================================================================
+
+import Module from 'utilities/module';
+import {createElement, on, off} from 'utilities/dom';
+import {debounce} from 'utilities/object';
+import { IS_FIREFOX } from 'src/utilities/constants';
+
+import Metadata from './metadata';
+
+const STYLE_VALIDATOR = createElement('span');
+
+const HAS_COMPRESSOR = window.AudioContext && window.DynamicsCompressorNode != null;
+
+function rotateButton(event) {
+ const target = event.currentTarget,
+ icon = target && target.querySelector('figure');
+ if ( ! icon || icon.classList.contains('ffz-i-t-reset-clicked') )
+ return;
+
+ icon.classList.toggle('ffz-i-t-reset', false);
+ icon.classList.toggle('ffz-i-t-reset-clicked', true);
+
+ setTimeout(() => {
+ icon.classList.toggle('ffz-i-t-reset', true);
+ icon.classList.toggle('ffz-i-t-reset-clicked', false);
+ }, 500);
+}
+
+export default class Player extends Module {
+ constructor(...args) {
+ super(...args);
+
+ this.inject('i18n');
+ this.inject('settings');
+ this.inject('site.fine');
+ this.inject('metadata', Metadata);
+ this.inject('site.css_tweaks');
+
+ // Settings
+
+ this.settings.add('player.embed-metadata', {
+ default: true,
+ changed: () => {
+ for(const inst of this.Player.instances)
+ this.updateGUI(inst);
+ }
+ });
+
+ if ( HAS_COMPRESSOR ) {
+ this.settings.add('player.compressor.enable', {
+ default: true,
+ changed: () => {
+ for(const inst of this.Player.instances)
+ this.addCompressorButton(inst);
+ }
+ });
+
+ this.settings.add('player.compressor.default', {
+ default: false,
+ changed: () => {
+ for(const inst of this.Player.instances)
+ this.compressPlayer(inst);
+ }
+ });
+
+ this.settings.add('player.compressor.threshold', {
+ default: -50,
+ changed: () => this.updateCompressors()
+ });
+
+ this.settings.add('player.compressor.knee', {
+ default: 40,
+ changed: () => this.updateCompressors()
+ });
+
+ this.settings.add('player.compressor.ratio', {
+ default: 12,
+ changed: () => this.updateCompressors()
+ });
+
+ this.settings.add('player.compressor.attack', {
+ default: 0,
+ changed: () => this.updateCompressors()
+ });
+
+ this.settings.add('player.compressor.release', {
+ default: 0.25,
+ changed: () => this.updateCompressors()
+ });
+ }
+
+ this.settings.add('player.allow-catchup', {
+ default: true,
+ changed: () => this.updatePlaybackRates()
+ });
+
+ this.settings.add('player.mute-click', {
+ default: false,
+ });
+
+ this.settings.add('player.volume-scroll', {
+ default: false,
+ });
+
+ this.settings.add('player.button.reset', {
+ default: true,
+ changed: () => {
+ for(const inst of this.Player.instances)
+ this.addResetButton(inst);
+ }
+ });
+
+ if ( document.pictureInPictureEnabled )
+ this.settings.add('player.button.pip', {
+ default: true,
+ changed: () => {
+ for(const inst of this.Player.instances)
+ this.addPiPButton(inst);
+ }
+ });
+
+ this.settings.add('player.volume-scroll-steps', {
+ default: 0.1,
+ });
+
+ this.settings.add('player.captions.font-size', {
+ default: '',
+ changed: () => this.updateCaptionsCSS()
+ });
+
+ this.settings.add('player.captions.font-family', {
+ default: '',
+ changed: () => this.updateCaptionsCSS()
+ });
+
+ /*this.settings.add('player.captions.custom-position', {
+ default: false,
+ changed: () => this.updateCaptionsCSS()
+ });
+
+ this.settings.add('player.captions.vertical', {
+ default: '10%',
+ changed: () => this.updateCaptionsCSS()
+ });
+
+ this.settings.add('player.captions.horizontal', {
+ default: '50%',
+ changed: () => this.updateCaptionsCSS()
+ });
+
+ this.settings.add('player.captions.alignment', {
+ default: 32,
+ changed: () => this.updateCaptionsCSS()
+ });*/
+
+ this.settings.add('player.ext-hide', {
+ default: 0,
+ changed: val => this.updateHideExtensions(val)
+ });
+
+ this.settings.add('player.ext-interaction', {
+ default: true,
+ changed: val => this.css_tweaks.toggle('player-ext-mouse', !val)
+ });
+
+ this.settings.add('player.vod.autoplay', {
+ default: true
+ });
+
+ this.settings.add('player.volume-always-shown', {
+ default: false,
+ changed: val => this.css_tweaks.toggle('player-volume', val)
+ });
+
+ this.settings.add('player.hide-mouse', {
+ default: true,
+ changed: val => this.css_tweaks.toggle('player-hide-mouse', val)
+ });
+
+
+ this.Player = this.fine.define(
+ 'highwind-player',
+ n => n.setPlayerActive && n.props?.playerEvents && n.props?.mediaPlayerInstance
+ );
+
+ this.PlayerSource = this.fine.define(
+ 'player-source',
+ n => n.setSrc && n.setInitialPlaybackSettings
+ );
+ }
+
+ async onEnable() {
+ await this.settings.awaitProvider();
+ await this.settings.provider.awaitReady();
+
+ this.css_tweaks.toggle('player-ext-mouse', ! this.settings.get('player.ext-interaction'));
+ this.css_tweaks.toggle('player-volume', this.settings.get('player.volume-always-shown'));
+ this.css_tweaks.toggle('player-hide-mouse', this.settings.get('player.hide-mouse'));
+
+ this.installVisibilityHook();
+ this.updateHideExtensions();
+ this.updateCaptionsCSS();
+
+ this.on(':reset', this.resetAllPlayers, this);
+
+ // TODO: Refactor common player code.
+
+ const t = this;
+
+ this.Player.ready((cls, instances) => {
+ const old_attach = cls.prototype.maybeAttachDomEventListeners;
+
+ cls.prototype.ffzInstall = function() {
+ if ( this._ffz_installed )
+ return;
+
+ this._ffz_installed = true;
+
+ if ( ! this._ffzUpdateVolume )
+ this._ffzUpdateVolume = debounce(this.ffzUpdateVolume.bind(this));
+
+ if ( ! this._ffzUpdateState )
+ this._ffzUpdateState = this.ffzUpdateState.bind(this);
+
+ if ( ! this._ffzErrorReset )
+ this._ffzErrorReset = t.addErrorResetButton.bind(t, this);
+
+ if ( ! this._ffzReady )
+ this._ffzReady = this.ffzReady.bind(this);
+
+ const inst = this,
+ old_active = this.setPlayerActive,
+ old_inactive = this.setPlayerInactive;
+
+ this.setPlayerActive = function() {
+ inst.ffzScheduleState();
+ return old_active.call(inst);
+ }
+
+ this.setPlayerInactive = function() {
+ inst.ffzScheduleState();
+ return old_inactive.call(inst);
+ }
+
+ this.ffzOnEnded = () => {
+ if ( t.settings.get('player.vod.autoplay') )
+ return;
+
+ t.parent.awaitElement(
+ '.autoplay-vod__content-container button',
+ this.props.containerRef || t.fine.getChildNode(this),
+ 1000
+ ).then(el => el.click());
+ }
+
+ const events = this.props.playerEvents;
+ if ( events ) {
+ on(events, 'Playing', this._ffzUpdateState);
+ on(events, 'PlayerError', this._ffzUpdateState);
+ on(events, 'PlayerError', this._ffzErrorReset);
+ on(events, 'Ended', this._ffzUpdateState);
+ on(events, 'Ended', this.ffzOnEnded);
+ on(events, 'Ready', this._ffzReady);
+ on(events, 'Idle', this._ffzUpdateState);
+ }
+ }
+
+ cls.prototype.ffzUpdateVolume = function() {
+ if ( document.hidden )
+ return;
+
+ const player = this.props.mediaPlayerInstance,
+ video = player?.mediaSinkManager?.video || player?.core?.mediaSinkManager?.video;
+ if ( video ) {
+ const volume = video.volume,
+ muted = player.isMuted();
+ if ( ! video.muted && player.getVolume() !== volume ) {
+ player.setVolume(volume);
+ player.setMuted(muted);
+ }
+ }
+ }
+
+ cls.prototype.ffzUninstall = function() {
+ if ( this._ffz_state_raf )
+ cancelAnimationFrame(this._ffz_state_raf);
+
+ const events = this.props.playerEvents;
+ if ( events && this._ffzUpdateState ) {
+ off(events, 'Playing', this._ffzUpdateState);
+ off(events, 'PlayerError', this._ffzUpdateState);
+ off(events, 'PlayerError', this._ffzErrorReset);
+ off(events, 'Ended', this._ffzUpdateState);
+ off(events, 'Ended', this.ffzOnEnded);
+ off(events, 'Ready', this._ffzReady);
+ off(events, 'Idle', this._ffzUpdateState);
+ }
+
+ this.ffzRemoveListeners();
+
+ this._ffz_state_raf = null;
+ this._ffzUpdateState = null;
+ this._ffzErrorReset = null;
+ this._ffzReady = null;
+ this.ffzOnEnded = null;
+ }
+
+ cls.prototype.ffzReady = function() {
+ const cont = this.props.containerRef;
+ if ( ! cont )
+ return;
+
+ requestAnimationFrame(() => {
+ const icons = cont.querySelectorAll('.ffz--player-reset figure');
+ for(const icon of icons) {
+ if ( icon._ffz_unspin )
+ clearTimeout(icon._ffz_unspin);
+
+ icon.classList.toggle('loading', false);
+ }
+ });
+ }
+
+ cls.prototype.ffzScheduleState = function() {
+ if ( ! this._ffzUpdateState )
+ this._ffzUpdateState = this.ffzUpdateState.bind(this);
+
+ if ( ! this._ffz_state_raf )
+ this._ffz_state_raf = requestAnimationFrame(this._ffzUpdateState);
+ }
+
+ cls.prototype.ffzUpdateState = function() {
+ this._ffz_state_raf = null;
+ const cont = this.props.containerRef,
+ player = this.props.mediaPlayerInstance;
+ if ( ! cont )
+ return;
+
+ const ds = cont.dataset;
+ ds.controls = this.state?.active || false;
+
+ ds.ended = player?.state?.playerState === 'Ended';
+ ds.paused = player?.state?.playerState === 'Idle';
+ }
+
+ cls.prototype.ffzAttachListeners = function() {
+ const cont = this.props.containerRef;
+ if ( ! cont || this._ffz_listeners )
+ return;
+
+ this._ffz_listeners = true;
+ if ( ! this._ffz_scroll_handler )
+ this._ffz_scroll_handler = this.ffzScrollHandler.bind(this);
+
+ if ( ! this._ffz_click_handler )
+ this._ffz_click_handler = this.ffzClickHandler.bind(this);
+
+ on(cont, 'wheel', this._ffz_scroll_handler);
+ on(cont, 'mousedown', this._ffz_click_handler);
+ }
+
+ cls.prototype.ffzRemoveListeners = function() {
+ const cont = this.props.containerRef;
+ if ( ! cont || ! this._ffz_listeners )
+ return;
+
+ if ( this._ffz_scroll_handler ) {
+ off(cont, 'wheel', this._ffz_scroll_handler);
+ this._ffz_scroll_handler = null;
+ }
+
+ if ( this._ffz_click_handler ) {
+ off(cont, 'mousedown', this._ffz_click_handler);
+ this._ffz_click_handler = null;
+ }
+
+ this._ffz_listeners = false;
+ }
+
+ cls.prototype.ffzClickHandler = function(event) {
+ if ( ! t.settings.get('player.mute-click') || ! event || event.button !== 1 )
+ return;
+
+ const player = this.props?.mediaPlayerInstance;
+ if ( ! player?.isMuted )
+ return;
+
+ const muted = ! player.isMuted();
+ player.setMuted(muted);
+ localStorage.setItem('video-muted', JSON.stringify({default: muted}));
+ event.preventDefault();
+ return false;
+ }
+
+ cls.prototype.ffzScrollHandler = function(event) {
+ if ( ! t.settings.get('player.volume-scroll') )
+ return;
+
+ const delta = event.wheelDelta || -(event.deltaY || event.detail || 0),
+ player = this.props?.mediaPlayerInstance,
+ video = player?.mediaSinkManager?.video || player?.core?.mediaSinkManager?.video;
+
+ if ( ! player?.getVolume )
+ return;
+
+ const amount = t.settings.get('player.volume-scroll-steps'),
+ old_volume = video?.volume ?? player.getVolume(),
+ volume = Math.max(0, Math.min(1, old_volume + (delta > 0 ? amount : -amount)));
+
+ player.setVolume(volume);
+ localStorage.volume = volume;
+
+ if ( volume !== 0 ) {
+ player.setMuted(false);
+ localStorage.setItem('video-muted', JSON.stringify({default: false}));
+ }
+
+ event.preventDefault();
+ return false;
+ }
+
+ cls.prototype.ffzMaybeRemoveNativeListeners = function() {
+ const cont = this.props.containerRef;
+ if ( cont && this.listenersAttached ) {
+ off(cont, 'mouseleave', this.setPlayerInactive);
+ off(cont, 'mouseenter', this.setPlayerActive);
+ off(cont, 'mousemove', this.onMouseMove);
+ this.listenersAttached = false;
+ }
+ }
+
+ cls.prototype.maybeAttachDomEventListeners = function() {
+ try {
+ this.ffzInstall();
+ this.ffzAttachListeners();
+ } catch(err) {
+ t.log.error('Error attaching event listener.', err);
+ }
+
+ return old_attach.call(this);
+ }
+
+
+ for(const inst of instances) {
+ const events = inst.props?.playerEvents;
+ if ( events ) {
+ off(events, 'Playing', inst.setPlayerActive);
+ off(events, 'PlayerSeekCompleted', inst.setPlayerActive);
+ }
+
+ inst.ffzMaybeRemoveNativeListeners();
+ inst.maybeAttachDomEventListeners();
+ inst.ffzScheduleState();
+
+ if ( events ) {
+ on(events, 'Playing', inst.setPlayerActive);
+ on(events, 'PlayerSeekCompleted', inst.setPlayerActive);
+ }
+
+ this.updateGUI(inst);
+ this.compressPlayer(inst);
+ this.updatePlaybackRate(inst);
+ }
+ });
+
+ this.Player.on('mount', inst => {
+ this.updateGUI(inst);
+ this.compressPlayer(inst);
+ this.updatePlaybackRate(inst);
+ });
+ this.Player.on('update', inst => {
+ this.updateGUI(inst);
+ this.compressPlayer(inst);
+ this.updatePlaybackRate(inst);
+ });
+
+ this.Player.on('unmount', inst => {
+ inst.ffzUninstall();
+ });
+
+
+ this.on('i18n:update', () => {
+ for(const inst of this.Player.instances) {
+ this.updateGUI(inst);
+ }
+ });
+ }
+
+
+ updateCaptionsCSS() {
+ // Font
+ const font_size = this.settings.get('player.captions.font-size');
+ let font_family = this.settings.get('player.captions.font-family');
+ if ( font_family.indexOf(' ') !== -1 && font_family.indexOf(',') === -1 && font_family.indexOf('"') === -1 && font_family.indexOf("'") === -1 )
+ font_family = `"${font_family}"`;
+
+ STYLE_VALIDATOR.style.fontSize = '';
+ STYLE_VALIDATOR.style.fontFamily = '';
+
+ STYLE_VALIDATOR.style.fontSize = font_size;
+ STYLE_VALIDATOR.style.fontFamily = font_family;
+
+ const font_out = [];
+ if ( STYLE_VALIDATOR.style.fontFamily )
+ font_out.push(`font-family: ${STYLE_VALIDATOR.style.fontFamily} !important;`);
+ if ( STYLE_VALIDATOR.style.fontSize )
+ font_out.push(`font-size: ${STYLE_VALIDATOR.style.fontSize} !important;`);
+
+ if ( font_out.length )
+ this.css_tweaks.set('captions-font', `.player-captions-container__caption-line {
+ ${font_out.join('\n\t')}
+}`)
+ else
+ this.css_tweaks.delete('captions-font');
+
+ // Position
+ /*const enabled = this.settings.get('player.captions.custom-position'),
+ vertical = this.settings.get('player.captions.vertical'),
+ horizontal = this.settings.get('player.captions.horizontal'),
+ alignment = this.settings.get('player.captions.alignment');
+
+ if ( ! enabled ) {
+ this.css_tweaks.delete('captions-position');
+ return;
+ }
+
+ const out = [], align_out = [],
+ align_horizontal = alignment % 10,
+ align_vertical = Math.floor(alignment / 10);
+
+ let custom_top = false,
+ custom_left = false;
+
+ STYLE_VALIDATOR.style.top = '';
+ STYLE_VALIDATOR.style.top = vertical;
+ if ( STYLE_VALIDATOR.style.top ) {
+ out.push(`${align_vertical === 3 ? 'bottom' : 'top'}: ${STYLE_VALIDATOR.style.top} !important;`)
+ out.push(`${align_vertical === 3 ? 'top' : 'bottom'}: unset !important;`);
+ custom_top = true;
+ }
+
+ STYLE_VALIDATOR.style.top = '';
+ STYLE_VALIDATOR.style.top = horizontal;
+ if ( STYLE_VALIDATOR.style.top ) {
+ if ( align_horizontal === 1 )
+ align_out.push(`align-items: flex-start !important;`);
+ else if ( align_horizontal === 3 )
+ align_out.push(`align-items: flex-end !important;`);
+
+ out.push(`${align_horizontal === 3 ? 'right' : 'left'}: ${STYLE_VALIDATOR.style.top} !important;`);
+ out.push(`${align_horizontal === 3 ? 'left' : 'right'}: unset !important;`);
+ custom_left = true;
+ }
+
+ if ( align_horizontal !== 2 )
+ out.push(`width: unset !important;`);
+
+ out.push(`transform: translate(${(!custom_left || align_horizontal === 2) ? '-50%' : '0'}, ${(!custom_top || align_vertical === 2) ? '-50%' : '0'})`);
+
+ this.css_tweaks.set('captions-position', `.player-captions-container {
+ ${out.join('\n\t')};
+}${align_out.length ? `.player-captions-container__caption-window {
+ ${align_out.join('\n\t')}
+}` : ''}`);*/
+ }
+
+
+ updateHideExtensions(val) {
+ if ( val === undefined )
+ val = this.settings.get('player.ext-hide');
+
+ this.css_tweaks.toggleHide('player-ext-hover', val === 1);
+ this.css_tweaks.toggleHide('player-ext', val === 2);
+ }
+
+
+ installVisibilityHook() {
+ if ( ! document.pictureInPictureEnabled ) {
+ this.log.info('Skipping visibility hooks. Picture-in-Picture is not available.');
+ return;
+ }
+
+ document.addEventListener('fullscreenchange', () => {
+ const fs = document.fullscreenElement,
+ pip = document.pictureInPictureElement;
+
+ if ( fs && pip && (fs === pip || fs.contains(pip)) )
+ document.exitPictureInPicture();
+
+ // Update the UI since we can't enter PiP from Fullscreen
+ for(const inst of this.Player.instances)
+ this.addPiPButton(inst);
+ });
+
+ try {
+ Object.defineProperty(document, 'hidden', {
+ configurable: true,
+ get() {
+ // If Picture in Picture is active, then we should not
+ // drop quality. Therefore, we need to trick Twitch
+ // into thinking the document is still active.
+ if ( document.pictureInPictureElement != null )
+ return false;
+
+ return document.visibilityState === 'hidden';
+ }
+ });
+ } catch(err) {
+ this.log.warn('Unable to install document visibility hook.', err);
+ }
+ }
+
+
+ updateGUI(inst) {
+ this.addPiPButton(inst);
+ this.addResetButton(inst);
+ this.addCompressorButton(inst, false);
+ this.addMetadata(inst);
+ //this.addFFZCCButton(inst);
+
+ /*const player = inst?.props?.mediaPlayerInstance;
+ if ( player && ! this.settings.get('player.allow-catchup') && player.setLiveSpeedUpRate )
+ player.setLiveSpeedUpRate(1);*/
+
+ if ( inst._ffzUpdateVolume )
+ inst._ffzUpdateVolume();
+
+ this.emit(':update-gui', inst);
+ }
+
+
+ /*addFFZCCButton(inst) {
+ if ( ! inst.props.isMenuShowing )
+ return;
+
+ const outer = inst.props.containerRef || this.fine.getChildNode(inst),
+ container = outer.querySelector('div[data-a-target="player-settings-menu"]');
+
+ if ( ! container )
+ return;
+
+ let lbl, cont = container.querySelector('.ffz--cc-button');
+ if ( ! cont ) {
+ const handler = () => {
+ const win = window.open(
+ 'https://twitch.tv/popout/frankerfacez/chat?ffz-settings=player',
+ '_blank',
+ 'resizable=yes,scrollbars=yes,width=850,height=600'
+ );
+
+ if ( win )
+ win.focus();
+ }
+
+ cont = ();
+
+ container.appendChild(cont);
+ } else
+ lbl = cont.querySelector('button > div > div');
+
+ lbl.textContent = this.i18n.t('site.menu_button', 'FrankerFaceZ Control Center');
+ }*/
+
+
+ addMetadata(inst) {
+ if ( ! inst._ffz_md_update )
+ inst._ffz_md_update = debounce(() => requestAnimationFrame(() => this._updateMetadata(inst)), 1000, 2);
+
+ inst._ffz_md_update();
+ }
+
+ _updateMetadata(inst) {
+ if ( inst._ffz_cont && ! document.contains(inst._ffz_cont) )
+ inst._ffz_cont = null;
+
+ const wanted = this.settings.get('player.embed-metadata');
+
+ if ( ! inst._ffz_cont ) {
+ if ( ! wanted )
+ return;
+
+ const outer = inst.props.containerRef || this.fine.getChildNode(inst),
+ container = outer && outer.querySelector('.player-controls__right-control-group');
+
+ if ( ! container )
+ return;
+
+ inst._ffz_cont = (
);
+ container.insertBefore(inst._ffz_cont, container.firstElementChild);
+ }
+
+ if ( ! wanted ) {
+ inst._ffz_cont.remove();
+ inst._ffz_cont = null;
+ return;
+ }
+
+ this.updateMetadata(inst);
+ }
+
+ updateMetadata(inst, keys) {
+ const cont = inst._ffz_cont;
+ if ( ! cont || ! document.contains(cont) )
+ return;
+
+ if ( ! keys )
+ keys = this.metadata.keys;
+ else if ( ! Array.isArray(keys) )
+ keys = [keys];
+
+ const source = this.parent.data,
+ user = source?.props?.data?.user;
+
+ const timers = inst._ffz_meta_timers = inst._ffz_meta_timers || {},
+ refresh_fn = key => this.updateMetadata(inst, key),
+ data = {
+ channel: {
+ id: user?.id,
+ login: source?.props?.channelLogin,
+ display_name: user?.displayName,
+ live: user?.stream?.id != null,
+ live_since: user?.stream?.createdAt
+ },
+ inst,
+ source
+ };
+
+ for(const key of keys)
+ this.metadata.render(key, data, cont, timers, refresh_fn);
+ }
+
+
+
+ addCompressorButton(inst, visible_only, tries = 0) {
+ const outer = inst.props.containerRef || this.fine.getChildNode(inst),
+ video = inst.props.mediaPlayerInstance?.mediaSinkManager?.video || inst.props.mediaPlayerInstance?.core?.mediaSinkManager?.video,
+ container = outer && outer.querySelector('.player-controls__left-control-group'),
+ has_comp = HAS_COMPRESSOR && video != null && this.settings.get('player.compressor.enable');
+
+ if ( ! container ) {
+ if ( ! has_comp )
+ return;
+
+ if ( tries < 5 )
+ return setTimeout(this.addCompressorButton.bind(this, inst, visible_only, (tries || 0) + 1), 250);
+
+ return;
+ }
+
+ let icon, tip, extra, ff_el, btn, cont = container.querySelector('.ffz--player-comp');
+ if ( ! has_comp ) {
+ if ( cont )
+ cont.remove();
+ return;
+ }
+
+ if ( ! cont ) {
+ cont = ();
+
+ container.appendChild(cont);
+ } else if ( visible_only )
+ return;
+ else {
+ icon = cont.querySelector('figure');
+ btn = cont.querySelector('button');
+ tip = cont.querySelector('.tw-tooltip .ffz--p-tip');
+ extra = cont.querySelector('.tw-tooltip .ffz--p-extra');
+ }
+
+ const comp_active = video._ffz_compressed,
+ can_apply = this.canCompress(inst),
+ label = can_apply ?
+ comp_active ?
+ this.i18n.t('player.comp_button.off', 'Disable Audio Compressor') :
+ this.i18n.t('player.comp_button.on', 'Audio Compressor')
+ : this.i18n.t('player.comp_button.disabled', 'Audio Compressor cannot be enabled when viewing Clips.');
+
+ extra.textContent = this.i18n.t('player.comp_button.help', 'See the FFZ Control Center for details. If audio breaks, please reset the player.');
+
+ if ( ff_el )
+ ff_el.textContent += `\n${this.i18n.t('player.comp_button.firefox', 'Playback Speed controls will not function for Firefox users when the Compressor has been enabled.')}`;
+
+ icon.classList.toggle('ffz-i-comp-on', comp_active);
+ icon.classList.toggle('ffz-i-comp-off', ! comp_active);
+ btn.disabled = ! can_apply;
+
+ btn.setAttribute('aria-label', label);
+ tip.textContent = label;
+ }
+
+ compressPlayer(inst, e) {
+ const video = inst.props.mediaPlayerInstance?.mediaSinkManager?.video ||
+ inst.props.mediaPlayerInstance?.core?.mediaSinkManager?.video;
+ if ( ! video || ! HAS_COMPRESSOR )
+ return;
+
+ const compressed = video._ffz_compressed || false;
+ let wanted = this.settings.get('player.compressor.default');
+ if ( e != null ) {
+ e.preventDefault();
+ video._ffz_toggled = true;
+ wanted = ! video._ffz_compressed;
+ }
+
+ if ( ! video._ffz_compressor ) {
+ if ( ! wanted )
+ return;
+
+ this.createCompressor(inst, video);
+
+ } else if ( ! video._ffz_comp_reset && ! this.canCompress(inst) ) {
+ video._ffz_comp_reset = true;
+ this.resetPlayer(inst);
+ return;
+ }
+
+ if ( wanted == compressed || (e == null && video._ffz_toggled) )
+ return;
+
+ const ctx = video._ffz_context,
+ comp = video._ffz_compressor,
+ src = video._ffz_source;
+
+ if ( ! ctx || ! comp || ! src )
+ return;
+
+ if ( wanted ) {
+ src.disconnect(ctx.destination);
+ src.connect(comp);
+ comp.connect(ctx.destination);
+ } else {
+ src.disconnect(comp);
+ comp.disconnect(ctx.destination);
+ src.connect(ctx.destination);
+ }
+
+ video._ffz_compressed = wanted;
+ this.addCompressorButton(inst);
+ }
+
+ canCompress(inst) { // eslint-disable-line class-methods-use-this
+ if ( ! HAS_COMPRESSOR )
+ return false;
+
+ const player = inst.props?.mediaPlayerInstance;
+ if ( player == null )
+ return false;
+
+ const video = player.mediaSinkManager?.video || player.core?.mediaSinkManager?.video;
+ if ( ! video )
+ return false;
+
+ if ( video.src ) {
+ const url = new URL(video.src);
+ if ( url.protocol !== 'blob:' )
+ return false;
+ } else
+ return false;
+
+ /*this.PlayerSource.check();
+ for(const si of this.PlayerSource.instances) {
+ if ( player === si.props?.mediaPlayerInstance ) {
+ return si.props?.playerType !== 'clips-watch' && si.props?.content?.type !== 'clip';
+ }
+ }*/
+
+ return true;
+ }
+
+ createCompressor(inst, video) {
+ if ( ! this.canCompress(inst) )
+ return;
+
+ let comp = video._ffz_compressor;
+ if ( ! comp ) {
+ const ctx = new AudioContext();
+ if ( ctx.state === 'suspended' ) {
+ this.log.info('Aborting due to browser auto-play policy.');
+ return;
+ }
+
+ video._ffz_context = ctx;
+ const src = video._ffz_source = ctx.createMediaElementSource(video);
+ src.connect(ctx.destination);
+
+ comp = video._ffz_compressor = ctx.createDynamicsCompressor();
+ video._ffz_compressed = false;
+ }
+
+ this.updateCompressor(null, comp);
+ }
+
+ updateCompressors() {
+ for(const inst of this.Player.instances)
+ this.updateCompressor(inst);
+ }
+
+ updateCompressor(inst, comp) {
+ if ( comp == null ) {
+ const video = inst.props.mediaPlayerInstance?.mediaSinkManager?.video ||
+ inst.props.mediaPlayerInstance?.core?.mediaSinkManager?.video;
+ comp = video?._ffz_compressor;
+ }
+
+ if ( ! comp )
+ return;
+
+ comp.threshold.value = this.settings.get('player.compressor.threshold');
+ comp.knee.value = this.settings.get('player.compressor.knee');
+ comp.ratio.value = this.settings.get('player.compressor.ratio');
+ comp.attack.value = this.settings.get('player.compressor.attack');
+ comp.release.value = this.settings.get('player.compressor.release');
+ }
+
+ updatePlaybackRates() {
+ for(const inst of this.Player.instances)
+ this.updatePlaybackRate(inst);
+ }
+
+ updatePlaybackRate(inst) {
+ const video = inst.props.mediaPlayerInstance?.mediaSinkManager?.video ||
+ inst.props.mediaPlayerInstance?.core?.mediaSinkManager?.video;
+
+ if ( ! video.setFFZPlaybackRate )
+ this.installPlaybackRate(video);
+
+ video.setFFZPlaybackRate(video.playbackRate);
+ }
+
+ installPlaybackRate(video) {
+ if ( video.setFFZPlaybackRate )
+ return;
+
+ let pbrate = video.playbackRate;
+
+ const t = this,
+ installProperty = () => {
+ if ( t.settings.get('player.allow-catchup') )
+ return;
+
+ Object.defineProperty(video, 'playbackRate', {
+ configurable: true,
+ get() {
+ return pbrate;
+ },
+ set(val) {
+ if ( val === 1 || val < 1 || val >= 1.1 )
+ video.setFFZPlaybackRate(val);
+ }
+ });
+ }
+
+ video.setFFZPlaybackRate = rate => {
+ delete video.playbackRate;
+ pbrate = rate;
+ video.playbackRate = rate;
+ installProperty();
+ };
+ }
+
+
+ addPiPButton(inst, tries = 0) {
+ const outer = inst.props.containerRef || this.fine.getChildNode(inst),
+ video = inst.props.mediaPlayerInstance?.mediaSinkManager?.video || inst.props.mediaPlayerInstance?.core?.mediaSinkManager?.video,
+ is_fs = video && document.fullscreenElement && document.fullscreenElement.contains(video),
+ container = outer && outer.querySelector('.player-controls__right-control-group'),
+ has_pip = document.pictureInPictureEnabled && this.settings.get('player.button.pip');
+
+ if ( ! container ) {
+ if ( ! has_pip )
+ return;
+
+ if ( tries < 5 )
+ return setTimeout(this.addPiPButton.bind(this, inst, (tries || 0) + 1), 250);
+
+ return; // this.log.warn('Unable to find container element for PiP button.');
+ }
+
+ let icon, tip, btn, cont = container.querySelector('.ffz--player-pip');
+ if ( ! has_pip ) {
+ if ( cont )
+ cont.remove();
+ return;
+ }
+
+ if ( ! cont ) {
+ cont = ();
+
+ let thing = container.querySelector('button[data-a-target="player-theatre-mode-button"]');
+ if ( ! thing )
+ thing = container.querySelector('button[data-a-target="player-fullscreen-button"]');
+
+ if ( thing ) {
+ container.insertBefore(cont, thing.parentElement);
+ } else
+ container.appendChild(cont);
+
+ } else {
+ icon = cont.querySelector('figure');
+ btn = cont.querySelector('button');
+ tip = cont.querySelector('.tw-tooltip');
+ }
+
+ const pip_active = !!document.pictureInPictureElement,
+ pip_swap = false, //pip_active && document.pictureInPictureElement !== video,
+ label = is_fs ?
+ this.i18n.t('player.pip_button.fs', 'Cannot use Picture-in-Picture when Fullscreen')
+ : pip_swap ?
+ this.i18n.t('player.pip_button.swap', 'Switch Picture-in-Picture')
+ : pip_active ?
+ this.i18n.t('player.pip_button.off', 'Exit Picture-in-Picture')
+ : this.i18n.t('player.pip_button', 'Picture-in-Picture');
+
+ icon.classList.toggle('ffz-i-t-pip-inactive', ! pip_active || pip_swap);
+ icon.classList.toggle('ffz-i-t-pip-active', pip_active && ! pip_swap);
+
+ btn.setAttribute('aria-label', label);
+ tip.textContent = label;
+ }
+
+
+ pipPlayer(inst, e) {
+ const video = inst.props.mediaPlayerInstance?.mediaSinkManager?.video ||
+ inst.props.mediaPlayerInstance?.core?.mediaSinkManager?.video;
+ if ( ! video || ! document.pictureInPictureEnabled )
+ return;
+
+ if ( e )
+ e.preventDefault();
+
+ if ( document.fullscreenElement && document.fullscreenElement.contains(video) )
+ return;
+
+ if ( ! video._ffz_pip_enter ) {
+ video.addEventListener('enterpictureinpicture', video._ffz_pip_enter = () => {
+ this.addPiPButton(inst);
+ });
+
+ video.addEventListener('leavepictureinpicture', video._ffz_pip_exit = () => {
+ this.addPiPButton(inst);
+ });
+ }
+
+ //const is_this = document.pictureInPictureElement === video;
+ if ( document.pictureInPictureElement )
+ document.exitPictureInPicture();
+ else
+ //if ( ! is_this )
+ video.requestPictureInPicture();
+ }
+
+
+ addResetButton(inst, tries = 0) {
+ const outer = inst.props.containerRef || this.fine.getChildNode(inst),
+ container = outer && outer.querySelector('.player-controls__right-control-group'),
+ has_reset = this.settings.get('player.button.reset');
+
+ if ( ! container ) {
+ if ( ! has_reset )
+ return;
+
+ if ( tries < 5 )
+ return setTimeout(this.addResetButton.bind(this, inst, (tries || 0) + 1), 250);
+
+ return; // this.log.warn('Unable to find container element for Reset button.');
+ }
+
+ let tip, btn, cont = container.querySelector('.ffz--player-reset');
+ if ( ! has_reset ) {
+ if ( cont )
+ cont.remove();
+ return;
+ }
+
+ if ( ! cont ) {
+ cont = ();
+
+ const thing = container.querySelector('.ffz--player-pip button') || container.querySelector('button[data-a-target="player-theatre-mode-button"]') || container.querySelector('button[data-a-target="player-fullscreen-button"]');
+ if ( thing ) {
+ container.insertBefore(cont, thing.parentElement);
+ } else
+ container.appendChild(cont);
+
+ } else {
+ btn = cont.querySelector('button');
+ tip = cont.querySelector('.tw-tooltip');
+ }
+
+ btn.setAttribute('aria-label',
+ tip.textContent = this.i18n.t(
+ 'player.reset_button',
+ 'Reset Player (Double-Click)'
+ ));
+ }
+
+
+ addErrorResetButton(inst, tries = 0) {
+ const outer = inst.props.containerRef || this.fine.getChildNode(inst),
+ container = outer && outer.querySelector('.content-overlay-gate'),
+ has_reset = this.settings.get('player.button.reset');
+
+ if ( ! container ) {
+ if ( ! has_reset )
+ return;
+
+ if ( tries < 2 )
+ this.parent.awaitElement(
+ '.autoplay-vod__content-container button',
+ this.props.containerRef || t.fine.getChildNode(this),
+ 1000
+ ).then(() => {
+ this.addErrorResetButton(inst, (tries || 0) + 1);
+
+ }).catch(() => {
+ this.log.warn('Unable to find container element for Error Reset button.');
+ });
+
+ return;
+ }
+
+ let tip, btn, cont = container.querySelector('.ffz--player-reset');
+ if ( ! has_reset ) {
+ if ( cont )
+ cont.remove();
+ return;
+ }
+
+ if ( ! cont ) {
+ cont = ();
+
+ container.appendChild(cont);
+
+ } else {
+ btn = cont.querySelector('button');
+ tip = cont.querySelector('.tw-tooltip');
+ }
+
+ btn.setAttribute('aria-label',
+ tip.textContent = this.i18n.t(
+ 'player.reset_button',
+ 'Double-Click to Reset Player'
+ ));
+ }
+
+
+
+ resetAllPlayers() {
+ for(const inst of this.Player.instances)
+ this.resetPlayer(inst);
+ }
+
+
+ resetPlayer(inst, e) {
+ const player = inst ? ((inst.mediaSinkManager || inst.core?.mediaSinkManager) ? inst : inst?.props?.mediaPlayerInstance) : null;
+
+ if ( e ) {
+ e.preventDefault();
+ const target = e.currentTarget,
+ icon = target && target.querySelector('figure');
+
+ if ( icon ) {
+ if ( icon.classList.contains('loading') )
+ return;
+
+ icon.classList.toggle('ffz-i-t-reset', true);
+ icon.classList.toggle('ffz-i-t-reset-clicked', false);
+
+ icon.classList.toggle('loading', true);
+ icon._ffz_unspin = setTimeout(() => {
+ icon._ffz_unspin = null;
+ icon.classList.toggle('loading', false);
+ }, 10000);
+ }
+ }
+
+ // Are we dealing with a VOD?
+ const duration = player.getDuration?.() ?? Infinity;
+ let position = -1;
+
+ if ( isFinite(duration) && ! isNaN(duration) && duration > 0 )
+ position = player.getPosition();
+
+ const video = player.mediaSinkManager?.video || player.core?.mediaSinkManager?.video;
+ if ( video?._ffz_compressor && player.attachHTMLVideoElement ) {
+ const new_vid = createElement('video'),
+ vol = video?.volume ?? player.getVolume(),
+ muted = player.isMuted();
+ new_vid.volume = muted ? 0 : vol;
+ new_vid.playsInline = true;
+ this.installPlaybackRate(new_vid);
+ video.replaceWith(new_vid);
+ player.attachHTMLVideoElement(new_vid);
+ setTimeout(() => {
+ player.setVolume(vol);
+ player.setMuted(muted);
+
+ //localStorage.volume = vol;
+ //localStorage.setItem('video-muted', JSON.stringify({default: muted}));
+ }, 0);
+ }
+
+ this.PlayerSource.check();
+ for(const inst of this.PlayerSource.instances) {
+ if ( ! player || player === inst.props?.mediaPlayerInstance )
+ inst.setSrc({isNewMediaPlayerInstance: false});
+ }
+
+ if ( position > 0 )
+ setTimeout(() => player.seekTo(position), 250);
+ }
+
+
+ get playerUI() {
+ const container = this.fine.searchTree(this.Player.first, n => n.props && n.props.uiContext, 150);
+ return container?.props?.uiContext;
+ }
+
+ get current() {
+ for(const inst of this.Player.instances)
+ if ( inst?.props?.mediaPlayerInstance )
+ return inst.props.mediaPlayerInstance;
+
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/sites/player/styles/player-main.scss b/src/sites/player/styles/player-main.scss
new file mode 100644
index 00000000..89409b7f
--- /dev/null
+++ b/src/sites/player/styles/player-main.scss
@@ -0,0 +1,50 @@
+@import 'styles/main.scss';
+
+.ffz--player-pip,
+.ffz--player-reset {
+ &:before {
+ font-size: 2rem;
+ margin-top: .7rem;
+ }
+
+ &:hover:before {
+ color: #a991d4;
+ }
+}
+
+.player-controls-bottom .player-tip {
+ top: auto;
+ bottom: 4rem;
+ height: auto;
+}
+
+div[data-a-target="player-settings-menu"] {
+ display: flex;
+ flex-direction: column;
+}
+
+.ffz--cc-button {
+ order: 999;
+}
+
+.ffz--player-meta-tray {
+ display: inline-flex;
+ color: var(--color-text-overlay);
+}
+
+.ffz-stat {
+ background-color: var(--color-background-pill);
+ color: var(--color-text-overlay);
+ display: inline-block;
+ position: relative;
+ line-height: 1;
+ text-align: center;
+ white-space: nowrap;
+ border-radius: 1000px;
+ padding: 0.3rem 0.5rem;
+ font-size: 75%;
+}
+
+.ffz-stat-text {
+ font-variant-numeric: tabular-nums;
+}
\ No newline at end of file
diff --git a/src/sites/twitch-clips/index.js b/src/sites/twitch-clips/index.js
index 6c5dbab6..e0c6bf85 100644
--- a/src/sites/twitch-clips/index.js
+++ b/src/sites/twitch-clips/index.js
@@ -32,8 +32,14 @@ export default class Clippy extends BaseSite {
//this.inject(Switchboard);
}
- onLoad() {
- this.populateModules();
+ async populateModules() {
+ const ctx = await require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/);
+ const modules = await this.populate(ctx, this.log);
+ this.log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
+ }
+
+ async onLoad() {
+ await this.populateModules();
}
onEnable() {
diff --git a/src/sites/twitch-twilight/index.js b/src/sites/twitch-twilight/index.js
index 3595ad20..f6a221c8 100644
--- a/src/sites/twitch-twilight/index.js
+++ b/src/sites/twitch-twilight/index.js
@@ -42,6 +42,12 @@ export default class Twilight extends BaseSite {
this._dom_updates = [];
}
+ async populateModules() {
+ const ctx = await require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/);
+ const modules = await this.populate(ctx, this.log);
+ this.log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
+ }
+
async onLoad() {
await this.populateModules();
@@ -54,6 +60,7 @@ export default class Twilight extends BaseSite {
this.router.route('user-home', '/:userName', null, state => state?.channelView === 'Home');
this.router.route(Twilight.DASH_ROUTES, 'dashboard.twitch.tv');
+ this.router.route(Twilight.PLAYER_ROUTES, 'player.twitch.tv');
}
onEnable() {
@@ -237,7 +244,8 @@ Twilight.ROUTE_NAMES = {
'dash': 'Dashboard',
'popout': 'Popout Chat',
'dash-chat': 'Dashboard Popout Chat',
- 'user-video': 'Channel Video'
+ 'user-video': 'Channel Video',
+ 'popout-player': 'Popout/Embed Player'
};
@@ -262,6 +270,11 @@ Twilight.SUNLIGHT_ROUTES = [
];
+Twilight.PLAYER_ROUTES = {
+ 'popout-player': '/'
+};
+
+
Twilight.DASH_ROUTES = {
'dash-stream-manager': '/u/:userName/stream-manager',
'dash-channel-analytics': '/u/:userName/channel-analytics',
diff --git a/src/sites/twitch-twilight/modules/player.jsx b/src/sites/twitch-twilight/modules/player.jsx
index eda9132c..a37375cb 100644
--- a/src/sites/twitch-twilight/modules/player.jsx
+++ b/src/sites/twitch-twilight/modules/player.jsx
@@ -53,7 +53,6 @@ export default class Player extends Module {
this.inject('settings');
this.inject('site.fine');
- this.inject('site.web_munch');
this.inject('site.css_tweaks');
this.inject('site.router');
@@ -92,6 +91,15 @@ export default class Player extends Module {
// Settings
+ this.settings.add('player.embed-metadata', {
+ default: true,
+ ui: {
+ path: 'Player > General >> Embed and Popout',
+ title: 'Show metadata when mousing over the player.',
+ component: 'setting-check-box'
+ }
+ });
+
if ( HAS_COMPRESSOR ) {
this.settings.add('player.compressor.enable', {
default: true,
@@ -1207,8 +1215,14 @@ export default class Player extends Module {
let comp = video._ffz_compressor;
if ( ! comp ) {
- const ctx = video._ffz_context = new AudioContext(),
- src = video._ffz_source = ctx.createMediaElementSource(video);
+ const ctx = new AudioContext();
+ if ( ctx.state === 'suspended' ) {
+ this.log.info('Aborting due to browser auto-play policy.');
+ return;
+ }
+
+ video._ffz_context = ctx;
+ const src = video._ffz_source = ctx.createMediaElementSource(video);
src.connect(ctx.destination);
diff --git a/src/utilities/filtering.js b/src/utilities/filtering.js
index 6c289e40..d589f77c 100644
--- a/src/utilities/filtering.js
+++ b/src/utilities/filtering.js
@@ -17,8 +17,14 @@ export function createTester(rules, filter_types, inverted = false, or = false,
continue;
const type = filter_types[rule.type];
- if ( ! type )
+ if ( ! type ) {
+ // Default to false if we cannot find a rule type.
+ // Just to be safe.
+ i++;
+ tests.push(() => false);
+ names.push(`f${i}`);
continue;
+ }
i++;
tests.push(type.createTest(rule.data, filter_types, rebuild));