diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..e3c2943 --- /dev/null +++ b/.envrc @@ -0,0 +1,7 @@ +export DIRENV_WARN_TIMEOUT=20s + +eval "$(devenv direnvrc)" + +# The use_devenv function supports passing flags to the devenv command +# For example: use devenv --impure --option services.postgres.enable:bool true +use devenv diff --git a/.gitignore b/.gitignore index 04c01ba..b7ef04c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ node_modules/ -dist/ \ No newline at end of file +dist/ + +# Devenv +.devenv/ +.direnv/ +.devenv* + +# Jetbrains IDE +.idea/ diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..c4b1cb2 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,103 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1748273445, + "owner": "cachix", + "repo": "devenv", + "rev": "668a50d8b7bdb19a0131f53c9f6c25c9071e1ffb", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1747372754, + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1746807397, + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "c5208b594838ea8e6cca5997fbf784b7cca1ca90", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": [ + "git-hooks" + ] + } + } + }, + "root": "root", + "version": 7 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..7cfef56 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,11 @@ +{ + languages.javascript = { + enable = true; + corepack.enable = true; + pnpm = { + enable = true; + install.enable = true; + }; + + }; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..c8da167 --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,5 @@ +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + +allowUnfree: true diff --git a/package.json b/package.json index c5dea64..8d5907b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "luna", - "version": "1.2.6-alpha", + "version": "1.2.9-alpha", "description": "A client mod for the Tidal music app for plugins", "author": { "name": "Inrixia", diff --git a/plugins/lib/src/classes/Album.ts b/plugins/lib/src/classes/Album.ts index 6645d4c..0bf1476 100644 --- a/plugins/lib/src/classes/Album.ts +++ b/plugins/lib/src/classes/Album.ts @@ -82,8 +82,8 @@ export class Album extends ContentBase implements MediaCollection { return this.tidalAlbum.numberOfTracks!; } public tMediaItems: () => Promise = memoize(async () => { - const playlistIitems = await TidalApi.albumItems(this.id); - return playlistIitems?.items ?? []; + const albumItems = await TidalApi.albumItems(this.id); + return albumItems ?? []; }); public async mediaItems() { return MediaItem.fromTMediaItems(await this.tMediaItems()); diff --git a/plugins/lib/src/classes/ContextMenu.ts b/plugins/lib/src/classes/ContextMenu.ts index 6ac63e0..9e4db73 100644 --- a/plugins/lib/src/classes/ContextMenu.ts +++ b/plugins/lib/src/classes/ContextMenu.ts @@ -19,7 +19,7 @@ export class ContextMenu { * Will return null if the element is not found (usually means no context menu is open) */ public static async getCurrent() { - const contextMenu = await observePromise(`[data-type="list-container__context-menu"]`, 1000); + const contextMenu = await observePromise(unloads, `[data-type="list-container__context-menu"]`, 1000); if (contextMenu !== null) { const templateButton = contextMenu.querySelector(`div[data-type="contextmenu-item"]`) as Element | undefined; contextMenu.addButton = (text, onClick) => { diff --git a/plugins/lib/src/classes/MediaItem/MediaItem.download.native.ts b/plugins/lib/src/classes/MediaItem/MediaItem.download.native.ts index 72317de..4c9cd20 100644 --- a/plugins/lib/src/classes/MediaItem/MediaItem.download.native.ts +++ b/plugins/lib/src/classes/MediaItem/MediaItem.download.native.ts @@ -13,7 +13,7 @@ import type { PlaybackInfo } from "../../helpers"; import type { MetaTags } from "./MediaItem.tags"; const downloads: Record } | undefined> = {}; -export const downloadProgress = async (trackId: redux.ItemId) => downloads[trackId]; +export const downloadProgress = async (trackId: redux.ItemId) => downloads[trackId]?.progress; export const download = async (playbackInfo: PlaybackInfo, path: string, tags?: MetaTags): Promise => { if (downloads[playbackInfo.trackId] !== undefined) return downloads[playbackInfo.trackId]!.promise; try { diff --git a/plugins/lib/src/classes/MediaItem/MediaItem.ts b/plugins/lib/src/classes/MediaItem/MediaItem.ts index 081092f..1789ce2 100644 --- a/plugins/lib/src/classes/MediaItem/MediaItem.ts +++ b/plugins/lib/src/classes/MediaItem/MediaItem.ts @@ -3,7 +3,7 @@ import type { IRecording, ITrack } from "musicbrainz-api"; import { ftch, ReactiveStore, type Tracer } from "@luna/core"; -import { getPlaybackInfo, type PlaybackInfo } from "../../helpers"; +import { getPlaybackInfo, parseDate, type PlaybackInfo } from "../../helpers"; import { libTrace, unloads } from "../../index.safe"; import * as redux from "../../redux"; import { Album } from "../Album"; @@ -13,7 +13,7 @@ import { PlayState } from "../PlayState"; import { Quality } from "../Quality"; import { TidalApi } from "../TidalApi"; import { download, downloadProgress } from "./MediaItem.download.native"; -import { makeTags, MetaTags } from "./MediaItem.tags"; +import { availableTags, makeTags, MetaTags } from "./MediaItem.tags"; type MediaFormat = { bitDepth?: number; @@ -29,6 +29,7 @@ type MediaItemCache = { export class MediaItem extends ContentBase { public static readonly trace: Tracer = libTrace.withSource(".MediaItem").trace; + public static readonly availableTags = availableTags; private static cache = ReactiveStore.getStore("@luna/MediaItemCache"); @@ -55,7 +56,7 @@ export class MediaItem extends ContentBase { return super.fromStore(itemId, "mediaItems", async (mediaItem) => { mediaItem = mediaItem ??= await this.fetchMediaItem(itemId, contentType); if (mediaItem === undefined) return; - return new MediaItem(itemId, mediaItem, await mediaItemCache); + return new MediaItem(itemId, mediaItem, contentType, await mediaItemCache); }); } public static fromIsrc: (isrc: string) => Promise = memoize(async (isrc) => { @@ -145,6 +146,7 @@ export class MediaItem extends ContentBase { constructor( public readonly id: redux.ItemId, tidalMediaItem: redux.MediaItem, + public readonly contentType: redux.ContentType, private readonly cache: MediaItemCache, ) { super(); @@ -230,7 +232,7 @@ export class MediaItem extends ContentBase { }); public async *isrcs(): AsyncIterable { - if (this.tidalItem.contentType !== "track") return; + if (this.contentType !== "track") return; const seen = new Set(); if (this.tidalItem.isrc) { yield this.tidalItem.isrc; @@ -259,17 +261,20 @@ export class MediaItem extends ContentBase { }); public releaseDate: () => Promise = memoize(async () => { - let releaseDate = this.tidalItem.releaseDate ?? this.tidalItem.streamStartDate; + let releaseDate = parseDate(this.tidalItem.releaseDate) ?? parseDate(this.tidalItem.streamStartDate); if (releaseDate === undefined) { const brainzItem = await this.brainzItem(); - releaseDate = brainzItem?.recording?.["first-release-date"] ?? releaseDate; + releaseDate = parseDate(brainzItem?.recording?.["first-release-date"]); } if (releaseDate === undefined) { const album = await this.album(); - releaseDate = album?.releaseDate ?? releaseDate; - releaseDate ??= (await album?.brainzAlbum())?.date ?? releaseDate; + releaseDate = parseDate(album?.releaseDate); + if (releaseDate === undefined) { + const brainzAlbum = await album?.brainzAlbum(); + releaseDate ??= parseDate(brainzAlbum?.date); + } } - if (releaseDate) return new Date(releaseDate); + return releaseDate; }); /** @@ -298,9 +303,6 @@ export class MediaItem extends ContentBase { // #endregion // #region Properties - public get contentType() { - return this.tidalItem.contentType; - } public get trackNumber() { return this.tidalItem.trackNumber; } @@ -311,18 +313,18 @@ export class MediaItem extends ContentBase { return this.tidalItem.peak; } public get replayGain(): number { - if (this.tidalItem.contentType !== "track") return 0; + if (this.contentType !== "track") return 0; return this.tidalItem.replayGain; } public get url(): string { return this.tidalItem.url; } public get qualityTags(): Quality[] { - if (this.tidalItem.contentType !== "track") return []; + if (this.contentType !== "track") return []; return Quality.fromMetaTags(this.tidalItem.mediaMetadata?.tags); } public get bestQuality(): Quality { - if (this.tidalItem.contentType !== "track") { + if (this.contentType !== "track") { this.trace.warn("MediaItem quality called on non-track!", this); return Quality.High; } diff --git a/plugins/lib/src/classes/TidalApi/index.ts b/plugins/lib/src/classes/TidalApi/index.ts index 4304fb3..2147b11 100644 --- a/plugins/lib/src/classes/TidalApi/index.ts +++ b/plugins/lib/src/classes/TidalApi/index.ts @@ -8,6 +8,7 @@ import { getCredentials } from "../../helpers"; import { libTrace } from "../../index.safe"; import * as redux from "../../redux"; +import type { AlbumPage } from "./types/AlbumPage"; import { PlaybackInfoResponse } from "./types/PlaybackInfo"; export type * from "./types"; @@ -61,9 +62,16 @@ export class TidalApi { return this.fetch(`https://desktop.tidal.com/v1/albums/${albumId}?${this.queryArgs()}`); } public static async albumItems(albumId: redux.ItemId) { - return this.fetch<{ items: redux.MediaItem[]; totalNumberOfItems: number; offset: number; limit: -1 }>( - `https://desktop.tidal.com/v1/albums/${albumId}/items?${this.queryArgs()}&limit=-1`, + const albumPage = await this.fetch( + `https://desktop.tidal.com/v1/pages/album?albumId=${albumId}&countryCode=NZ&locale=en_US&deviceType=DESKTOP`, ); + for (const row of albumPage?.rows ?? []) { + for (const module of row.modules) { + if (module.type === "ALBUM_ITEMS" && module.pagedList) { + return module.pagedList.items; + } + } + } } public static playlist(playlistUUID: redux.ItemId) { diff --git a/plugins/lib/src/classes/TidalApi/types/AlbumPage.ts b/plugins/lib/src/classes/TidalApi/types/AlbumPage.ts new file mode 100644 index 0000000..a3f40c1 --- /dev/null +++ b/plugins/lib/src/classes/TidalApi/types/AlbumPage.ts @@ -0,0 +1,15 @@ +import type { redux } from "@luna/lib"; + +export type AlbumPage = { + id: string; + title: string; + rows: { + modules: { + /** ALBUM_ITEMS is what we want */ + type: string; + pagedList?: { + items: redux.MediaItem[]; + }; + }[]; + }[]; +}; diff --git a/plugins/lib/src/helpers/index.ts b/plugins/lib/src/helpers/index.ts index 7e4b1f7..b38e3d3 100644 --- a/plugins/lib/src/helpers/index.ts +++ b/plugins/lib/src/helpers/index.ts @@ -2,4 +2,5 @@ export * from "./getCredentials"; export * from "./getPlaybackInfo"; export * from "./getPlaybackInfo.dasha.native"; export * from "./observable"; +export * from "./parseDate"; export * from "./safeTimeout"; diff --git a/plugins/lib/src/helpers/observable.ts b/plugins/lib/src/helpers/observable.ts index 94bd26d..b974939 100644 --- a/plugins/lib/src/helpers/observable.ts +++ b/plugins/lib/src/helpers/observable.ts @@ -1,6 +1,7 @@ // based on: https://github.com/KaiHax/kaihax/blob/master/src/patcher.ts import type { VoidFn } from "@inrixia/helpers"; +import type { LunaUnloads } from "@luna/core"; import { unloads } from "../index.safe"; export type ObserveCallback = (elem: E) => unknown; @@ -31,7 +32,7 @@ const observer = new MutationObserver((records) => { * @param cb The callback function to execute when a matching element is found cast to type T. * @returns An `Unload` function that, when called, will stop observing for this selector and callback pair. */ -export const observe = (selector: string, cb: ObserveCallback): VoidFn => { +export const observe = (unloads: LunaUnloads, selector: string, cb: ObserveCallback): VoidFn => { if (observables.size === 0) observer.observe(document.body, { subtree: true, @@ -39,10 +40,12 @@ export const observe = (selector: string, cb: Obser }); const entry: ObserveEntry = [selector, cb as ObserveCallback]; observables.add(entry); - return () => { + const unload = () => { observables.delete(entry); if (observables.size === 0) observer.disconnect(); }; + unloads.add(unload); + return unload; }; // Disconnect and remove observables on unload @@ -56,9 +59,9 @@ unloads.add(observables.clear.bind(observables)); * @param timeoutMs The maximum time (in milliseconds) to wait for the element to appear. * @returns A Promise that resolves with the found Element (cast to type T) or null if the timeout is reached. */ -export const observePromise = (selector: string, timeoutMs: number = 1000): Promise => +export const observePromise = (unloads: LunaUnloads, selector: string, timeoutMs: number = 1000): Promise => new Promise((res) => { - const unob = observe(selector, (elem) => { + const unob = observe(unloads, selector, (elem) => { unob(); clearTimeout(timeout); res(elem as T); diff --git a/plugins/lib/src/helpers/parseDate.ts b/plugins/lib/src/helpers/parseDate.ts new file mode 100644 index 0000000..54bee04 --- /dev/null +++ b/plugins/lib/src/helpers/parseDate.ts @@ -0,0 +1,6 @@ +export const parseDate = (date: string | Date | null | undefined): Date | undefined => { + if (date === null || date === undefined) return undefined; + if (typeof date === "string") date = new Date(date); + if (isNaN(date.getTime())) return undefined; + return date; +}; diff --git a/plugins/lib/src/index.ts b/plugins/lib/src/index.ts index 5d26b8d..ec6cb80 100644 --- a/plugins/lib/src/index.ts +++ b/plugins/lib/src/index.ts @@ -8,7 +8,9 @@ export * as redux from "./redux"; import { observePromise } from "./helpers/observable"; -observePromise("div[class^='_mainContainer'] > div[class^='_bar'] > div[class^='_title']", 30000).then((title) => { +import { unloads } from "./index.safe"; + +observePromise(unloads, "div[class^='_mainContainer'] > div[class^='_bar'] > div[class^='_title']", 30000).then((title) => { if (title !== null) title.innerHTML = 'TIDALuna BETA'; }); diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx index 67cae90..df028b9 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx @@ -82,7 +82,9 @@ export const LunaStore = React.memo(({ url, onRemove }: { url: string; onRemove: } /> - {pkg?.plugins.map((plugin) => } />)} + {pkg?.plugins.map((plugin) => ( + } /> + ))} ); diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx index 5d505a2..5a63e77 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx @@ -18,6 +18,8 @@ export const LunaStorePlugin = React.memo(({ url }: { url: string }) => { if (!plugin) return null; + const version = url.startsWith("http://127.0.0.1") ? `${plugin.package?.version ?? ""} [DEV]` : plugin.package?.version; + return ( setIsHovered(true)} @@ -41,6 +43,7 @@ export const LunaStorePlugin = React.memo(({ url }: { url: string }) => { { }; // Devs! Add your stores here <3 -// TODO: Abstract this to a git repo with versioned stores +// TODO: Abstract this to a git repo addToStores("https://github.com/Inrixia/neptune-plugins/releases/download/dev/store.json"); +addToStores("https://github.com/wont-stream/lunar/releases/download/dev/store.json"); +addToStores("https://github.com/jxnxsdev/luna-plugins/releases/download/latest/store.json"); export const PluginStoreTab = React.memo(() => { const [_storeUrls, setPluginStores] = useState(obyStore.unwrap(storeUrls)); diff --git a/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx b/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx index 3dec59c..fa1b61d 100644 --- a/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx +++ b/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx @@ -10,16 +10,20 @@ import { LunaAuthorDisplay, LunaLink } from "../../components"; export interface LunaPluginComponentProps extends PropsWithChildren { name: string; + version?: string; link?: string; loadError?: string; author?: LunaAuthor | string; desc?: ReactNode; sx?: BoxProps["sx"]; } -export const LunaPluginHeader = React.memo(({ name, loadError, author, desc, children, sx, link }: LunaPluginComponentProps) => ( +export const LunaPluginHeader = React.memo(({ name, version, loadError, author, desc, children, sx, link }: LunaPluginComponentProps) => ( - } /> + + {name} + {version && } + {children} {loadError && ( ( height: 40, ...props.sx, }} - children={props.children} + children={props.children ?? props.title} /> } /> diff --git a/render/src/LunaPlugin.ts b/render/src/LunaPlugin.ts index b52219c..6d9ea56 100644 --- a/render/src/LunaPlugin.ts +++ b/render/src/LunaPlugin.ts @@ -108,7 +108,7 @@ export class LunaPlugin { if (name in this.plugins) return this.plugins[name]; // Disable liveReload on load so people dont accidentally leave it on - storeInit.liveReload ??= false; + storeInit.liveReload = false; const store = await LunaPlugin.pluginStorage.getReactive(name); Object.assign(store, storeInit);