mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-14 11:51:47 +00:00
QOL: Move source code under the src directory. (#1318)
This commit is contained in:
35
src/providers/app-controls.ts
Normal file
35
src/providers/app-controls.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, ipcMain, ipcRenderer } from 'electron';
|
||||
|
||||
import config from '../config';
|
||||
|
||||
export const restart = () => {
|
||||
process.type === 'browser' ? restartInternal() : ipcRenderer.send('restart');
|
||||
};
|
||||
|
||||
export const setupAppControls = () => {
|
||||
ipcMain.on('restart', restart);
|
||||
ipcMain.handle('getDownloadsFolder', () => app.getPath('downloads'));
|
||||
ipcMain.on('reload', () => BrowserWindow.getFocusedWindow()?.webContents.loadURL(config.get('url')));
|
||||
ipcMain.handle('getPath', (_, ...args: string[]) => path.join(...args));
|
||||
};
|
||||
|
||||
function restartInternal() {
|
||||
app.relaunch({ execPath: process.env.PORTABLE_EXECUTABLE_FILE });
|
||||
// ExecPath will be undefined if not running portable app, resulting in default behavior
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function sendToFrontInternal(channel: string, ...args: unknown[]) {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(channel, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const sendToFront
|
||||
= process.type === 'browser'
|
||||
? sendToFrontInternal
|
||||
: () => {
|
||||
console.error('sendToFront called from renderer');
|
||||
};
|
||||
91
src/providers/decorators.ts
Normal file
91
src/providers/decorators.ts
Normal file
@ -0,0 +1,91 @@
|
||||
export function singleton<T extends (...params: never[]) => unknown>(fn: T): T {
|
||||
let called = false;
|
||||
|
||||
return ((...args) => {
|
||||
if (called) {
|
||||
return;
|
||||
}
|
||||
|
||||
called = true;
|
||||
return fn(...args);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export function debounce<T extends (...params: never[]) => unknown>(fn: T, delay: number): T {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return ((...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fn(...args), delay);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export function cache<T extends (...params: P) => R, P extends never[], R>(fn: T): T {
|
||||
let lastArgs: P;
|
||||
let lastResult: R;
|
||||
return ((...args: P) => {
|
||||
if (
|
||||
args.length !== lastArgs?.length
|
||||
|| args.some((arg, i) => arg !== lastArgs[i])
|
||||
) {
|
||||
lastArgs = args;
|
||||
lastResult = fn(...args);
|
||||
}
|
||||
|
||||
return lastResult;
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/*
|
||||
The following are currently unused, but potentially useful in the future
|
||||
*/
|
||||
|
||||
export function throttle<T extends (...params: unknown[]) => unknown>(fn: T, delay: number): T {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
return ((...args) => {
|
||||
if (timeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined;
|
||||
fn(...args);
|
||||
}, delay);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
function memoize<T extends (...params: unknown[]) => unknown>(fn: T): T {
|
||||
const cache = new Map();
|
||||
|
||||
return ((...args) => {
|
||||
const key = JSON.stringify(args);
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, fn(...args));
|
||||
}
|
||||
|
||||
return cache.get(key) as unknown;
|
||||
}) as T;
|
||||
}
|
||||
|
||||
function retry<T extends (...params: unknown[]) => unknown>(fn: T, { retries = 3, delay = 1000 } = {}): T {
|
||||
return ((...args) => {
|
||||
try {
|
||||
return fn(...args);
|
||||
} catch (error) {
|
||||
if (retries > 0) {
|
||||
retries--;
|
||||
setTimeout(() => retry(fn, { retries, delay })(...args), delay);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export default {
|
||||
singleton,
|
||||
debounce,
|
||||
cache,
|
||||
throttle,
|
||||
memoize,
|
||||
retry,
|
||||
};
|
||||
4
src/providers/dom-elements.ts
Normal file
4
src/providers/dom-elements.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const getSongMenu = () =>
|
||||
document.querySelector('ytmusic-menu-popup-renderer tp-yt-paper-listbox');
|
||||
|
||||
export default { getSongMenu };
|
||||
23
src/providers/extracted-data.ts
Normal file
23
src/providers/extracted-data.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export const startingPages: Record<string, string> = {
|
||||
'Default': '',
|
||||
'Home': 'FEmusic_home',
|
||||
'Explore': 'FEmusic_explore',
|
||||
'New Releases': 'FEmusic_new_releases',
|
||||
'Charts': 'FEmusic_charts',
|
||||
'Moods & Genres': 'FEmusic_moods_and_genres',
|
||||
'Library': 'FEmusic_library_landing',
|
||||
'Playlists': 'FEmusic_liked_playlists',
|
||||
'Songs': 'FEmusic_liked_videos',
|
||||
'Albums': 'FEmusic_liked_albums',
|
||||
'Artists': 'FEmusic_library_corpus_track_artists',
|
||||
'Subscribed Artists': 'FEmusic_library_corpus_artists',
|
||||
'Uploads': 'FEmusic_library_privately_owned_landing',
|
||||
'Uploaded Playlists': 'FEmusic_liked_playlists',
|
||||
'Uploaded Songs': 'FEmusic_library_privately_owned_tracks',
|
||||
'Uploaded Albums': 'FEmusic_library_privately_owned_releases',
|
||||
'Uploaded Artists': 'FEmusic_library_privately_owned_artists',
|
||||
};
|
||||
|
||||
export default {
|
||||
startingPages,
|
||||
};
|
||||
12
src/providers/prompt-options.ts
Normal file
12
src/providers/prompt-options.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { getAssetsDirectoryLocation } from '../plugins/utils';
|
||||
|
||||
const iconPath = path.join(getAssetsDirectoryLocation(), 'youtube-music-tray.png');
|
||||
|
||||
const promptOptions = {
|
||||
customStylesheet: 'dark',
|
||||
icon: iconPath,
|
||||
};
|
||||
|
||||
export default () => promptOptions;
|
||||
45
src/providers/protocol-handler.ts
Normal file
45
src/providers/protocol-handler.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
import getSongControls from './song-controls';
|
||||
|
||||
export const APP_PROTOCOL = 'youtubemusic';
|
||||
|
||||
let protocolHandler: ((cmd: string) => void) | undefined;
|
||||
|
||||
export function setupProtocolHandler(win: BrowserWindow) {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(
|
||||
APP_PROTOCOL,
|
||||
process.execPath,
|
||||
[path.resolve(process.argv[1])],
|
||||
);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(APP_PROTOCOL);
|
||||
}
|
||||
|
||||
const songControls = getSongControls(win);
|
||||
|
||||
protocolHandler = ((cmd: keyof typeof songControls) => {
|
||||
if (Object.keys(songControls).includes(cmd)) {
|
||||
songControls[cmd]();
|
||||
}
|
||||
}) as (cmd: string) => void;
|
||||
}
|
||||
|
||||
export function handleProtocol(cmd: string) {
|
||||
protocolHandler?.(cmd);
|
||||
}
|
||||
|
||||
export function changeProtocolHandler(f: (cmd: string) => void) {
|
||||
protocolHandler = f;
|
||||
}
|
||||
|
||||
export default {
|
||||
APP_PROTOCOL,
|
||||
setupProtocolHandler,
|
||||
handleProtocol,
|
||||
changeProtocolHandler,
|
||||
};
|
||||
|
||||
8
src/providers/song-controls-front.ts
Normal file
8
src/providers/song-controls-front.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
export const setupSongControls = () => {
|
||||
document.addEventListener('apiLoaded', (event) => {
|
||||
ipcRenderer.on('seekTo', (_, t: number) => event.detail.seekTo(t));
|
||||
ipcRenderer.on('seekBy', (_, t: number) => event.detail.seekBy(t));
|
||||
}, { once: true, passive: true });
|
||||
};
|
||||
62
src/providers/song-controls.ts
Normal file
62
src/providers/song-controls.ts
Normal file
@ -0,0 +1,62 @@
|
||||
// This is used for to control the songs
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
type Modifiers = (Electron.MouseInputEvent | Electron.MouseWheelInputEvent | Electron.KeyboardInputEvent)['modifiers'];
|
||||
export const pressKey = (window: BrowserWindow, key: string, modifiers: Modifiers = []) => {
|
||||
window.webContents.sendInputEvent({
|
||||
type: 'keyDown',
|
||||
modifiers,
|
||||
keyCode: key,
|
||||
});
|
||||
};
|
||||
|
||||
export default (win: BrowserWindow) => {
|
||||
const commands = {
|
||||
// Playback
|
||||
previous: () => pressKey(win, 'k'),
|
||||
next: () => pressKey(win, 'j'),
|
||||
playPause: () => pressKey(win, ';'),
|
||||
like: () => pressKey(win, '+'),
|
||||
dislike: () => pressKey(win, '_'),
|
||||
go10sBack: () => pressKey(win, 'h'),
|
||||
go10sForward: () => pressKey(win, 'l'),
|
||||
go1sBack: () => pressKey(win, 'h', ['shift']),
|
||||
go1sForward: () => pressKey(win, 'l', ['shift']),
|
||||
shuffle: () => pressKey(win, 's'),
|
||||
switchRepeat(n = 1) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
pressKey(win, 'r');
|
||||
}
|
||||
},
|
||||
// General
|
||||
volumeMinus10: () => pressKey(win, '-'),
|
||||
volumePlus10: () => pressKey(win, '='),
|
||||
fullscreen: () => pressKey(win, 'f'),
|
||||
muteUnmute: () => pressKey(win, 'm'),
|
||||
maximizeMinimisePlayer: () => pressKey(win, 'q'),
|
||||
// Navigation
|
||||
goToHome() {
|
||||
pressKey(win, 'g');
|
||||
pressKey(win, 'h');
|
||||
},
|
||||
goToLibrary() {
|
||||
pressKey(win, 'g');
|
||||
pressKey(win, 'l');
|
||||
},
|
||||
goToSettings() {
|
||||
pressKey(win, 'g');
|
||||
pressKey(win, ',');
|
||||
},
|
||||
goToExplore() {
|
||||
pressKey(win, 'g');
|
||||
pressKey(win, 'e');
|
||||
},
|
||||
search: () => pressKey(win, '/'),
|
||||
showShortcuts: () => pressKey(win, '/', ['shift']),
|
||||
};
|
||||
return {
|
||||
...commands,
|
||||
play: commands.playPause,
|
||||
pause: commands.playPause,
|
||||
};
|
||||
};
|
||||
144
src/providers/song-info-front.ts
Normal file
144
src/providers/song-info-front.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import { singleton } from './decorators';
|
||||
import { getImage, SongInfo } from './song-info';
|
||||
|
||||
import { YoutubePlayer } from '../types/youtube-player';
|
||||
import { GetState } from '../types/datahost-get-state';
|
||||
|
||||
let songInfo: SongInfo = {} as SongInfo;
|
||||
export const getSongInfo = () => songInfo;
|
||||
|
||||
const $ = <E extends Element = Element>(s: string): E | null => document.querySelector<E>(s);
|
||||
const $$ = <E extends Element = Element>(s: string): NodeListOf<E> => document.querySelectorAll<E>(s);
|
||||
|
||||
ipcRenderer.on('update-song-info', async (_, extractedSongInfo: SongInfo) => {
|
||||
songInfo = extractedSongInfo;
|
||||
if (songInfo.imageSrc) songInfo.image = await getImage(songInfo.imageSrc);
|
||||
});
|
||||
|
||||
// Used because 'loadeddata' or 'loadedmetadata' weren't firing on song start for some users (https://github.com/th-ch/youtube-music/issues/473)
|
||||
const srcChangedEvent = new CustomEvent('srcChanged');
|
||||
|
||||
export const setupSeekedListener = singleton(() => {
|
||||
$('video')?.addEventListener('seeked', (v) => {
|
||||
if (v.target instanceof HTMLVideoElement) {
|
||||
ipcRenderer.send('seeked', v.target.currentTime);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const setupTimeChangedListener = singleton(() => {
|
||||
const progressObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
const target = mutation.target as Node & { value: string };
|
||||
ipcRenderer.send('timeChanged', target.value);
|
||||
songInfo.elapsedSeconds = Number(target.value);
|
||||
}
|
||||
});
|
||||
const progressBar = $('#progress-bar');
|
||||
if (progressBar) {
|
||||
progressObserver.observe(progressBar, { attributeFilter: ['value'] });
|
||||
}
|
||||
});
|
||||
|
||||
export const setupRepeatChangedListener = singleton(() => {
|
||||
const repeatObserver = new MutationObserver((mutations) => {
|
||||
|
||||
// provided by YouTube Music
|
||||
ipcRenderer.send(
|
||||
'repeatChanged',
|
||||
(mutations[0].target as Node & {
|
||||
__dataHost: {
|
||||
getState: () => GetState;
|
||||
}
|
||||
}).__dataHost.getState().queue.repeatMode,
|
||||
);
|
||||
});
|
||||
repeatObserver.observe($('#right-controls .repeat')!, { attributeFilter: ['title'] });
|
||||
|
||||
// Emit the initial value as well; as it's persistent between launches.
|
||||
// provided by YouTube Music
|
||||
ipcRenderer.send(
|
||||
'repeatChanged',
|
||||
$<HTMLElement & {
|
||||
getState: () => GetState;
|
||||
}>('ytmusic-player-bar')?.getState().queue.repeatMode,
|
||||
);
|
||||
});
|
||||
|
||||
export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => {
|
||||
$('video')?.addEventListener('volumechange', () => {
|
||||
ipcRenderer.send('volumeChanged', api.getVolume());
|
||||
});
|
||||
// Emit the initial value as well; as it's persistent between launches.
|
||||
ipcRenderer.send('volumeChanged', api.getVolume());
|
||||
});
|
||||
|
||||
export default () => {
|
||||
document.addEventListener('apiLoaded', (apiEvent) => {
|
||||
ipcRenderer.on('setupTimeChangedListener', () => {
|
||||
setupTimeChangedListener();
|
||||
});
|
||||
|
||||
ipcRenderer.on('setupRepeatChangedListener', () => {
|
||||
setupRepeatChangedListener();
|
||||
});
|
||||
|
||||
ipcRenderer.on('setupVolumeChangedListener', () => {
|
||||
setupVolumeChangedListener(apiEvent.detail);
|
||||
});
|
||||
|
||||
ipcRenderer.on('setupSeekedListener', () => {
|
||||
setupSeekedListener();
|
||||
});
|
||||
|
||||
const playPausedHandler = (e: Event, status: string) => {
|
||||
if (e.target instanceof HTMLVideoElement && Math.round(e.target.currentTime) > 0) {
|
||||
ipcRenderer.send('playPaused', {
|
||||
isPaused: status === 'pause',
|
||||
elapsedSeconds: Math.floor(e.target.currentTime),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const playPausedHandlers = {
|
||||
playing: (e: Event) => playPausedHandler(e, 'playing'),
|
||||
pause: (e: Event) => playPausedHandler(e, 'pause'),
|
||||
};
|
||||
|
||||
// Name = "dataloaded" and abit later "dataupdated"
|
||||
apiEvent.detail.addEventListener('videodatachange', (name: string) => {
|
||||
if (name !== 'dataloaded') {
|
||||
return;
|
||||
}
|
||||
const video = $<HTMLVideoElement>('video');
|
||||
video?.dispatchEvent(srcChangedEvent);
|
||||
|
||||
for (const status of ['playing', 'pause'] as const) { // for fix issue that pause event not fired
|
||||
video?.addEventListener(status, playPausedHandlers[status]);
|
||||
}
|
||||
setTimeout(sendSongInfo, 200);
|
||||
});
|
||||
|
||||
const video = $('video')!;
|
||||
for (const status of ['playing', 'pause'] as const) {
|
||||
video.addEventListener(status, playPausedHandlers[status]);
|
||||
}
|
||||
|
||||
function sendSongInfo() {
|
||||
const data = apiEvent.detail.getPlayerResponse();
|
||||
|
||||
for (const e of $$<HTMLAnchorElement>('.byline.ytmusic-player-bar > .yt-simple-endpoint')) {
|
||||
if (e.href?.includes('browse/FEmusic_library_privately_owned_release') || e.href?.includes('browse/MPREb')) {
|
||||
data.videoDetails.album = e.textContent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
data.videoDetails.elapsedSeconds = 0;
|
||||
data.videoDetails.isPaused = false;
|
||||
ipcRenderer.send('video-src-changed', data);
|
||||
}
|
||||
}, { once: true, passive: true });
|
||||
};
|
||||
148
src/providers/song-info.ts
Normal file
148
src/providers/song-info.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { BrowserWindow, ipcMain, nativeImage, net } from 'electron';
|
||||
|
||||
import { cache } from './decorators';
|
||||
|
||||
import config from '../config';
|
||||
|
||||
import type { GetPlayerResponse } from '../types/get-player-response';
|
||||
|
||||
export interface SongInfo {
|
||||
title: string;
|
||||
artist: string;
|
||||
views: number;
|
||||
uploadDate?: string;
|
||||
imageSrc?: string | null;
|
||||
image?: Electron.NativeImage | null;
|
||||
isPaused?: boolean;
|
||||
songDuration: number;
|
||||
elapsedSeconds?: number;
|
||||
url?: string;
|
||||
album?: string | null;
|
||||
videoId: string;
|
||||
playlistId?: string;
|
||||
}
|
||||
|
||||
// Fill songInfo with empty values
|
||||
export const songInfo: SongInfo = {
|
||||
title: '',
|
||||
artist: '',
|
||||
views: 0,
|
||||
uploadDate: '',
|
||||
imageSrc: '',
|
||||
image: null,
|
||||
isPaused: undefined,
|
||||
songDuration: 0,
|
||||
elapsedSeconds: 0,
|
||||
url: '',
|
||||
album: undefined,
|
||||
videoId: '',
|
||||
playlistId: '',
|
||||
};
|
||||
|
||||
// Grab the native image using the src
|
||||
export const getImage = cache(
|
||||
async (src: string): Promise<Electron.NativeImage> => {
|
||||
|
||||
const result = await net.fetch(src);
|
||||
const buffer = await result.arrayBuffer();
|
||||
const output = nativeImage.createFromBuffer(Buffer.from(buffer));
|
||||
if (output.isEmpty() && !src.endsWith('.jpg') && src.includes('.jpg')) { // Fix hidden webp files (https://github.com/th-ch/youtube-music/issues/315)
|
||||
return getImage(src.slice(0, src.lastIndexOf('.jpg') + 4));
|
||||
}
|
||||
|
||||
return output;
|
||||
},
|
||||
);
|
||||
|
||||
const handleData = async (data: GetPlayerResponse, win: Electron.BrowserWindow) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const microformat = data.microformat?.microformatDataRenderer;
|
||||
if (microformat) {
|
||||
songInfo.uploadDate = microformat.uploadDate;
|
||||
songInfo.url = microformat.urlCanonical?.split('&')[0];
|
||||
songInfo.playlistId = new URL(microformat.urlCanonical).searchParams.get('list') ?? '';
|
||||
// Used for options.resumeOnStart
|
||||
config.set('url', microformat.urlCanonical);
|
||||
}
|
||||
|
||||
const { videoDetails } = data;
|
||||
if (videoDetails) {
|
||||
songInfo.title = cleanupName(videoDetails.title);
|
||||
songInfo.artist = cleanupName(videoDetails.author);
|
||||
songInfo.views = Number(videoDetails.viewCount);
|
||||
songInfo.songDuration = Number(videoDetails.lengthSeconds);
|
||||
songInfo.elapsedSeconds = videoDetails.elapsedSeconds;
|
||||
songInfo.isPaused = videoDetails.isPaused;
|
||||
songInfo.videoId = videoDetails.videoId;
|
||||
songInfo.album = data?.videoDetails?.album; // Will be undefined if video exist
|
||||
|
||||
const thumbnails = videoDetails.thumbnail?.thumbnails;
|
||||
songInfo.imageSrc = thumbnails.at(-1)?.url.split('?')[0];
|
||||
if (songInfo.imageSrc) songInfo.image = await getImage(songInfo.imageSrc);
|
||||
|
||||
win.webContents.send('update-song-info', songInfo);
|
||||
}
|
||||
};
|
||||
|
||||
// This variable will be filled with the callbacks once they register
|
||||
export type SongInfoCallback = (songInfo: SongInfo, event?: string) => void;
|
||||
const callbacks: SongInfoCallback[] = [];
|
||||
|
||||
// This function will allow plugins to register callback that will be triggered when data changes
|
||||
const registerCallback = (callback: SongInfoCallback) => {
|
||||
callbacks.push(callback);
|
||||
};
|
||||
|
||||
let handlingData = false;
|
||||
|
||||
const registerProvider = (win: BrowserWindow) => {
|
||||
// This will be called when the song-info-front finds a new request with song data
|
||||
ipcMain.on('video-src-changed', async (_, data: GetPlayerResponse) => {
|
||||
handlingData = true;
|
||||
await handleData(data, win);
|
||||
handlingData = false;
|
||||
for (const c of callbacks) {
|
||||
c(songInfo, 'video-src-changed');
|
||||
}
|
||||
});
|
||||
ipcMain.on('playPaused', (_, { isPaused, elapsedSeconds }: { isPaused: boolean, elapsedSeconds: number }) => {
|
||||
songInfo.isPaused = isPaused;
|
||||
songInfo.elapsedSeconds = elapsedSeconds;
|
||||
if (handlingData) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const c of callbacks) {
|
||||
c(songInfo, 'playPaused');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const suffixesToRemove = [
|
||||
' - topic',
|
||||
'vevo',
|
||||
' (performance video)',
|
||||
' (clip officiel)',
|
||||
];
|
||||
|
||||
export function cleanupName(name: string): string {
|
||||
if (!name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
name = name.replace(/\((?:official)? ?(?:music)? ?(?:lyrics?)? ?(?:video)?\)$/i, '');
|
||||
const lowCaseName = name.toLowerCase();
|
||||
for (const suffix of suffixesToRemove) {
|
||||
if (lowCaseName.endsWith(suffix)) {
|
||||
return name.slice(0, -suffix.length);
|
||||
}
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
export default registerCallback;
|
||||
export const setupSongInfo = registerProvider;
|
||||
Reference in New Issue
Block a user