QOL: Move source code under the src directory. (#1318)

This commit is contained in:
Angelos Bouklis
2023-10-15 15:52:48 +03:00
committed by GitHub
parent 30c8dcf730
commit 7625a3aa52
159 changed files with 102 additions and 71 deletions

View 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');
};

View 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,
};

View File

@ -0,0 +1,4 @@
export const getSongMenu = () =>
document.querySelector('ytmusic-menu-popup-renderer tp-yt-paper-listbox');
export default { getSongMenu };

View 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,
};

View 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;

View 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,
};

View 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 });
};

View 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,
};
};

View 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
View 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;