diff --git a/libs/shared/src/components/nav-bar-link.component.tsx b/libs/shared/src/components/nav-bar-link.component.tsx new file mode 100644 index 0000000..022d5e1 --- /dev/null +++ b/libs/shared/src/components/nav-bar-link.component.tsx @@ -0,0 +1,159 @@ +import { getPlatformApiOrThrow } from '@shared/utils/spicetify-utils'; +import type { History, HistoryEntry } from '../platform/history'; +import React, { useEffect, useState } from 'react'; +import type { LocalStorageAPI } from '@shared/platform/local-storage'; + +export type NavBarLinkProps = { + icon: JSX.Element; + activeIcon: JSX.Element; + href: string; + label: string; +}; + +export function NavBarLink(props: Readonly): JSX.Element { + const history = getPlatformApiOrThrow('History'); + const initialActive = history.location.pathname === props.href; + const sidebar = document.querySelector('.Root__nav-bar'); + + if (sidebar == null) { + throw new Error('Could not find sidebar'); + } + + const [active, setActive] = useState(initialActive); + const [isLibX, setIsLibX] = useState(isLibraryXEnabled(sidebar)); + const [isCollapsed, setIsCollapsed] = useState(isSideBarCollapsed()); + + useEffect(() => { + function handleHistoryChange(e: HistoryEntry): void { + setActive(e.pathname === props.href); + } + + const unsubscribe = history.listen(handleHistoryChange); + return unsubscribe; + }, []); + + useEffect(() => { + // From https://github.dev/spicetify/spicetify-cli/blob/master/jsHelper/sidebarConfig.js + // Check if library X has been enabled / disabled in experimental settings + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.attributeName === 'class') { + if (isLibraryXEnabled(mutation.target as HTMLElement)) { + setIsLibX(true); + } else { + setIsLibX(false); + } + } + } + }); + + observer.observe(sidebar, { + childList: true, + attributes: true, + attributeFilter: ['class'], + }); + + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + // Observe sidebar width changes + const observer = new ResizeObserver(() => { + setIsCollapsed(isSideBarCollapsed()); + }); + + observer.observe(sidebar); + + return () => { + observer.disconnect(); + }; + }, []); + + function navigate(): void { + history.push(props.href); + } + + if (sidebar == null) { + return <>; + } + + function isSideBarCollapsed(): boolean { + return ( + getPlatformApiOrThrow('LocalStorageAPI').getItem( + 'ylx-sidebar-state', + ) === 1 + ); + } + + function isLibraryXEnabled(sidebar: HTMLElement): boolean { + return ( + sidebar.classList.contains('hasYLXSidebar') || + !!sidebar.querySelector('.main-yourLibraryX-entryPoints') + ); + } + + if (isLibX) { + const link = ( + + {props.icon} + {props.activeIcon} + {!isCollapsed && ( + + {props.label} + + )} + + ); + + return ( +
  • + {isCollapsed ? ( + + {link} + + ) : ( + link + )} +
  • + ); + } else { + return ( + <> +
  • + +
    {props.icon}
    +
    + {props.activeIcon} +
    + + {props.label} + +
    +
  • + + ); + } +} diff --git a/libs/shared/src/cosmos/models/query-parameters.ts b/libs/shared/src/cosmos/models/query-parameters.ts new file mode 100644 index 0000000..899079f --- /dev/null +++ b/libs/shared/src/cosmos/models/query-parameters.ts @@ -0,0 +1,26 @@ +export type FilterParameter = { + property: keyof T; + value: any; + operator: + | 'eq' + | 'ne' + | 'lt' + | 'le' + | 'gt' + | 'ge' + | 'contains' + | 'startsWith' + | 'bitMask'; +}; + +export type SortParameter = { + property: keyof T; + desc?: true; +}; + +export type QueryParameter = { + filters?: FilterParameter[]; + sort?: SortParameter[]; + start?: number; + length?: number; +}; diff --git a/libs/shared/src/cosmos/utils/build-url.ts b/libs/shared/src/cosmos/utils/build-url.ts new file mode 100644 index 0000000..b28de37 --- /dev/null +++ b/libs/shared/src/cosmos/utils/build-url.ts @@ -0,0 +1,42 @@ +import type { QueryParameter } from '../models/query-parameters'; + +export function buildQueryString(parameters: QueryParameter): string { + const parts: string[] = []; + + if (parameters.sort !== undefined && parameters.sort.length > 0) { + const options = parameters.sort + .map((o) => + o.desc === true ? `${String(o.property)} DESC` : o.property, + ) + .join(','); + + parts.push(`sort=${encodeURIComponent(options)}`); + } + + if (parameters.filters !== undefined && parameters.filters.length > 0) { + const options = parameters.filters + .map((f) => `${String(f.property)} ${f.operator} ${f.value}`) + .join(','); + + parts.push(`filter=${encodeURIComponent(options)}`); + } + + if (parameters.start !== undefined) { + parts.push(`start=${parameters.start}`); + } + + if (parameters.length !== undefined) { + parts.push(`length=${parameters.length}`); + } + + return parts.join('&'); +} + +export function buildUrl( + url: string, + parameters?: QueryParameter, +): string { + const queryString = + parameters !== undefined ? '?' + buildQueryString(parameters) : ''; + return url + queryString; +} diff --git a/libs/shared/src/debug/list-icons.ts b/libs/shared/src/debug/list-icons.ts new file mode 100644 index 0000000..588f959 --- /dev/null +++ b/libs/shared/src/debug/list-icons.ts @@ -0,0 +1,15 @@ +/** + * List all the existing icons in the context menu. + */ +export function listIcons(): void { + const items = Object.entries(Spicetify.SVGIcons).map(([iconName, icon]) => { + return new Spicetify.ContextMenu.Item( + iconName, + () => {}, + () => true, + `${icon}` as any, + ); + }); + + new Spicetify.ContextMenu.SubMenu('Icons', items, () => true).register(); +} diff --git a/libs/shared/src/debug/register-proxy.ts b/libs/shared/src/debug/register-proxy.ts new file mode 100644 index 0000000..9fcbc23 --- /dev/null +++ b/libs/shared/src/debug/register-proxy.ts @@ -0,0 +1,79 @@ +function createProxyHandler( + objectName: string, +): ProxyHandler { + const handler = { + get(target: T, property: string, receiver: any) { + const targetValue = Reflect.get(target, property, receiver); + + if (typeof targetValue === 'function') { + return function (...args: unknown[]) { + const result = targetValue.apply(this, args); + console.log( + `[${objectName}] - CALL`, + property, + args, + `-->`, + result, + ); + return result; + }; + } else { + console.log( + `[${objectName}] - GET`, + property, + '-->', + targetValue, + ); + return targetValue; + } + }, + }; + + return handler; +} + +/** + * Register a proxy around an instance of an object. + * @param object The object to spy on. + * @param objectName A name to be printed to the console. + */ +export function registerProxy(object: T, objectName: string): void { + const prototype = Object.create(Object.getPrototypeOf(object)); + Object.setPrototypeOf( + object, + new Proxy(prototype, createProxyHandler(objectName)), + ); + + console.log(`Registered proxy for ${objectName}.`); +} + +/** + * Wrap all APIs exposed by Spicetify.Platform with a proxy. + */ +export function registerPlatformProxies(): void { + for (const [name, api] of Object.entries(Spicetify.Platform)) { + registerProxy(api, name); + } +} + +export function registerServicesProxies(): void { + const servicesMap = new Map(); + + for (const [platformName, platformApi] of Object.entries( + Spicetify.Platform, + )) { + for (const [name, service] of Object.entries(platformApi as any).filter( + ([n, s]) => n.startsWith('_'), + )) { + const fullName = `${platformName}.${name}`; + if (!servicesMap.has(name)) { + servicesMap.set(name, service); + try { + registerProxy(service, fullName); + } catch {} + } + } + } + + console.log(servicesMap); +} diff --git a/libs/shared/src/graphQL/graphQL-client.ts b/libs/shared/src/graphQL/graphQL-client.ts new file mode 100644 index 0000000..b164e71 --- /dev/null +++ b/libs/shared/src/graphQL/graphQL-client.ts @@ -0,0 +1,336 @@ +import type { AlbumData } from './models/album-data'; +import type { GraphQLResponse } from './models/response'; +import { IsErrorResponse, ThrowWithErrorMessage } from './utils/graphQL-utils'; +import type { TrackNameData } from './models/track-name-data'; +import type { EpisodeNameData } from './models/episode-name-data'; +import type { ArtistMinimalData } from './models/artist-minimal-data'; +import type { NpvEpisodeData } from './models/npv-episode-data'; +import type { AlbumNameAndTracksData } from './models/album-name-and-tracks-data'; + +// Decorate +// ---------------------------------------- + +export async function decorateItemsForEnhance(): Promise { + throw new Error('Method not implemented.'); +} + +export async function decorateContextEpisodesOrChapters(): Promise { + throw new Error('Method not implemented.'); +} + +export async function decorateContextTracks(): Promise { + throw new Error('Method not implemented.'); +} + +export async function decoratePlaylists(): Promise { + throw new Error('Method not implemented.'); +} + +// Fetch extracted colors +// ---------------------------------------- + +export async function fetchExtractedColors(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorAndImageForAlbumEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorAndImageForArtistEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorAndImageForEpisodeEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorAndImageForPlaylistEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorAndImageForPodcastEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorAndImageForTrackEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorForAlbumEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorForArtistEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorForEpisodeEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorForPlaylistEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorForPodcastEntity(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchExtractedColorForTrackEntity(): Promise { + throw new Error('Method not implemented.'); +} + +// Album +// ---------------------------------------- + +export async function getAlbum( + uri: Spicetify.URI, + locale: typeof Spicetify.Locale, + offset: number, + limit: number, +): Promise { + if (uri.type !== Spicetify.URI.Type.ALBUM) { + throw new Error(`URI '${uri.toString()}' is not an album.`); + } + + const response = (await Spicetify.GraphQL.Request( + Spicetify.GraphQL.Definitions.getAlbum, + { + uri: uri.toString(), + locale: locale.getLocale(), + offset, + limit, + }, + )) as GraphQLResponse; + + if (IsErrorResponse(response)) { + ThrowWithErrorMessage(response); + } + + return response.data; +} + +export async function getAlbumNameAndTracks( + uri: Spicetify.URI, + offset: number, + limit: number, +): Promise { + if (uri.type !== Spicetify.URI.Type.ALBUM) { + throw new Error(`URI '${uri.toString()}' is not an album.`); + } + + const response = (await Spicetify.GraphQL.Request( + Spicetify.GraphQL.Definitions.getAlbumNameAndTracks, + { + uri: uri.toString(), + offset, + limit, + }, + )) as GraphQLResponse; + + if (IsErrorResponse(response)) { + ThrowWithErrorMessage(response); + } + + return response.data; +} + +export async function queryAlbumTrackUris(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryAlbumTracks(): Promise { + throw new Error('Method not implemented.'); +} + +// Episode +// ---------------------------------------- + +/** + * Get the name of an episode. + * @param uri The URI of the episode. + * @returns The name of the episode. + */ +export async function getEpisodeName( + uri: Spicetify.URI, +): Promise { + if (uri.type !== Spicetify.URI.Type.EPISODE) { + throw new Error(`URI '${uri.toString()}' is not an episode.`); + } + + const response = (await Spicetify.GraphQL.Request( + Spicetify.GraphQL.Definitions.getEpisodeName, + { + uri: uri.toString(), + }, + )) as GraphQLResponse; + + if (IsErrorResponse(response)) { + ThrowWithErrorMessage(response); + } + + return response.data; +} + +export async function queryNpvEpisode( + uri: Spicetify.URI, +): Promise { + if (uri.type !== Spicetify.URI.Type.EPISODE) { + throw new Error(`URI '${uri.toString()}' is not an episode.`); + } + + const response = (await Spicetify.GraphQL.Request( + Spicetify.GraphQL.Definitions.queryNpvEpisode, + { + uri: uri.toString(), + }, + )) as GraphQLResponse; + + if (IsErrorResponse(response)) { + ThrowWithErrorMessage(response); + } + + return response.data; +} + +// Track +// ---------------------------------------- + +/** + * Get the name of a track. + * @param uri The URI of the track. + * @returns The name of the track. + */ +export async function getTrackName(uri: Spicetify.URI): Promise { + if (uri.type !== Spicetify.URI.Type.TRACK) { + throw new Error(`URI '${uri.toString()}' is not a track.`); + } + + const response = (await Spicetify.GraphQL.Request( + Spicetify.GraphQL.Definitions.getTrackName, + { + uri: uri.toString(), + }, + )) as GraphQLResponse; + + if (IsErrorResponse(response)) { + ThrowWithErrorMessage(response); + } + + return response.data; +} + +export async function queryTrackArtists(): Promise { + throw new Error('Method not implemented.'); +} + +export async function fetchTracksForRadioStation(): Promise { + throw new Error('Method not implemented.'); +} + +// Artist +// ---------------------------------------- + +export async function queryArtistOverview(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistAppearsOn(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistDiscographyAlbums(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistDiscographySingles(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistDiscographyCompilations(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistDiscographyAll(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistDiscographyOverview(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistPlaylists(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistDiscoveredOn(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistFeaturing(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryArtistRelated(): Promise { + throw new Error('Method not implemented.'); +} + +/** + * Get minimal informations about an artist. + * @param uri The artist URI. + * @returns Minimal informations about the artist. + */ +export async function queryArtistMinimal( + uri: Spicetify.URI, +): Promise { + if (uri.type !== Spicetify.URI.Type.ARTIST) { + throw new Error(`URI '${uri.toString()}' is not an artist.`); + } + + const response = (await Spicetify.GraphQL.Request( + Spicetify.GraphQL.Definitions.queryArtistMinimal, + { + uri: uri.toString(), + }, + )) as GraphQLResponse; + + if (IsErrorResponse(response)) { + ThrowWithErrorMessage(response); + } + + return response.data; +} + +/** + * Get Now Playing View info for the artist. + */ +export async function queryNpvArtist( + artistUri: Spicetify.URI, + trackUri: Spicetify.URI, +): Promise { + throw new Error('Method not implemented.'); +} + +// Other +// ---------------------------------------- + +export async function queryFullscreenMode(): Promise { + throw new Error('Method not implemented.'); +} + +export async function searchModalResults(): Promise { + throw new Error('Method not implemented.'); +} + +export async function queryWhatsNewFeed(): Promise { + throw new Error('Method not implemented.'); +} + +export async function whatsNewFeedNewItems(): Promise { + throw new Error('Method not implemented.'); +} + +export async function browseAll(): Promise { + throw new Error('Method not implemented.'); +} diff --git a/libs/shared/src/graphQL/models/album-data.ts b/libs/shared/src/graphQL/models/album-data.ts new file mode 100644 index 0000000..1908648 --- /dev/null +++ b/libs/shared/src/graphQL/models/album-data.ts @@ -0,0 +1,137 @@ +import type { ImageSource } from './image-source'; + +export type AlbumData = { + albumUnion: { + __typename: 'Album'; + uri: string; + name: string; + artists: { + totalCount: number; + items: { + id: string; + uri: string; + profile: { + name: string; + }; + visuals: { + avatarImage: { + sources: ImageSource[]; + }; + }; + sharingInfo: { + shareUrl: string; + }; + }[]; + }; + coverArt: { + extractedColors: { + colorRaw: { + hex: string; + }; + colorLight: { + hex: string; + }; + colorDark: { + hex: string; + }; + }; + sources: ImageSource[]; + }; + discs: { + totalCount: number; + items: { + number: number; + tracks: { + totalCount: number; + }; + }[]; + }; + releases: { + totalCount: number; + items: unknown[]; + }; + type: 'SINGLE'; + date: { + isoString: string; + precision: 'DAY'; + }; + playability: { + playable: boolean; + reason: 'PLAYABLE'; + }; + label: string; + copyright: { + totalCount: number; + items: { + type: string; + text: string; + }[]; + }; + courtesyLine: string; + saved: boolean; + sharingInfo: { + shareUrl: string; + shareId: string; + }; + tracks: { + totalCount: number; + items: { + uid: string; + track: { + saved: boolean; + uri: string; + name: string; + playCount: string; + discNumber: number; + trackNumber: number; + contentRating: { + label: string; + }; + relinkingInformation: unknown | null; + duration: { + totalMilliseconds: number; + }; + playability: { + playable: boolean; + }; + artists: { + items: { + uri: string; + profile: { + name: string; + }; + }[]; + }; + }; + }[]; + }[]; + moreAlbumsByArtist: { + items: { + discography: { + popularReleasesAlbums: { + items: { + id: string; + uri: string; + name: string; + date: { + year: number; + }; + coverArt: { + sources: ImageSource[]; + }; + playability: { + playable: boolean; + reason: 'PLAYABLE'; + }; + sharingInfo: { + shareId: string; + shareUrl: string; + }; + type: 'SINGLE'; + }[]; + }; + }; + }[]; + }; + }; +}; diff --git a/libs/shared/src/graphQL/models/album-name-and-tracks-data.ts b/libs/shared/src/graphQL/models/album-name-and-tracks-data.ts new file mode 100644 index 0000000..a35c7e4 --- /dev/null +++ b/libs/shared/src/graphQL/models/album-name-and-tracks-data.ts @@ -0,0 +1,16 @@ +export type AlbumNameAndTracksData = { + albumUnion: { + __typename: 'Album'; + name: string; + tracks: { + items: { + track: { + uri: string; + }; + }[]; + pagingInfo: { + nextOffset: null | number; + }; + }; + }; +}; diff --git a/libs/shared/src/graphQL/models/artist-minimal-data.ts b/libs/shared/src/graphQL/models/artist-minimal-data.ts new file mode 100644 index 0000000..dcf0c0c --- /dev/null +++ b/libs/shared/src/graphQL/models/artist-minimal-data.ts @@ -0,0 +1,10 @@ +export type ArtistMinimalData = { + artistUnion: { + __typename: 'Artist'; + id: string; + uri: string; + profile: { + name: string; + }; + }; +}; diff --git a/libs/shared/src/graphQL/models/episode-name-data.ts b/libs/shared/src/graphQL/models/episode-name-data.ts new file mode 100644 index 0000000..6439e82 --- /dev/null +++ b/libs/shared/src/graphQL/models/episode-name-data.ts @@ -0,0 +1,6 @@ +export type EpisodeNameData = { + episodeUnionV2: { + __typename: 'Episode'; + name: string; + }; +}; diff --git a/libs/shared/src/graphQL/models/error-response.ts b/libs/shared/src/graphQL/models/error-response.ts new file mode 100644 index 0000000..ee9e550 --- /dev/null +++ b/libs/shared/src/graphQL/models/error-response.ts @@ -0,0 +1,10 @@ +import type { GraphQLResponse } from './response'; + +export type ErrorResponse = GraphQLResponse & { + errors: { + extensions: { classification: string }[]; + message: string; + path?: string[]; + locations?: { line: number; column: number }[]; + }[]; +}; diff --git a/libs/shared/src/graphQL/models/image-source.ts b/libs/shared/src/graphQL/models/image-source.ts new file mode 100644 index 0000000..1fd9b41 --- /dev/null +++ b/libs/shared/src/graphQL/models/image-source.ts @@ -0,0 +1 @@ +export type ImageSource = { url: string; width: number; height: number }; diff --git a/libs/shared/src/graphQL/models/npv-episode-data.ts b/libs/shared/src/graphQL/models/npv-episode-data.ts new file mode 100644 index 0000000..f282da2 --- /dev/null +++ b/libs/shared/src/graphQL/models/npv-episode-data.ts @@ -0,0 +1,30 @@ +export type NpvEpisodeData = { + episodeUnionV2: { + __typename: 'Episode'; + id: string; + uri: string; + name: string; + podcastV2: { + data: { + __typename: 'Podcast'; + uri: string; + name: string; + topics: { + items: { + __typename: 'PodcastTopic'; + title: string; + uri: string; + }[]; + }; + }; + }; + type: 'PODCAST_EPISODE'; + transcripts: { + items: { + uri: string; + cdnUrl: string; + language: string; + }[]; + }; + }; +}; diff --git a/libs/shared/src/graphQL/models/response.ts b/libs/shared/src/graphQL/models/response.ts new file mode 100644 index 0000000..dbe8be4 --- /dev/null +++ b/libs/shared/src/graphQL/models/response.ts @@ -0,0 +1,4 @@ +export type GraphQLResponse = { + data: T; + extensions: unknown; +}; diff --git a/libs/shared/src/graphQL/models/track-name-data.ts b/libs/shared/src/graphQL/models/track-name-data.ts new file mode 100644 index 0000000..4b93aac --- /dev/null +++ b/libs/shared/src/graphQL/models/track-name-data.ts @@ -0,0 +1,6 @@ +export type TrackNameData = { + trackUnion: { + __typename: 'Track'; + name: string; + }; +}; diff --git a/libs/shared/src/graphQL/utils/graphQL-utils.ts b/libs/shared/src/graphQL/utils/graphQL-utils.ts new file mode 100644 index 0000000..8252979 --- /dev/null +++ b/libs/shared/src/graphQL/utils/graphQL-utils.ts @@ -0,0 +1,12 @@ +import type { ErrorResponse } from '../models/error-response'; +import type { GraphQLResponse } from '../models/response'; + +export function IsErrorResponse( + response: GraphQLResponse, +): response is ErrorResponse { + return (response as any).errors !== undefined; +} + +export function ThrowWithErrorMessage(response: ErrorResponse): never { + throw new Error(response.errors.map((e) => e.message).join('\n')); +} diff --git a/libs/shared/src/platform/clipboard.ts b/libs/shared/src/platform/clipboard.ts new file mode 100644 index 0000000..5084f15 --- /dev/null +++ b/libs/shared/src/platform/clipboard.ts @@ -0,0 +1,4 @@ +export type ClipboardAPI = { + copy: (value: any) => Promise; + paste: () => Promise; +}; diff --git a/libs/shared/src/platform/history.ts b/libs/shared/src/platform/history.ts new file mode 100644 index 0000000..f6c93a2 --- /dev/null +++ b/libs/shared/src/platform/history.ts @@ -0,0 +1,38 @@ +export type HistoryEntry = { + hash: string; + key: string; + pathname: string; + search: string; + state: unknown; +}; + +export type History = { + action: string; + + block: () => unknown; + canGo: (x: unknown) => unknown; + + createHref: (entry: HistoryEntry) => string; + + entries: HistoryEntry[]; + length: number; + + /** + * Add a listener to the History. + * @param callback The callback to call when the history changes. + * @returns The unsubscribe callback. + */ + listen: ( + callback: (event: HistoryEntry) => void | Promise, + ) => () => void; + + location: HistoryEntry; + + push: (href: string | HistoryEntry) => void; + + replace: (href: string | HistoryEntry) => void; + + goForward: () => void; + + goBack: () => void; +}; diff --git a/libs/shared/src/platform/library.ts b/libs/shared/src/platform/library.ts new file mode 100644 index 0000000..a6312a7 --- /dev/null +++ b/libs/shared/src/platform/library.ts @@ -0,0 +1,44 @@ +export type LibraryAPI = { + add: (param: { uris: string[]; silent?: boolean }) => Promise; + remove: (param: { uris: string[]; silent?: boolean }) => Promise; + + /** + * Check if an URI is in the library using the cache. + * If the item is not in the cache, return undefined. + * @param uri + * @returns + */ + containsSync: (uri: string) => boolean | undefined; + contains: (...uris: string[]) => Promise; + + getEvents: () => LibraryAPIEventManager; +}; + +export type LibraryAPIEventType = 'operation_complete'; + +export type LibraryAPIEventManager = { + addListener: ( + type: LibraryAPIEventType, + listener: (event: LibraryAPIEvent) => void, + ) => LibraryAPIEventManager; + + removeListener: ( + type: LibraryAPIEventType, + listener: (event: LibraryAPIEvent) => void, + ) => LibraryAPIEventManager; +}; + +export type LibraryAPIEvent = { + defaultPrevented: boolean; + immediateStopped: boolean; + stopped: boolean; + type: LibraryAPIEventType; + data: T; +}; + +export type LibraryAPIOperationCompleteEvent = LibraryAPIEvent<{ + operation: 'add' | 'remove'; + uris: string[]; + error: null | unknown; + silent: boolean; +}>; diff --git a/libs/shared/src/platform/local-files.ts b/libs/shared/src/platform/local-files.ts new file mode 100644 index 0000000..b04cfbf --- /dev/null +++ b/libs/shared/src/platform/local-files.ts @@ -0,0 +1,71 @@ +export type LocalURI = { + hasBase64Id: false; + + getPath: () => string; + toString: () => string; + toURI: () => string; + toURL: () => string; + toURLPath: () => string; +}; + +export type LocalTrackURI = LocalURI & { + type: 'local'; + track: string; + album: string; + artist: string; + duration: number; +}; + +export type LocalArtistURI = LocalURI & { + type: 'local-artist'; + artist: string; +}; + +export type LocalAlbumURI = LocalURI & { + type: 'local-album'; + artist: string; + album: string; +}; + +export type LocalTrack = { + type: 'track'; + uid: string; + addedAt: Date; + uri: string; + name: string; + album: { + type: 'album'; + uri: string; + name: string; + images: { + url: string; + label: string; + }[]; + }; + artists: { + type: 'artist'; + uri: string; + name: string; + }[]; + discNumber: number; + trackNumber: number; + duration: { + milliseconds: number; + }; + isExplicit: boolean; + isLocal: true; + isPlayable: boolean; + is19PlusOnly: boolean; +}; + +export type LocalTrackSortOption = { + field: 'ALBUM' | 'TITLE' | 'ARTIST' | 'DURATION'; + order: 'DESC' | 'ASC'; +}; + +export type LocalFilesAPI = { + getTracks: ( + sort?: LocalTrackSortOption | undefined, + search?: string | '', + ) => Promise; +}; diff --git a/libs/shared/src/platform/local-storage.ts b/libs/shared/src/platform/local-storage.ts new file mode 100644 index 0000000..e2e7d30 --- /dev/null +++ b/libs/shared/src/platform/local-storage.ts @@ -0,0 +1,5 @@ +export type LocalStorageAPI = { + clearItem: (key: string) => void; + getItem: (key: string) => any; + setItem: (key: string, item: any) => void; +}; diff --git a/libs/shared/src/platform/platform.ts b/libs/shared/src/platform/platform.ts new file mode 100644 index 0000000..000f95b --- /dev/null +++ b/libs/shared/src/platform/platform.ts @@ -0,0 +1,66 @@ +// Last updated: March 2023 + +import type { ClipboardAPI } from './clipboard'; +import type { LocalFilesAPI } from './local-files'; +import type { PlayerAPI } from './player'; +import type { PlaylistAPI } from './playlist'; +import type { RootlistAPI } from './rootlist'; +import type { Session } from './session'; +import type { Translations } from './translations'; +import type { UserAPI } from './user'; +import type { History } from './history'; +import type { ShowAPI } from './show'; +import type { LocalStorageAPI } from './local-storage'; +import type { LibraryAPI } from './library'; + +export type Platform = { + Session: Session; + Transport: unknown; + EventSender: unknown; + Translations: Translations; + FeatureFlags: unknown; + History: History; + AdManagers: unknown; + RemoteConfiguration: unknown; + ActionStoreAPI: unknown; + AuthorizationAPI: unknown; + ClipboardAPI: ClipboardAPI; + ConnectAPI: unknown; + SocialConnectAPI: unknown; + ControlMessageAPI: unknown; + FacebookAPI: unknown; + FollowAPI: unknown; + GraphQLLoader: unknown; + LibraryAPI: LibraryAPI; + LocalFilesAPI: LocalFilesAPI; + OfflineAPI: unknown; + PlatformData: unknown; + PlayerAPI: PlayerAPI; + ShuffleAPI: unknown; + PlayHistoryAPI: unknown; + PlaylistAPI: PlaylistAPI; + PlaylistPermissionsAPI: unknown; + PrivateSessionAPI: unknown; + RadioStationAPI: unknown; + RecaptchaLoggerAPI: unknown; + RecentlyPlayedAPI: unknown; + ReportAPI: unknown; + RootlistAPI: RootlistAPI; + SegmentsAPI: unknown; + ShowAPI: ShowAPI; + AudiobooksPremiumConsumptionCapObserverAPI: unknown; + UpdateAPI: unknown; + UserAPI: UserAPI; + VideoAPI: unknown; + EnhanceAPI: unknown; + SEOExperiments: unknown; + SingAlongAPI: unknown; + PlaybackAPI: unknown; + UBILogger: unknown; + CollectionPlatformAPI: unknown; + LocalStorageAPI: LocalStorageAPI; + IndexedDbAPI: unknown; + EqualizerAPI: unknown; + BuddyFeedAPI: unknown; + PanelAP: unknown; +}; diff --git a/libs/shared/src/platform/player.ts b/libs/shared/src/platform/player.ts new file mode 100644 index 0000000..32eac1a --- /dev/null +++ b/libs/shared/src/platform/player.ts @@ -0,0 +1,7 @@ +export type PlayerAPI = { + addToQueue: (tracks: { uri: string }[]) => Promise; + seekForward: (value: number) => Promise; + seekBackward: (value: number) => Promise; + seekBy: (value: number) => Promise; + seekTo: (value: number) => Promise; +}; diff --git a/libs/shared/src/platform/playlist.ts b/libs/shared/src/platform/playlist.ts new file mode 100644 index 0000000..7f87e54 --- /dev/null +++ b/libs/shared/src/platform/playlist.ts @@ -0,0 +1,120 @@ +export type PlaylistParameters = { + decorateFormatListData?: boolean; + hydrateCollaboratorsWithMembers?: boolean; + withSync?: boolean; +}; + +export type QueryParameters = { + filter: string; + limit: number; + offset: number; + sort: string | undefined; +}; + +export type Playlist = { + contents: { + items: unknown[]; + limit: number; + offset: number; + totalLength: number; + }; + metadata: { + canAdd: boolean; + canPlay: boolean; + canRemove: boolean; + canReportAnnotationAbuse: boolean; + collaborators: { + count: number; + items: unknown[]; + }[]; + description: string; + duration: { + isEstimate: boolean; + milliseconds: number; + }; + formatListData: { + attributes: { + autoplay: string; + 'correlation-id': string; + episode_description: string; + header_image_url_desktop: string; + image_url: string; + isAlgotorial: string; + mediaListConfig: string; + primary_color: string; + request_id: string; + status: string; + uri: string; + }; + type: string; + }; + hasDateAdded: boolean; + hasEpisodes: boolean | null; + hasSpotifyAudiobooks: boolean | null; + hasSpotifyTracks: boolean; + images: { + url: string; + label: string; + }[]; + isCollaborative: boolean; + isLoaded: boolean; + isOwnedBySelf: boolean; + isPublished: boolean; + madeFor: unknown | null; + name: string; + owner: { + displayName: string; + images: { + url: string; + label: string; + }[]; + type: string; + uri: string; + username: string; + }; + permissions: { + canAdministratePermissions: boolean; + canCancelMembership: boolean; + canView: boolean; + isPrivate: boolean; + }; + totalLength: number; + totalLikes: number; + type: 'playlist'; + unfilteredTotalLength: number; + uri: string; + }; +}; + +export type PlaylistAPI = { + add: ( + playlistUri: string, + tracks: string[], + options: any | { after: 'end' }, + ) => Promise; + + applyModifications: ( + playlistUri: string, + modification: { + operation: string | 'add'; + uris: string[]; + after: 'end' | string; + }, + ) => Promise; + + getMetadata: ( + playlistUri: string, + playlistParameters: PlaylistParameters, + ) => Promise; + + getContents: ( + playlistUri: string, + queryParameters: QueryParameters, + ) => Promise; + + getPlaylist: ( + playlistUri: string, + playlistParameters: PlaylistParameters, + queryParameters: QueryParameters, + ) => Promise; +}; diff --git a/libs/shared/src/platform/rootlist.ts b/libs/shared/src/platform/rootlist.ts new file mode 100644 index 0000000..76f4404 --- /dev/null +++ b/libs/shared/src/platform/rootlist.ts @@ -0,0 +1,27 @@ +import type { User } from './user'; + +export type Folder = { + type: 'folder'; + addedAt: Date; + items: (Playlist | Folder)[]; + name: string; + uri: string; +}; + +export type Playlist = { + type: 'playlist'; + uri: string; + name: string; + owner: User; + + // NOTE: There are more properties that we don't care about for now +}; + +export type RootlistFolder = Folder & { + name: ''; + totalItemCount: number; +}; + +export type RootlistAPI = { + getContents: () => Promise; +}; diff --git a/libs/shared/src/platform/session.ts b/libs/shared/src/platform/session.ts new file mode 100644 index 0000000..7e0aaac --- /dev/null +++ b/libs/shared/src/platform/session.ts @@ -0,0 +1,8 @@ +export type Session = { + accessToken: string; + accessTokenExpirationTimestampMs: number; + isAnonymous: boolean; + locale: string; + market: string; + valid: boolean; +}; diff --git a/libs/shared/src/platform/show.ts b/libs/shared/src/platform/show.ts new file mode 100644 index 0000000..4f1e46f --- /dev/null +++ b/libs/shared/src/platform/show.ts @@ -0,0 +1,87 @@ +export type ShowMetadata = { + type: 'show'; + uri: string; + name: string; + description: string; + htmlDescription: string; + coverArt: { url: string; width: number; height: number }[]; + trailer: { + type: 'episode'; + uri: string; + name: string; + coverArt: { url: string; width: number; height: number }[]; + audio: { + items: unknown[]; + }; + audioPreview: unknown | null; + sharingInfo: unknown | null; + duration: { + milliseconds: number; + }; + contentRating: { + label: string; + }; + }; + topics: { + uri: string; + title: string; + }[]; + podcastType: string; + showTypes: unknown[]; + publisherName: string; + consumptionOrder: string; + nextBestEpisode: { + type: string; + data: { + type: 'episode'; + uri: string; + name: string; + description: string; + htmlDescription: string; + episodeType: string; + coverArt: { url: string; width: number; height: number }[]; + playedState: { + playPositionMilliseconds: number; + playPosition: number; + state: string; + }; + mediaTypes: string[]; + audio: { + items: unknown[]; + }; + audioPreview: unknown | null; + sharingInfo: unknown | null; + segmentsCount: number; + podcast: unknown | null; + podcastSubscription: { + isPaywalled: boolean; + isUserSubscribed: boolean; + }; + releaseDate: { + isoString: string; + }; + playability: { + playable: boolean; + reason: string; + }; + contentRating: { + label: string; + }; + duration: { + milliseconds: number; + }; + contentInformation: unknown | null; + transcripts: { + uri: string; + language: string; + curated: boolean; + cdnUrl: string; + }[]; + }; + }; + gatedContentAccessReason: unknown | null; +}; + +export type ShowAPI = { + getMetadata: (uri: string) => Promise; +}; diff --git a/libs/shared/src/platform/translations.ts b/libs/shared/src/platform/translations.ts new file mode 100644 index 0000000..ce5085c --- /dev/null +++ b/libs/shared/src/platform/translations.ts @@ -0,0 +1 @@ +export type Translations = Record; diff --git a/libs/shared/src/platform/user.ts b/libs/shared/src/platform/user.ts new file mode 100644 index 0000000..6581363 --- /dev/null +++ b/libs/shared/src/platform/user.ts @@ -0,0 +1,11 @@ +export type User = { + type: 'user'; + displayName: string; + images: []; + uri: string; + username: string; +}; + +export type UserAPI = { + getUser: () => Promise; +}; diff --git a/libs/shared/src/types/css-modules.d.ts b/libs/shared/src/types/css-modules.d.ts new file mode 100644 index 0000000..55e43c2 --- /dev/null +++ b/libs/shared/src/types/css-modules.d.ts @@ -0,0 +1,9 @@ +declare module '*.module.css' { + const classes: Record; + export default classes; +} + +declare module '*.module.scss' { + const classes: Record; + export default classes; +} diff --git a/CustomApps/history-in-sidebar/src/types/spicetify.d.ts b/libs/shared/src/types/spicetify.d.ts similarity index 65% rename from CustomApps/history-in-sidebar/src/types/spicetify.d.ts rename to libs/shared/src/types/spicetify.d.ts index 56f7666..65c0168 100644 --- a/CustomApps/history-in-sidebar/src/types/spicetify.d.ts +++ b/libs/shared/src/types/spicetify.d.ts @@ -1315,498 +1315,616 @@ declare namespace Spicetify { /** 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 `