diff --git a/bun.lockb b/bun.lockb index b1b27fe5..9d4f65a7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 1b3a6d18..8972f2da 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,7 +1,6 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite"; import { resolve } from "path"; import devtools from "solid-devtools/vite"; -import lucidePreprocess from "vite-plugin-lucide-preprocess"; import solid from "vite-plugin-solid"; export default defineConfig({ @@ -18,7 +17,6 @@ export default defineConfig({ }, }, plugins: [ - lucidePreprocess(), devtools({ autoname: true, locator: { diff --git a/package.json b/package.json index fe8b4f1e..186d9c4a 100644 --- a/package.json +++ b/package.json @@ -36,9 +36,10 @@ "fastest-levenshtein": "^1.0.16", "get-audio-duration": "^4.0.1", "graceful-fs": "^4.2.11", - "lucide-solid": "^0.452.0", + "lucide-solid": "^0.460.0", "node-addon-api": "^8.2.1", "polished": "^4.3.1", + "realm": "~12.6.2", "sharp": "^0.33.5", "solid-focus-trap": "^0.1.7", "tailwind-merge": "^2.5.3" diff --git a/src/RequestAPI.d.ts b/src/RequestAPI.d.ts index 3784e3c2..2e06b5f8 100644 --- a/src/RequestAPI.d.ts +++ b/src/RequestAPI.d.ts @@ -15,6 +15,7 @@ import type { } from "./@types"; import type { SearchQuery } from "./main/lib/search-parser/@search-types"; import type { ConfigError, ConfigSuccess } from "./main/lib/template-parser/parser/TemplateParser"; +import { OsuDirectory } from "./main/router/dir-router"; export type RequestAPI = { "resource::get": ( @@ -37,11 +38,11 @@ export type RequestAPI = { "queue::create": (payload: QueueCreatePayload) => void; "queue::shuffle": () => void; - "dir::select": () => Optional; - "dir::autoGetOsuDir": () => Optional; - "dir::submit": (dir: string) => void; + "dir::select": () => Optional; + "dir::autoGetOsuDirs": () => Optional; + "dir::submit": (dir: OsuDirectory) => void; - "discord::play": (song: Song, duration?: number) => void; + "discord::play": (song: Song, length: number, duration: number) => void; "discord::pause": (song: Song) => void; "error::dismissed": () => void; diff --git a/src/main/lib/osu-file-parser/LazerTypes.ts b/src/main/lib/osu-file-parser/LazerTypes.ts new file mode 100644 index 00000000..9ce80667 --- /dev/null +++ b/src/main/lib/osu-file-parser/LazerTypes.ts @@ -0,0 +1,67 @@ +type BeatmapMetadata = { + Title: string; + TitleUnicode: string; + Artist: string; + ArtistUnicode: string; + Author?: { OnlineID: number; Username: string; CountryCode: string }; + Source?: string; + Tags?: string; + PreviewTime: number; + AudioFile: string; + BackgroundFile?: string; +}; + +export type Beatmap = { + ID: Realm.BSON.UUID; + DifficultyName?: string; + Metadata: BeatmapMetadata; + BeatmapSet?: BeatmapSet; + Status: number; + OnlineID: number; + Length: number; + BPM: number; + Hash: string; + StarRating: number; + MD5Hash?: string; + OnlineMD5Hash?: string; + LastLocalUpdate?: string; + LastOnlineUpdate?: string; + Hidden: boolean; + EndTimeObjectCount: number; + TotalObjectCount: number; + AudioLeadIn: number; + StackLeniency: number; + SpecialStyle: boolean; + LetterboxInBreaks: boolean; + WidescreenStoryboard: boolean; + EpilepsyWarning: boolean; + SamplesMatchPlaybackRate: boolean; + LastPlayed?: string; + DistanceSpacing: number; + BeatDivisor: number; + GridSize: number; + TimelineZoom: number; + EditorTimestamp?: number; + CountdownOffset: number; +}; + +type RealmFile = { + File: { + Hash: string; + }; + Filename: string; +}; + +export type BeatmapSet = { + ID: Realm.BSON.UUID; + OnlineID: number; + DateAdded: string; + DateSubmitted?: string; + DateRanked?: string; + Beatmaps: Beatmap[]; + Status: number; + DeletePending: boolean; + Hash?: string; + Protected: boolean; + Files: RealmFile[]; +}; diff --git a/src/main/lib/osu-file-parser/OsuParser.ts b/src/main/lib/osu-file-parser/OsuParser.ts index da0acc8f..ef09d010 100644 --- a/src/main/lib/osu-file-parser/OsuParser.ts +++ b/src/main/lib/osu-file-parser/OsuParser.ts @@ -2,11 +2,13 @@ import { AudioSource, ImageSource, ResourceID, Result, Song } from "../../../@ty import { access } from "../fs-promises"; import { fail, ok } from "../rust-like-utils-backend/Result"; import { assertNever } from "../tungsten/assertNever"; +import { BeatmapSet } from "./LazerTypes"; import { OsuFile } from "./OsuFile"; import fs from "graceful-fs"; import os from "os"; import path from "path/posix"; import readline from "readline"; +import Realm from "realm"; const bgFileNameRegex = /.*"(?, Table, Table], string> >; -// Overriding Buffer prototype because I'm lazy. -// Should probably get moved to another file, or make a wrapper instead. - class BufferReader { buffer: Buffer; pos: number; @@ -113,7 +112,112 @@ class BufferReader { } export class OsuParser { - static async parseDatabase( + static async parseLazerDatabase( + databasePath: string, + update?: (i: number, total: number, file: string) => any, + ): DirParseResult { + const currentDir = databasePath.replaceAll("\\", "/"); + + const realm = await Realm.open({ + path: currentDir + "/client.realm", + readOnly: true, + schemaVersion: 23, + }); + const beatmapSets = realm.objects("BeatmapSet"); + + const songTable = new Map(); + const audioTable = new Map(); + const imageTable = new Map(); + + let i = 0; + for (const beatmapSet of beatmapSets) { + try { + const beatmaps = beatmapSet.Beatmaps; + + for (const beatmap of beatmaps) { + try { + const song: Song = { + audio: "", + osuFile: "", + path: "", + ctime: "", + dateAdded: beatmapSet.DateAdded, + title: beatmap.Metadata.Title, + artist: beatmap.Metadata.Artist, + creator: beatmap.Metadata.Author?.Username ?? "No Creator", + bpm: [[beatmap.BPM]], + duration: beatmap.Length, + diffs: [beatmap.DifficultyName ?? "Unknown difficulty"], + }; + + song.osuFile = path.join( + currentDir, + "files", + beatmap.Hash[0], + beatmap.Hash.substring(0, 2), + beatmap.Hash, + ); + + const songHash = beatmapSet.Files.find( + (file) => file.Filename.toLowerCase() === beatmap.Metadata.AudioFile.toLowerCase(), + )?.File.Hash; + + if (songHash) { + song.audio = path.join( + currentDir, + "files", + songHash[0], + songHash.substring(0, 2), + songHash, + ); + } + + const existingSong = songTable.get(song.audio); + if (existingSong) { + existingSong.diffs.push(song.diffs[0]); + continue; + } + + /* Note: in lots of places throughout the application, it relies on the song.path parameter, which in the + stable parser is the path of the folder that holds all the files. This folder doesn't exist in lazer's + file structure, so for now I'm just passing the audio location as the path parameter. In initial testing + this doesn't seem to break anything but just leaving this note in case it does */ + song.path = song.audio; + + const bgHash = beatmapSet.Files.find( + (file) => file.Filename === beatmap.Metadata.BackgroundFile, + )?.File.Hash; + + if (bgHash) { + song.bg = path.join(currentDir, "files", bgHash[0], bgHash.substring(0, 2), bgHash); + } + + song.beatmapSetID = beatmapSet.OnlineID; + + songTable.set(song.audio, song); + audioTable.set(song.audio, { + songID: song.audio, + path: song.audio, + ctime: String(beatmapSet.DateAdded), + }); + + if (update) { + update(i + 1, beatmapSets.length, song.title); + i++; + } + } catch (err) { + console.error("Error while parsing beatmap: ", err); + } + } + } catch (err) { + console.error("Error while parsing beatmapset: ", err); + } + } + + return ok([songTable, audioTable, imageTable]); + } + + static async parseStableDatabase( databasePath: string, update?: (i: number, total: number, file: string) => any, ): DirParseResult { @@ -300,11 +404,11 @@ export class OsuParser { db.readInt(); // last edit time db.readByte(); // mania scroll speed - const audioFilePath = songsFolderPath + "/" + folder + "/" + audio_filename; - const osuFilePath = songsFolderPath + "/" + folder + "/" + osu_filename; + const audioFilePath = path.join(songsFolderPath, folder, audio_filename); + const osuFilePath = path.join(songsFolderPath, folder, osu_filename); song.osuFile = osuFilePath; song.audio = audioFilePath; - song.path = songsFolderPath + "/" + folder; + song.path = path.join(songsFolderPath, folder); // Check if the song has already been processed, and add the diff name to the existing song if so const existingSong = songTable.get(audioFilePath); @@ -320,7 +424,9 @@ export class OsuParser { } const bgSrc = osuFile.value.props.get("bgSrc"); - song.bg = songsFolderPath + "/" + folder + "/" + bgSrc; + if (bgSrc) { + song.bg = path.join(songsFolderPath, folder, bgSrc); + } if (song.audio != last_audio_filepath) { songTable.set(song.audio, song); diff --git a/src/main/main.ts b/src/main/main.ts index d55b7afd..9f01e6a5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -54,7 +54,7 @@ async function configureOsuDir(mainWindow: BrowserWindow) { while (true) { await Router.dispatch(mainWindow, "changeScene", "dir-select"); - const dir = await dirSubmit(); + const dirData = await dirSubmit(); await Router.dispatch(mainWindow, "changeScene", "loading"); await Router.dispatch(mainWindow, "loadingScene::setTitle", "Importing songs from osu!"); @@ -68,7 +68,11 @@ async function configureOsuDir(mainWindow: BrowserWindow) { }); }, UPDATE_DELAY_MS); - tables = await OsuParser.parseDatabase(dir, update); + if (dirData.version == "stable") { + tables = await OsuParser.parseStableDatabase(dirData.path, update); + } else { + tables = await OsuParser.parseLazerDatabase(dirData.path, update); + } // Cancel ongoing throttled update, so it does not look bad when it finishes and afterward the update overwrites // finished state cancelUpdate(); @@ -82,14 +86,14 @@ async function configureOsuDir(mainWindow: BrowserWindow) { if (tables.value[SONGS].size === 0) { await showError( mainWindow, - `No songs found in folder: ${dir}. Please make sure this is the directory where you have all your songs saved.`, + `No songs found in folder: ${dirData.path}. Please make sure this is the directory where you have all your songs saved.`, ); // Try again continue; } // All went smoothly. Save osu directory and continue with import procedure - settings.write("osuSongsDir", dir); + settings.write("osuSongsDir", dirData.path); break; } diff --git a/src/main/router/dir-router.ts b/src/main/router/dir-router.ts index 11d03bd8..fa7c52ef 100644 --- a/src/main/router/dir-router.ts +++ b/src/main/router/dir-router.ts @@ -1,54 +1,130 @@ import { Router } from "../lib/route-pass/Router"; import { none, some } from "../lib/rust-like-utils-backend/Optional"; import { dialog } from "electron"; +import fs from "fs"; import path from "path"; +export type OsuDirectory = { + version: "stable" | "lazer" | "none"; + path: string; +}; + Router.respond("dir::select", () => { - const path = dialog.showOpenDialogSync({ + const result = dialog.showOpenDialogSync({ title: "Select your osu! folder", properties: ["openDirectory"], }); - if (path === undefined) { + if (result === undefined) { return none(); } + const p = result[0]; - return some(path[0]); + if (fs.existsSync(path.join(p, "osu!.db"))) { + return some({ version: "stable", path: p }); + } else if (fs.existsSync(path.join(p, "client.realm"))) { + return some({ version: "lazer", path: p }); + } else { + return none(); + } }); -Router.respond("dir::autoGetOsuDir", () => { +function getStoragePath(iniPath: string) { + const firstLine = fs.readFileSync(iniPath, "utf-8").split("=")[1]; + return firstLine.trim(); +} + +Router.respond("dir::autoGetOsuDirs", () => { if (process.platform === "win32") { - if (process.env.LOCALAPPDATA === undefined) { + const dirs: OsuDirectory[] = []; + + if ( + process.env.LOCALAPPDATA != undefined && + fs.existsSync(path.join(process.env.LOCALAPPDATA, "osu!")) + ) { + dirs.push({ version: "stable", path: path.join(process.env.LOCALAPPDATA, "osu!") }); + } + + if (process.env.APPDATA != undefined && fs.existsSync(path.join(process.env.APPDATA, "osu"))) { + if (fs.existsSync(path.join(process.env.APPDATA, "osu", "client.realm"))) { + dirs.push({ version: "lazer", path: path.join(process.env.APPDATA, "osu") }); + } else if (fs.existsSync(path.join(process.env.APPDATA, "osu", "storage.ini"))) { + const p = getStoragePath(path.join(process.env.APPDATA, "osu", "storage.ini")); + dirs.push({ version: "lazer", path: p }); + } + } + + if (dirs.length > 0) { + return some(dirs); + } else { return none(); } - return some(path.join(process.env.LOCALAPPDATA, "osu!")); } else if (process.platform === "linux") { - if (process.env.XDG_DATA_HOME === undefined) { + const dirs: OsuDirectory[] = []; + const homePath = process.env.XDG_DATA_HOME ?? `${process.env.HOME}/.local/share`; + + if (homePath != undefined && fs.existsSync(path.join(homePath, "osu-wine", "osu!"))) { + dirs.push({ + version: "stable", + path: path.join(homePath, "osu-wine", "osu!"), + }); + } + + if (homePath != undefined && fs.existsSync(path.join(homePath, "osu"))) { + if (fs.existsSync(path.join(homePath, "osu", "client.realm"))) { + dirs.push({ version: "lazer", path: path.join(homePath, "osu") }); + } else if (fs.existsSync(path.join(homePath, "osu", "storage.ini"))) { + const p = getStoragePath(path.join(homePath, "osu", "storage.ini")); + dirs.push({ version: "lazer", path: p }); + } + } + + if (dirs.length > 0) { + return some(dirs); + } else { return none(); } - return some(path.join(process.env.XDG_DATA_HOME, "osu-wine", "osu!")); + } else if (process.platform === "darwin" && process.env.HOME) { + if ( + fs.existsSync( + path.join(process.env.HOME, "Library", "Application Support", "osu", "client.realm"), + ) + ) { + return some([ + { + version: "lazer", + path: path.join(process.env.HOME, "Library", "Application Support", "osu"), + }, + ]); + } else if ( + fs.existsSync( + path.join(process.env.HOME, "Library", "Application Support", "osu", "storage.ini"), + ) + ) { + const p = getStoragePath( + path.join(process.env.HOME, "Library", "Application Support", "osu", "storage.ini"), + ); + return some([{ version: "lazer", path: p }]); + } + return none(); } return none(); }); -let pendingDirRequests: ((dir: string) => void)[] = []; +type DirSubmitResolve = (value: OsuDirectory) => void; -Router.respond("dir::submit", (_evt, dir) => { - // Resolve all pending promises with value from client - for (let i = 0; i < pendingDirRequests.length; i++) { - pendingDirRequests[i](dir); - } +let pendingDirRequest: DirSubmitResolve | undefined = undefined; - pendingDirRequests = []; +Router.respond("dir::submit", (_evt, dir: OsuDirectory) => { + if (pendingDirRequest) { + pendingDirRequest(dir); + + pendingDirRequest = undefined; + } }); -/** - * Await submitted directory from client. This function works on suspending promise's resolve function in array of - * pending requests. When user clicks Submit button the directory is passed to all pending resolve functions and the - * promises are resolved - */ -export function dirSubmit(): Promise { +export function dirSubmit(): Promise { return new Promise((resolve) => { - pendingDirRequests.push(resolve); + pendingDirRequest = resolve; }); } diff --git a/src/main/router/discord-router.ts b/src/main/router/discord-router.ts index 13634e3c..b039279e 100644 --- a/src/main/router/discord-router.ts +++ b/src/main/router/discord-router.ts @@ -13,10 +13,9 @@ const defaultPresence: SetActivity = { type: 2, // listening buttons: [{ label: "Check out osu!radio", url: "https://github.com/Team-BTMC/osu-radio" }], }; - -Router.respond("discord::play", async (_evt, song, duration) => { - const startTimestamp = new Date(new Date().getTime() - (duration ? duration * 1000 : 0)); - const endTimestamp = startTimestamp.getTime() + song.duration * 1000; +Router.respond("discord::play", async (_evt, song, length, position) => { + const endTimestamp = new Date(new Date().getTime() + (length - position) * 1000); + const startTimestamp = new Date(endTimestamp.getTime() - length * 1000); const response = await fetch( `https://assets.ppy.sh/beatmaps/${song.beatmapSetID}/covers/list@2x.jpg`, diff --git a/src/renderer/src/assets/osu-lazer-logo.png b/src/renderer/src/assets/osu-lazer-logo.png new file mode 100644 index 00000000..73b60ecf Binary files /dev/null and b/src/renderer/src/assets/osu-lazer-logo.png differ diff --git a/src/renderer/src/assets/osu-stable-logo.png b/src/renderer/src/assets/osu-stable-logo.png new file mode 100644 index 00000000..db7b6e89 Binary files /dev/null and b/src/renderer/src/assets/osu-stable-logo.png differ diff --git a/src/renderer/src/components/song/song.utils.ts b/src/renderer/src/components/song/song.utils.ts index eecff1ec..b1304126 100644 --- a/src/renderer/src/components/song/song.utils.ts +++ b/src/renderer/src/components/song/song.utils.ts @@ -29,16 +29,16 @@ const [media, setMedia] = createSignal(); export { media, setMedia }; const [song, setSong] = createSignal(DEFAULT_SONG); -export { song, setSong }; +export { setSong, song }; const [duration, setDuration] = createSignal(0); export { duration, setDuration }; const [timestamp, setTimestamp] = createSignal(0); -export { timestamp, setTimestamp }; +export { setTimestamp, timestamp }; const [valueBeforeMute, setValueBeforeMute] = createSignal(); -export { valueBeforeMute, setValueBeforeMute }; +export { setValueBeforeMute, valueBeforeMute }; const [isSeeking, setIsSeeking] = createSignal({ value: false, @@ -57,7 +57,7 @@ export const setSpeed = (newValue: ZeroToOne) => { _setSpeed(newValue); player.playbackRate = newValue; }; -export { volume, speed }; +export { speed, volume }; let bgPath: Optional; @@ -123,10 +123,18 @@ export async function play(): Promise { setIsPlaying(true); await player.play().catch((reason) => console.error(reason)); - await setMediaSession(currentSong); + const waitForDuration = async (): Promise => { + while (isNaN(player.duration)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return player.duration; + }; + const duration = await waitForDuration(); + await window.api.request("discord::play", currentSong, duration, player.currentTime); - await window.api.request("discord::play", currentSong, player.currentTime); - document.title = `${currentSong.artist} - ${currentSong.title}`; + setIsPlaying(true); + + await setMediaSession(currentSong); } export async function pause() { @@ -300,7 +308,6 @@ window.api.listen("queue::songChanged", async (s) => { setMedia(new URL(resource.value)); setSong(s); - await window.api.request("discord::play", s); await play(); player.playbackRate = speed(); }); @@ -323,9 +330,6 @@ player.addEventListener("timeupdate", async () => { setTimestamp(player.currentTime); const currentSong = song(); - // Discord - await window.api.request("discord::play", currentSong, player.currentTime); - // Media session setMediaSessionPosition(); @@ -378,7 +382,7 @@ export const handleSeekEnd = () => { } if (!pausedSeekingStart) { - player.play(); + play(); } }; diff --git a/src/renderer/src/scenes/dir-select-scene/DirSelectScene.tsx b/src/renderer/src/scenes/dir-select-scene/DirSelectScene.tsx index c9b3c193..6d0bac92 100644 --- a/src/renderer/src/scenes/dir-select-scene/DirSelectScene.tsx +++ b/src/renderer/src/scenes/dir-select-scene/DirSelectScene.tsx @@ -1,20 +1,26 @@ +import "../main-scene/styles.css"; +import osuLazerLogo from "@renderer/assets/osu-lazer-logo.png"; +import osuStableLogo from "@renderer/assets/osu-stable-logo.png"; import Button from "@renderer/components/button/Button"; -import { createSignal, onMount } from "solid-js"; +import { WindowsControls } from "@renderer/components/windows-control/WindowsControl"; +import { Accessor, Component, createSignal, For, onMount, Setter, Show } from "solid-js"; +import { OsuDirectory } from "src/main/router/dir-router"; export default function DirSelectScene() { - const [dir, setDir] = createSignal(""); + const [dirs, setDirs] = createSignal([]); + const [selectedDir, setSelectedDir] = createSignal({ version: "none", path: "" }); - onMount(() => { - autodetectDir(); + onMount(async () => { + await autodetectDir(); }); const autodetectDir = async () => { - const autoGetDir = await window.api.request("dir::autoGetOsuDir"); + const autoGetDir = await window.api.request("dir::autoGetOsuDirs"); if (autoGetDir.isNone) { return; } - setDir(autoGetDir.value); + setDirs(autoGetDir.value); }; const selectDir = async () => { @@ -23,17 +29,28 @@ export default function DirSelectScene() { return; } - setDir(opt.value); + setDirs((dirs) => [...dirs, opt.value]); + setSelectedDir(opt.value); }; const submitDir = async () => { - if (dir() === "") { + if (selectedDir().version === "none") { return; } - await window.api.request("dir::submit", dir()); + await window.api.request("dir::submit", selectedDir()); }; + const [os, setOs] = createSignal(); + + onMount(async () => { + const fetchOS = async () => { + return await window.api.request("os::platform"); + }; + + setOs(await fetchOS()); + }); + const GRADIENT = `radial-gradient(at 1% 75%, hsla(228,61%,67%,0.1) 0px, transparent 50%), radial-gradient(at 20% 56%, hsla(87,75%,61%,0.1) 0px, transparent 50%), radial-gradient(at 35% 34%, hsla(50,72%,67%,0.1) 0px, transparent 50%), @@ -44,34 +61,40 @@ export default function DirSelectScene() { return (
+
+ {os() !== "darwin" && } +

Welcome to osu! radio

-
- -
- {" "} - {dir() === "" ? "[No folder selected]" : dir()}{" "} - +

You can always change this in settings.

-

You can always change this in settings.

-
@@ -79,3 +102,38 @@ export default function DirSelectScene() {
); } + +type InstallationCardProps = { + directory: OsuDirectory; + selectedDir: Accessor; + setSelectedDir: Setter; +}; + +const InstallationCard: Component = (props) => { + return ( +
props.setSelectedDir(props.directory)} + > + +
+

{props.directory.path}

+
+
+ {props.directory.version.toUpperCase()} +
+
+
+
+ ); +}; diff --git a/src/renderer/src/scenes/main-scene/MainScene.tsx b/src/renderer/src/scenes/main-scene/MainScene.tsx index d5cf72ac..27305867 100644 --- a/src/renderer/src/scenes/main-scene/MainScene.tsx +++ b/src/renderer/src/scenes/main-scene/MainScene.tsx @@ -15,12 +15,20 @@ import SongImage from "@renderer/components/song/SongImage"; import SongQueue from "@renderer/components/song/song-queue/SongQueue"; import { song } from "@renderer/components/song/song.utils"; import { WindowsControls } from "@renderer/components/windows-control/WindowsControl"; -import { os } from "@renderer/lib/os"; import { Layers3Icon } from "lucide-solid"; -import { Accessor, Component, createSignal, Match, Switch } from "solid-js"; +import { Accessor, Component, createSignal, Match, onMount, Switch } from "solid-js"; const MainScene: Component = () => { const { maxSidebarWidth, offsetFromPanel } = useMainResizableOptions(); + const [os, setOs] = createSignal(); + + onMount(async () => { + const fetchOS = async () => { + return await window.api.request("os::platform"); + }; + + setOs(await fetchOS()); + }); return (