diff --git a/electron-builder.yml b/electron-builder.yml index 59905f5da6..74b4c28b42 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,5 +1,5 @@ appId: com.github.th-ch.pear-desktop -productName: Pear Desktop +productName: YouTube Music files: - '!*' - dist diff --git a/package.json b/package.json index 7d2a014a69..7f2a650937 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "pear-music", "desktopName": "com.github.th_ch.pear_music", - "productName": "Pear Desktop", + "productName": "YouTube Music", "version": "3.11.0", - "description": "Pear Desktop App - including custom plugins", + "description": "YouTube Music Desktop App - including custom plugins", "main": "./dist/main/index.js", "type": "module", "license": "MIT", @@ -65,6 +65,7 @@ "dependencies": { "@dehoist/romanize-thai": "1.0.0", "@electron-toolkit/tsconfig": "1.0.1", + "@indic-transliteration/sanscript": "1.3.3", "@electron/remote": "2.1.3", "@ffmpeg.wasm/core-mt": "0.12.0", "@ffmpeg.wasm/main": "0.12.0", @@ -134,7 +135,7 @@ "virtua": "0.42.3", "vudio": "2.1.1", "x11": "2.3.0", - "youtubei.js": "15.0.1", + "youtubei.js": "^16.0.1", "zod": "4.1.5" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 649e5b18bd..3be2e3e38e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@hono/zod-validator': specifier: 0.7.2 version: 0.7.2(hono@4.9.6)(zod@4.1.5) + '@indic-transliteration/sanscript': + specifier: 1.3.3 + version: 1.3.3 '@jellybrick/dbus-next': specifier: 0.10.3 version: 0.10.3 @@ -250,8 +253,8 @@ importers: specifier: 2.3.0 version: 2.3.0 youtubei.js: - specifier: 15.0.1 - version: 15.0.1 + specifier: ^16.0.1 + version: 16.0.1 zod: specifier: 4.1.5 version: 4.1.5 @@ -867,6 +870,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@indic-transliteration/common_maps@1.0.5': + resolution: {integrity: sha512-XbWDA5AXGE+Nh4uGr/yN9ZM8avRBy4F1KQL+DLgQGOdsQ390lcW4fga0NSjg4C/rOpMd0rHZv2YFV3Bq3UbpkQ==} + + '@indic-transliteration/sanscript@1.3.3': + resolution: {integrity: sha512-zNGeARmQTPIlubwgEhl/JumpwTPHrdT/cNsQeCL+G67SQmjJe3qRnMIYghXiVt7+KDso/pU1Ky2ZfD/RBISfJQ==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -3214,9 +3223,6 @@ packages: resolution: {integrity: sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==} engines: {node: '>=18'} - jintr@3.3.1: - resolution: {integrity: sha512-nnOzyhf0SLpbWuZ270Omwbj5LcXUkTcZkVnK8/veJXtSZOiATM5gMZMdmzN75FmTyj+NVgrGaPdH12zIJ24oIA==} - jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} @@ -3480,6 +3486,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meriyah@6.1.4: + resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} + engines: {node: '>=18.0.0'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -4515,6 +4525,9 @@ packages: resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} engines: {node: '>=10'} + toml@2.3.6: + resolution: {integrity: sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ==} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -4875,8 +4888,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - youtubei.js@15.0.1: - resolution: {integrity: sha512-2slapqJS5NuXKHvcACEknyVz0AjH/TrXaOhDM0q2twQKa54kCmfj+7B/2nGfd20uzAe29zW1ejk2qOc4ABuGkg==} + youtubei.js@16.0.1: + resolution: {integrity: sha512-3802bCAGkBc2/G5WUTc0l/bO5mPYJbQAHL04d9hE9PnrDHoBUT8MN721Yqt4RCNncAXdHcfee9VdJy3Fhq1r5g==} zlibjs@0.3.1: resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} @@ -5407,6 +5420,13 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@indic-transliteration/common_maps@1.0.5': {} + + '@indic-transliteration/sanscript@1.3.3': + dependencies: + '@indic-transliteration/common_maps': 1.0.5 + toml: 2.3.6 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -8200,10 +8220,6 @@ snapshots: '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 - jintr@3.3.1: - dependencies: - acorn: 8.15.0 - jpeg-js@0.4.4: {} js-tokens@4.0.0: {} @@ -8470,6 +8486,8 @@ snapshots: merge2@1.4.1: {} + meriyah@6.1.4: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -9521,6 +9539,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + toml@2.3.6: {} + totalist@3.0.1: {} truncate-utf8-bytes@1.0.2: @@ -9904,11 +9924,10 @@ snapshots: yocto-queue@0.1.0: {} - youtubei.js@15.0.1: + youtubei.js@16.0.1: dependencies: '@bufbuild/protobuf': 2.6.3 - jintr: 3.3.1 - undici: 6.21.3 + meriyah: 6.1.4 zlibjs@0.3.1: {} diff --git a/src/loader/renderer.ts b/src/loader/renderer.ts index 6195a9b8d9..4265545e9c 100644 --- a/src/loader/renderer.ts +++ b/src/loader/renderer.ts @@ -30,9 +30,9 @@ export const createContext = ( invoke: (event: string, ...args: unknown[]) => window.ipcRenderer.invoke(event, ...args), on: (event: string, listener: CallableFunction) => { - window.ipcRenderer.on(event, (_, ...args: unknown[]) => { + window.ipcRenderer.on(event, (event, ...args: unknown[]) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call - listener(...args); + listener(event, ...args); }); }, removeAllListeners: (event: string) => { diff --git a/src/music-player.css b/src/music-player.css index 670e02d444..cb035b2c40 100644 --- a/src/music-player.css +++ b/src/music-player.css @@ -86,4 +86,4 @@ tp-yt-paper-item.ytmusic-guide-entry-renderer::before { tp-yt-iron-dropdown, tp-yt-paper-dialog { app-region: no-drag; -} +} \ No newline at end of file diff --git a/src/plugins/ad-speedup/index.ts b/src/plugins/ad-speedup/index.ts new file mode 100644 index 0000000000..7455ce0947 --- /dev/null +++ b/src/plugins/ad-speedup/index.ts @@ -0,0 +1,91 @@ +import { createPlugin } from '@/utils'; +import { t } from '@/i18n'; + +let observer: MutationObserver | null = null; +let lastAdCheck = 0; +let checkInterval: NodeJS.Timeout | null = null; + +function checkAndSkipAd() { + // Throttle checks to avoid performance issues + const now = Date.now(); + if (now - lastAdCheck < 100) return; + lastAdCheck = now; + + const video = document.querySelector('video'); + if (!video) return; + + const adContainer = document.querySelector('.ytp-ad-player-overlay, .video-ads, .ytp-ad-module'); + const adText = document.querySelector('.ytp-ad-text, .ytp-ad-preview-text'); + + // Check if ad is playing + const isAd = adContainer || adText || + document.querySelector('.ad-showing') || + document.querySelector('.advertisement'); + + if (isAd) { + // Mute and speed up the video + if (!video.muted) { + video.muted = true; + } + if (video.playbackRate !== 16) { + video.playbackRate = 16; + } + + // Try to click skip button if available + const skipButton = document.querySelector( + '.ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button' + ); + if (skipButton && skipButton.offsetParent !== null) { + skipButton.click(); + } + } else { + // Restore normal playback when not an ad + if (video.muted) { + video.muted = false; + } + if (video.playbackRate !== 1) { + video.playbackRate = 1; + } + } +} + +export default createPlugin({ + name: () => t('plugins.ad-speedup.name'), + description: () => t('plugins.ad-speedup.description'), + restartNeeded: false, + config: { + enabled: true, + }, + renderer: { + start() { + // Check for ads periodically + checkInterval = setInterval(() => { + checkAndSkipAd(); + }, 500); + + // Also watch for DOM changes + observer = new MutationObserver(() => { + checkAndSkipAd(); + }); + + const targetNode = document.body; + if (targetNode) { + observer.observe(targetNode, { + childList: true, + subtree: true, + }); + } + }, + + stop() { + if (observer) { + observer.disconnect(); + observer = null; + } + if (checkInterval) { + clearInterval(checkInterval); + checkInterval = null; + } + }, + }, +}); diff --git a/src/plugins/adblocker/index.ts b/src/plugins/adblocker/index.ts new file mode 100644 index 0000000000..7868b28d80 --- /dev/null +++ b/src/plugins/adblocker/index.ts @@ -0,0 +1,75 @@ +import { ElectronBlocker } from '@ghostery/adblocker-electron'; +import { session } from 'electron'; + +import { createPlugin } from '@/utils'; +import { t } from '@/i18n'; + +import type { BackendContext } from '@/types/contexts'; + +export type AdBlockerPluginConfig = { + enabled: boolean; + cache: boolean; + additionalBlockLists: string[]; +}; + +let blocker: ElectronBlocker | null = null; + +const defaultBlockLists = [ + 'https://easylist.to/easylist/easylist.txt', + 'https://easylist.to/easylist/easyprivacy.txt', + 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt', + 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/badware.txt', + 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/privacy.txt', + 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/resource-abuse.txt', + 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/unbreak.txt', + 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt', +]; + +export default createPlugin({ + name: () => t('plugins.adblocker.name'), + description: () => t('plugins.adblocker.description'), + restartNeeded: false, + config: { + enabled: true, + cache: true, + additionalBlockLists: [], + }, + backend: { + async start({ getConfig }: BackendContext) { + const config = await getConfig(); + const blockLists = [...defaultBlockLists, ...config.additionalBlockLists]; + + try { + blocker = await ElectronBlocker.fromLists( + fetch, + blockLists, + { + enableCompression: true, + }, + ); + + blocker.enableBlockingInSession(session.defaultSession); + + console.log('[AdBlocker] Ad blocker enabled successfully'); + } catch (error) { + console.error('[AdBlocker] Failed to initialize ad blocker:', error); + } + }, + + async onConfigChange(newConfig: AdBlockerPluginConfig) { + if (!newConfig.enabled && blocker) { + blocker.disableBlockingInSession(session.defaultSession); + blocker = null; + console.log('[AdBlocker] Ad blocker disabled'); + } + }, + + stop() { + if (blocker) { + blocker.disableBlockingInSession(session.defaultSession); + blocker = null; + console.log('[AdBlocker] Ad blocker stopped'); + } + }, + }, +}); diff --git a/src/plugins/custom-output-device/renderer.ts b/src/plugins/custom-output-device/renderer.ts index d58af90026..8249dc0d9e 100644 --- a/src/plugins/custom-output-device/renderer.ts +++ b/src/plugins/custom-output-device/renderer.ts @@ -54,10 +54,14 @@ export const renderer = createRenderer< navigator.mediaDevices.ondevicechange = async () => await updateDeviceList(context); - document.addEventListener('peard:audio-can-play', this.audioCanPlayHandler, { - once: true, - passive: true, - }); + document.addEventListener( + 'peard:audio-can-play', + this.audioCanPlayHandler, + { + once: true, + passive: true, + }, + ); await updateDeviceList(context); }, diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts index 0f2fe402f2..8e0ee43e87 100644 --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -5,6 +5,7 @@ import { randomBytes } from 'node:crypto'; import { app, type BrowserWindow, dialog, ipcMain } from 'electron'; import { Innertube, + Platform, UniversalCache, Utils, YTNodes, @@ -129,9 +130,14 @@ export const onMainLoad = async ({ win = _win; config = await getConfig(); + // Set up Platform shim for signature function evaluation + Platform.shim.eval = (code: string) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return eval(code); + }; + yt = await Innertube.create({ cache: new UniversalCache(false), - player_id: '0004de42', cookie: await getCookieFromWindow(win), generate_session_locally: true, fetch: getNetFetchAsFetch(), @@ -405,8 +411,7 @@ async function downloadSongUnsafe( let targetFileExtension: string; if (!presetSetting?.extension) { targetFileExtension = - VideoFormatList.find((it) => it.itag === format.itag)?.container ?? - 'mp3'; + VideoFormatList.find((it) => it.itag === format.itag)?.container ?? 'mp3'; } else { targetFileExtension = presetSetting?.extension ?? 'mp3'; } diff --git a/src/plugins/notifications/interactive.ts b/src/plugins/notifications/interactive.ts index 4d1bb0e733..c9982c8043 100644 --- a/src/plugins/notifications/interactive.ts +++ b/src/plugins/notifications/interactive.ts @@ -260,7 +260,9 @@ export default ( songControls = getSongControls(win); let currentSeconds = 0; - on('peard:player-api-loaded', () => send('peard:setup-time-changed-listener')); + on('peard:player-api-loaded', () => + send('peard:setup-time-changed-listener'), + ); let savedSongInfo: SongInfo; let lastUrl: string | undefined; diff --git a/src/plugins/sponsorblock/index.ts b/src/plugins/sponsorblock/index.ts index fad806569a..4347ab97d6 100644 --- a/src/plugins/sponsorblock/index.ts +++ b/src/plugins/sponsorblock/index.ts @@ -1,4 +1,5 @@ import is from 'electron-is'; +import { IpcRendererEvent } from 'electron'; import { createPlugin } from '@/utils'; @@ -105,7 +106,7 @@ export default createPlugin({ }, resetSegments: () => (currentSegments = []), start({ ipc }) { - ipc.on('sponsorblock-skip', (segments: Segment[]) => { + ipc.on('sponsorblock-skip', (_event: unknown, segments: Segment[]) => { currentSegments = segments; }); }, diff --git a/src/plugins/synced-lyrics/providers/SimpMusicLyrics.ts b/src/plugins/synced-lyrics/providers/SimpMusicLyrics.ts new file mode 100644 index 0000000000..a4f43bad02 --- /dev/null +++ b/src/plugins/synced-lyrics/providers/SimpMusicLyrics.ts @@ -0,0 +1,138 @@ +import { jaroWinkler } from '@skyra/jaro-winkler'; + +import { config } from '../renderer/renderer'; +import { LRC } from '../parsers/lrc'; + +import type { LyricProvider, LyricResult, SearchSongInfo } from '../types'; + +export class SimpMusicLyrics implements LyricProvider { + name = 'SimpMusicLyrics'; + baseUrl = 'https://api-lyrics.simpmusic.org/v1'; + + async search({ + title, + alternativeTitle, + artist, + songDuration, + }: SearchSongInfo): Promise { + let data: SimpMusicSong[] = []; + + let query = new URLSearchParams({ q: `${title} ${artist}` }); + let url = `${this.baseUrl}/search?${query.toString()}`; + let response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error (${response.statusText})`); + } + + let json = (await response.json()) as SimpMusicResponse; + data = json?.data ?? []; + + if (!data.length) { + query = new URLSearchParams({ q: title }); + url = `${this.baseUrl}/search?${query.toString()}`; + + response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error (${response.statusText})`); + } + + json = (await response.json()) as SimpMusicResponse; + data = json?.data ?? []; + } + + if (!data.length && alternativeTitle) { + query = new URLSearchParams({ q: alternativeTitle }); + url = `${this.baseUrl}/search?${query.toString()}`; + + response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error (${response.statusText})`); + } + + json = (await response.json()) as SimpMusicResponse; + data = json?.data ?? []; + } + + if (!Array.isArray(data) || data.length === 0) { + return null; + } + + const filteredResults: SimpMusicSong[] = []; + + for (const item of data) { + const { artistName } = item; + const artists = artist.split(/[&,]/g).map((i) => i.trim()); + const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim()); + + const permutations: [string, string][] = []; + for (const a of artists) { + for (const b of itemArtists) { + permutations.push([a.toLowerCase(), b.toLowerCase()]); + } + } + + const ratio = Math.max( + ...permutations.map(([x, y]) => jaroWinkler(x, y)), + ); + if (ratio < 0.85) continue; + + filteredResults.push(item); + } + + if (!filteredResults.length) return null; + + filteredResults.sort( + (a, b) => + Math.abs(a.durationSeconds - songDuration) - + Math.abs(b.durationSeconds - songDuration), + ); + + const maxVote = Math.max(...filteredResults.map((r) => r.vote ?? 0)); + + const topVoted = filteredResults.filter((r) => (r.vote ?? 0) === maxVote); + + const best = topVoted[0]; + + if (!best) return null; + + if (Math.abs(best.durationSeconds - songDuration) > 15) { + return null; + } + + const raw = best.syncedLyrics; + const plain = best.plainLyric; + + if (!raw && !plain) return null; + + return { + title: best.songTitle, + artists: best.artistName.split(/[&,]/g).map((a) => a.trim()), + lines: raw + ? LRC.parse(raw).lines.map((l) => ({ + ...l, + status: 'upcoming' as const, + })) + : undefined, + lyrics: plain, + }; + } +} + +type SimpMusicResponse = { + type: string; + data: SimpMusicSong[]; + success?: boolean; +}; + +type SimpMusicSong = { + id: string; + videoId?: string; + songTitle: string; + artistName: string; + albumName?: string; + durationSeconds: number; + plainLyric?: string; + syncedLyrics?: string; + vote?: number; +}; diff --git a/src/plugins/synced-lyrics/providers/index.ts b/src/plugins/synced-lyrics/providers/index.ts index 80413b0fe7..0e915c0f82 100644 --- a/src/plugins/synced-lyrics/providers/index.ts +++ b/src/plugins/synced-lyrics/providers/index.ts @@ -7,6 +7,7 @@ export enum ProviderNames { LRCLib = 'LRCLib', MusixMatch = 'MusixMatch', LyricsGenius = 'LyricsGenius', + SimpMusicLyrics = 'SimpMusic Lyrics', // Megalobiz = 'Megalobiz', } diff --git a/src/plugins/synced-lyrics/providers/renderer.ts b/src/plugins/synced-lyrics/providers/renderer.ts index 77f16c9608..027b9765ab 100644 --- a/src/plugins/synced-lyrics/providers/renderer.ts +++ b/src/plugins/synced-lyrics/providers/renderer.ts @@ -3,11 +3,13 @@ import { YTMusic } from './YTMusic'; import { LRCLib } from './LRCLib'; import { MusixMatch } from './MusixMatch'; import { LyricsGenius } from './LyricsGenius'; +import { SimpMusicLyrics } from './SimpMusicLyrics'; export const providers = { [ProviderNames.YTMusic]: new YTMusic(), [ProviderNames.LRCLib]: new LRCLib(), [ProviderNames.MusixMatch]: new MusixMatch(), [ProviderNames.LyricsGenius]: new LyricsGenius(), + [ProviderNames.SimpMusicLyrics]: new SimpMusicLyrics(), // [ProviderNames.Megalobiz]: new Megalobiz(), // Disabled because it is too unstable and slow } as const; diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 3e18626bed..99ec8cb09e 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -59,7 +59,6 @@ const shouldSwitchProvider = (providerData: ProviderState) => { const providerBias = (p: ProviderName) => (lyricsStore.lyrics[p].state === 'done' ? 1 : -1) + (lyricsStore.lyrics[p].data?.lines?.length ? 2 : -1) + - // eslint-disable-next-line prettier/prettier (lyricsStore.lyrics[p].data?.lines?.length && p === ProviderNames.YTMusic ? 1 : 0) + diff --git a/src/plugins/synced-lyrics/renderer/utils.tsx b/src/plugins/synced-lyrics/renderer/utils.tsx index 1c6a410bd2..5f550d478c 100644 --- a/src/plugins/synced-lyrics/renderer/utils.tsx +++ b/src/plugins/synced-lyrics/renderer/utils.tsx @@ -5,6 +5,7 @@ import { romanize as esHangulRomanize } from 'es-hangul'; import hanja from 'hanja'; import * as pinyin from 'tiny-pinyin'; import { romanize as romanizeThaiFrag } from '@dehoist/romanize-thai'; +import Sanscript from '@indic-transliteration/sanscript'; import { lazy } from 'lazy-var'; import { detect } from 'tinyld'; @@ -155,6 +156,14 @@ const hasChinese = (lines: string[]) => const hasThai = (lines: string[]) => lines.some((line) => /[\u0E00-\u0E7F]+/.test(line)); +// https://en.wikipedia.org/wiki/Bengali_(Unicode_block) +const hasBengali = (lines: string[]) => + lines.some((line) => /[\u0980-\u09FF]+/.test(line)); + +// https://en.wikipedia.org/wiki/Devanagari_(Unicode_block) +const hasHindi = (lines: string[]) => + lines.some((line) => /[\u0900-\u097F]+/.test(line)); + export const romanizeJapanese = async (line: string) => (await kuroshiro.get()).convert(line, { to: 'romaji', @@ -190,11 +199,25 @@ export const romanizeThai = (line: string) => { return latin; }; +export const romanizeBengali = (line: string) => { + return line.replaceAll(/[\u0980-\u09FF]+/g, (match) => + Sanscript.t(match, 'bengali', 'itrans'), + ); +}; + +export const romanizeHindi = (line: string) => { + return line.replaceAll(/[\u0900-\u097F]+/g, (match) => + Sanscript.t(match, 'devanagari', 'itrans'), + ); +}; + const handlers: Record Promise | string> = { ja: romanizeJapanese, ko: romanizeHangul, zh: romanizeChinese, th: romanizeThai, + bn: romanizeBengali, + hi: romanizeHindi, }; export const romanize = async (line: string) => { @@ -210,6 +233,8 @@ export const romanize = async (line: string) => { if (hasKorean([line])) line = romanizeHangul(line); if (hasChinese([line])) line = romanizeChinese(line); if (hasThai([line])) line = romanizeThai(line); + if (hasBengali([line])) line = romanizeBengali(line); + if (hasHindi([line])) line = romanizeHindi(line); return line; }; diff --git a/src/plugins/touchbar/index.ts b/src/plugins/touchbar/index.ts index e8bfbc1a9a..7561df4324 100644 --- a/src/plugins/touchbar/index.ts +++ b/src/plugins/touchbar/index.ts @@ -1,11 +1,12 @@ import { nativeImage, type NativeImage, TouchBar } from 'electron'; +import musicPlayerIcon from '@assets/icon.png?asset&asarUnpack'; + import { createPlugin } from '@/utils'; import { getSongControls } from '@/providers/song-controls'; import { registerCallback, SongInfoEvent } from '@/providers/song-info'; import { t } from '@/i18n'; -import musicPlayerIcon from '@assets/icon.png?asset&asarUnpack'; import { Platform } from '@/types/plugins'; export default createPlugin({ diff --git a/src/plugins/video-toggle/button-switcher.css b/src/plugins/video-toggle/button-switcher.css index 3a9796888e..6eaec3363e 100644 --- a/src/plugins/video-toggle/button-switcher.css +++ b/src/plugins/video-toggle/button-switcher.css @@ -9,84 +9,46 @@ .video-toggle-custom-mode .video-switch-button { z-index: 999; box-sizing: border-box; - padding: 0; - margin-top: 20px; - margin-left: 10px; - background: rgba(33, 33, 33, 0.4); - border-radius: 30px; - overflow: hidden; - width: 20rem; - text-align: center; - font-size: 18px; - letter-spacing: 1px; + padding: 12px; + margin: 20px 10px 0px 10px; + background: rgba(33, 33, 33, 0.7); + border-radius: 50%; + width: 48px; + height: 48px; color: #fff; - padding-right: 10rem; position: absolute; -} - -.video-toggle-custom-mode .video-switch-button:before { - content: attr(data-video-button-text); - position: absolute; - top: 0; - bottom: 0; - right: 0; - width: 10rem; + cursor: pointer; display: flex; align-items: center; justify-content: center; - z-index: 3; - pointer-events: none; + transition: background 0.2s ease, transform 0.2s ease; + border: none; + outline: none; } -.video-toggle-custom-mode .video-switch-button-checkbox { - cursor: pointer; - position: absolute; - top: 0; - left: 0; - bottom: 0; - width: 100%; - height: 100%; - opacity: 0; - z-index: 2; +.video-toggle-custom-mode .video-switch-button:hover { + background: rgba(33, 33, 33, 0.9); + transform: scale(1.1); } -.video-toggle-custom-mode .video-switch-button-label-span { - position: relative; +.video-toggle-icon { + width: 24px; + height: 24px; } -.video-toggle-custom-mode - .video-switch-button-checkbox:checked - + .video-switch-button-label:before { - transform: translateX(10rem); - transition: transform 300ms linear; +/* disable the native toggler */ +.video-toggle-custom-mode #av-id { + display: none; } -.video-toggle-custom-mode - .video-switch-button-checkbox - + .video-switch-button-label { - position: relative; - padding: 15px 0; +.video-toggle-custom-mode #song-video.ytmusic-player { display: block; - user-select: none; - pointer-events: none; } -.video-toggle-custom-mode - .video-switch-button-checkbox - + .video-switch-button-label:before { - content: ''; - background: rgba(60, 60, 60, 0.4); - height: 100%; - width: 100%; - position: absolute; - left: 0; - top: 0; - border-radius: 30px; - transform: translateX(0); - transition: transform 300ms; +.video-toggle-custom-mode #song-image { + display: none; } -/* disable the native toggler */ -.video-toggle-custom-mode #av-id { - display: none; +.video-toggle-custom-mode ytmusic-player { + margin: unset; } diff --git a/src/plugins/video-toggle/index.tsx b/src/plugins/video-toggle/index.tsx index 84e480ffab..e81021db50 100644 --- a/src/plugins/video-toggle/index.tsx +++ b/src/plugins/video-toggle/index.tsx @@ -108,6 +108,7 @@ export default createPlugin({ renderer: { config: null as VideoTogglePluginConfig | null, + setVideoVisible: null as ((visible: boolean) => void) | null, applyStyleClass: (config: VideoTogglePluginConfig) => { if (config.forceHide) { document.body.classList.add('video-toggle-force-hide'); @@ -119,6 +120,7 @@ export default createPlugin({ }, async start({ getConfig }) { const config = await getConfig(); + this.config = config; this.applyStyleClass(config); if (config.forceHide) { @@ -155,9 +157,12 @@ export default createPlugin({ }, async onPlayerApiReady(api, { getConfig }) { const [showButton, setShowButton] = createSignal(true); + const [videoVisible, setVideoVisible] = createSignal(true); const config = await getConfig(); this.config = config; + setVideoVisible(!config.hideVideo); + this.setVideoVisible = setVideoVisible; const moveVolumeHud = (await window.mainConfig.plugins.isEnabled( 'precise-volume', @@ -177,14 +182,11 @@ export default createPlugin({ () => ( { - const target = e.target as HTMLInputElement; - - setVideoState(target.checked); + initialVideoVisible={videoVisible()} + onVideoToggle={(showVideo) => { + setVideoVisible(showVideo); + setVideoState(showVideo); }} - onClick={(e) => e.stopPropagation()} - songButtonText={t('plugins.video-toggle.templates.button-song')} - videoButtonText={t('plugins.video-toggle.templates.button-video')} /> ), @@ -206,11 +208,6 @@ export default createPlugin({ } window.mainConfig.plugins.setOptions('video-toggle', this.config); - const checkbox = document.querySelector( - '.video-switch-button-checkbox', - ); // custom mode - if (checkbox) checkbox.checked = !this.config?.hideVideo; - if (player) { player.style.margin = showVideo ? '' : 'auto 0px'; player.setAttribute( @@ -218,11 +215,13 @@ export default createPlugin({ showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED', ); - document.querySelector( + const songVideo = document.querySelector( '#song-video.ytmusic-player', - )!.style.display = showVideo ? 'block' : 'none'; - document.querySelector('#song-image')!.style.display = - showVideo ? 'none' : 'block'; + ); + const songImage = document.querySelector('#song-image'); + + if (songVideo) songVideo.style.display = showVideo ? 'block' : 'none'; + if (songImage) songImage.style.display = showVideo ? 'none' : 'block'; if (showVideo && video && !video.style.top) { video.style.top = `${ @@ -328,28 +327,37 @@ export default createPlugin({ video?.addEventListener('peard:src-changed', videoStarted); observeThumbnail(); videoStarted(); - switch (config.align) { - case 'right': { - switchButtonContainer.style.justifyContent = 'flex-end'; - return; - } - - case 'middle': { - switchButtonContainer.style.justifyContent = 'center'; - return; - } - default: - case 'left': { - switchButtonContainer.style.justifyContent = 'flex-start'; - } - } + const alignmentMap = { + right: 'flex-end', + middle: 'center', + left: 'flex-start', + }; + switchButtonContainer.style.justifyContent = + alignmentMap[config.align] || alignmentMap.left; }, 0); } }, onConfigChange(newConfig) { this.config = newConfig; this.applyStyleClass(newConfig); + + if (this.setVideoVisible) { + this.setVideoVisible(!newConfig.hideVideo); + } + + const switchButtonContainer = document.querySelector( + '#ytmd-video-toggle-switch-button-container', + ); + if (switchButtonContainer) { + const alignmentMap = { + right: 'flex-end', + middle: 'center', + left: 'flex-start', + }; + switchButtonContainer.style.justifyContent = + alignmentMap[newConfig.align] || alignmentMap.left; + } }, }, }); diff --git a/src/plugins/video-toggle/templates/video-switch-button.tsx b/src/plugins/video-toggle/templates/video-switch-button.tsx index c996bb9c3f..7b207f9aca 100644 --- a/src/plugins/video-toggle/templates/video-switch-button.tsx +++ b/src/plugins/video-toggle/templates/video-switch-button.tsx @@ -1,28 +1,51 @@ +import { createSignal, Show } from 'solid-js'; + export interface VideoSwitchButtonProps { - onClick?: (event: MouseEvent) => void; - onChange?: (event: Event) => void; - songButtonText: string; - videoButtonText: string; + initialVideoVisible?: boolean; + onVideoToggle?: (showVideo: boolean) => void; } -export const VideoSwitchButton = (props: VideoSwitchButtonProps) => ( -
props.onClick?.(e)} - onChange={(e) => props.onChange?.(e)} - > - - -
-); +export const VideoSwitchButton = (props: VideoSwitchButtonProps) => { + const [videoVisible, setVideoVisible] = createSignal( + props.initialVideoVisible ?? true, + ); + + const toggleVideo = () => { + const newVisible = !videoVisible(); + setVideoVisible(newVisible); + props.onVideoToggle?.(newVisible); + }; + + return ( + + ); +}; diff --git a/src/providers/app-controls.ts b/src/providers/app-controls.ts index 9b29f4fe65..d2abaddf87 100644 --- a/src/providers/app-controls.ts +++ b/src/providers/app-controls.ts @@ -12,7 +12,9 @@ export const setupAppControls = () => { ipcMain.on('peard:reload', () => BrowserWindow.getFocusedWindow()?.webContents.loadURL(config.get('url')), ); - ipcMain.handle('peard:get-path', (_, ...args: string[]) => path.join(...args)); + ipcMain.handle('peard:get-path', (_, ...args: string[]) => + path.join(...args), + ); }; function restartInternal() { diff --git a/src/providers/song-controls.ts b/src/providers/song-controls.ts index 3de3e1a31f..b2b14000c9 100644 --- a/src/providers/song-controls.ts +++ b/src/providers/song-controls.ts @@ -85,7 +85,10 @@ export const getSongControls = (win: BrowserWindow) => { const isFullscreenValue = parseBooleanFromArgsType(isFullscreen); if (isFullscreenValue !== null) { win.setFullScreen(isFullscreenValue); - win.webContents.send('peard:click-fullscreen-button', isFullscreenValue); + win.webContents.send( + 'peard:click-fullscreen-button', + isFullscreenValue, + ); } }, requestFullscreenInformation: () => {