diff --git a/assets/tray-icons/default/pause.png b/assets/tray-icons/default/pause.png new file mode 100644 index 0000000000..4f450034ac Binary files /dev/null and b/assets/tray-icons/default/pause.png differ diff --git a/assets/tray-icons/default/play.png b/assets/tray-icons/default/play.png new file mode 100644 index 0000000000..63b51bff17 Binary files /dev/null and b/assets/tray-icons/default/play.png differ diff --git a/assets/tray-icons/fluent/pause.png b/assets/tray-icons/fluent/pause.png new file mode 100644 index 0000000000..26d75817d1 Binary files /dev/null and b/assets/tray-icons/fluent/pause.png differ diff --git a/assets/tray-icons/fluent/play.png b/assets/tray-icons/fluent/play.png new file mode 100644 index 0000000000..b9c6be7989 Binary files /dev/null and b/assets/tray-icons/fluent/play.png differ diff --git a/assets/tray-icons/material/pause.png b/assets/tray-icons/material/pause.png new file mode 100644 index 0000000000..94cb921886 Binary files /dev/null and b/assets/tray-icons/material/pause.png differ diff --git a/assets/tray-icons/material/play.png b/assets/tray-icons/material/play.png new file mode 100644 index 0000000000..a25481a886 Binary files /dev/null and b/assets/tray-icons/material/play.png differ diff --git a/assets/youtube-music-tray-paused.png b/assets/youtube-music-tray-paused.png deleted file mode 100644 index 071192713b..0000000000 Binary files a/assets/youtube-music-tray-paused.png and /dev/null differ diff --git a/assets/youtube-music-tray.png b/assets/youtube-music-tray.png deleted file mode 100644 index bd53e7dc72..0000000000 Binary files a/assets/youtube-music-tray.png and /dev/null differ diff --git a/src/config/defaults.ts b/src/config/defaults.ts index dcd56128a7..318e8a03ad 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -8,6 +8,12 @@ export interface WindowPositionConfig { y: number; } +export enum TrayIconTheme { + Default = 'default', + Fluent = 'fluent', + Material = 'material', +} + export interface DefaultConfig { 'window-size': WindowSizeConfig; 'window-maximized': boolean; @@ -16,6 +22,7 @@ export interface DefaultConfig { options: { language?: string; tray: boolean; + trayIconTheme: TrayIconTheme; appVisible: boolean; autoUpdates: boolean; alwaysOnTop: boolean; @@ -51,6 +58,7 @@ const defaultConfig: DefaultConfig = { 'url': 'https://music.youtube.com', 'options': { tray: false, + trayIconTheme: TrayIconTheme.Default, appVisible: true, autoUpdates: true, alwaysOnTop: false, diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 29ca0629dd..dd6919015c 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -145,6 +145,14 @@ "enabled-and-hide-app": "Enabled and hide app", "enabled-and-show-app": "Enabled and show app", "play-pause-on-click": "Play/Pause on click" + }, + "theme": { + "label": "Tray Icon Theme", + "submenu": { + "default": "Default", + "fluent": "Fluent", + "material": "Material" + } } }, "visual-tweaks": { diff --git a/src/index.ts b/src/index.ts index 753a7d4da8..9bf7560f3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -728,7 +728,7 @@ app.whenReady().then(async () => { mainWindow = await createMainWindow(); await setApplicationMenu(mainWindow); await refreshMenu(mainWindow); - setUpTray(app, mainWindow); + setUpTray({ app, win: mainWindow }); setupProtocolHandler(mainWindow); diff --git a/src/menu.ts b/src/menu.ts index 0f560e71cf..b97de5b320 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -24,6 +24,10 @@ import promptOptions from './providers/prompt-options'; import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu'; import { setLanguage, t } from '@/i18n'; +import { setUpTray } from '@/tray'; + +import { TrayIconTheme } from '@/config/defaults'; + import packageJson from '../package.json'; export type MenuTemplate = Electron.MenuItemConstructorOptions[]; @@ -413,6 +417,53 @@ export const mainMenuTemplate = async ( }, }, { type: 'separator' }, + { + label: t('main.menu.options.submenu.tray.theme.label'), + submenu: [ + { + label: 'Default', + type: 'radio', + checked: + config.get('options.trayIconTheme') === + TrayIconTheme.Default, + click() { + config.setMenuOption( + 'options.trayIconTheme', + TrayIconTheme.Default, + ); + setUpTray({ app, win }); + }, + }, + { + label: 'Fluent', + type: 'radio', + checked: + config.get('options.trayIconTheme') === + TrayIconTheme.Fluent, + click() { + config.setMenuOption( + 'options.trayIconTheme', + TrayIconTheme.Fluent, + ); + setUpTray({ app, win }); + }, + }, + { + label: 'Material', + type: 'radio', + checked: + config.get('options.trayIconTheme') === + TrayIconTheme.Material, + click() { + config.setMenuOption( + 'options.trayIconTheme', + TrayIconTheme.Material, + ); + setUpTray({ app, win }); + }, + }, + ], + }, { label: t( 'main.menu.options.submenu.tray.submenu.play-pause-on-click', diff --git a/src/providers/prompt-options.ts b/src/providers/prompt-options.ts index 43d592aa0e..4d74dc40eb 100644 --- a/src/providers/prompt-options.ts +++ b/src/providers/prompt-options.ts @@ -1,8 +1,8 @@ -import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack'; +import trayIconPlay from '@assets/tray-icons/default/play.png?asset&asarUnpack'; const promptOptions = { customStylesheet: 'dark', - icon: youtubeMusicTrayIcon, + icon: trayIconPlay, }; export default () => promptOptions; diff --git a/src/tray.ts b/src/tray.ts index 6059d0e07a..08c74b562b 100644 --- a/src/tray.ts +++ b/src/tray.ts @@ -1,147 +1,260 @@ import { Menu, nativeImage, screen, Tray } from 'electron'; 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 defaultPlayIcon from '@assets/tray-icons/default/play.png?asset&asarUnpack'; +import defaultPauseIcon from '@assets/tray-icons/default/pause.png?asset&asarUnpack'; +import fluentPlayIcon from '@assets/tray-icons/fluent/play.png?asset&asarUnpack'; +import fluentPauseIcon from '@assets/tray-icons/fluent/pause.png?asset&asarUnpack'; +import materialPlayIcon from '@assets/tray-icons/material/play.png?asset&asarUnpack'; +import materialPauseIcon from '@assets/tray-icons/material/pause.png?asset&asarUnpack'; import config from './config'; - import { restart } from './providers/app-controls'; -import registerCallback, { SongInfoEvent } from './providers/song-info'; +import registerCallback, { + SongInfo, + SongInfoEvent, +} from './providers/song-info'; import getSongControls from './providers/song-controls'; - import { t } from '@/i18n'; +import { TrayIconTheme } from '@/config/defaults'; + +import type { + App, + BrowserWindow, + NativeImage, + KeyboardEvent, + Rectangle, +} from 'electron'; import type { MenuTemplate } from './menu'; -// Prevent tray being garbage collected -let tray: Electron.Tray | undefined; +/** + * This ensures that the tray instance is not garbage-collected. + */ +let tray: Tray | undefined; +const ICON_SIZE = 16; -type TrayEvent = ( - event: Electron.KeyboardEvent, - bounds: Electron.Rectangle, -) => void; +interface AppContext { + app: App; + win: BrowserWindow; +} -export const setTrayOnClick = (fn: TrayEvent) => { - if (!tray) { - return; +interface IconSet { + play: NativeImage; + pause: NativeImage; +} + +interface SongControls { + playPause: () => void; + next: () => void; + previous: () => void; +} + +type TrayEvent = (event: KeyboardEvent, bounds: Rectangle) => void; + +const getIcons = (theme: TrayIconTheme) => { + switch (theme) { + case TrayIconTheme.Fluent: + return { + play: fluentPlayIcon, + pause: fluentPauseIcon, + }; + case TrayIconTheme.Material: + return { + play: materialPlayIcon, + pause: materialPauseIcon, + }; + case TrayIconTheme.Default: + default: + return { + play: defaultPlayIcon, + pause: defaultPauseIcon, + }; + } +}; + +const createTrayIcon = (iconPath: string, pixelRatio: number): NativeImage => { + const iconDimensions = { + width: ICON_SIZE * pixelRatio, + height: ICON_SIZE * pixelRatio, + }; + + return nativeImage.createFromPath(iconPath).resize(iconDimensions); +}; + +export const createTrayIconSet = ( + theme: TrayIconTheme, + pixelRatio: number, +): IconSet => { + const { play: playIconPath, pause: pauseIconPath } = getIcons(theme); + + return { + play: createTrayIcon(playIconPath, pixelRatio), + pause: createTrayIcon(pauseIconPath, pixelRatio), + }; +}; + +const createTrayMenu = ( + songControls: SongControls, + showWindow: () => AppContext, +): MenuTemplate => [ + { + label: t('main.tray.play-pause'), + click: songControls.playPause, + }, + { + label: t('main.tray.next'), + click: songControls.next, + }, + { + label: t('main.tray.previous'), + click: songControls.previous, + }, + { + label: t('main.tray.show'), + click: showWindow, + }, + { + type: 'separator', + }, + { + label: t('main.tray.restart'), + click: restart, + }, + { + type: 'separator', + }, + { + label: t('main.tray.quit'), + role: 'quit', + }, +]; + +const updateTrayTooltip = ( + tray: Tray, + songInfo: SongInfo, + iconSet: IconSet, +): void => { + const { title, artist, isPaused } = songInfo; + + tray.setToolTip(t('main.tray.tooltip.with-song-info', { title, artist })); + tray.setImage(isPaused ? iconSet.play : iconSet.pause); +}; + +const handleTrayClick = ( + context: AppContext, + togglePlayPause: () => void, +): void => + config.get('options.trayClickPlayPause') + ? togglePlayPause() + : toggleWindowVisibility(context); + +const toggleWindowVisibility = (appContext: AppContext): void => { + const { win, app } = appContext; + const isMac = is.macOS(); + const isVisible = win.isVisible(); + + if (isVisible) { + win.hide(); + if (isMac) { + app.dock?.hide(); + } + } else { + win.show(); + if (isMac) { + app.dock?.show(); + } } +}; + +const configureMacTraySettings = (tray: Tray): void => { + tray.setIgnoreDoubleClickEvents(true); +}; + +const getPixelRatio = (): number => { + const isWindows = is.windows(); + const defaultScaleFactor = 1; + const scaleFactor = screen.getPrimaryDisplay().scaleFactor; + + return isWindows ? scaleFactor || defaultScaleFactor : defaultScaleFactor; +}; + +export const setTrayOnClick = (fn: TrayEvent): void => { + if (!tray) return; tray.removeAllListeners('click'); tray.on('click', fn); }; -// Won't do anything on macOS since its disabled -export const setTrayOnDoubleClick = (fn: TrayEvent) => { - if (!tray) { - return; - } +/** + * This behavior is disabled on macOS as double-click events are ignored + * via `setIgnoreDoubleClickEvents(true)` in `setMacSpecificTraySettings`. + */ +export const setTrayOnDoubleClick = (listener: TrayEvent): void => { + if (!tray) return; tray.removeAllListeners('double-click'); - tray.on('double-click', fn); + tray.on('double-click', listener); }; -export const setUpTray = (app: Electron.App, win: Electron.BrowserWindow) => { - if (!config.get('options.tray')) { - tray = undefined; +export const setUpTray = (appContext: AppContext): void => { + const isTrayEnabled = config.get('options.tray'); + if (!isTrayEnabled) { + destroyTray(); return; } - const { playPause, next, previous } = getSongControls(win); - - const pixelRatio = is.windows() - ? screen.getPrimaryDisplay().scaleFactor || 1 - : 1; - const defaultTrayIcon = nativeImage - .createFromPath(defaultTrayIconAsset) - .resize({ - width: 16 * pixelRatio, - height: 16 * pixelRatio, - }); - const pausedTrayIcon = nativeImage - .createFromPath(pausedTrayIconAsset) - .resize({ - width: 16 * pixelRatio, - height: 16 * pixelRatio, - }); - - tray = new Tray(defaultTrayIcon); - - tray.setToolTip(t('main.tray.tooltip.default')); - - // MacOS only - tray.setIgnoreDoubleClickEvents(true); + const trayIcons = createTrayIcons(); + initializeTray(trayIcons.play); + configureMacTraySettings(tray!); - tray.on('click', () => { - if (config.get('options.trayClickPlayPause')) { - playPause(); - } else if (win.isVisible()) { - win.hide(); - app.dock?.hide(); - } else { - win.show(); - app.dock?.show(); - } - }); + const trayMenu = createAppTrayMenu(appContext); + configureTrayMenu(trayMenu); + + configureTrayClickHandlers(appContext); + registerSongInfoCallback(trayIcons); +}; + +const createTrayIcons = (): IconSet => { + const theme = getTrayTheme(); + const pixelRatio = getPixelRatio(); - const template: MenuTemplate = [ - { - label: t('main.tray.play-pause'), - click() { - playPause(); - }, - }, - { - label: t('main.tray.next'), - click() { - next(); - }, - }, - { - label: t('main.tray.previous'), - click() { - previous(); - }, - }, - { - label: t('main.tray.show'), - click() { - win.show(); - app.dock?.show(); - }, - }, - { type: 'separator' }, - { - label: t('main.tray.restart'), - click: restart, - }, - { type: 'separator' }, - { - label: t('main.tray.quit'), - role: 'quit', - }, - ]; - - const trayMenu = Menu.buildFromTemplate(template); - tray.setContextMenu(trayMenu); + return createTrayIconSet(theme, pixelRatio); +}; + +const createAppTrayMenu = (appContext: AppContext): MenuTemplate => { + const songControls = getSongControls(appContext.win); + const showWindow = (): AppContext => appContext; + + return createTrayMenu(songControls, showWindow); +}; + +const destroyTray = (): void => { + tray?.destroy(); + tray = undefined; +}; + +const getTrayTheme = (): TrayIconTheme => { + return config.get('options.trayIconTheme') || TrayIconTheme.Default; +}; +const initializeTray = (icon: NativeImage): void => { + destroyTray(); + tray = new Tray(icon); +}; + +const configureTrayMenu = (menuTemplate: MenuTemplate): void => { + tray?.setContextMenu(Menu.buildFromTemplate(menuTemplate)); + tray?.setToolTip(t('main.tray.tooltip.default')); +}; + +const configureTrayClickHandlers = (context: AppContext): void => { + const songControls = getSongControls(context.win).playPause; + tray?.on('click', () => handleTrayClick(context, songControls)); +}; + +const registerSongInfoCallback = (iconSet: IconSet): void => { registerCallback((songInfo, event) => { - if (event === SongInfoEvent.TimeChanged) return; - - if (tray) { - if (typeof songInfo.isPaused === 'undefined') { - tray.setImage(defaultTrayIcon); - return; - } - - tray.setToolTip( - t('main.tray.tooltip.with-song-info', { - artist: songInfo.artist, - title: songInfo.title, - }), - ); - - tray.setImage(songInfo.isPaused ? pausedTrayIcon : defaultTrayIcon); - } + if (!tray || event === SongInfoEvent.TimeChanged) return; + updateTrayTooltip(tray, songInfo, iconSet); }); };