mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-09 01:31:46 +00:00
362 lines
11 KiB
JavaScript
362 lines
11 KiB
JavaScript
/**
|
||
* VolumeFader
|
||
* Sophisticated Media Volume Fading
|
||
*
|
||
* Requires browser support for:
|
||
* - HTMLMediaElement
|
||
* - requestAnimationFrame()
|
||
* - ES6
|
||
*
|
||
* Does not depend on any third-party library.
|
||
*
|
||
* License: MIT
|
||
*
|
||
* Nick Schwarzenberg
|
||
* v0.2.0, 07/2016
|
||
*/
|
||
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
// Internal utility: check if value is a valid volume level and throw if not
|
||
const validateVolumeLevel = (value) => {
|
||
// Number between 0 and 1?
|
||
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
|
||
// Yup, that's fine
|
||
|
||
} else {
|
||
// Abort and throw an exception
|
||
throw new TypeError('Number between 0 and 1 expected as volume!');
|
||
}
|
||
};
|
||
|
||
// Main class
|
||
class VolumeFader {
|
||
/**
|
||
* VolumeFader Constructor
|
||
*
|
||
* @param media {HTMLMediaElement} - audio or video element to be controlled
|
||
* @param options {Object} - an object with optional settings
|
||
* @throws {TypeError} if options.initialVolume or options.fadeDuration are invalid
|
||
*
|
||
* options:
|
||
* .logger: {Function} logging `function(stuff, …)` for execution information (default: no logging)
|
||
* .fadeScaling: {Mixed} either 'linear', 'logarithmic' or a positive number in dB (default: logarithmic)
|
||
* .initialVolume: {Number} media volume 0…1 to apply during setup (volume not touched by default)
|
||
* .fadeDuration: {Number} time in milliseconds to complete a fade (default: 1000 ms)
|
||
*/
|
||
constructor(media, options) {
|
||
// Passed media element of correct type?
|
||
if (media instanceof HTMLMediaElement) {
|
||
// Save reference to media element
|
||
this.media = media;
|
||
} else {
|
||
// Abort and throw an exception
|
||
throw new TypeError('Media element expected!');
|
||
}
|
||
|
||
// Make sure options is an object
|
||
options = options || {};
|
||
|
||
// Log function passed?
|
||
if (typeof options.logger === 'function') {
|
||
// Set log function to the one specified
|
||
this.logger = options.logger;
|
||
} else {
|
||
// Set log function explicitly to false
|
||
this.logger = false;
|
||
}
|
||
|
||
// Linear volume fading?
|
||
if (options.fadeScaling == 'linear') {
|
||
// Pass levels unchanged
|
||
this.scale = {
|
||
internalToVolume: (level) => level,
|
||
volumeToInternal: (level) => level,
|
||
};
|
||
|
||
// Log setting
|
||
this.logger && this.logger('Using linear fading.');
|
||
}
|
||
// No linear, but logarithmic fading…
|
||
else {
|
||
let dynamicRange;
|
||
|
||
// Default dynamic range?
|
||
if (
|
||
options.fadeScaling === undefined
|
||
|| options.fadeScaling == 'logarithmic'
|
||
) {
|
||
// Set default of 60 dB
|
||
dynamicRange = 3;
|
||
}
|
||
// Custom dynamic range?
|
||
else if (
|
||
!Number.isNaN(options.fadeScaling)
|
||
&& options.fadeScaling > 0
|
||
) {
|
||
// Turn amplitude dB into a multiple of 10 power dB
|
||
dynamicRange = options.fadeScaling / 2 / 10;
|
||
}
|
||
// Unsupported value
|
||
else {
|
||
// Abort and throw exception
|
||
throw new TypeError(
|
||
"Expected 'linear', 'logarithmic' or a positive number as fade scaling preference!",
|
||
);
|
||
}
|
||
|
||
// Use exponential/logarithmic scaler for expansion/compression
|
||
this.scale = {
|
||
internalToVolume: (level) =>
|
||
this.exponentialScaler(level, dynamicRange),
|
||
volumeToInternal: (level) =>
|
||
this.logarithmicScaler(level, dynamicRange),
|
||
};
|
||
|
||
// Log setting if not default
|
||
options.fadeScaling
|
||
&& this.logger
|
||
&& this.logger(
|
||
'Using logarithmic fading with '
|
||
+ String(10 * dynamicRange)
|
||
+ ' dB dynamic range.',
|
||
);
|
||
}
|
||
|
||
// Set initial volume?
|
||
if (options.initialVolume !== undefined) {
|
||
// Validate volume level and throw if invalid
|
||
validateVolumeLevel(options.initialVolume);
|
||
|
||
// Set initial volume
|
||
this.media.volume = options.initialVolume;
|
||
|
||
// Log setting
|
||
this.logger
|
||
&& this.logger(
|
||
'Set initial volume to ' + String(this.media.volume) + '.',
|
||
);
|
||
}
|
||
|
||
// Fade duration given?
|
||
if (options.fadeDuration === undefined) {
|
||
// Set default fade duration (1000 ms)
|
||
this.fadeDuration = 1000;
|
||
} else {
|
||
// Try to set given fade duration (will log if successful and throw if not)
|
||
this.setFadeDuration(options.fadeDuration);
|
||
}
|
||
|
||
// Indicate that fader is not active yet
|
||
this.active = false;
|
||
|
||
// Initialization done
|
||
this.logger && this.logger('Initialized for', this.media);
|
||
}
|
||
|
||
/**
|
||
* Re(start) the update cycle.
|
||
* (this.active must be truthy for volume updates to take effect)
|
||
*
|
||
* @return {Object} VolumeFader instance for chaining
|
||
*/
|
||
start() {
|
||
// Set fader to be active
|
||
this.active = true;
|
||
|
||
// Start by running the update method
|
||
this.updateVolume();
|
||
|
||
// Return instance for chaining
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Stop the update cycle.
|
||
* (interrupting any fade)
|
||
*
|
||
* @return {Object} VolumeFader instance for chaining
|
||
*/
|
||
stop() {
|
||
// Set fader to be inactive
|
||
this.active = false;
|
||
|
||
// Return instance for chaining
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Set fade duration.
|
||
* (used for future calls to fadeTo)
|
||
*
|
||
* @param {Number} fadeDuration - fading length in milliseconds
|
||
* @throws {TypeError} if fadeDuration is not a number greater than zero
|
||
* @return {Object} VolumeFader instance for chaining
|
||
*/
|
||
setFadeDuration(fadeDuration) {
|
||
// If duration is a valid number > 0…
|
||
if (!Number.isNaN(fadeDuration) && fadeDuration > 0) {
|
||
// Set fade duration
|
||
this.fadeDuration = fadeDuration;
|
||
|
||
// Log setting
|
||
this.logger
|
||
&& this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
|
||
} else {
|
||
// Abort and throw an exception
|
||
throw new TypeError('Positive number expected as fade duration!');
|
||
}
|
||
|
||
// Return instance for chaining
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Define a new fade and start fading.
|
||
*
|
||
* @param {Number} targetVolume - level to fade to in the range 0…1
|
||
* @param {Function} callback - (optional) function to be called when fade is complete
|
||
* @throws {TypeError} if targetVolume is not in the range 0…1
|
||
* @return {Object} VolumeFader instance for chaining
|
||
*/
|
||
fadeTo(targetVolume, callback) {
|
||
// Validate volume and throw if invalid
|
||
validateVolumeLevel(targetVolume);
|
||
|
||
// Define new fade
|
||
this.fade = {
|
||
// Volume start and end point on internal fading scale
|
||
volume: {
|
||
start: this.scale.volumeToInternal(this.media.volume),
|
||
end: this.scale.volumeToInternal(targetVolume),
|
||
},
|
||
// Time start and end point
|
||
time: {
|
||
start: Date.now(),
|
||
end: Date.now() + this.fadeDuration,
|
||
},
|
||
// Optional callback function
|
||
callback,
|
||
};
|
||
|
||
// Start fading
|
||
this.start();
|
||
|
||
// Log new fade
|
||
this.logger && this.logger('New fade started:', this.fade);
|
||
|
||
// Return instance for chaining
|
||
return this;
|
||
}
|
||
|
||
// Convenience shorthand methods for common fades
|
||
fadeIn(callback) {
|
||
this.fadeTo(1, callback);
|
||
}
|
||
|
||
fadeOut(callback) {
|
||
this.fadeTo(0, callback);
|
||
}
|
||
|
||
/**
|
||
* Internal: Update media volume.
|
||
* (calls itself through requestAnimationFrame)
|
||
*
|
||
* @param {Number} targetVolume - linear level to fade to (0…1)
|
||
* @param {Function} callback - (optional) function to be called when fade is complete
|
||
*/
|
||
updateVolume() {
|
||
// Fader active and fade available to process?
|
||
if (this.active && this.fade) {
|
||
// Get current time
|
||
const now = Date.now();
|
||
|
||
// Time left for fading?
|
||
if (now < this.fade.time.end) {
|
||
// Compute current fade progress
|
||
const progress
|
||
= (now - this.fade.time.start)
|
||
/ (this.fade.time.end - this.fade.time.start);
|
||
|
||
// Compute current level on internal scale
|
||
const level
|
||
= progress * (this.fade.volume.end - this.fade.volume.start)
|
||
+ this.fade.volume.start;
|
||
|
||
// Map fade level to volume level and apply it to media element
|
||
this.media.volume = this.scale.internalToVolume(level);
|
||
|
||
// Schedule next update
|
||
root.requestAnimationFrame(this.updateVolume.bind(this));
|
||
} else {
|
||
// Log end of fade
|
||
this.logger
|
||
&& this.logger(
|
||
'Fade to ' + String(this.fade.volume.end) + ' complete.',
|
||
);
|
||
|
||
// Time is up, jump to target volume
|
||
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
|
||
|
||
// Set fader to be inactive
|
||
this.active = false;
|
||
|
||
// Done, call back (if callable)
|
||
typeof this.fade.callback === 'function' && this.fade.callback();
|
||
|
||
// Clear fade
|
||
this.fade = undefined;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Internal: Exponential scaler with dynamic range limit.
|
||
*
|
||
* @param {Number} input - logarithmic input level to be expanded (float, 0…1)
|
||
* @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0…∞)
|
||
* @return {Number} - expanded level (float, 0…1)
|
||
*/
|
||
exponentialScaler(input, dynamicRange) {
|
||
// Special case: make zero (or any falsy input) return zero
|
||
if (input == 0) {
|
||
// Since the dynamic range is limited,
|
||
// allow a zero to produce a plain zero instead of a small faction
|
||
// (audio would not be recognized as silent otherwise)
|
||
return 0;
|
||
}
|
||
|
||
// Scale 0…1 to minus something × 10 dB
|
||
input = (input - 1) * dynamicRange;
|
||
|
||
// Compute power of 10
|
||
return 10 ** input;
|
||
}
|
||
|
||
/**
|
||
* Internal: Logarithmic scaler with dynamic range limit.
|
||
*
|
||
* @param {Number} input - exponential input level to be compressed (float, 0…1)
|
||
* @param {Number} dynamicRange - coerced input range, in multiples of 10 dB (float, 0…∞)
|
||
* @return {Number} - compressed level (float, 0…1)
|
||
*/
|
||
logarithmicScaler(input, dynamicRange) {
|
||
// Special case: make zero (or any falsy input) return zero
|
||
if (input == 0) {
|
||
// Logarithm of zero would be -∞, which would map to zero anyway
|
||
return 0;
|
||
}
|
||
|
||
// Compute base-10 logarithm
|
||
input = Math.log10(input);
|
||
|
||
// Scale minus something × 10 dB to 0…1 (clipping at 0)
|
||
return Math.max(1 + input / dynamicRange, 0);
|
||
}
|
||
}
|
||
|
||
// Export class to root scope
|
||
root.VolumeFader = VolumeFader;
|
||
})(window);
|