mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
Merge branch 'master' into master
This commit is contained in:
107
.github/workflows/build.yml
vendored
107
.github/workflows/build.yml
vendored
@ -23,20 +23,40 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Get yarn cache directory path
|
- name: Expose yarn config as "$GITHUB_OUTPUT"
|
||||||
id: yarn-cache-dir-path
|
id: yarn-config
|
||||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: actions/cache@v3
|
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
|
||||||
id: yarn-cache
|
# Yarn cache is also reusable between arch and os.
|
||||||
|
- name: Restore yarn cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: yarn-download-cache
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
|
||||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-yarn-
|
yarn-download-cache-
|
||||||
|
|
||||||
|
# Invalidated on yarn.lock changes
|
||||||
|
- name: Restore yarn install state
|
||||||
|
id: yarn-install-state-cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: .yarn/ci-cache/
|
||||||
|
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn --frozen-lockfile
|
shell: bash
|
||||||
|
run: |
|
||||||
|
yarn install --immutable --inline-builds
|
||||||
|
env:
|
||||||
|
# CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action.
|
||||||
|
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
|
||||||
|
YARN_NM_MODE: "hardlinks-local" # Hardlinks-(local|global) reduces io / node_modules size
|
||||||
|
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: GabrielBB/xvfb-action@v1
|
uses: GabrielBB/xvfb-action@v1
|
||||||
@ -45,31 +65,48 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
run: yarn test
|
run: yarn test
|
||||||
|
|
||||||
- name: Build on Mac
|
# Build and release if it's the main repository
|
||||||
if: startsWith(matrix.os, 'macOS')
|
- name: Build and release on Mac
|
||||||
|
if: startsWith(matrix.os, 'macOS') && github.repository == 'th-ch/youtube-music'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
yarn run release:mac
|
yarn run release:mac
|
||||||
|
|
||||||
- name: Build on Linux
|
- name: Build and release on Linux
|
||||||
if: startsWith(matrix.os, 'ubuntu')
|
if: startsWith(matrix.os, 'ubuntu') && github.repository == 'th-ch/youtube-music'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
yarn run release:linux
|
yarn run release:linux
|
||||||
|
|
||||||
- name: Build on Windows
|
- name: Build and release on Windows
|
||||||
if: startsWith(matrix.os, 'windows')
|
if: startsWith(matrix.os, 'windows') && github.repository == 'th-ch/youtube-music'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
yarn run release:win
|
yarn run release:win
|
||||||
|
|
||||||
|
# Only build without release if it is a fork
|
||||||
|
- name: Build on Mac
|
||||||
|
if: startsWith(matrix.os, 'macOS') && github.repository != 'th-ch/youtube-music'
|
||||||
|
run: |
|
||||||
|
yarn run build:mac
|
||||||
|
|
||||||
|
- name: Build on Linux
|
||||||
|
if: startsWith(matrix.os, 'ubuntu') && github.repository != 'th-ch/youtube-music'
|
||||||
|
run: |
|
||||||
|
yarn run build:linux
|
||||||
|
|
||||||
|
- name: Build on Windows
|
||||||
|
if: startsWith(matrix.os, 'windows') && github.repository != 'th-ch/youtube-music'
|
||||||
|
run: |
|
||||||
|
yarn run build:win
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Release YouTube Music
|
name: Release YouTube Music
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.repository == 'th-ch/youtube-music' && github.ref == 'refs/heads/master'
|
||||||
needs: build
|
needs: build
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@ -81,20 +118,40 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Get yarn cache directory path
|
- name: Expose yarn config as "$GITHUB_OUTPUT"
|
||||||
id: yarn-cache-dir-path
|
id: yarn-config
|
||||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: actions/cache@v3
|
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
|
||||||
id: yarn-cache
|
# Yarn cache is also reusable between arch and os.
|
||||||
|
- name: Restore yarn cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: yarn-download-cache
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
|
||||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-yarn-
|
yarn-download-cache-
|
||||||
|
|
||||||
|
# Invalidated on yarn.lock changes
|
||||||
|
- name: Restore yarn install state
|
||||||
|
id: yarn-install-state-cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: .yarn/ci-cache/
|
||||||
|
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn --frozen-lockfile
|
shell: bash
|
||||||
|
run: |
|
||||||
|
yarn install --immutable --inline-builds
|
||||||
|
env:
|
||||||
|
# CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action.
|
||||||
|
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
|
||||||
|
YARN_NM_MODE: "hardlinks-local" # Hardlinks-(local|global) reduces io / node_modules size
|
||||||
|
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -4,3 +4,11 @@ node_modules
|
|||||||
electron-builder.yml
|
electron-builder.yml
|
||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|||||||
9
.yarn/plugins/@yarnpkg/plugin-after-install.cjs
vendored
Normal file
9
.yarn/plugins/@yarnpkg/plugin-after-install.cjs
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
//prettier-ignore
|
||||||
|
module.exports = {
|
||||||
|
name: "@yarnpkg/plugin-after-install",
|
||||||
|
factory: function (require) {
|
||||||
|
var plugin=(()=>{var g=Object.create,r=Object.defineProperty;var x=Object.getOwnPropertyDescriptor;var C=Object.getOwnPropertyNames;var k=Object.getPrototypeOf,y=Object.prototype.hasOwnProperty;var I=t=>r(t,"__esModule",{value:!0});var i=t=>{if(typeof require!="undefined")return require(t);throw new Error('Dynamic require of "'+t+'" is not supported')};var h=(t,o)=>{for(var e in o)r(t,e,{get:o[e],enumerable:!0})},w=(t,o,e)=>{if(o&&typeof o=="object"||typeof o=="function")for(let n of C(o))!y.call(t,n)&&n!=="default"&&r(t,n,{get:()=>o[n],enumerable:!(e=x(o,n))||e.enumerable});return t},a=t=>w(I(r(t!=null?g(k(t)):{},"default",t&&t.__esModule&&"default"in t?{get:()=>t.default,enumerable:!0}:{value:t,enumerable:!0})),t);var j={};h(j,{default:()=>b});var c=a(i("@yarnpkg/core")),m={afterInstall:{description:"Hook that will always run after install",type:c.SettingsType.STRING,default:""}};var u=a(i("clipanion")),d=a(i("@yarnpkg/core"));var p=a(i("@yarnpkg/shell")),l=async(t,o)=>{var f;let e=t.get("afterInstall"),n=!!((f=t.projectCwd)==null?void 0:f.endsWith(`dlx-${process.pid}`));return e&&!n?(o&&console.log("Running `afterInstall` hook..."),(0,p.execute)(e,[],{cwd:t.projectCwd||void 0})):0};var s=class extends u.Command{async execute(){let o=await d.Configuration.find(this.context.cwd,this.context.plugins);return l(o,!1)}};s.paths=[["after-install"]];var P={configuration:m,commands:[s],hooks:{afterAllInstalled:async t=>{if(await l(t.configuration,!0))throw new Error("The `afterInstall` hook failed, see output above.")}}},b=P;return j;})();
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
};
|
||||||
873
.yarn/releases/yarn-3.4.1.cjs
vendored
Executable file
873
.yarn/releases/yarn-3.4.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
9
.yarnrc.yml
Normal file
9
.yarnrc.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
afterInstall: yarn postinstall
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- path: .yarn/plugins/@yarnpkg/plugin-after-install.cjs
|
||||||
|
spec: "https://raw.githubusercontent.com/mhassan1/yarn-plugin-after-install/v0.3.1/bundles/@yarnpkg/plugin-after-install.js"
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-3.4.1.cjs
|
||||||
20
package.json
20
package.json
@ -71,10 +71,10 @@
|
|||||||
"test:debug": "DEBUG=pw:browser* playwright test",
|
"test:debug": "DEBUG=pw:browser* playwright test",
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"start:debug": "ELECTRON_ENABLE_LOGGING=1 electron .",
|
"start:debug": "ELECTRON_ENABLE_LOGGING=1 electron .",
|
||||||
"icon": "del assets/generated && electron-icon-maker --input=assets/youtube-music.png --output=assets/generated",
|
"icon": "del-cli assets/generated && electron-icon-builder --input=assets/youtube-music.png --output=assets/generated",
|
||||||
"generate:package": "node utils/generate-package-json.js",
|
"generate:package": "node utils/generate-package-json.js",
|
||||||
"postinstall": "yarn run icon && yarn run plugins",
|
"postinstall": "yarn run icon && yarn run plugins",
|
||||||
"clean": "del dist",
|
"clean": "del-cli dist",
|
||||||
"build": "yarn run clean && electron-builder --win --mac --linux",
|
"build": "yarn run clean && electron-builder --win --mac --linux",
|
||||||
"build:linux": "yarn run clean && electron-builder --linux",
|
"build:linux": "yarn run clean && electron-builder --linux",
|
||||||
"build:mac": "yarn run clean && electron-builder --mac dmg:x64",
|
"build:mac": "yarn run clean && electron-builder --mac dmg:x64",
|
||||||
@ -83,8 +83,8 @@
|
|||||||
"lint": "xo",
|
"lint": "xo",
|
||||||
"changelog": "auto-changelog",
|
"changelog": "auto-changelog",
|
||||||
"plugins": "yarn run plugin:adblocker && yarn run plugin:bypass-age-restrictions",
|
"plugins": "yarn run plugin:adblocker && yarn run plugin:bypass-age-restrictions",
|
||||||
"plugin:adblocker": "del plugins/adblocker/ad-blocker-engine.bin && node plugins/adblocker/blocker.js",
|
"plugin:adblocker": "del-cli plugins/adblocker/ad-blocker-engine.bin && node plugins/adblocker/blocker.js",
|
||||||
"plugin:bypass-age-restrictions": "yarn run generate:package Simple-YouTube-Age-Restriction-Bypass",
|
"plugin:bypass-age-restrictions": "del-cli node_modules/simple-youtube-age-restriction-bypass/package.json && yarn run generate:package simple-youtube-age-restriction-bypass",
|
||||||
"release:linux": "yarn run clean && electron-builder --linux -p always -c.snap.publish=github",
|
"release:linux": "yarn run clean && electron-builder --linux -p always -c.snap.publish=github",
|
||||||
"release:mac": "yarn run clean && electron-builder --mac -p always",
|
"release:mac": "yarn run clean && electron-builder --mac -p always",
|
||||||
"release:win": "yarn run clean && electron-builder --win -p always"
|
"release:win": "yarn run clean && electron-builder --win -p always"
|
||||||
@ -94,11 +94,10 @@
|
|||||||
"npm": "Please use yarn and not npm"
|
"npm": "Please use yarn and not npm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cliqz/adblocker-electron": "^1.25.1",
|
"@cliqz/adblocker-electron": "^1.25.2",
|
||||||
"@ffmpeg/core": "^0.11.0",
|
"@ffmpeg/core": "^0.11.0",
|
||||||
"@ffmpeg/ffmpeg": "^0.11.6",
|
"@ffmpeg/ffmpeg": "^0.11.6",
|
||||||
"@foobar404/wave": "^2.0.4",
|
"@foobar404/wave": "^2.0.4",
|
||||||
"Simple-YouTube-Age-Restriction-Bypass": "https://gitpkg.now.sh/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/dist?v2.5.4",
|
|
||||||
"async-mutex": "^0.4.0",
|
"async-mutex": "^0.4.0",
|
||||||
"browser-id3-writer": "^4.4.0",
|
"browser-id3-writer": "^4.4.0",
|
||||||
"butterchurn": "^2.6.7",
|
"butterchurn": "^2.6.7",
|
||||||
@ -115,12 +114,15 @@
|
|||||||
"electron-unhandled": "^4.0.1",
|
"electron-unhandled": "^4.0.1",
|
||||||
"electron-updater": "^5.3.0",
|
"electron-updater": "^5.3.0",
|
||||||
"filenamify": "^4.3.0",
|
"filenamify": "^4.3.0",
|
||||||
|
"howler": "^2.2.3",
|
||||||
"html-to-text": "^9.0.3",
|
"html-to-text": "^9.0.3",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"mpris-service": "^2.1.2",
|
"mpris-service": "^2.1.2",
|
||||||
"node-fetch": "^2.6.8",
|
"node-fetch": "^2.6.8",
|
||||||
"node-notifier": "^10.0.1",
|
"node-notifier": "^10.0.1",
|
||||||
|
"simple-youtube-age-restriction-bypass": "https://gitpkg.now.sh/api/pkg.tgz?url=zerodytrash/Simple-YouTube-Age-Restriction-Bypass&commit=v2.5.4",
|
||||||
"vudio": "^2.1.1",
|
"vudio": "^2.1.1",
|
||||||
|
"youtubei.js": "^2.9.0",
|
||||||
"ytdl-core": "^4.11.1",
|
"ytdl-core": "^4.11.1",
|
||||||
"ytpl": "^2.3.0"
|
"ytpl": "^2.3.0"
|
||||||
},
|
},
|
||||||
@ -131,7 +133,8 @@
|
|||||||
"electron": "^22.0.2",
|
"electron": "^22.0.2",
|
||||||
"electron-builder": "^23.6.0",
|
"electron-builder": "^23.6.0",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-icon-maker": "0.0.5",
|
"electron-icon-builder": "^2.0.1",
|
||||||
|
"node-gyp": "^9.3.1",
|
||||||
"playwright": "^1.29.2",
|
"playwright": "^1.29.2",
|
||||||
"xo": "^0.53.1"
|
"xo": "^0.53.1"
|
||||||
},
|
},
|
||||||
@ -161,5 +164,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@3.4.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
module.exports = () => {
|
module.exports = () => {
|
||||||
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
|
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
|
||||||
require("Simple-YouTube-Age-Restriction-Bypass/Simple-YouTube-Age-Restriction-Bypass.user.js");
|
require("simple-youtube-age-restriction-bypass/dist/Simple-YouTube-Age-Restriction-Bypass.user.js");
|
||||||
};
|
};
|
||||||
|
|||||||
13
plugins/crossfade/back.js
Normal file
13
plugins/crossfade/back.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const { ipcMain } = require("electron");
|
||||||
|
const { Innertube } = require("youtubei.js");
|
||||||
|
|
||||||
|
module.exports = async (win, options) => {
|
||||||
|
const yt = await Innertube.create();
|
||||||
|
|
||||||
|
ipcMain.handle("audio-url", async (_, videoID) => {
|
||||||
|
const info = await yt.getBasicInfo(videoID);
|
||||||
|
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||||
|
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
};
|
||||||
360
plugins/crossfade/fader.js
Normal file
360
plugins/crossfade/fader.js
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
let validateVolumeLevel = (value) => {
|
||||||
|
// number between 0 and 1?
|
||||||
|
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
|
||||||
|
// yup, that's fine
|
||||||
|
return;
|
||||||
|
} 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) {
|
||||||
|
// try to set given fade duration (will log if successful and throw if not)
|
||||||
|
this.setFadeDuration(options.fadeDuration);
|
||||||
|
} else {
|
||||||
|
// set default fade duration (1000 ms)
|
||||||
|
this.fadeDuration = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: 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
|
||||||
|
let now = Date.now();
|
||||||
|
|
||||||
|
// time left for fading?
|
||||||
|
if (now < this.fade.time.end) {
|
||||||
|
// compute current fade progress
|
||||||
|
let progress =
|
||||||
|
(now - this.fade.time.start) /
|
||||||
|
(this.fade.time.end - this.fade.time.start);
|
||||||
|
|
||||||
|
// compute current level on internal scale
|
||||||
|
let 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;
|
||||||
|
} else {
|
||||||
|
// scale 0…1 to minus something × 10 dB
|
||||||
|
input = (input - 1) * dynamicRange;
|
||||||
|
|
||||||
|
// compute power of 10
|
||||||
|
return Math.pow(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;
|
||||||
|
} else {
|
||||||
|
// 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);
|
||||||
152
plugins/crossfade/front.js
Normal file
152
plugins/crossfade/front.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
const { ipcRenderer } = require("electron");
|
||||||
|
const { Howl } = require("howler");
|
||||||
|
|
||||||
|
// Extracted from https://github.com/bitfasching/VolumeFader
|
||||||
|
require("./fader");
|
||||||
|
|
||||||
|
let transitionAudio; // Howler audio used to fade out the current music
|
||||||
|
let firstVideo = true;
|
||||||
|
let transitioning = false;
|
||||||
|
|
||||||
|
// Crossfade options that can be overridden in plugin options
|
||||||
|
let crossfadeOptions = {
|
||||||
|
fadeInDuration: 1500, // ms
|
||||||
|
fadeOutDuration: 5000, // ms
|
||||||
|
exitMusicBeforeEnd: 10, // s
|
||||||
|
fadeScaling: "linear",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStreamURL = async (videoID) => {
|
||||||
|
const url = await ipcRenderer.invoke("audio-url", videoID);
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVideoIDFromURL = (url) => {
|
||||||
|
return new URLSearchParams(url.split("?")?.at(-1)).get("v");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReadyToCrossfade = () => {
|
||||||
|
return transitionAudio && transitionAudio.state() === "loaded";
|
||||||
|
};
|
||||||
|
|
||||||
|
const watchVideoIDChanges = (cb) => {
|
||||||
|
navigation.addEventListener("navigate", (event) => {
|
||||||
|
const currentVideoID = getVideoIDFromURL(
|
||||||
|
event.currentTarget.currentEntry.url
|
||||||
|
);
|
||||||
|
const nextVideoID = getVideoIDFromURL(event.destination.url);
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextVideoID &&
|
||||||
|
currentVideoID &&
|
||||||
|
(firstVideo || nextVideoID !== currentVideoID)
|
||||||
|
) {
|
||||||
|
if (isReadyToCrossfade()) {
|
||||||
|
crossfade(() => {
|
||||||
|
cb(nextVideoID);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cb(nextVideoID);
|
||||||
|
firstVideo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAudioForCrossfade = async (url) => {
|
||||||
|
if (transitionAudio) {
|
||||||
|
transitionAudio.unload();
|
||||||
|
}
|
||||||
|
transitionAudio = new Howl({
|
||||||
|
src: url,
|
||||||
|
html5: true,
|
||||||
|
volume: 0,
|
||||||
|
});
|
||||||
|
await syncVideoWithTransitionAudio();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncVideoWithTransitionAudio = async () => {
|
||||||
|
const video = document.querySelector("video");
|
||||||
|
const videoFader = new VolumeFader(video, {
|
||||||
|
fadeScaling: crossfadeOptions.fadeScaling,
|
||||||
|
fadeDuration: crossfadeOptions.fadeInDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
await transitionAudio.play();
|
||||||
|
await transitionAudio.seek(video.currentTime);
|
||||||
|
|
||||||
|
video.onseeking = () => {
|
||||||
|
transitionAudio.seek(video.currentTime);
|
||||||
|
};
|
||||||
|
video.onpause = () => {
|
||||||
|
transitionAudio.pause();
|
||||||
|
};
|
||||||
|
video.onplay = async () => {
|
||||||
|
await transitionAudio.play();
|
||||||
|
await transitionAudio.seek(video.currentTime);
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
const videoVolume = video.volume;
|
||||||
|
video.volume = 0;
|
||||||
|
videoFader.fadeTo(videoVolume);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exit just before the end for the transition
|
||||||
|
const transitionBeforeEnd = () => {
|
||||||
|
if (
|
||||||
|
video.currentTime >=
|
||||||
|
video.duration - crossfadeOptions.exitMusicBeforeEnd &&
|
||||||
|
isReadyToCrossfade()
|
||||||
|
) {
|
||||||
|
video.removeEventListener("timeupdate", transitionBeforeEnd);
|
||||||
|
|
||||||
|
// Go to next video - XXX: does not support "repeat 1" mode
|
||||||
|
document.querySelector(".next-button").click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
video.ontimeupdate = transitionBeforeEnd;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onApiLoaded = () => {
|
||||||
|
watchVideoIDChanges(async (videoID) => {
|
||||||
|
if (!transitioning) {
|
||||||
|
const url = await getStreamURL(videoID);
|
||||||
|
await createAudioForCrossfade(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const crossfade = (cb) => {
|
||||||
|
if (!isReadyToCrossfade()) {
|
||||||
|
cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transitioning = true;
|
||||||
|
|
||||||
|
const video = document.querySelector("video");
|
||||||
|
|
||||||
|
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
|
||||||
|
initialVolume: video.volume,
|
||||||
|
fadeScaling: crossfadeOptions.fadeScaling,
|
||||||
|
fadeDuration: crossfadeOptions.fadeOutDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade out the music
|
||||||
|
video.volume = 0;
|
||||||
|
fader.fadeOut(() => {
|
||||||
|
transitioning = false;
|
||||||
|
cb();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = (options) => {
|
||||||
|
crossfadeOptions = {
|
||||||
|
...crossfadeOptions,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("apiLoaded", onApiLoaded, {
|
||||||
|
once: true,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -8,8 +8,7 @@
|
|||||||
#nav-bar-background {
|
#nav-bar-background {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
position: sticky !important;
|
top: 30px !important;
|
||||||
top: 0 !important;
|
|
||||||
height: 75px !important;
|
height: 75px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -235,6 +235,7 @@ function setTooltip(volume) {
|
|||||||
function setupLocalArrowShortcuts() {
|
function setupLocalArrowShortcuts() {
|
||||||
if (options.arrowsShortcut) {
|
if (options.arrowsShortcut) {
|
||||||
window.addEventListener('keydown', (event) => {
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if ($('ytmusic-search-box').opened) return;
|
||||||
switch (event.code) {
|
switch (event.code) {
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@ -66,6 +66,8 @@ winget install th-ch.YouTubeMusic
|
|||||||
|
|
||||||
- **Captions selector**: Enable captions
|
- **Captions selector**: Enable captions
|
||||||
|
|
||||||
|
- **Crossfade**: Crossfade between songs
|
||||||
|
|
||||||
- **Disable Autoplay**: Makes every song start in "paused" mode
|
- **Disable Autoplay**: Makes every song start in "paused" mode
|
||||||
|
|
||||||
- [**Discord**](https://discord.com/): Show your friends what you listen to with [Rich Presence](https://user-images.githubusercontent.com/28219076/104362104-a7a0b980-5513-11eb-9744-bb89eabe0016.png)
|
- [**Discord**](https://discord.com/): Show your friends what you listen to with [Rich Presence](https://user-images.githubusercontent.com/28219076/104362104-a7a0b980-5513-11eb-9744-bb89eabe0016.png)
|
||||||
|
|||||||
Reference in New Issue
Block a user