Compare commits

..

22 Commits

Author SHA1 Message Date
721271d902 fix: Update changelog script in package.json 2025-09-07 15:38:35 +09:00
a2151930ec Bump version to 3.11.0 2025-09-07 15:18:33 +09:00
bdc9f42681 fix: change deprecated option rollupOptions to rolldownOptions 2025-09-07 15:18:02 +09:00
0b3c6f9e1f chore(i18n): Translated using Weblate (Korean)
Currently translated at 100.0% (456 of 456 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2025-09-07 08:16:14 +02:00
ebe373bdc6 chore(package.json): add NODE_ENV 2025-09-07 13:45:40 +09:00
ab91e6d735 Fixed missing videochange dataupdated event when using shuffle (#3659)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: rewhex <gitea@cluser.local>
2025-09-07 13:43:48 +09:00
be3ae4d789 feat(synced-lyrics): preferred provider (global/per-song) (#3741)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-07 13:35:29 +09:00
336b7fe5e9 fix(api-server): use ipc instead of ipcMain 2025-09-07 12:47:08 +09:00
5ecd39f324 feat(api-server): send shuffle state over websocket (#3837) 2025-09-07 04:29:38 +09:00
eb50596961 feat(synced-lyrics): add new "spacer" (#3742)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-07 04:26:03 +09:00
aede0cd643 chore(i18n): Translated using Weblate (Vietnamese)
Currently translated at 100.0% (452 of 452 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/vi/
2025-09-06 17:20:17 +02:00
25f51784c3 chore(i18n): Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (452 of 452 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/zh_Hans/
2025-09-06 17:20:17 +02:00
7acac6d11c chore(i18n): Translated using Weblate (Turkish)
Currently translated at 100.0% (452 of 452 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/tr/
2025-09-06 17:20:16 +02:00
1972ef70b5 chore(i18n): Translated using Weblate (Russian)
Currently translated at 99.7% (451 of 452 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ru/
2025-09-06 17:20:16 +02:00
c4455ed3d2 chore(i18n): Translated using Weblate (Korean)
Currently translated at 100.0% (452 of 452 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2025-09-06 17:20:16 +02:00
c1a06ab955 feat: remove unnecessary export default for better tree-shaking 2025-09-06 22:31:02 +09:00
26fa1f85b2 fix(discord-rpc, scrobbler): Align artist and title with the last.fm's de facto standard
- Display only the main artist.
- Display the title in its original language without romanization.

- fix #3358
- fix #3641
2025-09-06 10:25:54 +09:00
555817e2f5 feat(downloader): Add context menu button for playlists and albums (#3768)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-06 09:12:24 +09:00
f8654dfdb9 fix(i18n): fix missing i18n 2025-09-06 09:08:17 +09:00
96c0fc412c chore(i18n): Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (442 of 442 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pt_BR/
2025-09-06 00:00:58 +00:00
a70a4106df chore(i18n): Translated using Weblate (Russian)
Currently translated at 100.0% (442 of 442 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ru/
2025-09-06 00:00:57 +00:00
895210cbb6 feat(transparent-player): new plugin for Acrylic, Mica or Tabbed effects (#3529)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-06 09:00:39 +09:00
67 changed files with 1202 additions and 441 deletions

View File

@ -45,7 +45,7 @@ export default defineConfig({
formats: ['es'],
},
outDir: 'dist/main',
rollupOptions: {
rolldownOptions: {
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/index.ts',
},
@ -96,7 +96,7 @@ export default defineConfig({
commonjsOptions: {
ignoreDynamicRequires: true,
},
rollupOptions: {
rolldownOptions: {
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/preload.ts',
},
@ -149,7 +149,7 @@ export default defineConfig({
name: 'renderer',
},
outDir: 'dist/renderer',
rollupOptions: {
rolldownOptions: {
external: ['electron', ...builtinModules],
input: './src/index.html',
},

View File

@ -2,7 +2,7 @@
"name": "youtube-music",
"desktopName": "com.github.th_ch.youtube_music",
"productName": "YouTube Music",
"version": "3.10.0",
"version": "3.11.0",
"description": "YouTube Music Desktop App - including custom plugins",
"main": "./dist/main/index.js",
"type": "module",
@ -20,8 +20,8 @@
"vite:inspect": "pnpm clean && electron-vite build --mode development && pnpm exec serve .vite-inspect",
"start": "pnpm electron-vite preview",
"start:debug": "pnpm cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
"dev": "pnpm cross-env NODE_OPTIONS=--enable-source-maps electron-vite dev --watch",
"dev:renderer": "pnpm cross-env NODE_OPTIONS=--enable-source-maps electron-vite dev",
"dev": "pnpm cross-env NODE_ENV=development NODE_OPTIONS=--enable-source-maps electron-vite dev --watch",
"dev:renderer": "pnpm cross-env NODE_ENV=development NODE_OPTIONS=--enable-source-maps electron-vite dev",
"dev:debug": "pnpm cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
"clean": "pnpm del-cli dist && pnpm del-cli pack && pnpm del-cli .vite-inspect",
"dist": "pnpm clean && pnpm build && pnpm electron-builder --win --mac --linux -p never",
@ -33,7 +33,7 @@
"dist:win": "pnpm clean && pnpm build && pnpm electron-builder --win -p never",
"dist:win:x64": "pnpm clean && pnpm build && pnpm electron-builder --win nsis-web:x64 -p never",
"lint": "pnpm eslint ./src",
"changelog": "pnpm dlx --yes auto-changelog",
"changelog": "pnpm dlx auto-changelog",
"release:linux": "pnpm clean && pnpm build && pnpm electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "pnpm clean && pnpm build && pnpm electron-builder --mac -p always",
"release:win": "pnpm clean && pnpm build && pnpm electron-builder --win -p always",

View File

@ -31,6 +31,7 @@ export interface DefaultConfig {
likeButtons: string;
proxy: string;
startingPage: string;
backgroundMaterial?: 'none' | 'mica' | 'acrylic' | 'tabbed';
overrideUserAgent: boolean;
usePodcastParticipantAsArtist: boolean;
themes: string[];
@ -39,7 +40,7 @@ export interface DefaultConfig {
'plugins': Record<string, unknown>;
}
const defaultConfig: DefaultConfig = {
export const defaultConfig: DefaultConfig = {
'window-size': {
width: 1100,
height: 550,
@ -73,5 +74,3 @@ const defaultConfig: DefaultConfig = {
},
'plugins': {},
};
export default defaultConfig;

View File

@ -1,30 +1,36 @@
import { deepmergeCustom } from 'deepmerge-ts';
import defaultConfig from './defaults';
import store, { type IStore } from './store';
import plugins from './plugins';
import { store, type IStore } from './store';
import { restart } from '@/providers/app-controls';
import type { defaultConfig } from './defaults';
const deepmerge = deepmergeCustom({
mergeArrays: false,
});
const set = (key: string, value: unknown) => {
export { defaultConfig } from './defaults';
export * as plugins from './plugins';
export const set = (key: string, value: unknown) => {
store.set(key, value);
};
const setPartial = (key: string, value: object, defaultValue?: object) => {
export const setPartial = (
key: string,
value: object,
defaultValue?: object,
) => {
const newValue = deepmerge(defaultValue ?? {}, store.get(key) ?? {}, value);
store.set(key, newValue);
};
function setMenuOption(key: string, value: unknown) {
export const setMenuOption = (key: string, value: unknown) => {
set(key, value);
if (store.get('options.restartOnConfigChanges')) {
restart();
}
}
};
// MAGIC OF TYPESCRIPT
@ -74,18 +80,11 @@ type PathValue<T, K extends string> =
? PathValue<T[A], B>
: T;
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) =>
export const get = <Key extends Paths<typeof defaultConfig>>(key: Key) =>
store.get(key) as PathValue<typeof defaultConfig, typeof key>;
export default {
defaultConfig,
get,
set,
setPartial,
setMenuOption,
edit: () => store.openInEditor(),
watch(cb: Parameters<IStore['onDidAnyChange']>[0]) {
store.onDidAnyChange(cb);
},
plugins,
export const edit = () => store.openInEditor();
export const watch = (cb: Parameters<IStore['onDidAnyChange']>[0]) => {
store.onDidAnyChange(cb);
};

View File

@ -1,7 +1,7 @@
import { deepmerge } from 'deepmerge-ts';
import { allPlugins } from 'virtual:plugins';
import store from './store';
import { store } from './store';
import { restart } from '@/providers/app-controls';
@ -68,13 +68,3 @@ export function enable(plugin: string) {
export function disable(plugin: string) {
setMenuOptions(plugin, { enabled: false }, []);
}
export default {
isEnabled,
getPlugins,
enable,
disable,
setOptions,
setMenuOptions,
getOptions,
};

View File

@ -1,6 +1,6 @@
import Store from 'electron-store';
import defaults from './defaults';
import { defaultConfig as defaults } from './defaults';
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
@ -257,7 +257,7 @@ const migrations = {
},
};
export default new Store({
export const store = new Store({
defaults: {
...defaults,
// README: 'plugin' uses deepmerge to populate the default values, so it is not necessary to include it here

View File

@ -853,6 +853,26 @@
"description": "Fügt ein TouchBar-Widget für macOS-Benutzer hinzu",
"name": "TouchBar"
},
"transparent-player": {
"description": "Macht das Player-Fenster transparent",
"name": "Transparent Player",
"menu": {
"opacity": {
"label": "Hintergrund-Sichtbarkeit",
"submenu": {
"percent": "{{opacity}}%"
}
},
"type": {
"label": "Typ",
"submenu": {
"acrylic": "Acrylic",
"mica": "Mica",
"tabbed": "Tabbed"
}
}
}
},
"tuna-obs": {
"description": "Integration mit dem OBS-Plugin Tuna",
"name": "Tuna OBS"

View File

@ -758,6 +758,7 @@
"token": "Enter ListenBrainz user token"
},
"scrobble-alternative-title": "Use alternative titles",
"scrobble-alternative-artist": "Use alternative artists",
"scrobble-other-media": "Scrobble other media"
},
"name": "Scrobbler",
@ -812,6 +813,14 @@
"not-found": "⚠️ No lyrics found for this song."
},
"menu": {
"preferred-provider": {
"label": "Preferred Provider",
"tooltip": "Choose the default provider to use",
"none": {
"label": "None",
"tooltip": "No preferred provider"
}
},
"default-text-string": {
"label": "Default character between lyrics",
"tooltip": "Choose the default character to use for the gap between lyrics"
@ -874,6 +883,27 @@
"description": "Adds a TouchBar widget for macOS users",
"name": "TouchBar"
},
"transparent-player": {
"description": "Makes the app window transparent",
"name": "Transparent Player",
"menu": {
"opacity": {
"label": "Opacity",
"submenu": {
"percent": "{{opacity}}%"
}
},
"type": {
"label": "Type",
"submenu": {
"acrylic": "Acrylic",
"mica": "Mica",
"tabbed": "Tabbed",
"none": "None"
}
}
}
},
"tuna-obs": {
"description": "Integration with OBS's plugin Tuna",
"name": "Tuna OBS"

View File

@ -421,6 +421,19 @@
}
}
},
"custom-output-device": {
"description": "미디어 출력 장치 구성",
"menu": {
"device-selector": "장치 선택"
},
"name": "출력 장치 커스텀",
"prompt": {
"device-selector": {
"label": "사용할 미디어 출력 장치를 선택하세요",
"title": "출력 장치 선택"
}
}
},
"disable-autoplay": {
"description": "노래를 '일시 정지' 모드로 시작하게 합니다",
"menu": {
@ -444,7 +457,15 @@
"hide-duration-left": "남은 재생 시간 숨기기",
"hide-github-button": "GitHub 링크 버튼 숨기기",
"play-on-youtube-music": "유튜브 뮤직에서 재생",
"set-inactivity-timeout": "비활성 시간 제한 설정"
"set-inactivity-timeout": "비활성 시간 제한 설정",
"set-status-display-type": {
"label": "상태 텍스트",
"submenu": {
"artist": "{아티스트} 듣는 중",
"title": "{곡 제목} 듣는 중",
"youtube-music": "YouTube Music 듣는 중"
}
}
},
"name": "디스코드 활동 상태",
"prompt": {
@ -736,6 +757,7 @@
"listenbrainz": {
"token": "ListenBrainz 유저 토큰 입력"
},
"scrobble-alternative-artist": "대체 아티스트 명 사용",
"scrobble-alternative-title": "대체 제목 사용하기",
"scrobble-other-media": "다른 미디어 스크로블하기"
},
@ -821,6 +843,14 @@
"label": "가사를 최대한 정교하게 동기화",
"tooltip": "다음 줄의 표시를 밀리초 단위로 계산합니다 (성능에 약간의 영향을 미칠 수 있음)"
},
"preferred-provider": {
"label": "선호하는 가사 제공자",
"none": {
"label": "없음",
"tooltip": "선호하는 가사 제공자 없음"
},
"tooltip": "사용할 기본 가사 제공자를 선택하세요"
},
"romanization": {
"label": "가사 로마자 변환",
"tooltip": "가사가 영어가 아닌 언어로 되어있는 경우, 로마자 표기를 표시합니다."
@ -853,6 +883,27 @@
"description": "macOS 사용자를 위한 TouchBar 위젯을 추가합니다",
"name": "TouchBar"
},
"transparent-player": {
"description": "애플리케이션 창을 투명하게 만듭니다",
"menu": {
"opacity": {
"label": "불투명도",
"submenu": {
"percent": "{{opacity}}%"
}
},
"type": {
"label": "종류",
"submenu": {
"acrylic": "아크릴",
"mica": "미카",
"none": "없음",
"tabbed": "탭"
}
}
},
"name": "투명 플레이어"
},
"tuna-obs": {
"description": "OBS의 확장인 Tuna와의 통합을 활성화합니다",
"name": "Tuna OBS"

View File

@ -421,6 +421,19 @@
}
}
},
"custom-output-device": {
"description": "Configure um dispositivo de saída de mídia personalizado para músicas",
"menu": {
"device-selector": "Selecionar dispositivo"
},
"name": "Dispositivo de saída personalizado",
"prompt": {
"device-selector": {
"label": "Escolha o dispositivo de saída de mídia que será usado",
"title": "Selecionar dispositivo de saída"
}
}
},
"disable-autoplay": {
"description": "Faz a música começar no modo \"pausado\"",
"menu": {
@ -444,7 +457,15 @@
"hide-duration-left": "Ocultar duração restante",
"hide-github-button": "Ocultar botão do GitHub",
"play-on-youtube-music": "Reproduzir no YouTube Music",
"set-inactivity-timeout": "Definir tempo limite de inatividade"
"set-inactivity-timeout": "Definir tempo limite de inatividade",
"set-status-display-type": {
"label": "Texto de status",
"submenu": {
"artist": "Ouvindo {artist}",
"title": "Ouvindo {song title}",
"youtube-music": "Ouvindo YouTube Music"
}
}
},
"name": "Rich Presence do Discord",
"prompt": {

View File

@ -421,6 +421,19 @@
}
}
},
"custom-output-device": {
"description": "Настройка устройства вывода медиа для песен",
"menu": {
"device-selector": "Выберите устройство"
},
"name": "Пользовательское устройство вывода",
"prompt": {
"device-selector": {
"label": "Выберите устройство вывода медиа, которое будет использоваться",
"title": "Выберите устройство вывода"
}
}
},
"disable-autoplay": {
"description": "Запускает песню сразу на паузе",
"menu": {
@ -444,7 +457,15 @@
"hide-duration-left": "Скрыть сколько осталось времени",
"hide-github-button": "Скрыть ссылку на GitHub",
"play-on-youtube-music": "Воспроизвести на YouTube Music",
"set-inactivity-timeout": "Поставить таймер неактивности"
"set-inactivity-timeout": "Поставить таймер неактивности",
"set-status-display-type": {
"label": "Текст статуса",
"submenu": {
"artist": "Слушает {исполнитель}",
"title": "Слушает {название трека}",
"youtube-music": "Слушает YouTube Music"
}
}
},
"name": "Discord Rich Presence",
"prompt": {
@ -736,6 +757,7 @@
"listenbrainz": {
"token": "Введите токен пользователя ListenBrainz"
},
"scrobble-alternative-artist": "Использовать альтернативных исполнителей",
"scrobble-alternative-title": "Использовать альтернативные названия",
"scrobble-other-media": "Скробблинг других медиа"
},
@ -853,6 +875,27 @@
"description": "Добавляет виджет тачбара для пользователей macOS",
"name": "Тачбар"
},
"transparent-player": {
"description": "Делает окно приложения прозрачным",
"menu": {
"opacity": {
"label": "Непрозрачность",
"submenu": {
"percent": "{{opacity}}%"
}
},
"type": {
"label": "Тип",
"submenu": {
"acrylic": "Acrylic",
"mica": "Mica",
"none": "Отключено",
"tabbed": "Tabbed"
}
}
},
"name": "Прозрачный плеер"
},
"tuna-obs": {
"description": "Интеграция с плагином Tuna от OBS",
"name": "Tuna OBS"

View File

@ -421,6 +421,19 @@
}
}
},
"custom-output-device": {
"description": "Şarkılar için özel bir medya çıkış aygıtı ayarlayın",
"menu": {
"device-selector": "Aygıt Seçin"
},
"name": "Özel Çıkış Aygıtı",
"prompt": {
"device-selector": {
"label": "Kullanılacak medya çıkış aygıtını seçin",
"title": ıkış Aygıtını Seçin"
}
}
},
"disable-autoplay": {
"description": "Şarkıların otomatik olarak duraklatılmasını sağlar",
"menu": {
@ -444,7 +457,15 @@
"hide-duration-left": "Kalan süreyi gizle",
"hide-github-button": "GitHub bağlantısını gizle",
"play-on-youtube-music": "YouTube Music de oynat",
"set-inactivity-timeout": "Hareketsizlik zaman aşımını ayarla"
"set-inactivity-timeout": "Hareketsizlik zaman aşımını ayarla",
"set-status-display-type": {
"label": "Durum metni",
"submenu": {
"artist": "{artist} Dinleniyor",
"title": "{song title} Dinleniyor",
"youtube-music": "YouTube Müzik Dinleniyor"
}
}
},
"name": "Discord Etkinlik Durumu",
"prompt": {
@ -736,6 +757,7 @@
"listenbrainz": {
"token": "ListenBrainz kullanıcı kimliğinizi girin"
},
"scrobble-alternative-artist": "Alternatif sanatçıları kullan",
"scrobble-alternative-title": "Alternatif başlıklar kullan",
"scrobble-other-media": "Diğer medya ortamlarında listele"
},
@ -853,6 +875,27 @@
"description": "macOS kullanıcıları için bir TouchBar widget'ı ekler",
"name": "TouchBar"
},
"transparent-player": {
"description": "Uygulama penceresini şeffaf yapar",
"menu": {
"opacity": {
"label": "Opaklık",
"submenu": {
"percent": "%{{opacity}}"
}
},
"type": {
"label": "Tür",
"submenu": {
"acrylic": "Akrilik",
"mica": "Mika",
"none": "Hiçbiri",
"tabbed": "Sekmeli"
}
}
},
"name": "Şeffaf Oynatıcı"
},
"tuna-obs": {
"description": "OBS eklentisi Tuna ile entegrasyon sağlar",
"name": "Tuna OBS"

View File

@ -421,6 +421,19 @@
}
}
},
"custom-output-device": {
"description": "Cài đặt cho thiết bị đầu ra tùy chỉnh cho bài hát",
"menu": {
"device-selector": "Chọn thiết bị"
},
"name": "Thiết bị đầu ra tùy chỉnh",
"prompt": {
"device-selector": {
"label": "Chọn thiết bị phát làm đầu ra để dùng",
"title": "Chọn thiết bị đầu ra"
}
}
},
"disable-autoplay": {
"description": "Làm nhạc bắt đầu ở chế độ \"tạm dừng\". Ngoài ra có thể dừng nhạc khi khởi động ứng dụng (nếu có bật tính năng \"Tiếp tục bài hát cuối cùng khi ứng dụng khởi động\")",
"menu": {
@ -444,9 +457,17 @@
"hide-duration-left": "Ẩn thời lượng còn lại",
"hide-github-button": "Ẩn nút liên kết GitHub",
"play-on-youtube-music": "Phát trong Youtube Music",
"set-inactivity-timeout": "Đặt thời gian chờ không hoạt động"
"set-inactivity-timeout": "Đặt thời gian chờ không hoạt động",
"set-status-display-type": {
"label": "Văn bản trạng thái",
"submenu": {
"artist": "Đang nghe nhạc của {artist}",
"title": "Đang nghe nhạc {song title}",
"youtube-music": "Đang nghe Youtube Music"
}
}
},
"name": "Discord Rich Presence",
"name": "Tích hợp trạng thái Discord",
"prompt": {
"set-inactivity-timeout": {
"label": "Nhập thời gian chờ không hoạt động tính bằng giây:",
@ -736,6 +757,7 @@
"listenbrainz": {
"token": "Nhập mã người dùng ListenBrainz"
},
"scrobble-alternative-artist": "Dùng nghệ sĩ thay thế",
"scrobble-alternative-title": "Dùng tiêu đề thay thế",
"scrobble-other-media": "Scrobber nội dung khác"
},
@ -853,6 +875,27 @@
"description": "Thêm tiện ích TouchBar cho người dùng macOS",
"name": "TouchBar"
},
"transparent-player": {
"description": "Làm cho cửa sổ ứng dụng có hiệu ứng trong suốt",
"menu": {
"opacity": {
"label": "Độ mờ",
"submenu": {
"percent": "{{opacity}}%"
}
},
"type": {
"label": "Kiểu nền",
"submenu": {
"acrylic": "Acrylic",
"mica": "Mica",
"none": "Không có",
"tabbed": "Tabbed"
}
}
},
"name": "Trình phát trong suốt"
},
"tuna-obs": {
"description": "Tích hợp với plugin Tuna của OBS",
"name": "Tuna OBS"

View File

@ -421,6 +421,19 @@
}
}
},
"custom-output-device": {
"description": "配置歌曲的自定义输出媒体设备",
"menu": {
"device-selector": "选择设备"
},
"name": "自定义输出设备",
"prompt": {
"device-selector": {
"label": "选择要使用的输出媒体设备",
"title": "选择输出设备"
}
}
},
"disable-autoplay": {
"description": "让曲目开始时处于 “暂停” 模式",
"menu": {
@ -444,7 +457,15 @@
"hide-duration-left": "隐藏剩余时长",
"hide-github-button": "隐藏 GitHub 链接按钮",
"play-on-youtube-music": "转至 YouTube Music 播放",
"set-inactivity-timeout": "设置非活跃时长"
"set-inactivity-timeout": "设置非活跃时长",
"set-status-display-type": {
"label": "状态文本",
"submenu": {
"artist": "在听 {artist}",
"title": "在听 {song title}",
"youtube-music": "在听 YouTube Music"
}
}
},
"name": "Discord Rich Presence 状态显示",
"prompt": {
@ -736,6 +757,7 @@
"listenbrainz": {
"token": "输入 ListenBrainz 用户令牌"
},
"scrobble-alternative-artist": "使用替代艺术家",
"scrobble-alternative-title": "使用替代标题",
"scrobble-other-media": "记录其他媒体文件"
},
@ -853,6 +875,27 @@
"description": "为 macOS 用户启用 TouchBar 支持",
"name": "TouchBar"
},
"transparent-player": {
"description": "把应用窗口变透明",
"menu": {
"opacity": {
"label": "不透明",
"submenu": {
"percent": "{{opacity}}%"
}
},
"type": {
"label": "类型",
"submenu": {
"acrylic": "亚克力",
"mica": "云母",
"none": "无",
"tabbed": "标签"
}
}
},
"name": "透明播放器"
},
"tuna-obs": {
"description": "与 OBS 的 Tuna 插件集成",
"name": "Tuna OBS"

View File

@ -29,7 +29,7 @@ import { allPlugins, mainPlugins } from 'virtual:plugins';
import { languageResources } from 'virtual:i18n';
import config from '@/config';
import * as config from '@/config';
import { refreshMenu, setApplicationMenu } from '@/menu';
import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main';
@ -59,7 +59,7 @@ import ErrorHtmlAsset from '@assets/error.html?asset';
import { defaultAuthProxyConfig } from '@/plugins/auth-proxy-adapter/config';
import { type PluginConfig } from '@/types/plugins';
import type { PluginConfig } from '@/types/plugins';
// Catch errors and log them
unhandled({
@ -338,8 +338,8 @@ async function createMainWindow() {
titleBarStyle: useInlineMenu
? 'hidden'
: is.macOS()
? 'hiddenInset'
: 'default',
? 'hiddenInset'
: 'default',
autoHideMenuBar: config.get('options.hideMenu'),
};
@ -349,7 +349,7 @@ async function createMainWindow() {
delete decorations.titleBarStyle;
}
const win = new BrowserWindow({
const electronWindowSettings: Electron.BrowserWindowConstructorOptions = {
icon,
width: windowSize.width,
height: windowSize.height,
@ -369,7 +369,10 @@ async function createMainWindow() {
}),
},
...decorations,
});
};
const win = new BrowserWindow(electronWindowSettings);
await initHook(win);
initTheme(win);
@ -529,8 +532,8 @@ app.once('browser-window-created', (_event, win) => {
const updatedUserAgent = is.macOS()
? userAgents.mac
: is.windows()
? userAgents.windows
: userAgents.linux;
? userAgents.windows
: userAgents.linux;
win.webContents.userAgent = updatedUserAgent;
app.userAgentFallback = updatedUserAgent;
@ -951,15 +954,18 @@ function removeContentSecurityPolicy(
betterSession.webRequest.setResolver(
'onHeadersReceived',
async (listeners) => {
return listeners.reduce(async (accumulator, listener) => {
const acc = await accumulator;
if (acc.cancel) {
return acc;
}
return listeners.reduce(
async (accumulator, listener) => {
const acc = await accumulator;
if (acc.cancel) {
return acc;
}
const result = await listener.apply();
return { ...accumulator, ...result };
}, Promise.resolve({ cancel: false }));
const result = await listener.apply();
return { ...accumulator, ...result };
},
Promise.resolve({ cancel: false }),
);
},
);
}

View File

@ -3,7 +3,7 @@ import { type BrowserWindow, ipcMain } from 'electron';
import { deepmerge } from 'deepmerge-ts';
import { allPlugins, mainPlugins } from 'virtual:plugins';
import config from '@/config';
import * as config from '@/config';
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
import { t } from '@/i18n';

View File

@ -1,7 +1,7 @@
import { deepmerge } from 'deepmerge-ts';
import { allPlugins } from 'virtual:plugins';
import config from '@/config';
import * as config from '@/config';
import { setApplicationMenu } from '@/menu';
import { LoggerPrefix } from '@/utils';

View File

@ -3,7 +3,7 @@ import { allPlugins, preloadPlugins } from 'virtual:plugins';
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
import config from '@/config';
import * as config from '@/config';
import { t } from '@/i18n';

View File

@ -15,7 +15,7 @@ import { allPlugins } from 'virtual:plugins';
import { languageResources } from 'virtual:i18n';
import config from './config';
import * as config from './config';
import { restart } from './providers/app-controls';
import { startingPages } from './providers/extracted-data';

View File

@ -31,7 +31,7 @@ export default createPlugin<
alpha?: number,
ratioMultiply?: number,
): string;
updateColor(): void;
updateColor(alpha: number): void;
},
{
enabled: boolean;
@ -143,7 +143,16 @@ export default createPlugin<
document.documentElement.style.setProperty(DARK_COLOR_KEY, '0, 0, 0');
}
this.updateColor();
let alpha: number | null = null;
if (await window.mainConfig.plugins.isEnabled('transparent-player')) {
const value: unknown = window.mainConfig.get(
'plugins.transparent-player.opacity',
);
if (typeof value === 'number' && value >= 0 && value <= 1) {
alpha = value;
}
}
this.updateColor(alpha ?? 1);
});
},
onConfigChange(config) {
@ -163,7 +172,7 @@ export default createPlugin<
}
return `color-mix(in srgb, ${color} ${originalRatio}, ${keyColor} ${colorRatio})`;
},
updateColor() {
updateColor(alpha: number) {
const variableMap = {
'--ytmusic-color-black1': '#212121',
'--ytmusic-color-black2': '#181818',
@ -202,19 +211,20 @@ export default createPlugin<
Object.entries(variableMap).map(([variable, color]) => {
document.documentElement.style.setProperty(
variable,
this.getMixedColor(color, COLOR_KEY),
this.getMixedColor(color, COLOR_KEY, alpha),
'important',
);
});
document.body.style.setProperty(
'background',
this.getMixedColor('#030303', COLOR_KEY),
this.getMixedColor('rgba(3, 3, 3)', DARK_COLOR_KEY, alpha),
'important',
);
document.documentElement.style.setProperty(
'--ytmusic-background',
this.getMixedColor('#030303', DARK_COLOR_KEY),
// #030303
this.getMixedColor('rgba(3, 3, 3)', DARK_COLOR_KEY, alpha),
'important',
);
},

View File

@ -35,6 +35,7 @@ export const backend = createBackend<BackendType, APIServerConfig>({
ctx.ipc.send('ytmd:setup-repeat-changed-listener');
ctx.ipc.send('ytmd:setup-like-changed-listener');
ctx.ipc.send('ytmd:setup-volume-changed-listener');
ctx.ipc.send('ytmd:setup-shuffle-changed-listener');
});
ctx.ipc.on(
@ -69,6 +70,7 @@ export const backend = createBackend<BackendType, APIServerConfig>({
// Custom
init(backendCtx) {
this.app = new Hono();
const ws = createNodeWebSocket({
app: this.app,
});
@ -120,7 +122,7 @@ export const backend = createBackend<BackendType, APIServerConfig>({
() => this.volumeState,
);
registerAuth(this.app, backendCtx);
registerWebsocket(this.app, ws);
registerWebsocket(this.app, backendCtx, ws);
// swagger
this.app.openAPIRegistry.registerComponent(

View File

@ -1,7 +1,7 @@
import { createRoute, z } from '@hono/zod-openapi';
import { ipcMain } from 'electron';
import getSongControls from '@/providers/song-controls';
import { getSongControls } from '@/providers/song-controls';
import {
LikeType,
type RepeatMode,

View File

@ -1,4 +1,3 @@
import { ipcMain } from 'electron';
import { createRoute } from '@hono/zod-openapi';
import { type NodeWebSocket } from '@hono/node-ws';
@ -15,6 +14,8 @@ import type { WSContext } from 'hono/ws';
import type { Context, Next } from 'hono';
import type { RepeatMode, VolumeState } from '@/types/datahost-get-state';
import type { HonoApp } from '../types';
import type { BackendContext } from '@/types/contexts';
import type { APIServerConfig } from '@/plugins/api-server/config';
enum DataTypes {
PlayerInfo = 'PLAYER_INFO',
@ -23,6 +24,7 @@ enum DataTypes {
PositionChanged = 'POSITION_CHANGED',
VolumeChanged = 'VOLUME_CHANGED',
RepeatChanged = 'REPEAT_CHANGED',
ShuffleChanged = 'SHUFFLE_CHANGED',
}
type PlayerState = {
@ -32,11 +34,17 @@ type PlayerState = {
position: number;
volume: number;
repeat: RepeatMode;
shuffle: boolean;
};
export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => {
export const register = (
app: HonoApp,
{ ipc }: BackendContext<APIServerConfig>,
{ upgradeWebSocket }: NodeWebSocket,
) => {
let volumeState: VolumeState | undefined = undefined;
let repeat: RepeatMode = 'NONE';
let shuffle = false;
let lastSongInfo: SongInfo | undefined = undefined;
const sockets = new Set<WSContext<WebSocket>>();
@ -51,10 +59,12 @@ export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => {
songInfo,
volumeState,
repeat,
shuffle,
}: {
songInfo?: SongInfo;
volumeState?: VolumeState;
repeat: RepeatMode;
shuffle: boolean;
}): PlayerState => ({
song: songInfo,
isPlaying: songInfo ? !songInfo.isPaused : false,
@ -62,6 +72,7 @@ export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => {
position: songInfo?.elapsedSeconds ?? 0,
volume: volumeState?.state ?? 100,
repeat,
shuffle,
});
registerCallback((songInfo, event) => {
@ -83,7 +94,7 @@ export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => {
lastSongInfo = { ...songInfo };
});
ipcMain.on('ytmd:volume-changed', (_, newVolumeState: VolumeState) => {
ipc.on('ytmd:volume-changed', (newVolumeState: VolumeState) => {
volumeState = newVolumeState;
send(DataTypes.VolumeChanged, {
volume: volumeState.state,
@ -91,15 +102,20 @@ export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => {
});
});
ipcMain.on('ytmd:repeat-changed', (_, mode: RepeatMode) => {
ipc.on('ytmd:repeat-changed', (mode: RepeatMode) => {
repeat = mode;
send(DataTypes.RepeatChanged, { repeat });
});
ipcMain.on('ytmd:seeked', (_, t: number) => {
ipc.on('ytmd:seeked', (t: number) => {
send(DataTypes.PositionChanged, { position: t });
});
ipc.on('ytmd:shuffle-changed', (newShuffle: boolean) => {
shuffle = newShuffle;
send(DataTypes.ShuffleChanged, { shuffle });
});
app.openapi(
createRoute({
method: 'get',
@ -112,7 +128,7 @@ export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => {
},
},
}),
nodeWebSocket.upgradeWebSocket(() => ({
upgradeWebSocket(() => ({
onOpen(_, ws) {
// "Unsafe argument of type `WSContext<WebSocket>` assigned to a parameter of type `WSContext<WebSocket>`. (@typescript-eslint/no-unsafe-argument)" ????? what?
sockets.add(ws as WSContext<WebSocket>);
@ -124,6 +140,7 @@ export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => {
songInfo: lastSongInfo,
volumeState,
repeat,
shuffle,
}),
}),
);

View File

@ -1,17 +1,15 @@
import net from 'net';
import * as net from 'node:net';
import { SocksClient, type SocksClientOptions } from 'socks';
import is from 'electron-is';
import { createBackend, LoggerPrefix } from '@/utils';
import { type BackendType } from './types';
import config from '@/config';
import * as config from '@/config';
import { type AuthProxyConfig, defaultAuthProxyConfig } from '../config';
import type { BackendType } from './types';
import type { BackendContext } from '@/types/contexts';
// Parsing the upstream authentication SOCK proxy URL

View File

@ -23,13 +23,3 @@ export enum TimerKey {
UpdateTimeout = 'updateTimeout', // Timer for throttled activity updates
DiscordConnectRetry = 'discordConnectRetry', // Timer for Discord connection retries
}
/**
* An enum for Discord's activity.status_display_type field, governing which field of the activity should be used after
* "Listening to..." in the user's Discord status.
*/
export const DiscordStatusDisplayType = {
YOUTUBE_MUSIC: 0,
ARTIST: 1,
TITLE: 2,
} as const;

View File

@ -99,9 +99,9 @@ export class DiscordService {
const activityInfo: SetActivity = {
type: ActivityType.Listening,
statusDisplayType: config.statusDisplayType,
details: truncateString(songInfo.title, 128), // Song title
details: truncateString(songInfo.alternativeTitle ?? songInfo.title, 128), // Song title
detailsUrl: songInfo.url ?? undefined,
state: truncateString(songInfo.artist, 128), // Artist name
state: truncateString(songInfo.tags?.at(0) ?? songInfo.artist, 128), // Artist name
stateUrl: songInfo.artistUrl,
largeImageKey: songInfo.imageSrc ?? undefined,
largeImageText: songInfo.album

View File

@ -1,8 +1,9 @@
import { StatusDisplayType } from 'discord-api-types/v10';
import { createPlugin } from '@/utils';
import { backend } from './main';
import { onMenu } from './menu';
import { t } from '@/i18n';
import { DiscordStatusDisplayType } from './constants';
export type DiscordPluginConfig = {
enabled: boolean;
@ -37,7 +38,7 @@ export type DiscordPluginConfig = {
/**
* Controls which field is displayed in the Discord status text
*/
statusDisplayType: (typeof DiscordStatusDisplayType)[keyof typeof DiscordStatusDisplayType];
statusDisplayType: (typeof StatusDisplayType)[keyof typeof StatusDisplayType];
};
export default createPlugin({
@ -52,7 +53,7 @@ export default createPlugin({
playOnYouTubeMusic: true,
hideGitHubButton: false,
hideDurationLeft: false,
statusDisplayType: DiscordStatusDisplayType.ARTIST,
statusDisplayType: StatusDisplayType.Details,
} as DiscordPluginConfig,
menu: onMenu,
backend,

View File

@ -1,30 +1,27 @@
import prompt from 'custom-electron-prompt';
import { discordService } from './main';
import { StatusDisplayType } from 'discord-api-types/v10';
import { discordService } from './main';
import { singleton } from '@/providers/decorators';
import promptOptions from '@/providers/prompt-options';
import { setMenuOptions } from '@/config/plugins';
import { t } from '@/i18n';
import { DiscordStatusDisplayType } from './constants';
import type { MenuContext } from '@/types/contexts';
import type { DiscordPluginConfig } from './index';
import type { MenuTemplate } from '@/menu';
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
discordService?.registerRefreshCallback(refreshMenu);
});
const DiscordStatusDisplayTypeLabels = {
[DiscordStatusDisplayType.YOUTUBE_MUSIC]:
const DiscordStatusDisplayTypeLabels: Record<StatusDisplayType, string> = {
[StatusDisplayType.Name]:
'plugins.discord.menu.set-status-display-type.submenu.youtube-music',
[DiscordStatusDisplayType.ARTIST]:
[StatusDisplayType.State]:
'plugins.discord.menu.set-status-display-type.submenu.artist',
[DiscordStatusDisplayType.TITLE]:
[StatusDisplayType.Details]:
'plugins.discord.menu.set-status-display-type.submenu.title',
};
@ -105,18 +102,24 @@ export const onMenu = async ({
},
{
label: t('plugins.discord.menu.set-status-display-type.label'),
submenu: Object.values(DiscordStatusDisplayType).map(
(statusDisplayType) => ({
label: t(DiscordStatusDisplayTypeLabels[statusDisplayType]),
submenu: Object.values(StatusDisplayType)
.filter(
(v) => typeof StatusDisplayType[v as StatusDisplayType] !== 'number',
)
.map((statusDisplayType) => ({
label: t(
DiscordStatusDisplayTypeLabels[
statusDisplayType as StatusDisplayType
],
),
type: 'radio',
checked: config.statusDisplayType == statusDisplayType,
checked: config.statusDisplayType === statusDisplayType,
click() {
setConfig({
statusDisplayType,
statusDisplayType: statusDisplayType as StatusDisplayType,
});
},
}),
),
})),
},
];
};

View File

@ -2,11 +2,14 @@ import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import defaultConfig from '@/config/defaults';
import { defaultConfig } from '@/config/defaults';
import { getSongMenu } from '@/providers/dom-elements';
import { getSongInfo } from '@/providers/song-info-front';
import { t } from '@/i18n';
import { isMusicOrVideoTrack } from '@/plugins/utils/renderer/check';
import {
isAlbumOrPlaylist,
isMusicOrVideoTrack,
} from '@/plugins/utils/renderer/check';
import { DownloadButton } from './templates/download';
@ -25,7 +28,7 @@ const menuObserver = new MutationObserver(() => {
if (
!menu ||
menu.contains(buttonContainer) ||
!isMusicOrVideoTrack() ||
!(isMusicOrVideoTrack() || isAlbumOrPlaylist()) ||
!buttonContainer
) {
return;

View File

@ -329,6 +329,7 @@ export const TitleBar = (props: TitleBarProps) => {
data-macos={props.isMacOS}
data-show={mouseY() < 32}
data-ytmd-main-panel={true}
id={'ytmd-title-bar-main-panel'}
>
<IconButton
onClick={() => setCollapsed(!collapsed())}

View File

@ -7,7 +7,7 @@ import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpac
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
import getSongControls from '@/providers/song-controls';
import { getSongControls } from '@/providers/song-controls';
import {
registerCallback,
type SongInfo,

View File

@ -13,11 +13,17 @@ export interface ScrobblerPluginConfig {
*/
scrobbleOtherMedia: boolean;
/**
* Use alternative titles for scrobbling (Useful for non-roman song titles)
* Use alternative titles for scrobbling (Useful for non-roman song titles, e.g. (Not) A Devil -> デビルじゃないもん)
*
* @default false
* @default true
*/
alternativeTitles: boolean;
/**
* Use alternative artist for scrobbling (e.g., DECO27 & (or) PinocchioP -> DECO27 / marasy -> まらしぃ)
*
* @default true
*/
alternativeArtist: boolean;
scrobblers: {
lastfm: {
/**
@ -77,7 +83,8 @@ export interface ScrobblerPluginConfig {
export const defaultConfig: ScrobblerPluginConfig = {
enabled: false,
scrobbleOtherMedia: true,
alternativeTitles: false,
alternativeTitles: true,
alternativeArtist: true,
scrobblers: {
lastfm: {
enabled: false,

View File

@ -105,6 +105,15 @@ export const onMenu = async ({
setConfig(config);
},
},
{
label: t('plugins.scrobbler.menu.scrobble-alternative-artist'),
type: 'checkbox',
checked: Boolean(config.alternativeArtist),
click(item) {
config.alternativeArtist = item.checked;
setConfig(config);
},
},
{
label: 'Last.fm',
submenu: [

View File

@ -132,10 +132,15 @@ export class LastFmScrobbler extends ScrobblerBase {
? songInfo.alternativeTitle
: songInfo.title;
const artist =
config.alternativeArtist && songInfo.tags?.at(0) !== undefined
? songInfo.tags?.at(0)
: songInfo.artist;
const postData: LastFmSongData = {
track: title,
duration: songInfo.songDuration,
artist: songInfo.artist,
artist: artist,
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
api_key: config.scrobblers.lastfm.apiKey,
sk: config.scrobblers.lastfm.sessionKey,

View File

@ -81,8 +81,13 @@ function createRequestBody(
? songInfo.alternativeTitle
: songInfo.title;
const artist =
config.alternativeArtist && songInfo.tags?.at(0) !== undefined
? songInfo.tags?.at(0)
: songInfo.artist;
const trackMetadata = {
artist_name: songInfo.artist,
artist_name: artist,
track_name: title,
release_name: songInfo.album ?? undefined,
additional_info: {

View File

@ -2,8 +2,8 @@ import { type BrowserWindow, globalShortcut } from 'electron';
import is from 'electron-is';
import { register as registerElectronLocalShortcut } from 'electron-localshortcut';
import registerMPRIS from './mpris';
import getSongControls from '@/providers/song-controls';
import { registerMPRIS } from './mpris';
import { getSongControls } from '@/providers/song-controls';
import type { ShortcutMappingType, ShortcutsPluginConfig } from './index';

View File

@ -19,8 +19,8 @@ import {
type SongInfo,
SongInfoEvent,
} from '@/providers/song-info';
import getSongControls from '@/providers/song-controls';
import config from '@/config';
import { getSongControls } from '@/providers/song-controls';
import * as config from '@/config';
import { LoggerPrefix } from '@/utils';
import type { RepeatMode, VolumeState } from '@/types/datahost-get-state';
@ -84,7 +84,7 @@ function setupMPRIS() {
return instance;
}
function registerMPRIS(win: BrowserWindow) {
export function registerMPRIS(win: BrowserWindow) {
const songControls = getSongControls(win);
const {
playPause,
@ -363,5 +363,3 @@ function registerMPRIS(win: BrowserWindow) {
console.trace(error);
}
}
export default registerMPRIS;

View File

@ -1,5 +1,7 @@
import { t } from '@/i18n';
import { providerNames } from './providers';
import type { MenuItemConstructorOptions } from 'electron';
import type { MenuContext } from '@/types/contexts';
import type { SyncedLyricsPluginConfig } from './types';
@ -10,6 +12,35 @@ export const menu = async (
const config = await ctx.getConfig();
return [
{
label: t('plugins.synced-lyrics.menu.preferred-provider.label'),
toolTip: t('plugins.synced-lyrics.menu.preferred-provider.tooltip'),
type: 'submenu',
submenu: [
{
label: t('plugins.synced-lyrics.menu.preferred-provider.none.label'),
toolTip: t(
'plugins.synced-lyrics.menu.preferred-provider.none.tooltip',
),
type: 'radio',
checked: config.preferredProvider === undefined,
click() {
ctx.setConfig({ preferredProvider: undefined });
},
},
...providerNames.map(
(provider) =>
({
label: provider,
type: 'radio',
checked: config.preferredProvider === provider,
click() {
ctx.setConfig({ preferredProvider: provider });
},
}) as const,
),
],
},
{
label: t('plugins.synced-lyrics.menu.precise-timing.label'),
toolTip: t('plugins.synced-lyrics.menu.precise-timing.tooltip'),
@ -93,47 +124,23 @@ export const menu = async (
toolTip: t('plugins.synced-lyrics.menu.default-text-string.tooltip'),
type: 'submenu',
submenu: [
{
label: '',
type: 'radio',
checked: config.defaultTextString === '♪',
click() {
ctx.setConfig({
defaultTextString: '♪',
});
},
{ label: '♪', value: '♪' },
{ label: '" "', value: ' ' },
{ label: '...', value: ['.', '..', '...'] },
{ label: '•••', value: ['•', '••', '•••'] },
{ label: '———', value: '———' },
].map(({ label, value }) => ({
label,
type: 'radio',
checked:
typeof value === 'string'
? config.defaultTextString === value
: JSON.stringify(config.defaultTextString) ===
JSON.stringify(value),
click() {
ctx.setConfig({ defaultTextString: value });
},
{
label: '" "',
type: 'radio',
checked: config.defaultTextString === ' ',
click() {
ctx.setConfig({
defaultTextString: ' ',
});
},
},
{
label: '...',
type: 'radio',
checked: config.defaultTextString === '...',
click() {
ctx.setConfig({
defaultTextString: '...',
});
},
},
{
label: '———',
type: 'radio',
checked: config.defaultTextString === '———',
click() {
ctx.setConfig({
defaultTextString: '———',
});
},
},
],
})),
},
{
label: t('plugins.synced-lyrics.menu.romanization.label'),

View File

@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import { LRC } from '../parsers/lrc';
import { netFetch } from '../renderer';

View File

@ -1,191 +1,21 @@
import { createStore } from 'solid-js/store';
import * as z from 'zod';
import { createMemo } from 'solid-js';
import type { LyricResult } from '../types';
import { LRCLib } from './LRCLib';
import { LyricsGenius } from './LyricsGenius';
import { MusixMatch } from './MusixMatch';
import { YTMusic } from './YTMusic';
export enum ProviderNames {
YTMusic = 'YTMusic',
LRCLib = 'LRCLib',
MusixMatch = 'MusixMatch',
LyricsGenius = 'LyricsGenius',
// Megalobiz = 'Megalobiz',
}
import { getSongInfo } from '@/providers/song-info-front';
import type { LyricProvider, LyricResult } from '../types';
import type { SongInfo } from '@/providers/song-info';
export const providers = {
YTMusic: new YTMusic(),
LRCLib: new LRCLib(),
MusixMatch: new MusixMatch(),
LyricsGenius: new LyricsGenius(),
// Megalobiz: new Megalobiz(), // Disabled because it is too unstable and slow
} as const;
export type ProviderName = keyof typeof providers;
export const providerNames = Object.keys(providers) as ProviderName[];
export const ProviderNameSchema = z.enum(ProviderNames);
export type ProviderName = z.infer<typeof ProviderNameSchema>;
export const providerNames = ProviderNameSchema.options;
export type ProviderState = {
state: 'fetching' | 'done' | 'error';
data: LyricResult | null;
error: Error | null;
};
type LyricsStore = {
provider: ProviderName;
current: ProviderState;
lyrics: Record<ProviderName, ProviderState>;
};
const initialData = () =>
providerNames.reduce(
(acc, name) => {
acc[name] = { state: 'fetching', data: null, error: null };
return acc;
},
{} as LyricsStore['lyrics'],
);
export const [lyricsStore, setLyricsStore] = createStore<LyricsStore>({
provider: providerNames[0],
lyrics: initialData(),
get current(): ProviderState {
return this.lyrics[this.provider];
},
});
export const currentLyrics = createMemo(() => {
const provider = lyricsStore.provider;
return lyricsStore.lyrics[provider];
});
type VideoId = string;
type SearchCacheData = Record<ProviderName, ProviderState>;
interface SearchCache {
state: 'loading' | 'done';
data: SearchCacheData;
}
// TODO: Maybe use localStorage for the cache.
const searchCache = new Map<VideoId, SearchCache>();
export const fetchLyrics = (info: SongInfo) => {
if (searchCache.has(info.videoId)) {
const cache = searchCache.get(info.videoId)!;
if (cache.state === 'loading') {
setTimeout(() => {
fetchLyrics(info);
});
return;
}
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', () => {
// weird bug with solid-js
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
});
}
return;
}
const cache: SearchCache = {
state: 'loading',
data: initialData(),
};
searchCache.set(info.videoId, cache);
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', () => {
// weird bug with solid-js
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
});
}
const tasks: Promise<void>[] = [];
// prettier-ignore
for (
const [providerName, provider] of Object.entries(providers) as [
ProviderName,
LyricProvider,
][]
) {
const pCache = cache.data[providerName];
tasks.push(
provider
.search(info)
.then((res) => {
pCache.state = 'done';
pCache.data = res;
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', (old) => {
return {
...old,
[providerName]: {
state: 'done',
data: res ? { ...res } : null,
error: null,
},
};
});
}
})
.catch((error: Error) => {
pCache.state = 'error';
pCache.error = error;
console.error(error);
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', (old) => {
return {
...old,
[providerName]: { state: 'error', error, data: null },
};
});
}
}),
);
}
Promise.allSettled(tasks).then(() => {
cache.state = 'done';
searchCache.set(info.videoId, cache);
});
};
export const retrySearch = (provider: ProviderName, info: SongInfo) => {
setLyricsStore('lyrics', (old) => {
const pCache = {
state: 'fetching',
data: null,
error: null,
};
return {
...old,
[provider]: pCache,
};
});
providers[provider]
.search(info)
.then((res) => {
setLyricsStore('lyrics', (old) => {
return {
...old,
[provider]: { state: 'done', data: res, error: null },
};
});
})
.catch((error) => {
setLyricsStore('lyrics', (old) => {
return {
...old,
[provider]: { state: 'error', data: null, error },
};
});
});
};

View File

@ -0,0 +1,13 @@
import { ProviderNames } from './index';
import { YTMusic } from './YTMusic';
import { LRCLib } from './LRCLib';
import { MusixMatch } from './MusixMatch';
import { LyricsGenius } from './LyricsGenius';
export const providers = {
[ProviderNames.YTMusic]: new YTMusic(),
[ProviderNames.LRCLib]: new LRCLib(),
[ProviderNames.MusixMatch]: new MusixMatch(),
[ProviderNames.LyricsGenius]: new LyricsGenius(),
// [ProviderNames.Megalobiz]: new Megalobiz(), // Disabled because it is too unstable and slow
} as const;

View File

@ -2,7 +2,7 @@ import { t } from '@/i18n';
import { getSongInfo } from '@/providers/song-info-front';
import { lyricsStore, retrySearch } from '../../providers';
import { lyricsStore, retrySearch } from '../store';
interface ErrorDisplayProps {
error: Error;

View File

@ -11,18 +11,24 @@ import {
Switch,
} from 'solid-js';
import * as z from 'zod';
import {
currentLyrics,
lyricsStore,
type ProviderName,
providerNames,
ProviderNameSchema,
type ProviderState,
setLyricsStore,
} from '../../providers';
import { currentLyrics, lyricsStore, setLyricsStore } from '../store';
import { _ytAPI } from '../index';
import { config } from '../renderer';
import type { YtIcons } from '@/types/icons';
import type { PlayerAPIEvents } from '@/types/player-api-events';
const LocalStorageSchema = z.object({
provider: ProviderNameSchema,
});
export const providerIdx = createMemo(() =>
providerNames.indexOf(lyricsStore.provider),
@ -45,11 +51,19 @@ const providerBias = (p: ProviderName) =>
(lyricsStore.lyrics[p].data?.lyrics ? 1 : -1);
const pickBestProvider = () => {
const preferred = config()?.preferredProvider;
if (preferred) {
const data = lyricsStore.lyrics[preferred].data;
if (Array.isArray(data?.lines) || data?.lyrics) {
return { provider: preferred, force: true };
}
}
const providers = Array.from(providerNames);
providers.sort((a, b) => providerBias(b) - providerBias(a));
return providers[0];
return { provider: providers[0], force: false };
};
const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] =
@ -58,34 +72,91 @@ const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] =
export const LyricsPicker = (props: {
setStickRef: Setter<HTMLElement | null>;
}) => {
const [videoId, setVideoId] = createSignal<string | null>(null);
const [starredProvider, setStarredProvider] =
createSignal<ProviderName | null>(null);
createEffect(() => {
const id = videoId();
if (id === null) {
setStarredProvider(null);
return;
}
const key = `ytmd-sl-starred-${id}`;
const value = localStorage.getItem(key);
if (!value) {
setStarredProvider(null);
return;
}
const parseResult = LocalStorageSchema.safeParse(JSON.parse(value));
if (parseResult.success) {
setLyricsStore('provider', parseResult.data.provider);
setStarredProvider(parseResult.data.provider);
} else {
setStarredProvider(null);
}
});
const toggleStar = () => {
const id = videoId();
if (id === null) return;
const key = `ytmd-sl-starred-${id}`;
setStarredProvider((starredProvider) => {
if (lyricsStore.provider === starredProvider) {
localStorage.removeItem(key);
return null;
}
const provider = lyricsStore.provider;
localStorage.setItem(key, JSON.stringify({ provider }));
return provider;
});
};
const videoDataChangeHandler = (
name: string,
{ videoId }: PlayerAPIEvents['videodatachange']['value'],
) => {
setVideoId(videoId);
if (name !== 'dataloaded') return;
setHasManuallySwitchedProvider(false);
};
// prettier-ignore
{
onMount(() => _ytAPI?.addEventListener('videodatachange', videoDataChangeHandler));
onCleanup(() => _ytAPI?.removeEventListener('videodatachange', videoDataChangeHandler));
}
createEffect(() => {
// fallback to the next source, if the current one has an error
if (!hasManuallySwitchedProvider()) {
const bestProvider = pickBestProvider();
const starred = starredProvider();
if (starred !== null) {
setLyricsStore('provider', starred);
return;
}
const allProvidersFailed = providerNames.every((p) =>
shouldSwitchProvider(lyricsStore.lyrics[p]),
);
if (allProvidersFailed) return;
if (providerBias(lyricsStore.provider) < providerBias(bestProvider)) {
setLyricsStore('provider', bestProvider);
const { provider, force } = pickBestProvider();
if (
force ||
providerBias(lyricsStore.provider) < providerBias(provider)
) {
setLyricsStore('provider', provider);
}
}
});
onMount(() => {
const videoDataChangeHandler = (name: string) => {
if (name !== 'dataloaded') return;
setHasManuallySwitchedProvider(false);
};
_ytAPI?.addEventListener('videodatachange', videoDataChangeHandler);
onCleanup(() =>
_ytAPI?.removeEventListener('videodatachange', videoDataChangeHandler),
);
});
const next = () => {
setHasManuallySwitchedProvider(true);
setLyricsStore('provider', (prevProvider) => {
@ -176,9 +247,9 @@ export const LyricsPicker = (props: {
/>
</Match>
<Match when={currentLyrics().state === 'error'}>
<yt-icon-button
<yt-icon
icon={errorIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }}
style={{ padding: '5px', transform: 'scale(0.8)' }}
tabindex="-1"
/>
</Match>
@ -189,9 +260,9 @@ export const LyricsPicker = (props: {
currentLyrics().data?.lyrics)
}
>
<yt-icon-button
<yt-icon
icon={successIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }}
style={{ padding: '5px', transform: 'scale(0.8)' }}
tabindex="-1"
/>
</Match>
@ -202,9 +273,9 @@ export const LyricsPicker = (props: {
!currentLyrics().data?.lyrics
}
>
<yt-icon-button
<yt-icon
icon={notFoundIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }}
style={{ padding: '5px', transform: 'scale(0.8)' }}
tabindex="-1"
/>
</Match>
@ -213,6 +284,20 @@ export const LyricsPicker = (props: {
class="description ytmusic-description-shelf-renderer"
text={{ runs: [{ text: provider() }] }}
/>
<yt-icon
icon={
starredProvider() === provider()
? 'yt-sys-icons:star-filled'
: 'yt-sys-icons:star'
}
onClick={toggleStar}
style={{
padding: '5px',
transform: 'scale(0.8)',
cursor: 'pointer',
}}
tabindex="-1"
/>
</div>
)}
</Index>

View File

@ -1,10 +1,10 @@
import { createEffect, createMemo, For, Show, createSignal } from 'solid-js';
import { createEffect, For, Show, createSignal, createMemo } from 'solid-js';
import { type VirtualizerHandle } from 'virtua/solid';
import { type LineLyrics } from '@/plugins/synced-lyrics/types';
import { config } from '../renderer';
import { config, currentTime } from '../renderer';
import { _ytAPI } from '..';
import { canonicalize, romanize, simplifyUnicode } from '../utils';
@ -17,37 +17,84 @@ interface SyncedLineProps {
status: 'upcoming' | 'current' | 'previous';
}
export const SyncedLine = (props: SyncedLineProps) => {
const text = createMemo(() => {
if (!props.line.text.trim()) {
return config()?.defaultTextString ?? '';
}
return props.line.text;
const EmptyLine = (props: SyncedLineProps) => {
const states = createMemo(() => {
const defaultText = config()?.defaultTextString ?? '';
return Array.isArray(defaultText) ? defaultText : [defaultText];
});
const [romanization, setRomanization] = createSignal('');
const index = createMemo(() => {
const progress = currentTime() - props.line.timeInMs;
const total = props.line.duration;
const percentage = Math.min(1, progress / total);
return Math.max(0, Math.floor((states().length - 1) * percentage));
});
return (
<div
class={`synced-line ${props.status}`}
onClick={() => {
_ytAPI?.seekTo((props.line.timeInMs + 10) / 1000);
}}
>
<div class="description ytmusic-description-shelf-renderer" dir="auto">
<yt-formatted-string
text={{
runs: [
{
text: config()?.showTimeCodes ? `[${props.line.time}] ` : '',
},
],
}}
/>
<div class="text-lyrics">
<span>
<span>
<Show
fallback={
<yt-formatted-string
text={{ runs: [{ text: states()[0] }] }}
/>
}
when={states().length > 1}
>
<yt-formatted-string
text={{
runs: [
{
text: states().at(
props.status === 'current' ? index() : -1,
)!,
},
],
}}
/>
</Show>
</span>
</span>
</div>
</div>
</div>
);
};
export const SyncedLine = (props: SyncedLineProps) => {
const text = createMemo(() => props.line.text.trim());
const [romanization, setRomanization] = createSignal('');
createEffect(() => {
const input = canonicalize(text());
if (!config()?.romanization) return;
const input = canonicalize(text());
romanize(input).then((result) => {
setRomanization(canonicalize(result));
});
});
return (
<Show
fallback={
<yt-formatted-string
text={{
runs: [{ text: '' }],
}}
/>
}
when={text()}
>
<Show fallback={<EmptyLine {...props} />} when={text()}>
<div
class={`synced-line ${props.status}`}
onClick={() => {

View File

@ -3,8 +3,7 @@ import { waitForElement } from '@/utils/wait-for-element';
import { selectors, tabStates } from './utils';
import { setConfig, setCurrentTime } from './renderer';
import { fetchLyrics } from '../providers';
import { fetchLyrics } from './store';
import type { RendererContext } from '@/types/contexts';
import type { YoutubePlayer } from '@/types/youtube-player';

View File

@ -20,7 +20,7 @@ import {
PlainLyrics,
} from './components';
import { currentLyrics } from '../providers';
import { currentLyrics } from './store';
import type { LineLyrics, SyncedLyricsPluginConfig } from '../types';

View File

@ -0,0 +1,175 @@
import { createStore } from 'solid-js/store';
import { createMemo } from 'solid-js';
import { getSongInfo } from '@/providers/song-info-front';
import {
type ProviderName,
providerNames,
type ProviderState,
} from '../providers';
import { providers } from '../providers/renderer';
import type { LyricProvider } from '../types';
import type { SongInfo } from '@/providers/song-info';
type LyricsStore = {
provider: ProviderName;
current: ProviderState;
lyrics: Record<ProviderName, ProviderState>;
};
const initialData = () =>
providerNames.reduce(
(acc, name) => {
acc[name] = { state: 'fetching', data: null, error: null };
return acc;
},
{} as LyricsStore['lyrics'],
);
export const [lyricsStore, setLyricsStore] = createStore<LyricsStore>({
provider: providerNames[0],
lyrics: initialData(),
get current(): ProviderState {
return this.lyrics[this.provider];
},
});
export const currentLyrics = createMemo(() => {
const provider = lyricsStore.provider;
return lyricsStore.lyrics[provider];
});
type VideoId = string;
type SearchCacheData = Record<ProviderName, ProviderState>;
interface SearchCache {
state: 'loading' | 'done';
data: SearchCacheData;
}
// TODO: Maybe use localStorage for the cache.
const searchCache = new Map<VideoId, SearchCache>();
export const fetchLyrics = (info: SongInfo) => {
if (searchCache.has(info.videoId)) {
const cache = searchCache.get(info.videoId)!;
if (cache.state === 'loading') {
setTimeout(() => {
fetchLyrics(info);
});
return;
}
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', () => {
// weird bug with solid-js
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
});
}
return;
}
const cache: SearchCache = {
state: 'loading',
data: initialData(),
};
searchCache.set(info.videoId, cache);
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', () => {
// weird bug with solid-js
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
});
}
const tasks: Promise<void>[] = [];
// prettier-ignore
for (
const [providerName, provider] of Object.entries(providers) as [
ProviderName,
LyricProvider,
][]
) {
const pCache = cache.data[providerName];
tasks.push(
provider
.search(info)
.then((res) => {
pCache.state = 'done';
pCache.data = res;
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', (old) => {
return {
...old,
[providerName]: {
state: 'done',
data: res ? { ...res } : null,
error: null,
},
};
});
}
})
.catch((error: Error) => {
pCache.state = 'error';
pCache.error = error;
console.error(error);
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', (old) => {
return {
...old,
[providerName]: { state: 'error', error, data: null },
};
});
}
}),
);
}
Promise.allSettled(tasks).then(() => {
cache.state = 'done';
searchCache.set(info.videoId, cache);
});
};
export const retrySearch = (provider: ProviderName, info: SongInfo) => {
setLyricsStore('lyrics', (old) => {
const pCache = {
state: 'fetching',
data: null,
error: null,
};
return {
...old,
[provider]: pCache,
};
});
providers[provider]
.search(info)
.then((res) => {
setLyricsStore('lyrics', (old) => {
return {
...old,
[provider]: { state: 'done', data: res, error: null },
};
});
})
.catch((error) => {
setLyricsStore('lyrics', (old) => {
return {
...old,
[provider]: { state: 'error', data: null, error },
};
});
});
};

View File

@ -1,10 +1,12 @@
import type { SongInfo } from '@/providers/song-info';
import type { ProviderName } from './providers';
export type SyncedLyricsPluginConfig = {
enabled: boolean;
preferredProvider?: ProviderName;
preciseTiming: boolean;
showTimeCodes: boolean;
defaultTextString: string;
defaultTextString: string | string[];
showLyricsEvenIfInexact: boolean;
lineEffect: LineEffect;
romanization: boolean;

View File

@ -7,7 +7,7 @@ import nextIcon from '@assets/media-icons-black/next.png?asset&asarUnpack';
import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpack';
import { createPlugin } from '@/utils';
import getSongControls from '@/providers/song-controls';
import { getSongControls } from '@/providers/song-controls';
import {
registerCallback,
type SongInfo,

View File

@ -1,7 +1,7 @@
import { nativeImage, type NativeImage, TouchBar } from 'electron';
import { createPlugin } from '@/utils';
import getSongControls from '@/providers/song-controls';
import { getSongControls } from '@/providers/song-controls';
import { registerCallback, SongInfoEvent } from '@/providers/song-info';
import { t } from '@/i18n';

View File

@ -0,0 +1,112 @@
import { t } from '@/i18n';
import { createPlugin } from '@/utils';
import { Platform } from '@/types/plugins';
import { MaterialType, type TransparentPlayerConfig } from './types';
import style from './style.css?inline';
import type { BrowserWindow } from 'electron';
const defaultConfig: TransparentPlayerConfig = {
enabled: false,
opacity: 0.5,
type: MaterialType.ACRYLIC,
};
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
const typeList = Object.values(MaterialType);
export default createPlugin({
name: () => t('plugins.transparent-player.name'),
description: () => t('plugins.transparent-player.description'),
addedVersion: '3.11.x',
restartNeeded: true,
platform: Platform.Windows,
config: defaultConfig,
stylesheets: [style],
async menu({ getConfig, setConfig }) {
const config = await getConfig();
return [
{
label: t('plugins.transparent-player.menu.opacity.label'),
submenu: opacityList.map((opacity) => ({
label: t('plugins.transparent-player.menu.opacity.submenu.percent', {
opacity: opacity * 100,
}),
type: 'radio',
checked: config.opacity === opacity,
click() {
setConfig({ opacity });
},
})),
},
{
label: t('plugins.transparent-player.menu.type.label'),
submenu: typeList.map((type) => ({
label: t(`plugins.transparent-player.menu.type.submenu.${type}`),
type: 'radio',
checked: config.type === type,
click() {
setConfig({ type });
},
})),
},
];
},
backend: {
mainWindow: null as BrowserWindow | null,
async start({ window, getConfig }) {
this.mainWindow = window;
const config = await getConfig();
window.setBackgroundMaterial?.(config.type);
window.setBackgroundColor?.(`rgba(0, 0, 0, ${config.opacity})`);
},
onConfigChange(newConfig) {
this.mainWindow?.setBackgroundMaterial?.(newConfig.type);
},
stop({ window }) {
window.setBackgroundMaterial?.('none');
},
},
renderer: {
props: {
enabled: defaultConfig.enabled,
opacity: defaultConfig.opacity,
type: defaultConfig.type,
} as TransparentPlayerConfig,
async start({ getConfig }) {
const config = await getConfig();
this.props = config;
if (config.enabled) {
document.body.classList.add('transparent-background-color');
document.body.classList.add('transparent-player-backdrop-filter');
if (!(await window.mainConfig.plugins.isEnabled('album-color-theme'))) {
document.body.classList.add('transparent-player');
}
this.applyVariables();
}
},
onConfigChange(newConfig) {
this.props = newConfig;
this.applyVariables();
},
stop() {
document.body.classList.remove('transparent-background-color');
document.body.classList.remove('transparent-player-backdrop-filter');
document.body.classList.remove('transparent-player');
document.documentElement.style.removeProperty(
'--ytmd-transparent-player-opacity',
);
},
applyVariables(this: { props: TransparentPlayerConfig }) {
const { opacity } = this.props;
document.documentElement.style.setProperty(
'--ytmd-transparent-player-opacity',
opacity.toString(),
);
},
},
});

View File

@ -0,0 +1,106 @@
:root {
--ytmd-transparent-player-transparency-color: #111;
--ytmd-transparent-player-transparent-background: rgb(
from var(--ytmd-transparent-player-transparency-color) r g b /
var(--ytmd-transparent-player-opacity, 0.5)
);
--ytmd-transparent-player-transparent-background-dark: rgb(
from var(--ytmd-transparent-player-transparency-color) r g b / 0.8
);
--ytmd-transparent-player-backdrop-blur: blur(20px);
}
body.transparent-background-color {
background-color: var(--ytmd-transparent-player-transparent-background) !important;
}
body.transparent-player-backdrop-filter {
#layout {
#nav-bar-background,
#player-bar-background {
backdrop-filter: var(--ytmd-transparent-player-backdrop-blur) !important;
}
}
#search-page {
#tabs {
&.stuck {
backdrop-filter: var(--ytmd-transparent-player-backdrop-blur) !important;
}
}
}
ytmusic-menu-popup-renderer {
backdrop-filter: var(--ytmd-transparent-player-backdrop-blur) !important;
}
#ytmd-title-bar-main-panel {
backdrop-filter: var(--ytmd-transparent-player-backdrop-blur) !important;
}
}
body.transparent-player {
ytmusic-app {
ytmusic-app-layout[player-page-open] {
#nav-bar-background.ytmusic-app-layout,
#player-bar-background.ytmusic-app-layout {
opacity: 0 !important;
}
}
#layout {
#nav-bar-background,
#player-bar-background {
background: var(--ytmd-transparent-player-transparent-background-dark) !important;
}
#mini-guide-background {
background: none !important;
border: 0 !important;
}
#guide {
#guide-wrapper {
background: none !important;
border: 0 !important;
}
}
ytmusic-player-bar {
background: none !important;
}
#player-page {
background: none !important;
}
#search-page {
#tabs {
&.stuck {
background: var(--ytmd-transparent-player-transparent-background) !important;
}
}
}
#browse-page {
#background {
display: none !important;
}
.background-gradient {
background: none !important;
}
}
}
}
/* Window Top Panel */
nav[data-ytmd-main-panel] {
background-color: transparent !important;
}
/* Video Toggle Plugin */
.av-toggle.ytmusic-av-toggle {
background-color: var(--ytmd-transparent-player-transparent-background);
}
}

View File

@ -0,0 +1,12 @@
export enum MaterialType {
MICA = 'mica',
ACRYLIC = 'acrylic',
TABBED = 'tabbed',
NONE = 'none',
}
export type TransparentPlayerConfig = {
enabled: boolean;
opacity: number;
type: MaterialType;
};

View File

@ -22,6 +22,24 @@ export const isMusicOrVideoTrack = () => {
return false;
};
export const isAlbumOrPlaylist = () => {
for (const menuSelector of document.querySelectorAll<
HTMLAnchorElement & {
data: {
addToPlaylistEndpoint: {
playlistId: string;
};
clickTrackingParams: string;
};
}
>('tp-yt-paper-listbox #navigation-endpoint')) {
if (menuSelector?.data?.addToPlaylistEndpoint?.playlistId) {
return true;
}
}
return false;
};
export const isPlayerMenu = (menu?: HTMLElement | null) => {
return (
menu?.parentElement as

View File

@ -6,7 +6,7 @@ import {
} from 'electron';
import is from 'electron-is';
import config from './config';
import * as config from './config';
import {
forceLoadPreloadPlugin,

View File

@ -2,7 +2,7 @@ import path from 'node:path';
import { app, BrowserWindow, ipcMain } from 'electron';
import config from '@/config';
import * as config from '@/config';
export const restart = () => restartInternal();

View File

@ -2,5 +2,3 @@ export const getSongMenu = () =>
document.querySelector<HTMLElement>(
'ytmusic-menu-popup-renderer tp-yt-paper-listbox',
);
export default { getSongMenu };

View File

@ -2,7 +2,7 @@ import path from 'node:path';
import { app, type BrowserWindow } from 'electron';
import getSongControls from './song-controls';
import { getSongControls } from './song-controls';
export const APP_PROTOCOL = 'youtubemusic';
@ -36,10 +36,3 @@ export function changeProtocolHandler(
) {
protocolHandler = f;
}
export default {
APP_PROTOCOL,
setupProtocolHandler,
handleProtocol,
changeProtocolHandler,
};

View File

@ -36,7 +36,7 @@ const parseStringFromArgsType = (args: ArgsType<string>) => {
}
};
export default (win: BrowserWindow) => {
export const getSongControls = (win: BrowserWindow) => {
return {
// Playback
previous: () => win.webContents.send('ytmd:previous-video'),

View File

@ -12,6 +12,8 @@ import type {
import type { SongInfo } from './song-info';
import type { VideoDataChanged } from '@/types/video-data-changed';
const DATAUPDATED_FALLBACK_TIMEOUT_MS = 1500;
let songInfo: SongInfo = {} as SongInfo;
export const getSongInfo = () => songInfo;
@ -145,6 +147,7 @@ export const setupShuffleChangedListener = singleton(() => {
observer.observe(playerBar, {
attributes: true,
attributeFilter: ['shuffle-on'],
childList: false,
subtree: false,
});
@ -168,6 +171,7 @@ export const setupFullScreenChangedListener = singleton(() => {
observer.observe(playerBar, {
attributes: true,
attributeFilter: ['player-fullscreened'],
childList: false,
subtree: false,
});
@ -189,7 +193,7 @@ export const setupAutoPlayChangedListener = singleton(() => {
});
});
export default (api: YoutubePlayer) => {
export const setupSongInfo = (api: YoutubePlayer) => {
window.ipcRenderer.on('ytmd:setup-time-changed-listener', () => {
setupTimeChangedListener();
});
@ -251,12 +255,25 @@ export default (api: YoutubePlayer) => {
);
const waitingEvent = new Set<string>();
const waitingTimeouts = new Map<string, NodeJS.Timeout>();
const clearVideoTimeout = (videoId: string) => {
const timeoutId = waitingTimeouts.get(videoId);
if (timeoutId) {
clearTimeout(timeoutId);
waitingTimeouts.delete(videoId);
}
};
// Name = "dataloaded" and abit later "dataupdated"
// Sometimes "dataupdated" is not fired, so we need to fallback to "dataloaded"
api.addEventListener('videodatachange', (name, videoData) => {
videoEventDispatcher(name, videoData);
if (name === 'dataupdated' && waitingEvent.has(videoData.videoId)) {
waitingEvent.delete(videoData.videoId);
clearVideoTimeout(videoData.videoId);
sendSongInfo(videoData);
} else if (name === 'dataloaded') {
const video = document.querySelector<HTMLVideoElement>('video');
@ -267,7 +284,18 @@ export default (api: YoutubePlayer) => {
video?.addEventListener(status, playPausedHandlers[status]);
}
clearVideoTimeout(videoData.videoId);
waitingEvent.add(videoData.videoId);
const timeoutId = setTimeout(() => {
if (waitingEvent.has(videoData.videoId)) {
waitingEvent.delete(videoData.videoId);
waitingTimeouts.delete(videoData.videoId);
sendSongInfo(videoData);
}
}, DATAUPDATED_FALLBACK_TIMEOUT_MS);
waitingTimeouts.set(videoData.videoId, timeoutId);
}
});

View File

@ -2,7 +2,7 @@ import { type BrowserWindow, ipcMain, nativeImage, net } from 'electron';
import { Mutex } from 'async-mutex';
import config from '@/config';
import * as config from '@/config';
import type { GetPlayerResponse } from '@/types/get-player-response';

View File

@ -1,7 +1,7 @@
import i18next from 'i18next';
import { startingPages } from './providers/extracted-data';
import setupSongInfo from './providers/song-info-front';
import { setupSongInfo } from './providers/song-info-front';
import {
createContext,
forceLoadRendererPlugin,

2
src/reset.d.ts vendored
View File

@ -3,7 +3,7 @@ import '@total-typescript/ts-reset';
import type { ipcRenderer as electronIpcRenderer } from 'electron';
import type is from 'electron-is';
import type config from './config';
import type * as config from './config';
import type { VideoDataChanged } from '@/types/video-data-changed';
import type { t } from '@/i18n';
import type { trustedTypes } from 'trusted-types';

View File

@ -4,11 +4,11 @@ import is from 'electron-is';
import defaultTrayIconAsset from '@assets/youtube-music-tray.png?asset&asarUnpack';
import pausedTrayIconAsset from '@assets/youtube-music-tray-paused.png?asset&asarUnpack';
import config from './config';
import * as config from './config';
import { restart } from './providers/app-controls';
import { registerCallback, SongInfoEvent } from './providers/song-info';
import getSongControls from './providers/song-controls';
import { getSongControls } from './providers/song-controls';
import { t } from '@/i18n';

View File

@ -1,3 +1 @@
export const isTesting = () => process.env.NODE_ENV === 'test';
export default { isTesting };

View File

@ -48,8 +48,8 @@ declare module 'solid-js' {
'tp-yt-paper-icon-button': ComponentProps<'div'> &
TpYtPaperIconButtonProps;
'yt-icon-button': ComponentProps<'div'> & TpYtPaperIconButtonProps;
'tp-yt-iron-icon': ComponentProps<'div'>;
'yt-icon': ComponentProps<'div'>;
'tp-yt-iron-icon': ComponentProps<'div'> & TpYtPaperIconButtonProps;
'yt-icon': ComponentProps<'div'> & TpYtPaperIconButtonProps;
// input type="range" slider component
'tp-yt-paper-slider': ComponentProps<'input'> & {
'value'?: number | string;

View File

@ -3,7 +3,8 @@ import { fileURLToPath } from 'node:url';
import { globSync } from 'glob';
import { Project } from 'ts-morph';
import { Platform } from '../src/types/plugins'
import { Platform } from '../src/types/plugins';
const kebabToCamel = (text: string) =>
text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase());
@ -75,7 +76,7 @@ export const pluginVirtualModuleGenerator = (
}
writer.blankLine();
if (mode === "main" || mode === "preload") {
if (mode === 'main' || mode === 'preload') {
writer.writeLine("import * as is from 'electron-is';");
writer.writeLine('globalThis.electronIs = is;');
}
@ -137,7 +138,7 @@ export const pluginVirtualModuleGenerator = (
};
function supportsPlatform({ platform }: { platform: string }) {
if (typeof platform !== "number") return true;
if (typeof platform !== 'number') return true;
const is = globalThis.electronIs;