From 9393a32ba8aea02bdf42c5dd8156988180fff5b5 Mon Sep 17 00:00:00 2001 From: jeroentvb <36192730+jeroentvb@users.noreply.github.com> Date: Mon, 23 Oct 2023 20:20:59 +0200 Subject: [PATCH 1/4] feat(auto-sync): auto sync combined playlists on spotify launch - closes #2 --- README.md | 8 +- src/constants/index.ts | 4 +- src/extensions/auto-sync.tsx | 37 + src/types/index.ts | 1 + src/types/playlist-tracks-response.ts | 5 + src/types/spicetify.d.ts | 3292 +++++++++++++++---------- src/utils/combine-playlists.ts | 15 +- 7 files changed, 2000 insertions(+), 1362 deletions(-) create mode 100644 src/extensions/auto-sync.tsx create mode 100644 src/types/playlist-tracks-response.ts diff --git a/README.md b/README.md index 0f28b43..73c3ae7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # Playlist combiner Ever wanted to combine your spotify playlists into one big one? This Spicetify custom app is able to do just that. -The app currently allows combining playlist into one playlists and synchronizing them with the press of a button after adding songs to one of the source playlists. The ultimate goal is to add the songs from the source to the target automatically. But that's still w.i.p. + +### Features +* Combine multiple playlists into one +* Auto sync to add missing tracks when spotify starts ![combined playlists home page](docs/home.png) @@ -34,6 +37,3 @@ Playlists may also be added or removed via the edit modal. Removing a playlist d ``` ⚠️ Combining a large amount of songs into a playlist may take some time ``` - -## Todo -See the [issues](https://github.com/jeroentvb/spicetify-combined-playlists/issues) tab. diff --git a/src/constants/index.ts b/src/constants/index.ts index b912e40..0e394fc 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -7,6 +7,8 @@ export const GET_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists?limit= */ export const TRACKS_FROM_PLAYLIST_URL_FILTER = '?fields=items(track(uri)),next'; +export const GET_PLAYLIST_TRACKS_URL = (uri: string) => `sp://core-playlist/v1/playlist/${uri}/rows`; + export const ADD_TRACKS_TO_PLAYLIST_URL = (id: string) => `https://api.spotify.com/v1/playlists/${id}/tracks`; export const LS_KEY = 'combined-playlists'; @@ -16,7 +18,7 @@ export const CREATE_PLAYLIST_URL = (userId: string) => `https://api.spotify.com/ export const CREATE_NEW_PLAYLIST_IDENTIFIER = 'CREATE_NEW_PLAYLIST_IDENTIFIER'; export const LIKED_SONGS_PLAYLIST_FACADE: SpotifyPlaylist = { - name: Spicetify.Platform.Translations['shared.library.entity-row.liked-songs.title'], + name: Spicetify.Platform?.Translations['shared.library.entity-row.liked-songs.title'], collaborative: false, description: '', external_urls: { spotify: '' }, diff --git a/src/extensions/auto-sync.tsx b/src/extensions/auto-sync.tsx new file mode 100644 index 0000000..a3b2807 --- /dev/null +++ b/src/extensions/auto-sync.tsx @@ -0,0 +1,37 @@ +import { GET_PLAYLIST_TRACKS_URL, LS_KEY } from '../constants'; +import type { CombinedPlaylist, PlaylistRowsResponse } from '../types'; +import { addTracksToPlaylist } from '../utils'; + +(async () => { + while (!Spicetify?.Platform || !Spicetify?.CosmosAsync) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + synchronizeCombinedPlaylists(); +})(); + +export function synchronizeCombinedPlaylists() { + const combinedPlaylists: CombinedPlaylist[] = JSON.parse(Spicetify.LocalStorage.get(LS_KEY) as string) ?? []; + + Promise.all(combinedPlaylists.map(async ({ sources, target }) => { + const sourceUris = await Promise.all(sources.map(async (source) => { + const res: PlaylistRowsResponse = await Spicetify.CosmosAsync.get(GET_PLAYLIST_TRACKS_URL(source.uri)); + return res.rows.map(({ link }) => link); + // Flatten result and remove duplicates + })).then(res => Array.from(new Set(res.flat()))); + + const targetUris = await Spicetify.CosmosAsync.get(GET_PLAYLIST_TRACKS_URL(target.uri)) + .then((res: PlaylistRowsResponse) => res.rows.map(({ link }) => link)); + + const missingUris = sourceUris.filter(uri => !targetUris.includes(uri)); + + if (missingUris.length) { + Spicetify.showNotification(`Auto-syncing ${missingUris.length} missing tracks to playlist ${target.name}`); + // Endpoint only wants the id, not the full uri + await addTracksToPlaylist(target.uri.split(':').at(-1) as string, missingUris); + Spicetify.showNotification(`Auto-synced ${missingUris.length} missing tracks to playlist ${target.name} 🔥`); + } + })).catch((_err) => { + Spicetify.showNotification('An error while auto-syncing playlists', true); + }); +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 777860e..a53ea12 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,3 +2,4 @@ export * from './combined-playlist'; export * from './initial-playlist-form'; export * from './spotify-playlist'; export * from './collection-call-response'; +export * from './playlist-tracks-response'; diff --git a/src/types/playlist-tracks-response.ts b/src/types/playlist-tracks-response.ts new file mode 100644 index 0000000..1950320 --- /dev/null +++ b/src/types/playlist-tracks-response.ts @@ -0,0 +1,5 @@ +export interface PlaylistRowsResponse { + rows: { + link: string + }[] +} diff --git a/src/types/spicetify.d.ts b/src/types/spicetify.d.ts index ad3e9e5..92fcde9 100644 --- a/src/types/spicetify.d.ts +++ b/src/types/spicetify.d.ts @@ -1,1357 +1,1945 @@ declare namespace Spicetify { - type Metadata = Partial>; - type ContextTrack = { - uri: string; - uid?: string; - metadata?: Metadata; - }; - type ProvidedTrack = ContextTrack & { - removed?: string[]; - blocked?: string[]; - provider?: string; - }; - interface ContextOption { - contextURI?: string; - index?: number; - trackUri?: string; - page?: number; - trackUid?: string; - sortedBy?: string; - filteredBy?: string; - shuffleContext?: boolean; - repeatContext?: boolean; - repeatTrack?: boolean; - offset?: number; - next_page_url?: string; - restrictions?: Record; - referrer?: string; - }; - type PlayerState = { - timestamp: number; - context_uri: string; - context_url: string; - context_restrictions: Record; - index?: { - page: number; - track: number; - }; - track?: ProvidedTrack; - playback_id?: string; - playback_quality?: string; - playback_speed?: number; - position_as_of_timestamp: number; - duration: number; - is_playing: boolean; - is_paused: boolean; - is_buffering: boolean; - play_origin: { - feature_identifier: string; - feature_version: string; - view_uri?: string; - external_referrer?: string; - referrer_identifier?: string; - device_identifier?: string; - }; - options: { - shuffling_context?: boolean; - repeating_context?: boolean; - repeating_track?: boolean; - }; - restrictions: Record; - suppressions: { - providers: string[]; - }; - debug: { - log: string[]; - }; - prev_tracks: ProvidedTrack[]; - next_tracks: ProvidedTrack[]; - context_metadata: Metadata; - page_metadata: Metadata; - session_id: string; - queue_revision: string; - }; - namespace Player { - /** - * Register a listener `type` on Spicetify.Player. - * - * On default, `Spicetify.Player` always dispatch: - * - `songchange` type when player changes track. - * - `onplaypause` type when player plays or pauses. - * - `onprogress` type when track progress changes. - * - `appchange` type when user changes page. - */ - function addEventListener(type: string, callback: (event?: Event) => void): void; - function addEventListener(type: "songchange", callback: (event?: Event & { data: PlayerState }) => void): void; - function addEventListener(type: "onplaypause", callback: (event?: Event & { data: PlayerState }) => void): void; - function addEventListener(type: "onprogress", callback: (event?: Event & { data: number }) => void): void; - function addEventListener(type: "appchange", callback: (event?: Event & { data: { - /** - * App href path - */ - path: string; - /** - * App container - */ - container: HTMLElement; - } }) => void): void; - /** - * Skip to previous track. - */ - function back(): void; - /** - * An object contains all information about current track and player. - */ - const data: PlayerState; - /** - * Decrease a small amount of volume. - */ - function decreaseVolume(): void; - /** - * Dispatches an event at `Spicetify.Player`. - * - * On default, `Spicetify.Player` always dispatch - * - `songchange` type when player changes track. - * - `onplaypause` type when player plays or pauses. - * - `onprogress` type when track progress changes. - * - `appchange` type when user changes page. - */ - function dispatchEvent(event: Event): void; - const eventListeners: { - [key: string]: Array<(event?: Event) => void> - }; - /** - * Convert milisecond to `mm:ss` format - * @param milisecond - */ - function formatTime(milisecond: number): string; - /** - * Return song total duration in milisecond. - */ - function getDuration(): number; - /** - * Return mute state - */ - function getMute(): boolean; - /** - * Return elapsed duration in milisecond. - */ - function getProgress(): number; - /** - * Return elapsed duration in percentage (0 to 1). - */ - function getProgressPercent(): number; - /** - * Return current Repeat state (No repeat = 0/Repeat all = 1/Repeat one = 2). - */ - function getRepeat(): number; - /** - * Return current shuffle state. - */ - function getShuffle(): boolean; - /** - * Return track heart state. - */ - function getHeart(): boolean; - /** - * Return current volume level (0 to 1). - */ - function getVolume(): number; - /** - * Increase a small amount of volume. - */ - function increaseVolume(): void; - /** - * Return a boolean whether player is playing. - */ - function isPlaying(): boolean; - /** - * Skip to next track. - */ - function next(): void; - /** - * Pause track. - */ - function pause(): void; - /** - * Resume track. - */ - function play(): void; - /** - * Play a track, playlist, album, etc. immediately - * @param uri Spotify URI - * @param context - * @param options - */ - async function playUri(uri: string, context: any = {}, options: Options = {}); - /** - * Unregister added event listener `type`. - * @param type - * @param callback - */ - function removeEventListener(type: string, callback: (event?: Event) => void): void; - /** - * Seek track to position. - * @param position can be in percentage (0 to 1) or in milisecond. - */ - function seek(position: number): void; - /** - * Turn mute on/off - * @param state - */ - function setMute(state: boolean): void; - /** - * Change Repeat mode - * @param mode `0` No repeat. `1` Repeat all. `2` Repeat one track. - */ - function setRepeat(mode: number): void; - /** - * Turn shuffle on/off. - * @param state - */ - function setShuffle(state: boolean): void; - /** - * Set volume level - * @param level 0 to 1 - */ - function setVolume(level: number): void; - /** - * Seek to previous `amount` of milisecond - * @param amount in milisecond. Default: 15000. - */ - function skipBack(amount?: number): void; - /** - * Seek to next `amount` of milisecond - * @param amount in milisecond. Default: 15000. - */ - function skipForward(amount?: number): void; - /** - * Toggle Heart (Favourite) track state. - */ - function toggleHeart(): void; - /** - * Toggle Mute/No mute. - */ - function toggleMute(): void; - /** - * Toggle Play/Pause. - */ - function togglePlay(): void; - /** - * Toggle No repeat/Repeat all/Repeat one. - */ - function toggleRepeat(): void; - /** - * Toggle Shuffle/No shuffle. - */ - function toggleShuffle(): void; - } - /** - * Adds a track/album or array of tracks/albums to prioritized queue. - */ - function addToQueue(uri: string | string[]): Promise; - /** - * @deprecated - */ - const BridgeAPI: any; - /** - * @deprecated - */ - const CosmosAPI: any; - /** - * Async wrappers of CosmosAPI - */ - namespace CosmosAsync { - type Method = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "SUB"; - interface Error { - code: number; - error: string; - message: string; - stack?: string; - } - - type Headers = Record; - type Body = Record; - - interface Response { - body: any; - headers: Headers; - status: number; - uri: string; - static isSuccessStatus(status: number): boolean; - } - - function head(url: string, headers?: Headers): Promise; - function get(url: string, body?: Body, headers?: Headers): Promise; - function post(url: string, body?: Body, headers?: Headers): Promise; - function put(url: string, body?: Body, headers?: Headers): Promise; - function del(url: string, body?: Body, headers?: Headers): Promise; - function patch(url: string, body?: Body, headers?: Headers): Promise; - function sub(url: string, callback: ((b: Response.body) => void), onError?: ((e: Error) => void), body?: Body, headers?: Headers): Promise; - function postSub(url: string, body?: Body, callback: ((b: Response.body) => void), onError?: ((e: Error) => void)): Promise; - function request(method: Method, url: string, body?: Body, headers?: Headers): Promise; - function resolve(method: Method, url: string, body?: Body, headers?: Headers): Promise; - } - /** - * Fetch interesting colors from URI. - * @param uri Any type of URI that has artwork (playlist, track, album, artist, show, ...) - */ - function colorExtractor(uri: string): Promise<{ - DESATURATED: string; - LIGHT_VIBRANT: string; - PROMINENT: string; - VIBRANT: string; - VIBRANT_NON_ALARMING: string; - }>; - /** - * @deprecated - */ - function getAblumArtColors(): any; - /** - * Fetch track analyzed audio data. - * Beware, not all tracks have audio data. - * @param uri is optional. Leave it blank to get current track - * or specify another track uri. - */ - function getAudioData(uri?: string): Promise; - /** - * Set of APIs method to register, deregister hotkeys/shortcuts - */ - namespace Keyboard { - type ValidKey = "BACKSPACE" | "TAB" | "ENTER" | "SHIFT" | "CTRL" | "ALT" | "CAPS" | "ESCAPE" | "SPACE" | "PAGE_UP" | "PAGE_DOWN" | "END" | "HOME" | "ARROW_LEFT" | "ARROW_UP" | "ARROW_RIGHT" | "ARROW_DOWN" | "INSERT" | "DELETE" | "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z" | "WINDOW_LEFT" | "WINDOW_RIGHT" | "SELECT" | "NUMPAD_0" | "NUMPAD_1" | "NUMPAD_2" | "NUMPAD_3" | "NUMPAD_4" | "NUMPAD_5" | "NUMPAD_6" | "NUMPAD_7" | "NUMPAD_8" | "NUMPAD_9" | "MULTIPLY" | "ADD" | "SUBTRACT" | "DECIMAL_POINT" | "DIVIDE" | "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" | ";" | "=" | " | " | "-" | "." | "/" | "`" | "[" | "\\" | "]" | "\"" | "~" | "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "_" | "+" | ":" | "<" | ">" | "?" | "|"; - type KeysDefine = string | { - key: string; - ctrl?: boolean; - shift?: boolean; - alt?: boolean; - meta?: boolean; - }; - const KEYS: Record; - function registerShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void); - function registerIsolatedShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void); - function registerImportantShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void); - function _deregisterShortcut(keys: KeysDefine); - function deregisterImportantShortcut(keys: KeysDefine); - }; - - /** - * @deprecated - */ - const LiveAPI: any; - - namespace LocalStorage { - /** - * Empties the list associated with the object of all key/value pairs, if there are any. - */ - function clear(): void; - /** - * Get key value - */ - function get(key: string): string | null; - /** - * Delete key - */ - function remove(key: string): void; - /** - * Set new value for key - */ - function set(key: string, value: string): void; - } - /** - * To create and prepend custom menu item in profile menu. - */ - namespace Menu { - /** - * Create a single toggle. - */ - class Item { - constructor(name: string, isEnabled: boolean, onClick: (self: Item) => void); - name: string; - isEnabled: boolean; - /** - * Change item name - */ - setName(name: string): void; - /** - * Change item enabled state. - * Visually, item would has a tick next to it if its state is enabled. - */ - setState(isEnabled: boolean): void; - /** - * Item is only available in Profile menu when method "register" is called. - */ - register(): void; - /** - * Stop item to be prepended into Profile menu. - */ - deregister(): void; - } - - /** - * Create a sub menu to contain Item toggles. - * `Item`s in `subItems` array shouldn't be registered. - */ - class SubMenu { - constructor(name: string, subItems: Item[]); - name: string; - /** - * Change SubMenu name - */ - setName(name: string): void; - /** - * Add an item to sub items list - */ - addItem(item: Item); - /** - * Remove an item from sub items list - */ - removeItem(item: Item); - /** - * SubMenu is only available in Profile menu when method "register" is called. - */ - register(): void; - /** - * Stop SubMenu to be prepended into Profile menu. - */ - deregister(): void; - } - } - - /** - * Keyboard shortcut library - * - * Documentation: https://craig.is/killing/mice v1.6.5 - * - * Spicetify.Keyboard is wrapper of this library to be compatible with legacy Spotify, - * so new extension should use this library instead. - */ - function Mousetrap(element?: any): void; - - /** - * Contains vast array of internal APIs. - * Please explore in Devtool Console. - */ - const Platform: any; - /** - * Queue object contains list of queuing tracks, - * history of played tracks and current track metadata. - */ - const Queue: { - nextTracks: any[]; - prevTracks: any[]; - queueRevision: string; - track: any; - }; - /** - * Remove a track/album or array of tracks/albums from current queue. - */ - function removeFromQueue(uri: string | string[]): Promise; - /** - * Display a bubble of notification. Useful for a visual feedback. - */ - function showNotification(text: string): void; - /** - * Set of APIs method to parse and validate URIs. - */ - class URI { - constructor(type: string, props: any); - public type: string; - public id: string; - - /** - * Creates an application URI object from the current URI object. - * - * If the current URI object is already an application type, a copy is made. - * - * @return The current URI as an application URI. - */ - toAppType(): URI; - - /** - * Creates a URI object from an application URI object. - * - * If the current URI object is not an application type, a copy is made. - * - * @return The current URI as a real typed URI. - */ - toRealType(): URI; - - /** - * - * @return The URI representation of this uri. - */ - toURI(): string; - - /** - * - * @return The URI representation of this uri. - */ - toString(): string; - - /** - * Get the URL path of this uri. - * - * @param opt_leadingSlash True if a leading slash should be prepended. - * @return The path of this uri. - */ - toURLPath(opt_leadingSlash: boolean): string; - - /** - * - * @return The Play URL string for the uri. - */ - toPlayURL(): string; - - /** - * - * @return The URL string for the uri. - */ - toURL(): string; - - /** - * - * @return The Open URL string for the uri. - */ - toOpenURL(): string; - - /** - * - * @return The Play HTTPS URL string for the uri. - */ - toSecurePlayURL(): string; - - /** - * - * @return The HTTPS URL string for the uri. - */ - toSecureURL(): string; - - /** - * - * @return The Open HTTPS URL string for the uri. - */ - toSecureOpenURL(): string; - - /** - * - * @return The id of the uri as a bytestring. - */ - idToByteString(): string; - - getPath(): string; - - getBase62Id(): string; - - /** - * Checks whether two URI:s refer to the same thing even though they might - * not necessarily be equal. + type Icon = "album" | "artist" | "block" | "brightness" | "car" | "chart-down" | "chart-up" | "check" | "check-alt-fill" | "chevron-left" | "chevron-right" | "chromecast-disconnected" | "clock" | "collaborative" | "computer" | "copy" | "download" | "downloaded" | "edit" | "enhance" | "exclamation-circle" | "external-link" | "facebook" | "follow" | "fullscreen" | "gamepad" | "grid-view" | "heart" | "heart-active" | "instagram" | "laptop" | "library" | "list-view" | "location" | "locked" | "locked-active" | "lyrics" | "menu" | "minimize" | "minus" | "more" | "new-spotify-connect" | "offline" | "pause" | "phone" | "play" | "playlist" | "playlist-folder" | "plus-alt" | "plus2px" | "podcasts" | "projector" | "queue" | "repeat" | "repeat-once" | "search" | "search-active" | "shuffle" | "skip-back" | "skip-back15" | "skip-forward" | "skip-forward15" | "soundbetter" | "speaker" | "spotify" | "subtitles" | "tablet" | "ticket" | "twitter" | "visualizer" | "voice" | "volume" | "volume-off" | "volume-one-wave" | "volume-two-wave" | "watch" | "x"; + type Variant = "bass" | "forte" | "brio" | "altoBrio" | "alto" | "canon" | "celloCanon" | "cello" | "ballad" | "balladBold" | "viola" | "violaBold" | "mesto" | "mestoBold" | "metronome" | "finale" | "finaleBold" | "minuet" | "minuetBold"; + type SemanticColor = "textBase" | "textSubdued" | "textBrightAccent" | "textNegative" | "textWarning" | "textPositive" | "textAnnouncement" | "essentialBase" | "essentialSubdued" | "essentialBrightAccent" | "essentialNegative" | "essentialWarning" | "essentialPositive" | "essentialAnnouncement" | "decorativeBase" | "decorativeSubdued" | "backgroundBase" | "backgroundHighlight" | "backgroundPress" | "backgroundElevatedBase" | "backgroundElevatedHighlight" | "backgroundElevatedPress" | "backgroundTintedBase" | "backgroundTintedHighlight" | "backgroundTintedPress" | "backgroundUnsafeForSmallTextBase" | "backgroundUnsafeForSmallTextHighlight" | "backgroundUnsafeForSmallTextPress"; + type Metadata = Partial>; + type ContextTrack = { + uri: string; + uid?: string; + metadata?: Metadata; + }; + type ProvidedTrack = ContextTrack & { + removed?: string[]; + blocked?: string[]; + provider?: string; + }; + type ContextOption = { + contextURI?: string; + index?: number; + trackUri?: string; + page?: number; + trackUid?: string; + sortedBy?: string; + filteredBy?: string; + shuffleContext?: boolean; + repeatContext?: boolean; + repeatTrack?: boolean; + offset?: number; + next_page_url?: string; + restrictions?: Record; + referrer?: string; + }; + type PlayerState = { + timestamp: number; + context_uri: string; + context_url: string; + context_restrictions: Record; + index?: { + page: number; + track: number; + }; + track?: ProvidedTrack; + playback_id?: string; + playback_quality?: string; + playback_speed?: number; + position_as_of_timestamp: number; + duration: number; + is_playing: boolean; + is_paused: boolean; + is_buffering: boolean; + play_origin: { + feature_identifier: string; + feature_version: string; + view_uri?: string; + external_referrer?: string; + referrer_identifier?: string; + device_identifier?: string; + }; + options: { + shuffling_context?: boolean; + repeating_context?: boolean; + repeating_track?: boolean; + }; + restrictions: Record; + suppressions: { + providers: string[]; + }; + debug: { + log: string[]; + }; + prev_tracks: ProvidedTrack[]; + next_tracks: ProvidedTrack[]; + context_metadata: Metadata; + page_metadata: Metadata; + session_id: string; + queue_revision: string; + }; + namespace Player { + /** + * Register a listener `type` on Spicetify.Player. * - * These two Playlist URIs, for example, refer to the same playlist: + * On default, `Spicetify.Player` always dispatch: + * - `songchange` type when player changes track. + * - `onplaypause` type when player plays or pauses. + * - `onprogress` type when track progress changes. + * - `appchange` type when user changes page. + */ + function addEventListener(type: string, callback: (event?: Event) => void): void; + function addEventListener(type: "songchange", callback: (event?: Event & { data: PlayerState }) => void): void; + function addEventListener(type: "onplaypause", callback: (event?: Event & { data: PlayerState }) => void): void; + function addEventListener(type: "onprogress", callback: (event?: Event & { data: number }) => void): void; + function addEventListener(type: "appchange", callback: (event?: Event & { data: { + /** + * App href path + */ + path: string; + /** + * App container + */ + container: HTMLElement; + } }) => void): void; + /** + * Skip to previous track. + */ + function back(): void; + /** + * An object contains all information about current track and player. + */ + const data: PlayerState; + /** + * Decrease a small amount of volume. + */ + function decreaseVolume(): void; + /** + * Dispatches an event at `Spicetify.Player`. + * + * On default, `Spicetify.Player` always dispatch + * - `songchange` type when player changes track. + * - `onplaypause` type when player plays or pauses. + * - `onprogress` type when track progress changes. + * - `appchange` type when user changes page. + */ + function dispatchEvent(event: Event): void; + const eventListeners: { + [key: string]: Array<(event?: Event) => void> + }; + /** + * Convert milisecond to `mm:ss` format + * @param milisecond + */ + function formatTime(milisecond: number): string; + /** + * Return song total duration in milisecond. + */ + function getDuration(): number; + /** + * Return mute state + */ + function getMute(): boolean; + /** + * Return elapsed duration in milisecond. + */ + function getProgress(): number; + /** + * Return elapsed duration in percentage (0 to 1). + */ + function getProgressPercent(): number; + /** + * Return current Repeat state (No repeat = 0/Repeat all = 1/Repeat one = 2). + */ + function getRepeat(): number; + /** + * Return current shuffle state. + */ + function getShuffle(): boolean; + /** + * Return track heart state. + */ + function getHeart(): boolean; + /** + * Return current volume level (0 to 1). + */ + function getVolume(): number; + /** + * Increase a small amount of volume. + */ + function increaseVolume(): void; + /** + * Return a boolean whether player is playing. + */ + function isPlaying(): boolean; + /** + * Skip to next track. + */ + function next(): void; + /** + * Pause track. + */ + function pause(): void; + /** + * Resume track. + */ + function play(): void; + /** + * Play a track, playlist, album, etc. immediately + * @param uri Spotify URI + * @param context + * @param options + */ + function playUri(uri: string, context?: any, options?: any): Promise; + /** + * Unregister added event listener `type`. + * @param type + * @param callback + */ + function removeEventListener(type: string, callback: (event?: Event) => void): void; + /** + * Seek track to position. + * @param position can be in percentage (0 to 1) or in milisecond. + */ + function seek(position: number): void; + /** + * Turn mute on/off + * @param state + */ + function setMute(state: boolean): void; + /** + * Change Repeat mode + * @param mode `0` No repeat. `1` Repeat all. `2` Repeat one track. + */ + function setRepeat(mode: number): void; + /** + * Turn shuffle on/off. + * @param state + */ + function setShuffle(state: boolean): void; + /** + * Set volume level + * @param level 0 to 1 + */ + function setVolume(level: number): void; + /** + * Seek to previous `amount` of milisecond + * @param amount in milisecond. Default: 15000. + */ + function skipBack(amount?: number): void; + /** + * Seek to next `amount` of milisecond + * @param amount in milisecond. Default: 15000. + */ + function skipForward(amount?: number): void; + /** + * Toggle Heart (Favourite) track state. + */ + function toggleHeart(): void; + /** + * Toggle Mute/No mute. + */ + function toggleMute(): void; + /** + * Toggle Play/Pause. + */ + function togglePlay(): void; + /** + * Toggle No repeat/Repeat all/Repeat one. + */ + function toggleRepeat(): void; + /** + * Toggle Shuffle/No shuffle. + */ + function toggleShuffle(): void; + } + /** + * Adds a track or array of tracks to prioritized queue. + */ + function addToQueue(uri: ContextTrack[]): Promise; + /** + * @deprecated + */ + const BridgeAPI: any; + /** + * @deprecated + */ + const CosmosAPI: any; + /** + * Async wrappers of CosmosAPI + */ + namespace CosmosAsync { + type Method = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "SUB"; + interface Error { + code: number; + error: string; + message: string; + stack?: string; + } + + type Headers = Record; + type Body = Record; + + interface Response { + body: any; + headers: Headers; + status: number; + uri?: string; + } + + function head(url: string, headers?: Headers): Promise; + function get(url: string, body?: Body, headers?: Headers): Promise; + function post(url: string, body?: Body, headers?: Headers): Promise; + function put(url: string, body?: Body, headers?: Headers): Promise; + function del(url: string, body?: Body, headers?: Headers): Promise; + function patch(url: string, body?: Body, headers?: Headers): Promise; + function sub(url: string, callback: ((b: Response["body"]) => void), onError?: ((e: Error) => void), body?: Body, headers?: Headers): Promise; + function postSub(url: string, body: Body | null, callback: ((b: Response["body"]) => void), onError?: ((e: Error) => void)): Promise; + function request(method: Method, url: string, body?: Body, headers?: Headers): Promise; + function resolve(method: Method, url: string, body?: Body, headers?: Headers): Promise; + } + /** + * Fetch interesting colors from URI. + * @param uri Any type of URI that has artwork (playlist, track, album, artist, show, ...) + */ + function colorExtractor(uri: string): Promise<{ + DESATURATED: string; + LIGHT_VIBRANT: string; + PROMINENT: string; + VIBRANT: string; + VIBRANT_NON_ALARMING: string; + }>; + /** + * @deprecated + */ + function getAblumArtColors(): any; + /** + * Fetch track analyzed audio data. + * Beware, not all tracks have audio data. + * @param uri is optional. Leave it blank to get current track + * or specify another track uri. + */ + function getAudioData(uri?: string): Promise; + /** + * Set of APIs method to register, deregister hotkeys/shortcuts + */ + namespace Keyboard { + type ValidKey = "BACKSPACE" | "TAB" | "ENTER" | "SHIFT" | "CTRL" | "ALT" | "CAPS" | "ESCAPE" | "SPACE" | "PAGE_UP" | "PAGE_DOWN" | "END" | "HOME" | "ARROW_LEFT" | "ARROW_UP" | "ARROW_RIGHT" | "ARROW_DOWN" | "INSERT" | "DELETE" | "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z" | "WINDOW_LEFT" | "WINDOW_RIGHT" | "SELECT" | "NUMPAD_0" | "NUMPAD_1" | "NUMPAD_2" | "NUMPAD_3" | "NUMPAD_4" | "NUMPAD_5" | "NUMPAD_6" | "NUMPAD_7" | "NUMPAD_8" | "NUMPAD_9" | "MULTIPLY" | "ADD" | "SUBTRACT" | "DECIMAL_POINT" | "DIVIDE" | "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" | ";" | "=" | " | " | "-" | "." | "/" | "`" | "[" | "\\" | "]" | "\"" | "~" | "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "_" | "+" | ":" | "<" | ">" | "?" | "|"; + type KeysDefine = string | { + key: string; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; + meta?: boolean; + }; + const KEYS: Record; + function registerShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; + function registerIsolatedShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; + function registerImportantShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; + function _deregisterShortcut(keys: KeysDefine): void; + function deregisterImportantShortcut(keys: KeysDefine): void; + function changeShortcut(keys: KeysDefine, newKeys: KeysDefine): void; + } + + /** + * @deprecated + */ + const LiveAPI: any; + + namespace LocalStorage { + /** + * Empties the list associated with the object of all key/value pairs, if there are any. + */ + function clear(): void; + /** + * Get key value + */ + function get(key: string): string | null; + /** + * Delete key + */ + function remove(key: string): void; + /** + * Set new value for key + */ + function set(key: string, value: string): void; + } + /** + * To create and prepend custom menu item in profile menu. + */ + namespace Menu { + /** + * Create a single toggle. + */ + class Item { + constructor(name: string, isEnabled: boolean, onClick: (self: Item) => void, icon?: Icon | string); + name: string; + isEnabled: boolean; + /** + * Change item name + */ + setName(name: string): void; + /** + * Change item enabled state. + * Visually, item would has a tick next to it if its state is enabled. + */ + setState(isEnabled: boolean): void; + /** + * Change icon + */ + setIcon(icon: Icon | string): void; + /** + * Item is only available in Profile menu when method "register" is called. + */ + register(): void; + /** + * Stop item to be prepended into Profile menu. + */ + deregister(): void; + } + + /** + * Create a sub menu to contain Item toggles. + * `Item`s in `subItems` array shouldn't be registered. + */ + class SubMenu { + constructor(name: string, subItems: Item[]); + name: string; + /** + * Change SubMenu name + */ + setName(name: string): void; + /** + * Add an item to sub items list + */ + addItem(item: Item); + /** + * Remove an item from sub items list + */ + removeItem(item: Item); + /** + * SubMenu is only available in Profile menu when method "register" is called. + */ + register(): void; + /** + * Stop SubMenu to be prepended into Profile menu. + */ + deregister(): void; + } + } + + /** + * Keyboard shortcut library + * + * Documentation: https://craig.is/killing/mice v1.6.5 + * + * Spicetify.Keyboard is wrapper of this library to be compatible with legacy Spotify, + * so new extension should use this library instead. + */ + function Mousetrap(element?: any): void; + + /** + * Contains vast array of internal APIs. + * Please explore in Devtool Console. + */ + const Platform: any; + /** + * Queue object contains list of queuing tracks, + * history of played tracks and current track metadata. + */ + const Queue: { + nextTracks: any[]; + prevTracks: any[]; + queueRevision: string; + track: any; + }; + /** + * Remove a track or array of tracks from current queue. + */ + function removeFromQueue(uri: ContextTrack[]): Promise; + /** + * Display a bubble of notification. Useful for a visual feedback. + * @param message Message to display. Can use inline HTML for styling. + * @param isError If true, bubble will be red. Defaults to false. + * @param msTimeout Time in milliseconds to display the bubble. Defaults to Spotify's value. + */ + function showNotification(message: React.ReactNode, isError?: boolean, msTimeout?: number): void; + /** + * Set of APIs method to parse and validate URIs. + */ + class URI { + constructor(type: string, props: any); + public type: string; + public hasBase62Id: boolean; + + public id?: string; + public disc?: any; + public args?: any; + public category?: string; + public username?: string; + public track?: string; + public artist?: string; + public album?: string; + public duration?: number; + public query?: string; + public country?: string; + public global?: boolean; + public context?: string | typeof URI | null; + public anchor?: string; + public play?: any; + public toplist?: any; + + /** + * + * @return The URI representation of this uri. + */ + toURI(): string; + + /** + * + * @return The URI representation of this uri. + */ + toString(): string; + + /** + * Get the URL path of this uri. + * + * @param opt_leadingSlash True if a leading slash should be prepended. + * @return The path of this uri. + */ + toURLPath(opt_leadingSlash: boolean): string; + + /** + * + * @param origin The origin to use for the URL. + * @return The URL string for the uri. + */ + toURL(origin?: string): string; + + /** + * Clones a given SpotifyURI instance. + * + * @return An instance of URI. + */ + clone(): URI | null; + + /** + * Gets the path of the URI object by removing all hash and query parameters. + * + * @return The path of the URI object. + */ + getPath(): string; + + /** + * The various URI Types. + * + * Note that some of the types in this enum are not real URI types, but are + * actually URI particles. They are marked so. + * + */ + static Type: { + AD: string; + ALBUM: string; + GENRE: string; + QUEUE: string; + APPLICATION: string; + ARTIST: string; + ARTIST_TOPLIST: string; + ARTIST_CONCERTS: string; + AUDIO_FILE: string; + COLLECTION: string; + COLLECTION_ALBUM: string; + COLLECTION_ARTIST: string; + COLLECTION_MISSING_ALBUM: string; + COLLECTION_TRACK_LIST: string; + CONCERT: string; + CONTEXT_GROUP: string; + DAILY_MIX: string; + EMPTY: string; + EPISODE: string; + /** URI particle; not an actual URI. */ + FACEBOOK: string; + FOLDER: string; + FOLLOWERS: string; + FOLLOWING: string; + IMAGE: string; + INBOX: string; + INTERRUPTION: string; + LIBRARY: string; + LIVE: string; + ROOM: string; + EXPRESSION: string; + LOCAL: string; + LOCAL_TRACK: string; + LOCAL_ALBUM: string; + LOCAL_ARTIST: string; + MERCH: string; + MOSAIC: string; + PLAYLIST: string; + PLAYLIST_V2: string; + PRERELEASE: string; + PROFILE: string; + PUBLISHED_ROOTLIST: string; + RADIO: string; + ROOTLIST: string; + SEARCH: string; + SHOW: string; + SOCIAL_SESSION: string; + SPECIAL: string; + STARRED: string; + STATION: string; + TEMP_PLAYLIST: string; + TOPLIST: string; + TRACK: string; + TRACKSET: string; + USER_TOPLIST: string; + USER_TOP_TRACKS: string; + UNKNOWN: string; + MEDIA: string; + QUESTION: string; + POLL: string; + }; + + /** + * Creates a new URI object from a parsed string argument. + * + * @param str The string that will be parsed into a URI object. + * @throws TypeError If the string argument is not a valid URI, a TypeError will + * be thrown. + * @return The parsed URI object. + */ + static fromString(str: string): URI; + + /** + * Parses a given object into a URI instance. + * + * Unlike URI.fromString, this function could receive any kind of value. If + * the value is already a URI instance, it is simply returned. + * Otherwise the value will be stringified before parsing. + * + * This function also does not throw an error like URI.fromString, but + * instead simply returns null if it can't parse the value. + * + * @param value The value to parse. + * @return The corresponding URI instance, or null if the + * passed value is not a valid value. + */ + static from(value: any): URI | null; + + /** + * Checks whether two URI:s refer to the same thing even though they might + * not necessarily be equal. + * + * These two Playlist URIs, for example, refer to the same playlist: + * + * spotify:user:napstersean:playlist:3vxotOnOGDlZXyzJPLFnm2 + * spotify:playlist:3vxotOnOGDlZXyzJPLFnm2 + * + * @param baseUri The first URI to compare. + * @param refUri The second URI to compare. + * @return Whether they shared idenitity + */ + static isSameIdentity(baseUri: URI | string, refUri: URI | string): boolean; + + /** + * Returns the hex representation of a Base62 encoded id. + * + * @param id The base62 encoded id. + * @return The hex representation of the base62 id. + */ + static idToHex(id: string): string; + + /** + * Returns the base62 representation of a hex encoded id. + * + * @param hex The hex encoded id. + * @return The base62 representation of the id. + */ + static hexToId(hex: string): string; + + /** + * Creates a new 'album' type URI. + * + * @param id The id of the album. + * @param disc The disc number of the album. + * @return The album URI. + */ + static albumURI(id: string, disc: number): URI; + + /** + * Creates a new 'application' type URI. + * + * @param id The id of the application. + * @param args An array containing the arguments to the app. + * @return The application URI. + */ + static applicationURI(id: string, args: string[]): URI; + + /** + * Creates a new 'artist' type URI. + * + * @param id The id of the artist. + * @return The artist URI. + */ + static artistURI(id: string): URI; + + /** + * Creates a new 'collection' type URI. + * + * @param username The non-canonical username of the rootlist owner. + * @param category The category of the collection. + * @return The collection URI. + */ + static collectionURI(username: string, category: string): URI; + + /** + * Creates a new 'collection-album' type URI. + * + * @param username The non-canonical username of the rootlist owner. + * @param id The id of the album. + * @return The collection album URI. + */ + static collectionAlbumURI(username: string, id: string): URI; + + /** + * Creates a new 'collection-artist' type URI. + * + * @param username The non-canonical username of the rootlist owner. + * @param id The id of the artist. + * @return The collection artist URI. + */ + static collectionAlbumURI(username: string, id: string): URI; + + /** + * Creates a new 'concert' type URI. + * + * @param id The id of the concert. + * @return The concert URI. + */ + static concertURI(id: string): URI; + + /** + * Creates a new 'episode' type URI. + * + * @param id The id of the episode. + * @return The episode URI. + */ + static episodeURI(id: string): URI; + + /** + * Creates a new 'folder' type URI. + * + * @param id The id of the folder. + * @return The folder URI. + */ + static folderURI(id: string): URI; + + /** + * Creates a new 'local-album' type URI. + * + * @param artist The artist of the album. + * @param album The name of the album. + * @return The local album URI. + */ + static localAlbumURI(artist: string, album: string): URI; + + /** + * Creates a new 'local-artist' type URI. + * + * @param artist The name of the artist. + * @return The local artist URI. + */ + static localArtistURI(artist: string): URI; + + /** + * Creates a new 'playlist-v2' type URI. + * + * @param id The id of the playlist. + * @return The playlist URI. + */ + static playlistV2URI(id: string): URI; + + /** + * Creates a new 'prerelease' type URI. + * + * @param id The id of the prerelease. + * @return The prerelease URI. + */ + static prereleaseURI(id: string): URI; + + /** + * Creates a new 'profile' type URI. + * + * @param username The non-canonical username of the rootlist owner. + * @param args A list of arguments. + * @return The profile URI. + */ + static profileURI(username: string, args: string[]): URI; + + /** + * Creates a new 'search' type URI. + * + * @param query The unencoded search query. + * @return The search URI + */ + static searchURI(query: string): URI; + + /** + * Creates a new 'show' type URI. + * + * @param id The id of the show. + * @return The show URI. + */ + static showURI(id: string): URI; + + /** + * Creates a new 'station' type URI. + * + * @param args An array of arguments for the station. + * @return The station URI. + */ + static stationURI(args: string[]): URI; + + /** + * Creates a new 'track' type URI. + * + * @param id The id of the track. + * @param anchor The point in the track formatted as mm:ss + * @param context An optional context URI + * @param play Toggles autoplay + * @return The track URI. + */ + static trackURI(id: string, anchor: string, context?: string, play: boolean): URI; + + /** + * Creates a new 'user-toplist' type URI. + * + * @param username The non-canonical username of the toplist owner. + * @param toplist The toplist type. + * @return The user-toplist URI. + */ + static userToplistURI(username: string, toplist: string): URI; + + static isAd(uri: URI | string): boolean; + static isAlbum(uri: URI | string): boolean; + static isGenre(uri: URI | string): boolean; + static isQueue(uri: URI | string): boolean; + static isApplication(uri: URI | string): boolean; + static isArtist(uri: URI | string): boolean; + static isArtistToplist(uri: URI | string): boolean; + static isArtistConcerts(uri: URI | string): boolean; + static isAudioFile(uri: URI | string): boolean; + static isCollection(uri: URI | string): boolean; + static isCollectionAlbum(uri: URI | string): boolean; + static isCollectionArtist(uri: URI | string): boolean; + static isCollectionMissingAlbum(uri: URI | string): boolean; + static isCollectionTrackList(uri: URI | string): boolean; + static isConcert(uri: URI | string): boolean; + static isContextGroup(uri: URI | string): boolean; + static isDailyMix(uri: URI | string): boolean; + static isEmpty(uri: URI | string): boolean; + static isEpisode(uri: URI | string): boolean; + static isFacebook(uri: URI | string): boolean; + static isFolder(uri: URI | string): boolean; + static isFollowers(uri: URI | string): boolean; + static isFollowing(uri: URI | string): boolean; + static isImage(uri: URI | string): boolean; + static isInbox(uri: URI | string): boolean; + static isInterruption(uri: URI | string): boolean; + static isLibrary(uri: URI | string): boolean; + static isLive(uri: URI | string): boolean; + static isRoom(uri: URI | string): boolean; + static isExpression(uri: URI | string): boolean; + static isLocal(uri: URI | string): boolean; + static isLocalTrack(uri: URI | string): boolean; + static isLocalAlbum(uri: URI | string): boolean; + static isLocalArtist(uri: URI | string): boolean; + static isMerch(uri: URI | string): boolean; + static isMosaic(uri: URI | string): boolean; + static isPlaylist(uri: URI | string): boolean; + static isPlaylistV2(uri: URI | string): boolean; + static isPrerelease(uri: URI | string): boolean; + static isProfile(uri: URI | string): boolean; + static isPublishedRootlist(uri: URI | string): boolean; + static isRadio(uri: URI | string): boolean; + static isRootlist(uri: URI | string): boolean; + static isSearch(uri: URI | string): boolean; + static isShow(uri: URI | string): boolean; + static isSocialSession(uri: URI | string): boolean; + static isSpecial(uri: URI | string): boolean; + static isStarred(uri: URI | string): boolean; + static isStation(uri: URI | string): boolean; + static isTempPlaylist(uri: URI | string): boolean; + static isToplist(uri: URI | string): boolean; + static isTrack(uri: URI | string): boolean; + static isTrackset(uri: URI | string): boolean; + static isUserToplist(uri: URI | string): boolean; + static isUserTopTracks(uri: URI | string): boolean; + static isUnknown(uri: URI | string): boolean; + static isMedia(uri: URI | string): boolean; + static isQuestion(uri: URI | string): boolean; + static isPoll(uri: URI | string): boolean; + static isPlaylistV1OrV2(uri: URI | string): boolean; + } + + /** + * Create custom menu item and prepend to right click context menu + */ + namespace ContextMenu { + type OnClickCallback = (uris: string[], uids?: string[], contextUri?: string) => void; + type ShouldAddCallback = (uris: string[], uids?: string[], contextUri?: string) => boolean; + + // Single context menu item + class Item { + /** + * List of valid icons to use. + */ + static readonly iconList: Icon[]; + constructor(name: string, onClick: OnClickCallback, shouldAdd?: ShouldAddCallback, icon?: Icon, disabled?: boolean); + name: string; + icon: Icon | string; + disabled: boolean; + /** + * A function returning boolean determines whether item should be prepended. + */ + shouldAdd: ShouldAddCallback; + /** + * A function to call when item is clicked + */ + onClick: OnClickCallback; + /** + * Item is only available in Context Menu when method "register" is called. + */ + register: () => void; + /** + * Stop Item to be prepended into Context Menu. + */ + deregister: () => void; + } + + /** + * Create a sub menu to contain `Item`s. + * `Item`s in `subItems` array shouldn't be registered. + */ + class SubMenu { + constructor(name: string, subItems: Iterable, shouldAdd?: ShouldAddCallback, disabled?: boolean); + name: string; + disabled: boolean; + /** + * A function returning boolean determines whether item should be prepended. + */ + shouldAdd: ShouldAddCallback; + addItem: (item: Item) => void; + removeItem: (item: Item) => void; + /** + * SubMenu is only available in Context Menu when method "register" is called. + */ + register: () => void; + /** + * Stop SubMenu to be prepended into Context Menu. + */ + deregister: () => void; + } + } + + /** + * Popup Modal + */ + namespace PopupModal { + interface Content { + title: string; + /** + * You can specify a string for simple text display + * or a HTML element for interactive config/setting menu + */ + content: string | Element; + /** + * Bigger window + */ + isLarge?: boolean; + } + + function display(e: Content): void; + function hide(): void; + } + + /** React instance to create components */ + const React: any; + /** React DOM instance to render and mount components */ + const ReactDOM: any; + /** React DOM Server instance to render components to string */ + const ReactDOMServer: any; + + /** Stock React components exposed from Spotify library */ + namespace ReactComponent { + type ContextMenuProps = { + /** + * Decide whether to use the global singleton context menu (rendered in ) + * or a new inline context menu (rendered in a sibling + * element to `children`) + */ + renderInline?: boolean; + /** + * Determins what will trigger the context menu. For example, a click, or a right-click + */ + trigger?: 'click' | 'right-click'; + /** + * Determins is the context menu should open or toggle when triggered + */ + action?: 'toggle' | 'open'; + /** + * The preferred placement of the context menu when it opens. + * Relative to trigger element. + */ + placement?: 'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end'; + /** + * The x and y offset distances at which the context menu should open. + * Relative to trigger element and `position`. + */ + offset?: [number, number]; + /** + * Will stop the client from scrolling while the context menu is open + */ + preventScrollingWhileOpen?: boolean; + /** + * The menu UI to render inside of the context menu. + */ + menu: typeof Spicetify.ReactComponent.Menu | + typeof Spicetify.ReactComponent.AlbumMenu | + typeof Spicetify.ReactComponent.PodcastShowMenu | + typeof Spicetify.ReactComponent.ArtistMenu | + typeof Spicetify.ReactComponent.PlaylistMenu; + /** + * A child of the context menu. Should be ` + + + {!this.state.isInitializing &&
diff --git a/src/constants/index.ts b/src/constants/index.ts index 0e394fc..2e1ea6a 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -12,6 +12,7 @@ export const GET_PLAYLIST_TRACKS_URL = (uri: string) => `sp://core-playlist/v1/p export const ADD_TRACKS_TO_PLAYLIST_URL = (id: string) => `https://api.spotify.com/v1/playlists/${id}/tracks`; export const LS_KEY = 'combined-playlists'; +export const LS_KEY_SETTINGS = 'combined-playlists-settings'; export const CREATE_PLAYLIST_URL = (userId: string) => `https://api.spotify.com/v1/users/${userId}/playlists`; diff --git a/src/extensions/auto-sync.tsx b/src/extensions/auto-sync.tsx index a3b2807..7f60bb8 100644 --- a/src/extensions/auto-sync.tsx +++ b/src/extensions/auto-sync.tsx @@ -1,13 +1,14 @@ import { GET_PLAYLIST_TRACKS_URL, LS_KEY } from '../constants'; import type { CombinedPlaylist, PlaylistRowsResponse } from '../types'; -import { addTracksToPlaylist } from '../utils'; +import { addTracksToPlaylist, getCombinedPlaylistsSettings } from '../utils'; (async () => { while (!Spicetify?.Platform || !Spicetify?.CosmosAsync) { await new Promise(resolve => setTimeout(resolve, 100)); } - synchronizeCombinedPlaylists(); + const autoSync = getCombinedPlaylistsSettings().autoSync; + if (autoSync) synchronizeCombinedPlaylists(); })(); export function synchronizeCombinedPlaylists() { @@ -34,4 +35,4 @@ export function synchronizeCombinedPlaylists() { })).catch((_err) => { Spicetify.showNotification('An error while auto-syncing playlists', true); }); -} \ No newline at end of file +} diff --git a/src/types/combined-playlists-settings.ts b/src/types/combined-playlists-settings.ts new file mode 100644 index 0000000..225dd5f --- /dev/null +++ b/src/types/combined-playlists-settings.ts @@ -0,0 +1,3 @@ +export interface CombinedPlaylistsSettings { + autoSync: boolean; +} diff --git a/src/types/index.ts b/src/types/index.ts index a53ea12..46bd793 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,3 +3,4 @@ export * from './initial-playlist-form'; export * from './spotify-playlist'; export * from './collection-call-response'; export * from './playlist-tracks-response'; +export * from './combined-playlists-settings'; diff --git a/src/types/spicetify.d.ts b/src/types/spicetify.d.ts index 92fcde9..f5de49e 100644 --- a/src/types/spicetify.d.ts +++ b/src/types/spicetify.d.ts @@ -948,7 +948,7 @@ declare namespace Spicetify { * You can specify a string for simple text display * or a HTML element for interactive config/setting menu */ - content: string | Element; + content: string | Element | JSX.Element; /** * Bigger window */ diff --git a/src/utils/combined-playlists-settings.ts b/src/utils/combined-playlists-settings.ts new file mode 100644 index 0000000..150495f --- /dev/null +++ b/src/utils/combined-playlists-settings.ts @@ -0,0 +1,14 @@ +import { LS_KEY_SETTINGS } from '../constants'; +import { CombinedPlaylistsSettings } from '../types'; + +const DEFAULT_SETTINGS: CombinedPlaylistsSettings = { + autoSync: true +}; + +export function getCombinedPlaylistsSettings(): CombinedPlaylistsSettings { + return JSON.parse(Spicetify.LocalStorage.get(LS_KEY_SETTINGS) ?? 'null') || DEFAULT_SETTINGS; +} + +export function setCombinedPlaylistsSettings(settings: CombinedPlaylistsSettings) { + Spicetify.LocalStorage.set(LS_KEY_SETTINGS, JSON.stringify(settings)); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 5679b24..a83c578 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './get-paginated-spotify-data'; export * from './get-playlist-info'; export * from './split-array-in-chunks'; export * from './track-state'; +export * from './combined-playlists-settings'; From 0c6f495849407e803e56e5c3b15c709f3b461fa9 Mon Sep 17 00:00:00 2001 From: jeroentvb <36192730+jeroentvb@users.noreply.github.com> Date: Tue, 24 Oct 2023 19:39:13 +0200 Subject: [PATCH 3/4] refactor(combine-playlists): optimize and remove duplicate code --- src/app.tsx | 23 ++++++++++++++------ src/constants/index.ts | 3 ++- src/extensions/auto-sync.tsx | 32 +++++++--------------------- src/utils/combine-playlists.ts | 39 +++++++++++++++++++++------------- 4 files changed, 51 insertions(+), 46 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index 8f1e3d6..7ab9f4a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -75,7 +75,11 @@ class App extends React.Component, State> { ? await this.createPlaylist(formData.sources) : this.findPlaylist(formData.target); - await combinePlaylists(sourcePlaylists, targetPlaylist); + await combinePlaylists(sourcePlaylists, targetPlaylist) + .catch((err) => { + console.error('An error ocurred while combining playlists', err); + Spicetify.showNotification('An error ocurred while combining playlists', true); + }); this.saveCombinedPlaylist(sourcePlaylists, targetPlaylist); Spicetify.PopupModal.hide(); @@ -128,7 +132,17 @@ class App extends React.Component, State> { const { sources } = this.state.combinedPlaylists.find((combinedPlaylist) => combinedPlaylist.target.id === playlistToSync.id) as CombinedPlaylist; const sourcePlaylists = sources.map((sourcePlaylist) => this.findPlaylist(sourcePlaylist.id)); - await combinePlaylists(sourcePlaylists, playlistToSync); + await combinePlaylists(sourcePlaylists, playlistToSync) + .catch((err) => { + console.error('An error ocurred while syncing playlists', err); + Spicetify.showNotification('An error ocurred while syncing playlists', true); + }); + } + + @TrackState('isLoading') + async syncAllPlaylists() { + Spicetify.showNotification('Synchronizing all combined playlists'); + await synchronizeCombinedPlaylists(); } findPlaylist(id: string): SpotifyPlaylist { @@ -219,10 +233,7 @@ class App extends React.Component, State> { } - onClick={() => { - synchronizeCombinedPlaylists(); - Spicetify.showNotification('Synchronizing all combined playlists'); - }} + onClick={() => !this.state.isLoading && this.syncAllPlaylists()} > Synchronize all diff --git a/src/constants/index.ts b/src/constants/index.ts index 2e1ea6a..52ddd99 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -9,6 +9,8 @@ export const TRACKS_FROM_PLAYLIST_URL_FILTER = '?fields=items(track(uri)),next'; export const GET_PLAYLIST_TRACKS_URL = (uri: string) => `sp://core-playlist/v1/playlist/${uri}/rows`; +export const GET_LIKED_SONGS_LIST_URL = 'sp://core-collection/unstable/@/list/tracks/all?responseFormat=protobufJson'; + export const ADD_TRACKS_TO_PLAYLIST_URL = (id: string) => `https://api.spotify.com/v1/playlists/${id}/tracks`; export const LS_KEY = 'combined-playlists'; @@ -42,5 +44,4 @@ export const LIKED_SONGS_PLAYLIST_FACADE: SpotifyPlaylist = { }, type: 'playlist', uri: 'spotify:playlist:liked-songs-facade' - }; diff --git a/src/extensions/auto-sync.tsx b/src/extensions/auto-sync.tsx index 7f60bb8..da0a836 100644 --- a/src/extensions/auto-sync.tsx +++ b/src/extensions/auto-sync.tsx @@ -1,6 +1,6 @@ -import { GET_PLAYLIST_TRACKS_URL, LS_KEY } from '../constants'; -import type { CombinedPlaylist, PlaylistRowsResponse } from '../types'; -import { addTracksToPlaylist, getCombinedPlaylistsSettings } from '../utils'; +import { LS_KEY } from '../constants'; +import type { CombinedPlaylist } from '../types'; +import { combinePlaylists, getCombinedPlaylistsSettings } from '../utils'; (async () => { while (!Spicetify?.Platform || !Spicetify?.CosmosAsync) { @@ -14,25 +14,9 @@ import { addTracksToPlaylist, getCombinedPlaylistsSettings } from '../utils'; export function synchronizeCombinedPlaylists() { const combinedPlaylists: CombinedPlaylist[] = JSON.parse(Spicetify.LocalStorage.get(LS_KEY) as string) ?? []; - Promise.all(combinedPlaylists.map(async ({ sources, target }) => { - const sourceUris = await Promise.all(sources.map(async (source) => { - const res: PlaylistRowsResponse = await Spicetify.CosmosAsync.get(GET_PLAYLIST_TRACKS_URL(source.uri)); - return res.rows.map(({ link }) => link); - // Flatten result and remove duplicates - })).then(res => Array.from(new Set(res.flat()))); - - const targetUris = await Spicetify.CosmosAsync.get(GET_PLAYLIST_TRACKS_URL(target.uri)) - .then((res: PlaylistRowsResponse) => res.rows.map(({ link }) => link)); - - const missingUris = sourceUris.filter(uri => !targetUris.includes(uri)); - - if (missingUris.length) { - Spicetify.showNotification(`Auto-syncing ${missingUris.length} missing tracks to playlist ${target.name}`); - // Endpoint only wants the id, not the full uri - await addTracksToPlaylist(target.uri.split(':').at(-1) as string, missingUris); - Spicetify.showNotification(`Auto-synced ${missingUris.length} missing tracks to playlist ${target.name} 🔥`); - } - })).catch((_err) => { - Spicetify.showNotification('An error while auto-syncing playlists', true); - }); + return Promise.all(combinedPlaylists.map(({ sources, target }) => combinePlaylists(sources, target, true))) + .catch((err) => { + console.error('An error ocurred while auto-syncing playlists', err); + Spicetify.showNotification('An error ocurred while auto-syncing playlists', true); + }); } diff --git a/src/utils/combine-playlists.ts b/src/utils/combine-playlists.ts index 4232e07..81475d8 100644 --- a/src/utils/combine-playlists.ts +++ b/src/utils/combine-playlists.ts @@ -1,33 +1,42 @@ -import { ADD_TRACKS_TO_PLAYLIST_URL, LIKED_SONGS_PLAYLIST_FACADE, TRACKS_FROM_PLAYLIST_URL_FILTER } from '../constants'; -import { SpotifyCollectionCallResponse } from '../types'; -import { getPaginatedSpotifyData, splitArrayInChunks } from './'; +import { ADD_TRACKS_TO_PLAYLIST_URL, GET_LIKED_SONGS_LIST_URL, GET_PLAYLIST_TRACKS_URL, LIKED_SONGS_PLAYLIST_FACADE } from '../constants'; +import { PlaylistInfo, PlaylistRowsResponse, SpotifyCollectionCallResponse } from '../types'; +import { splitArrayInChunks } from './'; -export async function combinePlaylists(sourcePlaylists: SpotifyApi.PlaylistObjectSimplified[], targetPlaylist: SpotifyApi.PlaylistObjectSimplified) { - const allTrackUris = await Promise.all(sourcePlaylists.map(async (playlist): Promise => { +export async function combinePlaylists(sourcePlaylists: PlaylistInfo[], targetPlaylist: PlaylistInfo, autoSync = false) { + const sourceUris = await Promise.all(sourcePlaylists.map(async (playlist): Promise => { if (playlist.id === LIKED_SONGS_PLAYLIST_FACADE.id) { - return await Spicetify.CosmosAsync.get('sp://core-collection/unstable/@/list/tracks/all?responseFormat=protobufJson') + return Spicetify.CosmosAsync.get(GET_LIKED_SONGS_LIST_URL) .then((res: SpotifyCollectionCallResponse) => res.item.map(item => item.trackMetadata.link)); } else { - return await getPaginatedSpotifyData<{ track: { uri: string } }>(playlist.tracks.href + TRACKS_FROM_PLAYLIST_URL_FILTER) - .then(items => items.map(item => item.track.uri)); + return Spicetify.CosmosAsync.get(GET_PLAYLIST_TRACKS_URL(playlist.uri)) + .then((res: PlaylistRowsResponse) => res.rows.map((row) => row.link)); } // Flatten responses and remove duplicates })).then(arrays => Array.from(new Set(arrays.flat()))); - const targetTrackUris = await getPaginatedSpotifyData<{ track: { uri: string } }>(targetPlaylist.tracks.href + TRACKS_FROM_PLAYLIST_URL_FILTER) - .then(items => items.map(item => item.track.uri)); + const targetUris = await Spicetify.CosmosAsync.get(GET_PLAYLIST_TRACKS_URL(targetPlaylist.uri)) + .then((res: PlaylistRowsResponse) => res.rows.map(({ link }) => link)); // Filter duplicates from souces usig new Set, then filter duplicates from targetPlaylist using .filter - const sourcesTrackUris = allTrackUris.filter((sourceUri) => !targetTrackUris.includes(sourceUri)); - const splittedTrackUris = splitArrayInChunks(sourcesTrackUris); + const missingUris = sourceUris.filter((sourceUri) => !targetUris.includes(sourceUri)); + const uriChunks = splitArrayInChunks(missingUris); - await Promise.all(splittedTrackUris.map((trackUris) => { + if (missingUris.length > 0 && autoSync) { + Spicetify.showNotification(`Auto-syncing ${missingUris.length} missing tracks to playlist ${targetPlaylist.name}`); + } + + await Promise.all(uriChunks.map((trackUris) => { return addTracksToPlaylist(targetPlaylist.id, trackUris); })); - Spicetify.showNotification(`Added ${sourcesTrackUris.length} tracks to playlist: ${targetPlaylist.name}`); + if (missingUris.length > 0) { + const msg = autoSync + ? `Auto-synced ${missingUris.length} missing tracks to playlist ${targetPlaylist.name} 🔥` + : `Added ${missingUris.length} tracks to playlist: ${targetPlaylist.name}`; + Spicetify.showNotification(msg); + } } export function addTracksToPlaylist(playlistId: string, trackUris: string[]) { return Spicetify.CosmosAsync.post(ADD_TRACKS_TO_PLAYLIST_URL(playlistId), { uris: trackUris }); -} \ No newline at end of file +} From 7548831b411c5a53b5f7742eceff6b04cf281df4 Mon Sep 17 00:00:00 2001 From: jeroentvb <36192730+jeroentvb@users.noreply.github.com> Date: Tue, 24 Oct 2023 20:30:23 +0200 Subject: [PATCH 4/4] release: 1.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 774c910..79467f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "combined-playlists", - "version": "1.3.0", + "version": "1.4.0", "private": true, "scripts": { "build": "spicetify-creator",