diff --git a/rollup.config.js b/rollup.config.js index be0b98a0..e92eb27b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,6 +1,7 @@ import resolve from '@rollup/plugin-node-resolve' import babel from '@rollup/plugin-babel' import eslint from '@rollup/plugin-eslint' +import fs from 'fs' const common = { context: 'window', @@ -29,8 +30,9 @@ const scripts = ['background', 'options', 'content', 'injected'] export default scripts.map(script => Object.assign({}, { input: `./src/scripts/${script}.js`, - output: { - file: `./dist/scripts/${script}.js`, - format: 'iife' + output: { + file: `./dist/scripts/${script}.js`, + format: 'iife', + intro: fs.readFileSync('./src/scripts/intros/console.js', 'utf8') } }, common)) \ No newline at end of file diff --git a/src/manifest.json b/src/manifest.json index 68567777..d6857f69 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "Ambient light for YouTube™", "description": "Immersive yourself in YouTube™ videos with ambient light!", - "version": "2.37.34", + "version": "2.38.0", "homepage_url": "https://github.com/WesselKroos/youtube-ambilight", "author": "Wessel Kroos", "icons": { @@ -13,6 +13,7 @@ "minimum_chrome_version": "80", "browser_specific_settings": { "gecko": { + "id": "{e997c82e-7eb4-4f7f-8ca0-2e2c375c3016}", "strict_min_version": "74.0" } }, diff --git a/src/options.html b/src/options.html index a0375b42..7c2efab2 100644 --- a/src/options.html +++ b/src/options.html @@ -1,6 +1,7 @@
++ + + +
++ + +
++
+ +A crash report contains only anonymous data and will be deleted after 30 days. diff --git a/src/scripts/injected.js b/src/scripts/injected.js index 06b513b9..856de4b5 100644 --- a/src/scripts/injected.js +++ b/src/scripts/injected.js @@ -254,7 +254,7 @@ const loadAmbientlight = async () => { try { await Settings.getStoredSettingsCached() } catch(ex) { - console.warn('Ambient light for YouTube™ | The settings cannot be precached') + console.warn('The settings cannot be precached') console.error(ex) } diff --git a/src/scripts/intros/console.js b/src/scripts/intros/console.js new file mode 100644 index 00000000..d4b415a6 --- /dev/null +++ b/src/scripts/intros/console.js @@ -0,0 +1,25 @@ +// eslint-disable-next-line no-unused-vars +let console; + +(function() { + const preMessage = 'Ambient light for YouTube™ |' + + const enrich = (...args) => { + if (args.length <= 0) return args + + if(typeof args[0] === 'string') { + const [firstArg, ...postArgs] = args + return [`${preMessage} ${firstArg}`, ...postArgs] + } + + return [preMessage, ...args] + } + + console = { + log: (...args) => window.console.log(...enrich(...args)), + debug: (...args) => window.console.debug(...enrich(...args)), + warn: (...args) => window.console.warn(...enrich(...args)), + error: (...args) => window.console.error(...enrich(...args)), + dir: (...args) => window.console.dir(...args), + } +})(); \ No newline at end of file diff --git a/src/scripts/libs/ambientlight.js b/src/scripts/libs/ambientlight.js index 1fe1fca1..6eeccdc8 100644 --- a/src/scripts/libs/ambientlight.js +++ b/src/scripts/libs/ambientlight.js @@ -103,7 +103,7 @@ export default class Ambientlight { try { await this.enable(true) } catch(ex) { - console.warn('Ambient light for YouTube™ | Failed to enable on launch') + console.warn('Failed to enable on launch') SentryReporter.captureException(ex) } } @@ -236,7 +236,7 @@ export default class Ambientlight { } } } catch(ex) { - console.warn('Ambient light for YouTube™ | applyChromiumBug1142112Workaround error. Continuing ambientlight initialization...') + console.warn('applyChromiumBug1142112Workaround error. Continuing ambientlight initialization...') SentryReporter.captureException(ex) } } @@ -373,7 +373,7 @@ export default class Ambientlight { update } } catch(ex) { - console.warn('Ambient light for YouTube™ | applyChromiumBugVideoJitterWorkaround error. Continuing ambientlight initialization...') + console.warn('applyChromiumBugVideoJitterWorkaround error. Continuing ambientlight initialization...') SentryReporter.captureException(ex) this.enableChromiumBugVideoJitterWorkaround = false // Prevent retries } @@ -569,7 +569,7 @@ export default class Ambientlight { error: (ex) => { const videoElem = ex?.target; const error = videoElem?.error; - console.warn(`Ambient light for YouTube™ | Restoring the ambient light after a video error... + console.warn(`Restoring the ambient light after a video error... Video error: ${mediaErrorToString(error?.code)} ${error?.message ? `(${error?.message})` : ''} Video network state: ${networkStateToString(videoElem?.networkState)} Video ready state: ${readyStateToString(videoElem?.readyState)}`) @@ -654,7 +654,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) this.videoPlayerElem.setInternalSize() this.sizesChanged = true } catch(ex) { - console.warn('Ambient light for YouTube™ | Failed to resize the video player') + console.warn('Failed to resize the video player') } } @@ -751,7 +751,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) // await new Promise(resolve => raf(resolve)) // Wait for all layout style recalculations // this.sizesChanged = true // } catch(ex) { - // console.warn('Ambient light for YouTube™ | Failed to resize the video player') + // console.warn('Failed to resize the video player') // } // } if(!this.settings.enabled) return @@ -1007,7 +1007,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) // Try to apply the workaround once if(this.videoElem.src && !getImageDataAllowed && !this.crossOriginApplied) { - console.warn(`Ambient light for YouTube™ | Detected cross origin video. Applying workaround... ${this.videoElem.src}, ${this.videoElem.crossOrigin}`) + console.warn(`Detected cross origin video. Applying workaround... ${this.videoElem.src}, ${this.videoElem.crossOrigin}`) this.crossOriginApplied = true try { @@ -1016,7 +1016,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) this.videoPlayerElem.loadVideoById(this.videoPlayerElem.getVideoData().video_id) // Refreshes auto quality setting range above 480p this.videoElem.currentTime = currentTime } catch(ex) { - console.warn(`Ambient light for YouTube™ | Detected cross origin video. Failed to apply workaround... ${this.videoElem.src}, ${this.videoElem.crossOrigin}`) + console.warn(`Detected cross origin video. Failed to apply workaround... ${this.videoElem.src}, ${this.videoElem.crossOrigin}`) } } @@ -1084,7 +1084,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) if(!this.settings.webGLCrashDate) { SentryReporter.captureException(ex) } else { - console.log('Ambient light for YouTube™ | ', ex) + console.log(ex) } this.settings.handleWebGLCrash() } @@ -1146,7 +1146,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) if(!this.settings.webGLCrashDate) { SentryReporter.captureException(ex) } else { - console.log('Ambient light for YouTube™ | ', ex) + console.log(ex) } this.settings.handleWebGLCrash() } @@ -2096,9 +2096,10 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) if(this.averageVideoFramesDifference < .0175) return 1 // 1 seconds } + const frameFading = this.settings.frameFading ? Math.round(Math.pow(this.settings.frameFading, 2)) : 0 const frameFadingMax = (15 * Math.pow(ProjectorWebGL.subProjectorDimensionMax, 2)) - 1 - const realFramerateLimit = (this.settings.webGL && this.settings.frameFading > frameFadingMax) - ? Math.max(1, (frameFadingMax / (this.settings.frameFading || 1)) * this.settings.framerateLimit) + const realFramerateLimit = (this.settings.webGL && frameFading > frameFadingMax) + ? Math.max(1, (frameFadingMax / (frameFading || 1)) * this.settings.framerateLimit) : this.settings.framerateLimit return realFramerateLimit } @@ -2206,7 +2207,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) 'NS_ERROR_NOT_AVAILABLE', 'NS_ERROR_OUT_OF_MEMORY' ].includes(ex.name)) { - console.warn('Ambient light for YouTube™ | Failed to display the ambient light') + console.warn('Failed to display the ambient light') console.error(ex) return } @@ -2762,7 +2763,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) // } // } // } catch(ex) { - // console.log(`Ambient light for YouTube™ | Skipped disabling YouTube\'s own Ambient Mode: ${ex?.message}`) + // console.log(`Skipped disabling YouTube\'s own Ambient Mode: ${ex?.message}`) // } // settingsBtn?.click() // Close settings @@ -2780,7 +2781,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) // await new Promise(resolve => raf(resolve)) // Await rendering // settingsPopup.classList.remove('disable-youtube-ambient-mode-workaround') // } catch(ex) { - // console.log(`Ambient light for YouTube™ | Failed to automatically disable YouTube\'s own Ambient Mode: ${ex?.message}`) + // console.log(`Failed to automatically disable YouTube\'s own Ambient Mode: ${ex?.message}`) // } // } @@ -2807,7 +2808,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) this.sizesChanged = true } } catch(ex) { - console.warn('Ambient light for YouTube™ | Failed to execute HDR video check') + console.warn('Failed to execute HDR video check') } this.showedCompareWarning = false @@ -2868,7 +2869,7 @@ Video ready state: ${readyStateToString(videoElem?.readyState)}`) onVideoFrame = wrapErrorHandler(function onVideoFrame(compose, info) { if (!this.requestVideoFrameCallbackId) { - console.warn(`Ambient light for YouTube™ | Old rvfc fired. Ignoring a possible duplicate. ${this.requestVideoFrameCallbackId} | ${compose} | ${info}`) + console.warn(`Old rvfc fired. Ignoring a possible duplicate. ${this.requestVideoFrameCallbackId} | ${compose} | ${info}`) return } this.videoElem.requestVideoFrameCallback(() => {}) // Requesting as soon as possible to prevent skipped video frames on displays with a matching framerate diff --git a/src/scripts/libs/bar-detection.js b/src/scripts/libs/bar-detection.js index 154a33e1..85ec5332 100644 --- a/src/scripts/libs/bar-detection.js +++ b/src/scripts/libs/bar-detection.js @@ -321,7 +321,7 @@ export default class BarDetection { this.worker = workerFromCode(workerCode) this.worker.onmessage = (e) => { if(e.data.id !== -1) { - // console.warn('Ambient light for YouTube™ | Ignoring old bar detection message:', e.data) + // console.warn('Ignoring old bar detection message:', e.data) return } if(e.data.error) { @@ -437,7 +437,7 @@ export default class BarDetection { this.run !== run || e.data.id !== this.workerMessageId ) { - // console.warn('Ambient light for YouTube™ | Ignoring old bar detection percentage:', + // console.warn('Ignoring old bar detection percentage:', // this.workerMessageId, e.data.id, e.data.horizontalPercentage, e.data.verticalPercentage) resolve() return diff --git a/src/scripts/libs/canvas-webgl.js b/src/scripts/libs/canvas-webgl.js index 6f4da4e4..47663290 100644 --- a/src/scripts/libs/canvas-webgl.js +++ b/src/scripts/libs/canvas-webgl.js @@ -61,13 +61,13 @@ export class WebGLContext { this.scaleY = undefined this.program = undefined // Prevent warning: Cannot delete program from old context. in initCtx - console.log(`Ambient light for YouTube™ | WebGLContext lost (${this.lostCount})`) + console.log(`WebGLContext lost (${this.lostCount})`) this.setWebGLWarning('restore') }.bind(this)), false); this.canvas.addEventListener('webglcontextrestored', wrapErrorHandler(async function webGLContextRestored() { - // console.log(`Ambient light for YouTube™ | WebGLContext restored (${this.lostCount})`) + // console.log(`WebGLContext restored (${this.lostCount})`) if(this.lostCount >= 3) { - console.error('Ambient light for YouTube™ | WebGLContext was lost 3 times. The current restoration has been aborted to prevent an infinite restore loop.') + console.error('WebGLContext was lost 3 times. The current restoration has been aborted to prevent an infinite restore loop.') this.setWebGLWarning('3 times restore') return } @@ -80,12 +80,12 @@ export class WebGLContext { if(!this.ambientlight.projector?.lost && !this.ambientlight.projector?.blurLost) this.setWarning('') } else { - console.error(`Ambient light for YouTube™ | WebGLContext restore failed (${this.lostCount})`) + console.error(`WebGLContext restore failed (${this.lostCount})`) this.setWebGLWarning('restore') } }.bind(this)), false); this.canvas.addEventListener('webglcontextcreationerror', wrapErrorHandler(function webGLContextCreationError(e) { - // console.warn(`Ambient light for YouTube™ | WebGLContext creationerror: ${e.statusMessage}`) + // console.warn(`WebGLContext creationerror: ${e.statusMessage}`) this.webglcontextcreationerrors.push({ message: e.statusMessage || '?', time: performance.now(), @@ -113,7 +113,7 @@ export class WebGLContext { this.ctx.finish() // Wait for any pending draw calls to finish this.ctx.deleteProgram(this.program) // Free GPU memory } catch(ex) { - console.warn('Ambient light for YouTube™ | Failed to delete previous WebGLContext program', ex) + console.warn('Failed to delete previous WebGLContext program', ex) } this.program = undefined } @@ -606,7 +606,7 @@ export class WebGLContext { const invalid = this.isContextLost() || !this.program; if (invalid && !this.ctxIsInvalidWarned && !this.program) { this.ctxIsInvalidWarned = true - console.log('Ambient light for YouTube™ | WebGLContext is lost') + console.log('WebGLContext is lost') } return invalid; } diff --git a/src/scripts/libs/generic.js b/src/scripts/libs/generic.js index 724689b3..e8598d21 100644 --- a/src/scripts/libs/generic.js +++ b/src/scripts/libs/generic.js @@ -31,7 +31,7 @@ export const waitForDomElement = (check, container, timeout) => new Promise((res }) let errorHandler = (ex) => { - console.error(`Ambient light for YouTube™ |`, ex) + console.error(ex) } export const setErrorHandler = (handler) => { errorHandler = handler diff --git a/src/scripts/libs/projector-2d.js b/src/scripts/libs/projector-2d.js index 6716e4c1..b10a7ccc 100644 --- a/src/scripts/libs/projector-2d.js +++ b/src/scripts/libs/projector-2d.js @@ -25,7 +25,7 @@ export default class Projector2d { } onProjectorCtxLost = () => { - console.log('Ambient light for YouTube™ | Lost 2d projector') + console.log('Lost 2d projector') this.lostCount++ // event.preventDefault(); // Prevents restoration this.settings.setWarning('Failed to restore the renderer from a GPU crash.\nReload the page to try it again.\nOr the memory on your GPU is in use by another process.\nYou can additionallyt undo the last changed setting or reset all the settings to the default values.') @@ -33,13 +33,13 @@ export default class Projector2d { onProjectorCtxRestored = (event) => { if(this.lostCount >= 3 * this.projectors.length) { - console.error('Ambient light for YouTube™ | Projector2D context restore failed 3 times') + console.error('Projector2D context restore failed 3 times') this.settings.setWarning('Failed to restore 3 times the renderer from a GPU crash.\nReload the page to try it again.\nOr the memory on your GPU is in use by another process.\nYou can additionallyt undo the last changed setting or reset all the settings to the default values.') return } - console.log('Ambient light for YouTube™ | Restored 2d projector') + console.log('Restored 2d projector') const projectorElem = event.currentTarget projectorElem.width = 1 // Reset size this.ambientlight.buffersCleared = true // Trigger resize before redraw diff --git a/src/scripts/libs/projector-webgl.js b/src/scripts/libs/projector-webgl.js index 3d3ec386..28a17585 100644 --- a/src/scripts/libs/projector-webgl.js +++ b/src/scripts/libs/projector-webgl.js @@ -216,14 +216,14 @@ export default class ProjectorWebGL { this.blurLost = true this.blurLostCount++ this.invalidateShaderCache() - console.log(`Ambient light for YouTube™ | ProjectorWebGL blur context lost (${this.blurLostCount})`) + console.log(`ProjectorWebGL blur context lost (${this.blurLostCount})`) this.setWebGLWarning('restore') }.bind(this)) onBlurCtxRestored = wrapErrorHandler(async function wrappedOnBlurCtxRestored() { - console.log(`Ambient light for YouTube™ | ProjectorWebGL blur context restored (${this.blurLostCount})`) + console.log(`ProjectorWebGL blur context restored (${this.blurLostCount})`) if(this.blurLostCount >= 3) { - console.error('Ambient light for YouTube™ | ProjectorWebGL blur context was lost 3 times. The current restoration has been aborted to prevent an infinite restore loop.') + console.error('ProjectorWebGL blur context was lost 3 times. The current restoration has been aborted to prevent an infinite restore loop.') this.setWebGLWarning('3 times restore') return } @@ -236,7 +236,7 @@ export default class ProjectorWebGL { if(!this.lost && !this.ambientlight.projectorBuffer?.lost) this.setWarning('') } } else { - console.error(`Ambient light for YouTube™ | ProjectorWebGL blur context restore failed (${this.blurLostCount})`) + console.error(`ProjectorWebGL blur context restore failed (${this.blurLostCount})`) this.setWebGLWarning('restore') } }.bind(this)) @@ -288,7 +288,7 @@ export default class ProjectorWebGL { if(detected) return const message = 'The browser warned that this is a slow device. If you have a graphics card, make sure to enable hardware acceleration in the browser.\n(The resolution setting has been turned down to 25% for better performance)'; - // console.warn(`Ambient light for YouTube™ | ProjectorWebGL: ${message}`) + // console.warn(`ProjectorWebGL: ${message}`) this.setWarning(message, true) this.settings.set('resolution', 25, true) await contentScript.setStorageEntry('majorPerformanceCaveatDetected', true) @@ -310,14 +310,14 @@ export default class ProjectorWebGL { this.program = undefined // Prevent warning: Cannot delete program from old context. in initCtx this.invalidateShaderCache() - console.log(`Ambient light for YouTube™ | ProjectorWebGL context lost (${this.lostCount})`) + console.log(`ProjectorWebGL context lost (${this.lostCount})`) this.setWebGLWarning('restore') }.bind(this)) onCtxRestored = wrapErrorHandler(async function projectorCtxRestored() { - // console.log(`Ambient light for YouTube™ | ProjectorWebGL restored (${this.lostCount})`) + // console.log(`ProjectorWebGL restored (${this.lostCount})`) if(this.lostCount >= 3) { - console.error('Ambient light for YouTube™ | ProjectorWebGL context restore failed 3 times') + console.error('ProjectorWebGL context restore failed 3 times') this.setWebGLWarning('3 times restore') return } @@ -338,7 +338,7 @@ export default class ProjectorWebGL { if(!this.blurLost && !this.ambientlight.projectorBuffer?.lost) this.setWarning('') } } else { - console.error(`Ambient light for YouTube™ | ProjectorWebGL context restore failed (${this.lostCount})`) + console.error(`ProjectorWebGL context restore failed (${this.lostCount})`) this.setWebGLWarning('restore') return } @@ -348,7 +348,7 @@ export default class ProjectorWebGL { webglcontextcreationerrors = [] onCtxCreationError = wrapErrorHandler(function projectorCtxCreationError(e) { - // console.warn(`Ambient light for YouTube™ | ProjectorWebGL creationerror: ${e.statusMessage}`) + // console.warn(`ProjectorWebGL creationerror: ${e.statusMessage}`) this.webglcontextcreationerrors.push({ webGLVersion: this.webGLVersion, failIfMajorPerformanceCaveat: this.ctxOptions.failIfMajorPerformanceCaveat, @@ -474,10 +474,11 @@ export default class ProjectorWebGL { const maxProjectorTextures = maxTextures - 1 ProjectorWebGL.subProjectorDimensionMax = this.webGLVersion === 2 ? 3 : 2; // WebGL1 does not allow non-power-of-two textures this.subProjectorsCount = 1 - for(let i = 1; i < ProjectorWebGL.subProjectorDimensionMax && this.settings.frameFading + 1 > maxProjectorTextures * Math.pow(i, 2); i++) { + const frameFading = Math.round(Math.pow(this.settings.frameFading, 2)) + for(let i = 1; i < ProjectorWebGL.subProjectorDimensionMax && frameFading + 1 > maxProjectorTextures * Math.pow(i, 2); i++) { this.subProjectorsCount = Math.pow(i + 1, 2); } - this.projectorsCount = Math.min(this.settings.frameFading + 1, maxProjectorTextures * this.subProjectorsCount) + this.projectorsCount = Math.min(frameFading + 1, maxProjectorTextures * this.subProjectorsCount) const projectorsTextureCount = Math.ceil(this.projectorsCount / this.subProjectorsCount) for(let i = 0; i < projectorsTextureCount; i++) { this.projectorsTexture[i] = this.ctx.createTexture(); @@ -652,7 +653,7 @@ export default class ProjectorWebGL { this.ctx.finish() // Wait for any pending draw calls to finish this.ctx.deleteProgram(this.program) // Free GPU memory } catch(ex) { - console.warn('Ambient light for YouTube™ | Failed to delete previous ProjectorWebGL program', ex) + console.warn('Failed to delete previous ProjectorWebGL program', ex) } this.program = undefined this.invalidateShaderCache() @@ -693,7 +694,7 @@ export default class ProjectorWebGL { compiled = false this.ctx.deleteProgram(program) // Free GPU memory } catch(ex) { - console.warn('Ambient light for YouTube™ | Failed to delete new ProjectorWebGL program', ex) + console.warn('Failed to delete new ProjectorWebGL program', ex) } } @@ -1152,7 +1153,7 @@ export default class ProjectorWebGL { const invalid = (!this.ctx || this.ctx.isContextLost() || !this.program || !this.blurCtx || (this.blurCtx.isContextLost && this.blurCtx.isContextLost())) if (invalid && !this.ctxIsInvalidWarned && !this.program) { this.ctxIsInvalidWarned = true - console.log(`Ambient light for YouTube™ | ProjectorWebGL context is lost`) + console.log(`ProjectorWebGL context is lost`) } return invalid } diff --git a/src/scripts/libs/sentry-reporter.js b/src/scripts/libs/sentry-reporter.js index 3c4ed288..e58e2c3a 100644 --- a/src/scripts/libs/sentry-reporter.js +++ b/src/scripts/libs/sentry-reporter.js @@ -210,13 +210,13 @@ export default class SentryReporter { } } catch (ex) { console.warn(ex) } - console.error('Ambient light for YouTube™ | ', ex) + console.error(ex) if(ex.details) { console.error(ex.details) } if(this.overflowProtection === 3) { - console.warn('Ambient light for YouTube™ | Exception overflow protection enabled') + console.warn('Exception overflow protection enabled') } // Ignore errors we cannot fix @@ -225,7 +225,7 @@ export default class SentryReporter { if(!crashOptions?.crash) { - console.warn('Ambient light for YouTube™ | Crash reporting is disabled. If you want this error to be fixed, open the extension options to enable crash reporting. Then refresh the page and reproduce the error again to send a crash report.') + console.warn('Crash reporting is disabled. If you want this error to be fixed, open the extension options to enable crash reporting. Then refresh the page and reproduce the error again to send a crash report.') return } @@ -247,7 +247,7 @@ export default class SentryReporter { version: version || 'pending' }) } else { - console.warn('Ambient light for YouTube™ | Dropped error report because too many reports has been sent today or in the last 7 days') + console.warn('Dropped error report because too many reports has been sent today or in the last 7 days') return } await contentScript.setStorageEntry('reports', JSON.stringify(reportsThisWeek)) @@ -537,7 +537,7 @@ export default class SentryReporter { makeMain(previousHub) scope.clear() } catch (ex) { - console.error('Ambient light for YouTube™ | ', ex) + console.error(ex) } } } diff --git a/src/scripts/libs/settings-config.js b/src/scripts/libs/settings-config.js index 2f1875e7..4dd23fcc 100644 --- a/src/scripts/libs/settings-config.js +++ b/src/scripts/libs/settings-config.js @@ -143,7 +143,7 @@ const SettingsConfig = [ default: 100, min: 0, max: 100, - step: 1, + step: .1, }, { name: 'headerFillOpacity', @@ -153,7 +153,7 @@ const SettingsConfig = [ default: 100, min: -100, max: 100, - step: 1, + step: .1, advanced: true }, @@ -196,7 +196,7 @@ const SettingsConfig = [ default: 100, min: 0, max: 100, - step: 1 + step: .1 }, { name: 'surroundingContentFillOpacity', @@ -205,7 +205,7 @@ const SettingsConfig = [ default: 10, min: -100, max: 100, - step: 1 + step: .1 }, { name: 'pageBackgroundGreyness', diff --git a/src/scripts/libs/settings.js b/src/scripts/libs/settings.js index 534e4de6..ea9e0b38 100644 --- a/src/scripts/libs/settings.js +++ b/src/scripts/libs/settings.js @@ -113,6 +113,15 @@ export default class Settings { delete Settings.storedSettingsCached['setting-fadeOutEasing'] await contentScript.setStorageEntry('setting-fadeOutEasing', undefined, false) } + if(Settings.storedSettingsCached['setting-frameFading'] !== null) { + const value = Settings.storedSettingsCached['setting-frameFading'] + const max = SettingsConfig.find(setting => setting.name === 'frameFading').max + if(value > max) { + const newValue = Math.min(max, Math.round(Math.sqrt(value) * 50) / 50) + Settings.storedSettingsCached['setting-frameFading'] = newValue + await contentScript.setStorageEntry('setting-frameFading', newValue, false) + } + } // Disable enabled WebGL setting if not supported anymore if(Settings.storedSettingsCached['setting-webGL']) { @@ -585,9 +594,6 @@ export default class Settings { if(setting.valuePoints) { value = setting.valuePoints[value] } - if(setting.name === 'frameFading') { - value = Math.round(Math.pow(value, 2)) - } if(this[setting.name] === value) return @@ -1054,9 +1060,10 @@ export default class Settings { location.reload() } - framesToDuration(frames) { - if(!frames) return 'Off' + frameFadingValueToDuration(value) { + if(!value) return 'Off' + const frames = Math.pow(value, 2) const seconds = frames / 30 if (seconds < 1) return `${Math.round(seconds * 1000)} ms` return `${Math.round(seconds * 10) / 10} seconds` @@ -1078,7 +1085,7 @@ export default class Settings { return (this.framerateLimit == 0) ? 'max fps' : `${value} fps` } if(setting.name === 'frameFading') { - return this.framesToDuration(value) + return this.frameFadingValueToDuration(value) } if(setting.name === 'theme' || setting.name === 'enableInViews') { const snapPoint = setting.snapPoints.find(point => point.value === value) @@ -1117,7 +1124,7 @@ export default class Settings { } setTimeout(() => { - on(body, 'click', this.onCloseMenu, undefined, (listener) => this.onCloseMenuListener = listener) + on(body, 'click', this.onCloseMenu, { capture: true }, (listener) => this.onCloseMenuListener = listener) this.scrollToWarning() }, 100) } @@ -1388,9 +1395,7 @@ export default class Settings { getInputRangeValue(name) { const setting = SettingsConfig.find(setting => setting.name === name) || {} - if(name === 'frameFading') { - return Math.round(5 * (Math.exp(Math.log(this[name]) / 2))) / 5 - } else if(setting.valuePoints){ + if(setting.valuePoints){ return setting.valuePoints.indexOf(this[name]) } else { return this[name] @@ -1471,7 +1476,7 @@ export default class Settings { if(ex.message !== 'An unexpected error occurred') SentryReporter.captureException(ex) - this.logStorageWarningOnce(`Ambient light for YouTube™ | Failed to save settings ${JSON.stringify(this.pendingStorageEntries)}: ${ex.message}`) + this.logStorageWarningOnce(`Failed to save settings ${JSON.stringify(this.pendingStorageEntries)}: ${ex.message}`) } } diff --git a/src/scripts/libs/storage.js b/src/scripts/libs/storage.js index 587c6b58..288890db 100644 --- a/src/scripts/libs/storage.js +++ b/src/scripts/libs/storage.js @@ -1,7 +1,10 @@ import { appendErrorStack, wrapErrorHandler } from './generic' class Storage { - async set(name, value) { + async set(nameOrNamesAndValues, value = undefined) { + const multiple = typeof nameOrNamesAndValues !== 'string' + const namesAndValues = multiple ? nameOrNamesAndValues : { [nameOrNamesAndValues]: value } + const stack = new Error().stack return await new Promise(function storageSet(resolve, reject) { try { @@ -13,10 +16,10 @@ class Storage { reject(appendErrorStack(stack, ex)) } } - if(value === undefined) { - chrome.storage.local.remove([name], setCallback) + if(!multiple && value === undefined) { + chrome.storage.local.remove([nameOrNamesAndValues], setCallback) } else { - chrome.storage.local.set({ [name]: value }, setCallback) + chrome.storage.local.set(namesAndValues, setCallback) } } catch(ex) { reject(appendErrorStack(stack, ex)) @@ -25,7 +28,7 @@ class Storage { } async get(nameOrNames) { - const multiple = Array.isArray(nameOrNames) + const multiple = typeof nameOrNames !== 'string' const names = multiple ? nameOrNames : [nameOrNames] const stack = new Error().stack return await new Promise(function storageGet(resolve, reject) { @@ -46,11 +49,29 @@ class Storage { }) } + onChangedListeners = [] + addListener(handler) { try { - chrome.storage.local.onChanged.addListener(wrapErrorHandler(handler, true)) + const wrappedHandler = wrapErrorHandler(handler, true) + chrome.storage.local.onChanged.addListener(wrappedHandler) + this.onChangedListeners.push({ handler, wrappedHandler }) + } catch(ex) { + console.warn('Failed to listen to storage changes. If any setting changes you\'ll have to manually refresh the page to update them.') + console.debug(ex) + } + } + + removeListener(handler) { + try { + const entry = this.onChangedListeners.find((entry) => entry.handler === handler) + if(!entry) throw new Error('Cannot remove a storage.local.onChange listener that has never been added') + + chrome.storage.local.onChanged.removeListener(entry.wrappedHandler) + + this.onChangedListeners.splice(this.onChangedListeners.indexOf(entry), 1) } catch(ex) { - console.warn('Ambient light for YouTube™ | Failed to listen to crash report option changes. If any of them change you\'ll have to refresh the page to update the new crash report options.') + console.warn('Failed to listen to storage changes. If any setting changes you\'ll have to manually refresh the page to update them.') } } } diff --git a/src/scripts/libs/sync-storage.js b/src/scripts/libs/sync-storage.js new file mode 100644 index 00000000..c6bde825 --- /dev/null +++ b/src/scripts/libs/sync-storage.js @@ -0,0 +1,79 @@ +import { appendErrorStack, wrapErrorHandler } from './generic' + +class SyncStorage { + async set(nameOrNamesAndValues, value = undefined) { + const multiple = typeof nameOrNamesAndValues !== 'string' + const namesAndValues = multiple ? nameOrNamesAndValues : { [nameOrNamesAndValues]: value } + + const stack = new Error().stack + return await new Promise(function storageSet(resolve, reject) { + try { + const setCallback = () => { + try { + if (chrome.runtime.lastError) throw chrome.runtime.lastError + resolve() + } catch(ex) { + reject(appendErrorStack(stack, ex)) + } + } + if(!multiple && value === undefined) { + chrome.storage.sync.remove([nameOrNamesAndValues], setCallback) + } else { + chrome.storage.sync.set(namesAndValues, setCallback) + } + } catch(ex) { + reject(appendErrorStack(stack, ex)) + } + }) + } + + async get(nameOrNames) { + const multiple = typeof nameOrNames !== 'string' + const names = multiple ? nameOrNames : [nameOrNames] + const stack = new Error().stack + return await new Promise(function storageGet(resolve, reject) { + try { + chrome.storage.sync.get(names, function getCallback(result) { + try { + if (chrome.runtime.lastError) throw chrome.runtime.lastError + resolve(multiple ? result : ( + result[nameOrNames] === undefined ? null : result[nameOrNames] + )) + } catch(ex) { + reject(appendErrorStack(stack, ex)) + } + }) + } catch(ex) { + reject(appendErrorStack(stack, ex)) + } + }) + } + + onChangedListeners = [] + + addListener(handler) { + try { + const wrappedHandler = wrapErrorHandler(handler, true) + chrome.storage.sync.onChanged.addListener(wrappedHandler) + this.onChangedListeners.push({ handler, wrappedHandler }) + } catch(ex) { + console.warn('Failed to listen to sync-storage changes. If any setting changes you\'ll have to manually refresh the page to update them.') + console.debug(ex) + } + } + + removeListener(handler) { + try { + const entry = this.onChangedListeners.find((entry) => entry.handler === handler) + if(!entry) throw new Error('Cannot remove a storage.sync.onChange listener that has never been added') + + chrome.storage.sync.onChanged.removeListener(entry.wrappedHandler) + + this.onChangedListeners.splice(this.onChangedListeners.indexOf(entry), 1) + } catch(ex) { + console.warn('Failed to listen to sync-storage changes. If any setting changes you\'ll have to manually refresh the page to update them.') + } + } +} + +export const syncStorage = new SyncStorage() \ No newline at end of file diff --git a/src/scripts/libs/worker.js b/src/scripts/libs/worker.js index 911b6cd0..d8ffb46d 100644 --- a/src/scripts/libs/worker.js +++ b/src/scripts/libs/worker.js @@ -7,7 +7,7 @@ export const workerFromCode = (func) => { } return new Worker(URL.createObjectURL(new Blob(['(', func.toString(), ')()'], { type:'text/javascript' }))) } catch(error) { - console.warn(`Ambient light for YouTube™ | Failed to create a native worker. Creating a fallback worker on the main thread instead (${error.message})`) + console.warn(`Failed to create a native worker. Creating a fallback worker on the main thread instead (${error.message})`) class FallbackWorker { isFallbackWorker = true; @@ -19,7 +19,7 @@ export const workerFromCode = (func) => { data }) }, - onmessage: () => console.error('Ambient light for YouTube™ | onmessage not implemented'), + onmessage: () => console.error('onmessage not implemented'), isFallbackWorker: true } func.bind(globalScope)() diff --git a/src/scripts/options.js b/src/scripts/options.js index f3d5f78d..3e4c5a29 100644 --- a/src/scripts/options.js +++ b/src/scripts/options.js @@ -1,5 +1,7 @@ -import { defaultCrashOptions, storage } from './libs/storage'; +import { defaultCrashOptions, storage } from './libs/storage' +import { syncStorage } from './libs/sync-storage' import { getFeedbackFormLink, getPrivacyPolicyLink } from './libs/utils' +import SettingsConfig from './libs/settings-config' document.querySelector('#feedbackFormLink').href = getFeedbackFormLink() document.querySelector('#privacyPolicyLink').href = getPrivacyPolicyLink() @@ -48,4 +50,207 @@ if(!chrome?.storage?.local?.onChanged) { synchronizationWarning.textContent = 'Unable to synchronize any crash option changes to youtube pages that are already open. Make sure to refresh any open youtube pages after you\'ve changed an option.' synchronizationWarning.classList.add('warning') document.querySelector('.warnings-container').appendChild(synchronizationWarning) +} + +const importExportStatus = document.querySelector('#importExportStatus') +const importExportStatusDetails = document.querySelector('#importExportStatusDetails') +let importWarnings = [] +const importSettings = async (storageName, importJson) => { + try { + importExportStatus.textContent = '' + importExportStatus.classList.remove('has-error') + importExportStatusDetails.textContent = '' + importExportStatusDetails.scrollTo(0, 0) + + const jsonString = await importJson() + if(!jsonString) throw new Error('No settings found to import') + + let importedObject = JSON.parse(jsonString) + if(typeof importedObject !== 'object') throw new Error('No settings found to import') + + // Temporarely import the setting blur as blur2 + // https://github.com/WesselKroos/youtube-ambilight/issues/191#issuecomment-1703792823 + if('blur' in importedObject) { + importedObject.blur2 = importedObject.blur + delete importedObject.blur + } + + importedObject = Object.keys(importedObject) + .sort() + .reduce((obj, key) => (obj[key] = importedObject[key], obj), {}) + + const settings = {} + for(const name in importedObject) { + let value = importedObject[name] + + const setting = SettingsConfig.find(setting => setting.name === name) + if(!setting) { + importWarnings.push(`Skipped "${name}": ${JSON.stringify(value)}. This settings might have been removed or migrated to another name after an update.`) + continue + } + + const { type, min = 0, step = 0.1, max } = setting + if(type === 'checkbox' || type === 'section') { + if(typeof value !== 'boolean') { + importWarnings.push(`Skipped "${name}": ${JSON.stringify(value)} is not a boolean.`) + continue + } + } else if(type === 'list') { + if(typeof value !== 'number') { + importWarnings.push(`Skipped "${name}": ${JSON.stringify(value)} is not a number.`) + continue + } + const valueRoundingLeft = ((value - min) * 1000) % (step * 1000) + if(valueRoundingLeft !== 0) { + importWarnings.push(`Rounded down "${name}": ${JSON.stringify(value)} is not in steps of ${step}${min === undefined ? '' : ` from ${min}`}.`) + value = Math.round(value * 1000 - valueRoundingLeft) / 1000 + } + if(min !== undefined && value < min) { + importWarnings.push(`Clipped "${name}": ${JSON.stringify(value)} is lower than the minimum of ${min}.`) + value = min + } + if(max !== undefined && value > max) { + importWarnings.push(`Clipped "${name}": ${JSON.stringify(value)} is higher than the maximum of ${max}.`) + value = max + } + } + + settings[`setting-${name}`] = value + } + + if(!Object.keys(settings).length) throw new Error('No settings found to import') + + await storage.set(settings) + + importExportStatus.textContent = +`Imported ${Object.keys(settings).length} settings from ${storageName}. +(Refresh any open YouTube browser tabs to use the new settings.)${ +importWarnings.length ? `\n\nWith ${importWarnings.length} warnings:\n- ${importWarnings.join('\n- ')}` : '' +}` + if(importWarnings.length) { + importExportStatus.classList.add('has-error') + } + importWarnings = [] + + importExportStatusDetails.textContent = `View imported settings (Click)\nNote: The blur setting is internally converted to blur2\n\n${ + Object.keys(settings).map(key => `${key.substring('setting-'.length)}: ${JSON.stringify(settings[key])}`).join('\n') + }` + } catch(ex) { + console.error('Failed to import settings', ex) + importExportStatus.classList.add('has-error') + importExportStatus.textContent = `Failed to import settings: \n${ex?.message}` + } +} +const exportSettings = async (storageName, exportJson) => { + try { + importExportStatus.textContent = '' + importExportStatus.classList.remove('has-error') + importExportStatusDetails.textContent = '' + importExportStatusDetails.scrollTo(0, 0) + + const storedSettings = await storage.get(null) + + let exportObject = {} + Object.keys(storedSettings) + .filter(key => key.startsWith('setting-')) + .forEach(key => { + const name = key.substring('setting-'.length) + if(!SettingsConfig.some(setting => setting.name === name)) return + + exportObject[name] = storedSettings[key] + }) + if(!Object.keys(exportObject).length) throw new Error('Nothing to export. All settings still have their default values.') + + // Temporarely export the setting blur2 as blur + // https://github.com/WesselKroos/youtube-ambilight/issues/191#issuecomment-1703792823 + if('blur2' in exportObject) { + exportObject.blur = exportObject.blur2 + delete exportObject.blur2 + } + + exportObject = Object.keys(exportObject) + .sort() + .reduce((obj, key) => (obj[key] = exportObject[key], obj), {}) + + const jsonString = JSON.stringify(exportObject, null, 2) + await exportJson(jsonString) + importExportStatus.textContent = `Exported ${Object.keys(exportObject).length} settings to ${storageName}` + importExportStatusDetails.textContent = `View exported settings (Click)\n\n${ + Object.keys(exportObject).map(key => `${key}: ${JSON.stringify(exportObject[key])}`).join('\n') + }` + } catch(ex) { + console.error('Failed to export settings', ex) + importExportStatus.classList.add('has-error') + importExportStatus.textContent = `Failed to export settings: \n${ex?.message}` + } +} + +const importFileButton = document.querySelector('#importFileBtn') +const importFileInput = document.querySelector('[name="import-settings-file"]') +importFileInput.addEventListener('change', async () => { + if(!importFileInput.files.length) return + + await importSettings('a file', async () => { + return await new Promise((resolve, reject) => { + try { + const reader = new FileReader() + reader.addEventListener('load', e => resolve(e.target.result)) + reader.readAsText(importFileInput.files[0]) + } catch(ex) { + reject(ex) + } + importFileInput.value = '' + }) + }) +}) +importFileButton.addEventListener('click', () => importFileInput.click()) + +const exportFileButton = document.querySelector('#exportFileBtn') +exportFileButton.addEventListener('click', async () => { + await exportSettings('a file', (jsonString) => { + const blob = new Blob([jsonString], { type: 'text/plain' }) + + const link = document.createElement("a"); + link.setAttribute('href', URL.createObjectURL(blob)) + link.setAttribute('download', 'ambient-light-for-youtube-settings.json') + link.click() + + URL.revokeObjectURL(link.href) + }) +}) + +const importAccountButton = document.querySelector('#importAccountBtn') +importAccountButton.addEventListener('click', async () => { + await importSettings('cloud storage', async () => { + return await syncStorage.get('settings') + }) +}) + +const exportAccountButton = document.querySelector('#exportAccountBtn') +exportAccountButton.addEventListener('click', async () => { + await exportSettings('cloud storage', async (jsonString) => { + await syncStorage.set('settings', jsonString) + await syncStorage.set('settings-date', (new Date()).toJSON()) + }) +}) + +const importableAccountStatus = document.querySelector('#importableAccountStatus') +const updateImportableAccountStatus = async () => { + const jsonString = await syncStorage.get('settings-date') + if(jsonString) { + const settingsDate = new Date(jsonString) + importableAccountStatus.textContent = `Last cloud storage export was on: ${settingsDate.toLocaleDateString()} at ${settingsDate.toLocaleTimeString()}` + importAccountButton.disabled = false + } else { + importableAccountStatus.textContent = '' + importAccountButton.disabled = true + } +} +updateImportableAccountStatus() + +if(chrome?.storage?.sync?.onChanged) { + syncStorage.addListener(updateImportableAccountStatus) + window.addEventListener('beforeunload', () => { + syncStorage.removeListener(updateImportableAccountStatus) + }) } \ No newline at end of file diff --git a/src/styles/content.scss b/src/styles/content.scss index ebbceb90..fe46da44 100644 --- a/src/styles/content.scss +++ b/src/styles/content.scss @@ -214,17 +214,19 @@ html[data-ambientlight-enabled="true"] { --yt-spec-outline: var(--yt-spec-10-percent-layer); --ytal-background: rgba(0,0,0,.1); // fallback --ytal-background: rgba(0,0,0,calc((var(--ytal-fill-opacity, .55) - .5) * 2)); // fallback + --ytal-background-opacity: calc((max(var(--ytal-fill-opacity, .55), .5) * 2) - (min(var(--ytal-fill-opacity, .55), .5) * 2)); + --ytal-background-rgb: calc(max(0, min(255, 255 * (99 - var(--ytal-fill-opacity, .55) * 200)))); --ytal-background: rgba( - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), - calc((max(var(--ytal-fill-opacity, .55), .5) * 2) - (min(var(--ytal-fill-opacity, .55), .5) * 2)) + var(--ytal-background-rgb), + var(--ytal-background-rgb), + var(--ytal-background-rgb), + var(--ytal-background-opacity) ); --ytal-background-10-percent: rgba(0,0,0,.1); // fallback --ytal-background-10-percent: rgb( - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), + var(--ytal-background-rgb), + var(--ytal-background-rgb), + var(--ytal-background-rgb), .1 ); } @@ -243,17 +245,18 @@ html[data-ambientlight-enabled="true"] { --yt-spec-10-percent-layer: rgba(255, 255, 255, .1); --ytal-background: rgba(255,255,255,.1); // fallback --ytal-background: rgba(255,255,255,calc((var(--ytal-fill-opacity, .55) - .5) * 2)); // fallback + --ytal-background-rgb: calc(max(0, min(255, 255 * (-99 + var(--ytal-fill-opacity, .55) * 200)))); --ytal-background: rgba( - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), - calc((max(var(--ytal-fill-opacity, .55), .5) * 2) - (min(var(--ytal-fill-opacity, .55), .5) * 2)) + var(--ytal-background-rgb), + var(--ytal-background-rgb), + var(--ytal-background-rgb), + var(--ytal-background-opacity) ); --ytal-background-10-percent: rgba(255,255,255,.1); // fallback --ytal-background-10-percent: rgba( - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), + var(--ytal-background-rgb), + var(--ytal-background-rgb), + var(--ytal-background-rgb), .1 ); } @@ -333,10 +336,11 @@ html[data-ambientlight-enabled="true"] { --ytal-background: rgba(255,255,255,.1); // fallback --ytal-background: rgba(255,255,255,calc((var(--ytal-fill-opacity, .55) - .5) * 2)); // fallback + --ytal-background-rgb: calc(max(0, min(255, 255 * (-99 + var(--ytal-fill-opacity, .55) * 200)))); --ytal-background: rgba( - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (-99 + var(--ytal-fill-opacity, .55) * 200)), + var(--ytal-background-rgb), + var(--ytal-background-rgb), + var(--ytal-background-rgb), calc((max(var(--ytal-fill-opacity, .55), .5) * 2) - (min(var(--ytal-fill-opacity, .55), .5) * 2)) ); background: var(--ytal-background) !important; @@ -362,10 +366,11 @@ html[data-ambientlight-enabled="true"] { ytd-masthead { --ytal-background: rgba(0,0,0,.1); // fallback --ytal-background: rgba(0,0,0,calc((var(--ytal-fill-opacity, .55) - .5) * 2)); // fallback + --ytal-background-rgb: calc(max(0, min(255, 255 * (99 - var(--ytal-fill-opacity, .55) * 200)))); --ytal-background: rgba( - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), - calc(255 * (99 - var(--ytal-fill-opacity, .55) * 200)), + var(--ytal-background-rgb), + var(--ytal-background-rgb), + var(--ytal-background-rgb), calc((max(var(--ytal-fill-opacity, .55), .5) * 2) - (min(var(--ytal-fill-opacity, .55), .5) * 2)) ); background: var(--ytal-background) !important; @@ -2849,7 +2854,7 @@ html[data-ambientlight-enabled="true"] { height: 100%; min-height: calc(3000px + 100vh); /* Must at least be high enough to fit the panels + related list */ } - + #related.ytd-watch-flexy { position: sticky; top: 80px; diff --git a/src/styles/options.css b/src/styles/options.css index 953214eb..0ec83af1 100644 --- a/src/styles/options.css +++ b/src/styles/options.css @@ -44,7 +44,7 @@ body { } } -/* The Firefox */ +/* The Firefox options page within a tab */ @supports (-moz-appearance:button) { html { background: #23222b; @@ -145,7 +145,7 @@ label { transition: transform .25s; } .expandable.expanded .expandable__toggle { - transform: translateX(-1px) scaleX(-1); + transform: rotate(90deg) translateX(-1px) scaleX(-1); } .expandable__details { @@ -172,4 +172,60 @@ label { .donate-link { margin: 10px 0 25px; display: block; +} + +.import-export-status { + color: #5f5; + white-space: pre-wrap; + font-style: italic; + font-size: 13px; +} +.import-export-status.has-error { + color: #f55; +} +.import-export-status-details { + margin: 0 -3px; + border-radius: 4px; + padding: 0px 3px; + max-height: 180px; + background: #000; + overflow: auto; + color: #aaa; + white-space: pre-wrap; + font-style: italic; + font-size: 13px; +} +.import-export-status-details:not(:focus) { + max-height: 18px; + overflow: hidden; + cursor: pointer; +} + +.importable-account-status { + color: #aaa; + white-space: pre-wrap; + font-style: italic; + font-size: 13px; +} + +button:not(.expandable__toggle) { + margin-right: 5px; + box-shadow: #000 0 1px 2px 0; + border: none; + border-radius: 3px; + padding: 4px 6px; + background: #008ce3; + text-shadow: #ffffff44 0px 0px 2px; + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color .2s; +} +button:not(.expandable__toggle):hover, +button:not(.expandable__toggle):focus { + background: #50ace3; +} +button:not(.expandable__toggle):active { + box-shadow: #000 0 1px 2px 0 inset; } \ No newline at end of file