From d37cd2418ce55c5b51d83befb3a3a7fd1403eca0 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Tue, 20 Feb 2024 20:50:55 +0900 Subject: [PATCH] fix: fix bugs in MPRIS, and improve MPRIS (#1760) Co-authored-by: JellyBrick Co-authored-by: Totto <32566573+Totto16@users.noreply.github.com> --- src/index.ts | 4 +- src/plugins/music-together/index.ts | 3 +- src/plugins/music-together/queue/queue.ts | 3 +- src/plugins/music-together/types.ts | 41 ---- src/plugins/notifications/interactive.ts | 4 +- src/plugins/shortcuts/mpris-service.d.ts | 110 ++++++++-- src/plugins/shortcuts/mpris.ts | 212 ++++++++++++++------ src/providers/protocol-handler.ts | 12 +- src/providers/song-controls.ts | 93 ++++++--- src/providers/song-info-front.ts | 74 +++++-- src/providers/song-info.ts | 27 +-- src/renderer.ts | 54 ++++- src/types/datahost-get-state.ts | 132 ++---------- src/types/queue.ts | 40 ++++ src/types/youtube-music-desktop-internal.ts | 7 + 15 files changed, 516 insertions(+), 300 deletions(-) create mode 100644 src/types/queue.ts create mode 100644 src/types/youtube-music-desktop-internal.ts diff --git a/src/index.ts b/src/index.ts index eaff07c2d3..16a7040e45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -673,7 +673,9 @@ app.whenReady().then(async () => { ); } - handleProtocol(command); + const splited = decodeURIComponent(command).split(' '); + + handleProtocol(splited.shift()!, splited); return; } diff --git a/src/plugins/music-together/index.ts b/src/plugins/music-together/index.ts index 036febf16b..6713ca68c0 100644 --- a/src/plugins/music-together/index.ts +++ b/src/plugins/music-together/index.ts @@ -6,7 +6,7 @@ import { t } from '@/i18n'; import { createPlugin } from '@/utils'; import promptOptions from '@/providers/prompt-options'; -import { type AppElement, getDefaultProfile, type Permission, type Profile, type VideoData } from './types'; +import { getDefaultProfile, type Permission, type Profile, type VideoData } from './types'; import { Queue } from './queue'; import { Connection, type ConnectionEventUnion } from './connection'; import { createHostPopup } from './ui/host'; @@ -19,6 +19,7 @@ import style from './style.css?inline'; import type { YoutubePlayer } from '@/types/youtube-player'; import type { RendererContext } from '@/types/contexts'; import type { VideoDataChanged } from '@/types/video-data-changed'; +import type { AppElement } from '@/types/queue'; type RawAccountData = { accountName: { diff --git a/src/plugins/music-together/queue/queue.ts b/src/plugins/music-together/queue/queue.ts index 1b74c0d456..f2be75bcad 100644 --- a/src/plugins/music-together/queue/queue.ts +++ b/src/plugins/music-together/queue/queue.ts @@ -4,8 +4,9 @@ import { mapQueueItem } from './utils'; import { t } from '@/i18n'; import type { ConnectionEventUnion } from '@/plugins/music-together/connection'; -import type { Profile, QueueElement, VideoData } from '../types'; +import type { Profile, VideoData } from '../types'; import type { QueueItem } from '@/types/datahost-get-state'; +import type { QueueElement } from '@/types/queue'; const getHeaderPayload = (() => { let payload: { diff --git a/src/plugins/music-together/types.ts b/src/plugins/music-together/types.ts index 5ee86a7191..f00ffc316f 100644 --- a/src/plugins/music-together/types.ts +++ b/src/plugins/music-together/types.ts @@ -1,44 +1,3 @@ -import type { YoutubePlayer } from '@/types/youtube-player'; -import type { GetState, QueueItem } from '@/types/datahost-get-state'; - -type StoreState = GetState; -type Store = { - dispatch: (obj: { - type: string; - payload?: { - items?: QueueItem[]; - }; - }) => void; - - getState: () => StoreState; - replaceReducer: (param1: unknown) => unknown; - subscribe: (callback: () => void) => unknown; -} - -export type QueueElement = HTMLElement & { - dispatch(obj: { - type: string; - payload?: unknown; - }): void; - queue: QueueAPI; -}; -export type QueueAPI = { - getItems(): unknown[]; - store: { - store: Store, - }; - continuation?: string; - autoPlaying?: boolean; -}; -export type AppElement = HTMLElement & AppAPI; -export type AppAPI = { - queue_: QueueAPI; - playerApi_: YoutubePlayer; - openToast: (message: string) => void; - - // TODO: Add more -}; - export type Profile = { id: string; handleId: string; diff --git a/src/plugins/notifications/interactive.ts b/src/plugins/notifications/interactive.ts index 19f219755f..e20c411b1e 100644 --- a/src/plugins/notifications/interactive.ts +++ b/src/plugins/notifications/interactive.ts @@ -307,9 +307,9 @@ export default ( savedNotification?.close(); }); - changeProtocolHandler((cmd) => { + changeProtocolHandler((cmd, args) => { if (Object.keys(songControls).includes(cmd)) { - songControls[cmd as keyof typeof songControls](); + songControls[cmd as keyof typeof songControls](args as never); if ( config().refreshOnPlayPause && (cmd === 'pause' || (cmd === 'play' && !config().unpauseNotification)) diff --git a/src/plugins/shortcuts/mpris-service.d.ts b/src/plugins/shortcuts/mpris-service.d.ts index 8c87b6f1bb..08fabe318e 100644 --- a/src/plugins/shortcuts/mpris-service.d.ts +++ b/src/plugins/shortcuts/mpris-service.d.ts @@ -4,10 +4,10 @@ declare module '@jellybrick/mpris-service' { import { interface as dbusInterface } from 'dbus-next'; interface RootInterfaceOptions { - identity: string; - supportedUriSchemes: string[]; - supportedMimeTypes: string[]; - desktopEntry: string; + identity?: string; + supportedUriSchemes?: string[]; + supportedMimeTypes?: string[]; + desktopEntry?: string; } export interface Track { @@ -35,6 +35,32 @@ declare module '@jellybrick/mpris-service' { 'xesam:userRating'?: number; } + export type PlayBackStatus = 'Playing' | 'Paused' | 'Stopped'; + + export type LoopStatus = 'None' | 'Track' | 'Playlist'; + + export const PLAYBACK_STATUS_PLAYING: 'Playing'; + export const PLAYBACK_STATUS_PAUSED: 'Paused'; + export const PLAYBACK_STATUS_STOPPED: 'Stopped'; + + export const LOOP_STATUS_NONE: 'None'; + export const LOOP_STATUS_TRACK: 'Track'; + export const LOOP_STATUS_PLAYLIST: 'Playlist'; + + export type Interfaces = 'player' | 'trackList' | 'playlists'; + + export interface AdditionalPlayerOptions { + name: string; + supportedInterfaces: Interfaces[]; + } + + export type PlayerOptions = RootInterfaceOptions & AdditionalPlayerOptions; + + export interface Position { + trackId: string; + position: number; + } + declare class Player extends EventEmitter { constructor(opts: { name: string; @@ -43,18 +69,44 @@ declare module '@jellybrick/mpris-service' { supportedInterfaces?: string[]; }); + //RootInterface + on(event: 'quit', listener: () => void): this; + on(event: 'raise', listener: () => void): this; + on( + event: 'fullscreen', + listener: (fullscreenEnabled: boolean) => void, + ): this; + + emit(type: string, ...args: unknown[]): unknown; + name: string; identity: string; - fullscreen: boolean; + fullscreen?: boolean; supportedUriSchemes: string[]; supportedMimeTypes: string[]; canQuit: boolean; canRaise: boolean; - canSetFullscreen: boolean; + canSetFullscreen?: boolean; + desktopEntry?: string; hasTrackList: boolean; - desktopEntry: string; - playbackStatus: string; - loopStatus: string; + + // PlayerInterface + on(event: 'next', listener: () => void): this; + on(event: 'previous', listener: () => void): this; + on(event: 'pause', listener: () => void): this; + on(event: 'playpause', listener: () => void): this; + on(event: 'stop', listener: () => void): this; + on(event: 'play', listener: () => void): this; + on(event: 'seek', listener: (offset: number) => void): this; + on(event: 'open', listener: ({ uri: string }) => void): this; + on(event: 'loopStatus', listener: (status: LoopStatus) => void): this; + on(event: 'rate', listener: () => void): this; + on(event: 'shuffle', listener: (enableShuffle: boolean) => void): this; + on(event: 'volume', listener: (newVolume: number) => void): this; + on(event: 'position', listener: (position: Position) => void): this; + + playbackStatus: PlayBackStatus; + loopStatus: LoopStatus; shuffle: boolean; metadata: Track; volume: number; @@ -67,9 +119,40 @@ declare module '@jellybrick/mpris-service' { rate: number; minimumRate: number; maximumRate: number; - playlists: unknown[]; + + abstract getPosition(): number; + + seeked(position: number): void; + + // TracklistInterface + on(event: 'addTrack', listener: () => void): this; + on(event: 'removeTrack', listener: () => void): this; + on(event: 'goTo', listener: () => void): this; + + tracks: Track[]; + canEditTracks: boolean; + + on(event: '*', a: unknown[]): this; + + addTrack(track: string): void; + + removeTrack(trackId: string): void; + + // PlaylistsInterface + on(event: 'activatePlaylist', listener: () => void): this; + + playlists: Playlist[]; activePlaylist: string; + setPlaylists(playlists: Playlist[]): void; + + setActivePlaylist(playlistId: string): void; + + // Player methods + constructor(opts: PlayerOptions); + + on(event: 'error', listener: (error: Error) => void): this; + init(opts: RootInterfaceOptions): void; objectPath(subpath?: string): string; @@ -91,13 +174,6 @@ declare module '@jellybrick/mpris-service' { setPlaylists(playlists: Track[]): void; setActivePlaylist(playlistId: string): void; - - static PLAYBACK_STATUS_PLAYING: 'Playing'; - static PLAYBACK_STATUS_PAUSED: 'Paused'; - static PLAYBACK_STATUS_STOPPED: 'Stopped'; - static LOOP_STATUS_NONE: 'None'; - static LOOP_STATUS_TRACK: 'Track'; - static LOOP_STATUS_PLAYLIST: 'Playlist'; } interface MprisInterface extends dbusInterface.Interface { diff --git a/src/plugins/shortcuts/mpris.ts b/src/plugins/shortcuts/mpris.ts index 73a6e70ef9..64f465ff53 100644 --- a/src/plugins/shortcuts/mpris.ts +++ b/src/plugins/shortcuts/mpris.ts @@ -1,12 +1,27 @@ import { BrowserWindow, ipcMain } from 'electron'; -import MprisPlayer, { Track } from '@jellybrick/mpris-service'; +import MprisPlayer, { + Track, + LoopStatus, + type PlayBackStatus, + type PlayerOptions, + PLAYBACK_STATUS_STOPPED, + PLAYBACK_STATUS_PAUSED, + PLAYBACK_STATUS_PLAYING, + LOOP_STATUS_NONE, + LOOP_STATUS_PLAYLIST, + LOOP_STATUS_TRACK, + type Position, +} from '@jellybrick/mpris-service'; import registerCallback, { type SongInfo } from '@/providers/song-info'; import getSongControls from '@/providers/song-controls'; import config from '@/config'; import { LoggerPrefix } from '@/utils'; +import type { RepeatMode } from '@/types/datahost-get-state'; +import type { QueueResponse } from '@/types/youtube-music-desktop-internal'; + class YTPlayer extends MprisPlayer { /** * @type {number} The current position in microseconds @@ -14,12 +29,7 @@ class YTPlayer extends MprisPlayer { */ private currentPosition: number; - constructor(opts: { - name: string; - identity: string; - supportedMimeTypes?: string[]; - supportedInterfaces?: string[]; - }) { + constructor(opts: PlayerOptions) { super(opts); this.currentPosition = 0; @@ -33,35 +43,38 @@ class YTPlayer extends MprisPlayer { return this.currentPosition; } - setLoopStatus(status: string) { + setLoopStatus(status: LoopStatus) { this.loopStatus = status; } isPlaying(): boolean { - return this.playbackStatus === YTPlayer.PLAYBACK_STATUS_PLAYING; + return this.playbackStatus === PLAYBACK_STATUS_PLAYING; } isPaused(): boolean { - return this.playbackStatus === YTPlayer.PLAYBACK_STATUS_PAUSED; + return this.playbackStatus === PLAYBACK_STATUS_PAUSED; } isStopped(): boolean { - return this.playbackStatus === YTPlayer.PLAYBACK_STATUS_STOPPED; + return this.playbackStatus === PLAYBACK_STATUS_STOPPED; } - setPlaybackStatus(status: string) { + setPlaybackStatus(status: PlayBackStatus) { this.playbackStatus = status; } } function setupMPRIS() { const instance = new YTPlayer({ - name: 'youtube-music', + name: 'YoutubeMusic', identity: 'YouTube Music', supportedMimeTypes: ['audio/mpeg'], supportedInterfaces: ['player'], }); + instance.canRaise = true; + instance.canQuit = false; + instance.canSetFullscreen = true; instance.supportedUriSchemes = ['http', 'https']; instance.desktopEntry = 'youtube-music'; return instance; @@ -73,21 +86,27 @@ function registerMPRIS(win: BrowserWindow) { playPause, next, previous, - volumeMinus10, - volumePlus10, + setVolume, shuffle, switchRepeat, + setFullscreen, + requestFullscreenInformation, + requestQueueInformation, } = songControls; try { let currentSongInfo: SongInfo | null = null; const secToMicro = (n: number) => Math.round(Number(n) * 1e6); const microToSec = (n: number) => Math.round(Number(n) / 1e6); - const seekTo = (event: { - trackId: string; - position: number; - }) => { - if (event.trackId === currentSongInfo?.videoId) { + const correctId = (videoId: string) => { + return videoId.replace('-', '_MINUS_'); + }; + + const seekTo = (event: Position) => { + if ( + currentSongInfo?.videoId && + event.trackId.endsWith(correctId(currentSongInfo.videoId)) + ) { win.webContents.send('ytmd:seek-to', microToSec(event.position ?? 0)); } }; @@ -101,6 +120,10 @@ function registerMPRIS(win: BrowserWindow) { win.webContents.send('ytmd:setup-time-changed-listener', 'mpris'); win.webContents.send('ytmd:setup-repeat-changed-listener', 'mpris'); win.webContents.send('ytmd:setup-volume-changed-listener', 'mpris'); + win.webContents.send('ytmd:setup-fullscreen-changed-listener', 'mpris'); + win.webContents.send('ytmd:setup-autoplay-changed-listener', 'mpris'); + requestFullscreenInformation(); + requestQueueInformation(); }); ipcMain.on('ytmd:seeked', (_, t: number) => player.seeked(secToMicro(t))); @@ -109,29 +132,85 @@ function registerMPRIS(win: BrowserWindow) { player.setPosition(secToMicro(t)); }); - ipcMain.on('ytmd:repeat-changed', (_, mode: string) => { + ipcMain.on('ytmd:repeat-changed', (_, mode: RepeatMode) => { switch (mode) { case 'NONE': { - player.setLoopStatus(YTPlayer.LOOP_STATUS_NONE); + player.setLoopStatus(LOOP_STATUS_NONE); break; } case 'ONE': { - player.setLoopStatus(YTPlayer.LOOP_STATUS_TRACK); + player.setLoopStatus(LOOP_STATUS_TRACK); break; } case 'ALL': { - player.setLoopStatus(YTPlayer.LOOP_STATUS_PLAYLIST); + player.setLoopStatus(LOOP_STATUS_PLAYLIST); // No default break; } } + requestQueueInformation(); + }); + + ipcMain.on('ytmd:fullscreen-changed', (_, changedTo: boolean) => { + if (player.fullscreen === undefined || !player.canSetFullscreen) { + return; + } + + player.fullscreen = + changedTo !== undefined ? changedTo : !player.fullscreen; }); - player.on('loopStatus', (status: string) => { + + ipcMain.on( + 'ytmd:set-fullscreen', + (_, isFullscreen: boolean | undefined) => { + if (!player.canSetFullscreen || isFullscreen === undefined) { + return; + } + + player.fullscreen = isFullscreen; + }, + ); + + ipcMain.on( + 'ytmd:fullscreen-changed-supported', + (_, isFullscreenSupported: boolean) => { + player.canSetFullscreen = isFullscreenSupported; + }, + ); + ipcMain.on('ytmd:autoplay-changed', (_) => { + requestQueueInformation(); + }); + + ipcMain.on('ytmd:get-queue-response', (_, queue: QueueResponse) => { + if (!queue) { + return; + } + + const currentPosition = queue.items?.findIndex((it) => + it?.playlistPanelVideoRenderer?.selected || + it?.playlistPanelVideoWrapperRenderer?.primaryRenderer?.playlistPanelVideoRenderer?.selected + ) ?? 0; + player.canGoPrevious = currentPosition !== 0; + + let hasNext: boolean; + if (queue.autoPlaying) { + hasNext = true; + } else if (player.loopStatus === LOOP_STATUS_PLAYLIST) { + hasNext = true; + } else { + // Example: currentPosition = 0, queue.items.length = 29 -> hasNext = true + hasNext = !!(currentPosition - (queue?.items?.length ?? 0 - 1)); + } + + player.canGoNext = hasNext; + }); + + player.on('loopStatus', (status: LoopStatus) => { // SwitchRepeat cycles between states in that order const switches = [ - YTPlayer.LOOP_STATUS_NONE, - YTPlayer.LOOP_STATUS_PLAYLIST, - YTPlayer.LOOP_STATUS_TRACK, + LOOP_STATUS_NONE, + LOOP_STATUS_PLAYLIST, + LOOP_STATUS_TRACK, ]; const currentIndex = switches.indexOf(player.loopStatus); const targetIndex = switches.indexOf(status); @@ -142,33 +221,44 @@ function registerMPRIS(win: BrowserWindow) { }); player.on('raise', () => { + if (!player.canRaise) { + return; + } + win.setSkipTaskbar(false); win.show(); }); + player.on('fullscreen', (fullscreenEnabled: boolean) => { + setFullscreen(fullscreenEnabled); + }); + player.on('play', () => { if (!player.isPlaying()) { - player.setPlaybackStatus(YTPlayer.PLAYBACK_STATUS_PLAYING); + player.setPlaybackStatus(PLAYBACK_STATUS_PLAYING); playPause(); } }); player.on('pause', () => { - if (player.playbackStatus !== YTPlayer.PLAYBACK_STATUS_PAUSED) { - player.setPlaybackStatus(YTPlayer.PLAYBACK_STATUS_PAUSED); + if (!player.isPaused()) { + player.setPlaybackStatus(PLAYBACK_STATUS_PAUSED); playPause(); } }); player.on('playpause', () => { player.setPlaybackStatus( - player.isPlaying() - ? YTPlayer.PLAYBACK_STATUS_PAUSED - : YTPlayer.PLAYBACK_STATUS_PLAYING + player.isPlaying() ? PLAYBACK_STATUS_PAUSED : PLAYBACK_STATUS_PLAYING, ); playPause(); }); - player.on('next', next); - player.on('previous', previous); + player.on('next', () => { + next(); + }); + + player.on('previous', () => { + previous(); + }); player.on('seek', seekBy); player.on('position', seekTo); @@ -176,10 +266,18 @@ function registerMPRIS(win: BrowserWindow) { player.on('shuffle', (enableShuffle) => { if (enableShuffle) { shuffle(); + requestQueueInformation(); } }); player.on('open', (args: { uri: string }) => { - win.loadURL(args.uri); + win.loadURL(args.uri).then(() => { + requestQueueInformation(); + }); + }); + + player.on('error', (error: Error) => { + console.error(LoggerPrefix, 'Error in MPRIS'); + console.trace(error); }); let mprisVolNewer = false; @@ -198,7 +296,7 @@ function registerMPRIS(win: BrowserWindow) { } }); - player.on('volume', (newVolume) => { + player.on('volume', (newVolume: number) => { if (config.plugins.isEnabled('precise-volume')) { // With precise volume we can set the volume to the exact value. const newVol = ~~(newVolume * 100); @@ -208,31 +306,23 @@ function registerMPRIS(win: BrowserWindow) { win.webContents.send('setVolume', newVol); } } else { - // With keyboard shortcuts we can only change the volume in increments of 10, so round it. - let deltaVolume = Math.round((newVolume - player.volume) * 10); - while (deltaVolume !== 0 && deltaVolume > 0) { - volumePlus10(); - player.volume += 0.1; - deltaVolume--; - } - - while (deltaVolume !== 0 && deltaVolume < 0) { - volumeMinus10(); - player.volume -= 0.1; - deltaVolume++; - } + setVolume(newVolume * 100); } }); - registerCallback((songInfo) => { + registerCallback((songInfo: SongInfo) => { if (player) { const data: Track = { 'mpris:length': secToMicro(songInfo.songDuration), - 'mpris:artUrl': songInfo.imageSrc ?? undefined, + ...(songInfo.imageSrc + ? { 'mpris:artUrl': songInfo.imageSrc } + : undefined), 'xesam:title': songInfo.title, 'xesam:url': songInfo.url, 'xesam:artist': [songInfo.artist], - 'mpris:trackid': songInfo.videoId, + 'mpris:trackid': player.objectPath( + `Track/${correctId(songInfo.videoId)}`, + ), }; if (songInfo.album) { data['xesam:album'] = songInfo.album; @@ -241,22 +331,20 @@ function registerMPRIS(win: BrowserWindow) { player.metadata = data; - const currentElapsedMicroSeconds = secToMicro(songInfo.elapsedSeconds ?? 0); + const currentElapsedMicroSeconds = secToMicro( + songInfo.elapsedSeconds ?? 0, + ); player.setPosition(currentElapsedMicroSeconds); player.seeked(currentElapsedMicroSeconds); player.setPlaybackStatus( - songInfo.isPaused ? - YTPlayer.PLAYBACK_STATUS_PAUSED : - YTPlayer.PLAYBACK_STATUS_PLAYING + songInfo.isPaused ? PLAYBACK_STATUS_PAUSED : PLAYBACK_STATUS_PLAYING, ); } + requestQueueInformation(); }); } catch (error) { - console.error( - LoggerPrefix, - 'Error in MPRIS' - ); + console.error(LoggerPrefix, 'Error in MPRIS'); console.trace(error); } } diff --git a/src/providers/protocol-handler.ts b/src/providers/protocol-handler.ts index 1ad5232898..c881366494 100644 --- a/src/providers/protocol-handler.ts +++ b/src/providers/protocol-handler.ts @@ -6,7 +6,7 @@ import getSongControls from './song-controls'; export const APP_PROTOCOL = 'youtubemusic'; -let protocolHandler: ((cmd: string) => void) | undefined; +let protocolHandler: ((cmd: string, args: string[] | undefined) => void) | undefined; export function setupProtocolHandler(win: BrowserWindow) { if (process.defaultApp && process.argv.length >= 2) { @@ -19,18 +19,18 @@ export function setupProtocolHandler(win: BrowserWindow) { const songControls = getSongControls(win); - protocolHandler = ((cmd: keyof typeof songControls) => { + protocolHandler = ((cmd: keyof typeof songControls, args: string[] | undefined = undefined) => { if (Object.keys(songControls).includes(cmd)) { - songControls[cmd](); + songControls[cmd](args as never); } }) as (cmd: string) => void; } -export function handleProtocol(cmd: string) { - protocolHandler?.(cmd); +export function handleProtocol(cmd: string, args: string[] | undefined) { + protocolHandler?.(cmd, args); } -export function changeProtocolHandler(f: (cmd: string) => void) { +export function changeProtocolHandler(f: (cmd: string, args: string[] | undefined) => void) { protocolHandler = f; } diff --git a/src/providers/song-controls.ts b/src/providers/song-controls.ts index a421031ddd..9bb03eb36f 100644 --- a/src/providers/song-controls.ts +++ b/src/providers/song-controls.ts @@ -1,43 +1,82 @@ // This is used for to control the songs -import { BrowserWindow, ipcMain } from 'electron'; +import { BrowserWindow } from 'electron'; + +// see protocol-handler.ts +type ArgsType = T | string[] | undefined; + +const parseNumberFromArgsType = (args: ArgsType) => { + if (typeof args === 'number') { + return args; + } else if (Array.isArray(args)) { + return Number(args[0]); + } else { + return null; + } +}; + +const parseBooleanFromArgsType = (args: ArgsType) => { + if (typeof args === 'boolean') { + return args; + } else if (Array.isArray(args)) { + return args[0] === 'true'; + } else { + return null; + } +}; export default (win: BrowserWindow) => { - const commands = { + return { // Playback previous: () => win.webContents.send('ytmd:previous-video'), next: () => win.webContents.send('ytmd:next-video'), playPause: () => win.webContents.send('ytmd:toggle-play'), like: () => win.webContents.send('ytmd:update-like', 'LIKE'), dislike: () => win.webContents.send('ytmd:update-like', 'DISLIKE'), - go10sBack: () => win.webContents.send('ytmd:seek-by', -10), - go10sForward: () => win.webContents.send('ytmd:seek-by', 10), - go1sBack: () => win.webContents.send('ytmd:seek-by', -1), - go1sForward: () => win.webContents.send('ytmd:seek-by', 1), + goBack: (seconds: ArgsType) => { + const secondsNumber = parseNumberFromArgsType(seconds); + if (secondsNumber !== null) { + win.webContents.send('ytmd:seek-by', -secondsNumber); + } + }, + goForward: (seconds: ArgsType) => { + const secondsNumber = parseNumberFromArgsType(seconds); + if (secondsNumber !== null) { + win.webContents.send('ytmd:seek-by', seconds); + } + }, shuffle: () => win.webContents.send('ytmd:shuffle'), - switchRepeat: (n = 1) => win.webContents.send('ytmd:switch-repeat', n), + switchRepeat: (n: ArgsType = 1) => { + const repeat = parseNumberFromArgsType(n); + if (repeat !== null) { + win.webContents.send('ytmd:switch-repeat', n); + } + }, // General - volumeMinus10: () => { - ipcMain.once('ytmd:get-volume-return', (_, volume) => { - win.webContents.send('ytmd:update-volume', volume - 10); - }); - win.webContents.send('ytmd:get-volume'); + setVolume: (volume: ArgsType) => { + const volumeNumber = parseNumberFromArgsType(volume); + if (volumeNumber !== null) { + win.webContents.send('ytmd:update-volume', volume); + } }, - volumePlus10: () => { - ipcMain.once('ytmd:get-volume-return', (_, volume) => { - win.webContents.send('ytmd:update-volume', volume + 10); - }); - win.webContents.send('ytmd:get-volume'); + setFullscreen: (isFullscreen: ArgsType) => { + const isFullscreenValue = parseBooleanFromArgsType(isFullscreen); + if (isFullscreenValue !== null) { + win.setFullScreen(isFullscreenValue); + win.webContents.send('ytmd:click-fullscreen-button', isFullscreenValue); + } + }, + requestFullscreenInformation: () => { + win.webContents.send('ytmd:get-fullscreen'); + }, + requestQueueInformation: () => { + win.webContents.send('ytmd:get-queue'); }, - fullscreen: () => win.webContents.send('ytmd:toggle-fullscreen'), muteUnmute: () => win.webContents.send('ytmd:toggle-mute'), - search: () => win.webContents.sendInputEvent({ - type: 'keyDown', - keyCode: '/', - }), - }; - return { - ...commands, - play: commands.playPause, - pause: commands.playPause, + search: () => { + win.webContents.sendInputEvent({ + type: 'keyDown', + keyCode: '/', + }); + }, }; }; diff --git a/src/providers/song-info-front.ts b/src/providers/song-info-front.ts index 9b1281f4e5..b4cbdc9e7a 100644 --- a/src/providers/song-info-front.ts +++ b/src/providers/song-info-front.ts @@ -62,11 +62,13 @@ export const setupRepeatChangedListener = singleton(() => { // provided by YouTube Music window.ipcRenderer.send( 'ytmd:repeat-changed', - document.querySelector< - HTMLElement & { - getState: () => GetState; - } - >('ytmusic-player-bar')?.getState().queue.repeatMode, + document + .querySelector< + HTMLElement & { + getState: () => GetState; + } + >('ytmusic-player-bar') + ?.getState().queue.repeatMode, ); }); @@ -78,6 +80,46 @@ export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => { window.ipcRenderer.send('ytmd:volume-changed', api.getVolume()); }); +export const setupFullScreenChangedListener = singleton(() => { + const playerBar = document.querySelector('ytmusic-player-bar'); + + if (!playerBar) { + window.ipcRenderer.send('ytmd:fullscreen-changed-supported', false); + return; + } + + const observer = new MutationObserver(() => { + window.ipcRenderer.send( + 'ytmd:fullscreen-changed', + ( + playerBar?.attributes.getNamedItem('player-fullscreened') ?? null + ) !== null, + ); + }); + + observer.observe(playerBar, { + attributes: true, + childList: false, + subtree: false, + }); +}); + +export const setupAutoPlayChangedListener = singleton(() => { + const autoplaySlider = document.querySelector( + '.autoplay > tp-yt-paper-toggle-button', + ); + + const observer = new MutationObserver(() => { + window.ipcRenderer.send('ytmd:autoplay-changed'); + }); + + observer.observe(autoplaySlider!, { + attributes: true, + childList: false, + subtree: false, + }); +}); + export default (api: YoutubePlayer) => { window.ipcRenderer.on('ytmd:setup-time-changed-listener', () => { setupTimeChangedListener(); @@ -91,6 +133,14 @@ export default (api: YoutubePlayer) => { setupVolumeChangedListener(api); }); + window.ipcRenderer.on('ytmd:setup-fullscreen-changed-listener', () => { + setupFullScreenChangedListener(); + }); + + window.ipcRenderer.on('ytmd:setup-autoplay-changed-listener', () => { + setupAutoPlayChangedListener(); + }); + window.ipcRenderer.on('ytmd:setup-seeked-listener', () => { setupSeekedListener(); }); @@ -155,13 +205,13 @@ export default (api: YoutubePlayer) => { function sendSongInfo(videoData: VideoDataChangeValue) { const data = api.getPlayerResponse(); - data.videoDetails.album = - ( - Object.entries(videoData) - .find(([, value]) => value && Object.hasOwn(value, 'playerOverlays')) as [string, AlbumDetails | undefined] - )?.[1]?.playerOverlays?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album?.runs?.at( - 0, - )?.text; + data.videoDetails.album = ( + Object.entries(videoData).find( + ([, value]) => value && Object.hasOwn(value, 'playerOverlays'), + ) as [string, AlbumDetails | undefined] + )?.[1]?.playerOverlays?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album?.runs?.at( + 0, + )?.text; data.videoDetails.elapsedSeconds = 0; data.videoDetails.isPaused = false; diff --git a/src/providers/song-info.ts b/src/providers/song-info.ts index 79a6a2bfa3..5280a2e88a 100644 --- a/src/providers/song-info.ts +++ b/src/providers/song-info.ts @@ -120,7 +120,9 @@ const handleData = async ( songInfo.mediaType = MediaType.PodcastEpisode; // HACK: Podcast's participant is not the artist if (!config.get('options.usePodcastParticipantAsArtist')) { - songInfo.artist = cleanupName(data.microformat.microformatDataRenderer.pageOwnerDetails.name); + songInfo.artist = cleanupName( + data.microformat.microformatDataRenderer.pageOwnerDetails.name, + ); } break; default: @@ -128,14 +130,13 @@ const handleData = async ( // HACK: This is a workaround for "podcast" types where "musicVideoType" doesn't exist. Google :facepalm: if ( !config.get('options.usePodcastParticipantAsArtist') && - ( - data.responseContext.serviceTrackingParams - ?.at(0) - ?.params - ?.find((it) => it.key === 'ipcc')?.value ?? '1' - ) != '0' + (data.responseContext.serviceTrackingParams + ?.at(0) + ?.params?.find((it) => it.key === 'ipcc')?.value ?? '1') != '0' ) { - songInfo.artist = cleanupName(data.microformat.microformatDataRenderer.pageOwnerDetails.name); + songInfo.artist = cleanupName( + data.microformat.microformatDataRenderer.pageOwnerDetails.name, + ); } break; } @@ -165,10 +166,12 @@ const registerProvider = (win: BrowserWindow) => { // This will be called when the song-info-front finds a new request with song data ipcMain.on('ytmd:video-src-changed', async (_, data: GetPlayerResponse) => { - const tempSongInfo = await dataMutex.runExclusive(async () => { - songInfo = await handleData(data, win); - return songInfo; - }); + const tempSongInfo = await dataMutex.runExclusive( + async () => { + songInfo = await handleData(data, win); + return songInfo; + }, + ); if (tempSongInfo) { for (const c of callbacks) { diff --git a/src/renderer.ts b/src/renderer.ts index d5b86bea21..89aab8cf84 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -15,6 +15,8 @@ import { loadI18n, setLanguage, t as i18t } from '@/i18n'; import type { PluginConfig } from '@/types/plugins'; import type { YoutubePlayer } from '@/types/youtube-player'; +import type { QueueElement } from '@/types/queue'; +import type { QueueResponse } from '@/types/youtube-music-desktop-internal'; let api: (Element & YoutubePlayer) | null = null; let isPluginLoaded = false; @@ -61,18 +63,56 @@ async function onApiLoaded() { } }); window.ipcRenderer.on('ytmd:update-volume', (_, volume: number) => { - document.querySelector void }>('ytmusic-player-bar')?.updateVolume(volume); + document + .querySelector< + HTMLElement & { updateVolume: (volume: number) => void } + >('ytmusic-player-bar') + ?.updateVolume(volume); }); - window.ipcRenderer.on('ytmd:get-volume', (event) => { - event.sender.emit('ytmd:get-volume-return', api?.getVolume()); + + const isFullscreen = () => { + const isFullscreen = + document + .querySelector('ytmusic-player-bar') + ?.attributes.getNamedItem('player-fullscreened') ?? null; + + return isFullscreen !== null; + }; + + const clickFullscreenButton = (isFullscreenValue: boolean) => { + const fullscreen = isFullscreen(); + if (isFullscreenValue === fullscreen) { + return; + } + + if (fullscreen) { + document.querySelector('.exit-fullscreen-button')?.click(); + } else { + document.querySelector('.fullscreen-button')?.click(); + } + }; + + window.ipcRenderer.on('ytmd:get-fullscreen', (event) => { + event.sender.send('ytmd:set-fullscreen', isFullscreen()); }); - window.ipcRenderer.on('ytmd:toggle-fullscreen', (_) => { - document.querySelector void }>('ytmusic-player-bar')?.toggleFullscreen(); + + window.ipcRenderer.on('ytmd:click-fullscreen-button', (_, fullscreen: boolean | undefined) => { + clickFullscreenButton(fullscreen ?? false); }); + window.ipcRenderer.on('ytmd:toggle-mute', (_) => { document.querySelector void }>('ytmusic-player-bar')?.onVolumeTap(); }); + window.ipcRenderer.on('ytmd:get-queue', (event) => { + const queue = document.querySelector('#queue'); + event.sender.send('ytmd:get-queue-response', { + items: queue?.queue.getItems(), + autoPlaying: queue?.queue.autoPlaying, + continuation: queue?.queue.continuation, + } satisfies QueueResponse); + }); + const video = document.querySelector('video')!; const audioContext = new AudioContext(); const audioSource = audioContext.createMediaElementSource(video); @@ -236,7 +276,9 @@ const initObserver = async () => { // check document.documentElement is ready await new Promise((resolve) => { if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => resolve(), { once: true }); + document.addEventListener('DOMContentLoaded', () => resolve(), { + once: true, + }); } else { resolve(); } diff --git a/src/types/datahost-get-state.ts b/src/types/datahost-get-state.ts index 4729dc0b3a..ec223df568 100644 --- a/src/types/datahost-get-state.ts +++ b/src/types/datahost-get-state.ts @@ -1,3 +1,5 @@ +import type { PlayerConfig } from '@/types/get-player-response'; + export interface GetState { castStatus: CastStatus; entities: Entities; @@ -32,17 +34,11 @@ export interface Download { export interface Entities {} export interface LikeStatus { - videos: Videos; + videos: Record; playlists: Entities; } -export interface Videos { - tNVTuUEeWP0: Kqp1PyPRBzA; - KQP1PyPrBzA: Kqp1PyPRBzA; - 'o1iz4L-5zkQ': Kqp1PyPRBzA; -} - -export enum Kqp1PyPRBzA { +export enum LikeType { Dislike = 'DISLIKE', Indifferent = 'INDIFFERENT', Like = 'LIKE', @@ -195,14 +191,10 @@ export interface Target { export interface CommandWatchEndpoint { videoId: string; - params: PurpleParams; + params: string; watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs; } -export enum PurpleParams { - WAEB = 'wAEB', -} - export interface PurpleWatchEndpointMusicSupportedConfigs { watchEndpointMusicConfig: PurpleWatchEndpointMusicConfig; } @@ -381,7 +373,7 @@ export enum SharePanelType { export interface PurpleWatchEndpoint { videoId: string; playlistId: string; - params: PurpleParams; + params: string; loggingContext: LoggingContext; watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs; } @@ -466,7 +458,7 @@ export interface FeedbackEndpoint { } export interface PurpleLikeEndpoint { - status: Kqp1PyPRBzA; + status: LikeType; target: Target; actions?: LikeEndpointAction[]; } @@ -488,7 +480,7 @@ export interface PurpleToggledServiceEndpoint { } export interface FluffyLikeEndpoint { - status: Kqp1PyPRBzA; + status: LikeType; target: Target; } @@ -690,7 +682,7 @@ export interface FluffyDefaultServiceEndpoint { } export interface TentacledLikeEndpoint { - status: Kqp1PyPRBzA; + status: LikeType; target: AddToPlaylistEndpoint; actions?: LikeEndpointAction[]; } @@ -702,7 +694,7 @@ export interface FluffyToggledServiceEndpoint { } export interface StickyLikeEndpoint { - status: Kqp1PyPRBzA; + status: LikeType; target: AddToPlaylistEndpoint; } @@ -1185,81 +1177,6 @@ export interface PtrackingURLClass { headers: HeaderElement[]; } -export interface PlayerConfig { - audioConfig: AudioConfig; - streamSelectionConfig: StreamSelectionConfig; - mediaCommonConfig: MediaCommonConfig; - webPlayerConfig: WebPlayerConfig; -} - -export interface AudioConfig { - loudnessDb: number; - perceptualLoudnessDb: number; - enablePerFormatLoudness: boolean; -} - -export interface MediaCommonConfig { - dynamicReadaheadConfig: DynamicReadaheadConfig; -} - -export interface DynamicReadaheadConfig { - maxReadAheadMediaTimeMs: number; - minReadAheadMediaTimeMs: number; - readAheadGrowthRateMs: number; -} - -export interface StreamSelectionConfig { - maxBitrate: string; -} - -export interface WebPlayerConfig { - useCobaltTvosDash: boolean; - webPlayerActionsPorting: WebPlayerActionsPorting; - gatewayExperimentGroup: string; -} - -export interface WebPlayerActionsPorting { - subscribeCommand: SubscribeCommand; - unsubscribeCommand: UnsubscribeCommand; - addToWatchLaterCommand: AddToWatchLaterCommand; - removeFromWatchLaterCommand: RemoveFromWatchLaterCommand; -} - -export interface AddToWatchLaterCommand { - clickTrackingParams: string; - playlistEditEndpoint: AddToWatchLaterCommandPlaylistEditEndpoint; -} - -export interface AddToWatchLaterCommandPlaylistEditEndpoint { - playlistId: string; - actions: PurpleAction[]; -} - -export interface PurpleAction { - addedVideoId: string; - action: string; -} - -export interface RemoveFromWatchLaterCommand { - clickTrackingParams: string; - playlistEditEndpoint: RemoveFromWatchLaterCommandPlaylistEditEndpoint; -} - -export interface RemoveFromWatchLaterCommandPlaylistEditEndpoint { - playlistId: string; - actions: FluffyAction[]; -} - -export interface FluffyAction { - action: string; - removedVideoId: string; -} - -export interface SubscribeCommand { - clickTrackingParams: string; - subscribeEndpoint: SubscribeEndpoint; -} - export interface Storyboards { playerStoryboardSpecRenderer: PlayerStoryboardSpecRenderer; } @@ -1384,7 +1301,7 @@ export interface PlayerOverlayRendererAction { export interface LikeButtonRenderer { target: Target; - likeStatus: Kqp1PyPRBzA; + likeStatus: LikeType; trackingParams: string; likesAllowed: boolean; serviceEndpoints: ServiceEndpoint[]; @@ -1396,13 +1313,14 @@ export interface ServiceEndpoint { } export interface ServiceEndpointLikeEndpoint { - status: Kqp1PyPRBzA; + status: LikeType; target: Target; likeParams?: LikeParams; dislikeParams?: LikeParams; removeLikeParams?: LikeParams; } +// TODO: Add more export enum LikeParams { Oai3D = 'OAI%3D', } @@ -1467,16 +1385,12 @@ export interface CurrentVideoEndpoint { export interface CurrentVideoEndpointWatchEndpoint { videoId: string; - playlistId: PlaylistID; + playlistId: string; index: number; playlistSetVideoId: string; loggingContext: LoggingContext; } -export enum PlaylistID { - RDAMVMrkaNKAvksDE = 'RDAMVMrkaNKAvksDE', -} - export interface PlayerPageWatchNextResponseResponseContext { serviceTrackingParams: ServiceTrackingParam[]; } @@ -1536,6 +1450,8 @@ export interface FlagEndpoint { flagAction: string; } +export type RepeatMode = 'NONE' | 'ONE' | 'ALL'; + export interface Queue { automixItems: unknown[]; autoplay: boolean; @@ -1553,7 +1469,7 @@ export interface Queue { nextQueueItemId: number; playbackContentMode: string; queueContextParams: string; - repeatMode: string; + repeatMode: RepeatMode; responsiveSignals: ResponsiveSignals; selectedItemIndex: number; shuffleEnabled: boolean; @@ -1642,23 +1558,15 @@ export interface PlaylistPanelVideoRendererNavigationEndpoint { export interface FluffyWatchEndpoint { videoId: string; - playlistId?: PlaylistID; + playlistId?: string; index: number; - params: FluffyParams; - playerParams?: PlayerParams; + params: string; + playerParams?: string; playlistSetVideoId?: string; loggingContext?: LoggingContext; watchEndpointMusicSupportedConfigs: FluffyWatchEndpointMusicSupportedConfigs; } -export enum FluffyParams { - OAHyAQIIAQ3D3D = 'OAHyAQIIAQ%3D%3D', -} - -export enum PlayerParams { - The8Aub = '8AUB', -} - export interface FluffyWatchEndpointMusicSupportedConfigs { watchEndpointMusicConfig: FluffyWatchEndpointMusicConfig; } diff --git a/src/types/queue.ts b/src/types/queue.ts new file mode 100644 index 0000000000..fb0f3c7e48 --- /dev/null +++ b/src/types/queue.ts @@ -0,0 +1,40 @@ +import type { YoutubePlayer } from '@/types/youtube-player'; +import type { GetState, QueueItem } from '@/types/datahost-get-state'; + +type StoreState = GetState; +type Store = { + dispatch: (obj: { + type: string; + payload?: { + items?: QueueItem[]; + }; + }) => void; + + getState: () => StoreState; + replaceReducer: (param1: unknown) => unknown; + subscribe: (callback: () => void) => unknown; +} + +export type QueueElement = HTMLElement & { + dispatch(obj: { + type: string; + payload?: unknown; + }): void; + queue: QueueAPI; +}; +export type QueueAPI = { + getItems(): QueueItem[]; + store: { + store: Store, + }; + continuation?: string; + autoPlaying?: boolean; +}; +export type AppElement = HTMLElement & AppAPI; +export type AppAPI = { + queue_: QueueAPI; + playerApi_: YoutubePlayer; + openToast: (message: string) => void; + + // TODO: Add more +}; diff --git a/src/types/youtube-music-desktop-internal.ts b/src/types/youtube-music-desktop-internal.ts new file mode 100644 index 0000000000..75852658e5 --- /dev/null +++ b/src/types/youtube-music-desktop-internal.ts @@ -0,0 +1,7 @@ +import type { QueueItem } from '@/types/datahost-get-state'; + +export interface QueueResponse { + items?: QueueItem[]; + autoPlaying?: boolean; + continuation?: string; +}