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 = ()} + {popup = ()} +
); + + } else + btn = popup = el = (); + + 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 = (
+ {btn = ()} +