Switched to Solid js and Vite

This commit is contained in:
CaptSiro
2023-06-28 11:53:58 +02:00
commit 02ec236a01
63 changed files with 7227 additions and 0 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

20
.eslintrc Normal file
View File

@@ -0,0 +1,20 @@
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"plugin:@typescript-eslint/recommended"
],
"overrides": [
{
"files": ["*"],
"rules": {
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/ban-types": "off"
}
}
]
}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
out
*.log*

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

64
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,64 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_UNIFORM_INDENT" value="true" />
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
<option name="HTML_DO_NOT_INDENT_CHILDREN_OF" value="html" />
<option name="HTML_QUOTE_STYLE" value="Single" />
<option name="HTML_ENFORCE_QUOTES" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="KEEP_BLANK_LINES_IN_CODE" value="3" />
<option name="BLANK_LINES_AFTER_IMPORTS" value="3" />
<option name="BLANK_LINES_AROUND_CLASS" value="3" />
<option name="BLANK_LINES_AROUND_METHOD" value="3" />
<option name="SOFT_MARGINS" value="100" />
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="BLANK_LINES_AFTER_IMPORTS" value="3" />
<option name="BLANK_LINES_AROUND_CLASS" value="3" />
<option name="SOFT_MARGINS" value="100" />
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="TypeScriptCompiler">
<option name="recompileOnChanges" value="true" />
</component>
</project>

7
.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

View File

@@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="JSXNamespaceValidation" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
</profile>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/osu-radio-solidjs.iml" filepath="$PROJECT_DIR$/.idea/osu-radio-solidjs.iml" />
</modules>
</component>
</project>

12
.idea/osu-radio-solidjs.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json

4
.prettierrc.yaml Normal file
View File

@@ -0,0 +1,4 @@
singleQuote: false
semi: true
printWidth: 100
trailingComma: none

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

39
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,39 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
# osu-radio-solidjs
An Electron application with Solid and TypeScript
## Recommended IDE Setup
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## Project Setup
### Install
```bash
$ npm install
```
### Development
```bash
$ npm run dev
```
### Build
```bash
# For windows
$ npm run build:win
# For macOS
$ npm run build:mac
# For Linux
$ npm run build:linux
```

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

36
build/notarize.js Normal file
View File

@@ -0,0 +1,36 @@
const { notarize } = require('@electron/notarize')
module.exports = async (context) => {
if (process.platform !== 'darwin') return
console.log('aftersign hook triggered, start to notarize app.')
if (!process.env.CI) {
console.log(`skipping notarizing, not in CI.`)
return
}
if (!('APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env)) {
console.warn('skipping notarizing, APPLE_ID and APPLE_ID_PASS env variables must be set.')
return
}
const appId = 'com.electron.app'
const { appOutDir } = context
const appName = context.packager.appInfo.productFilename
try {
await notarize({
appBundleId: appId,
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLEIDPASS
})
} catch (error) {
console.error(error)
}
console.log(`done notarizing ${appId}.`)
}

3
dev-app-update.yml Normal file
View File

@@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: osu-radio-solidjs-updater

43
electron-builder.yml Normal file
View File

@@ -0,0 +1,43 @@
appId: com.electron.app
productName: osu-radio-solidjs
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
afterSign: build/notarize.js
win:
executableName: osu-radio-solidjs
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates

20
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import solid from 'vite-plugin-solid'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [solid()]
}
})

4774
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "osu-radio-solidjs",
"version": "1.0.0",
"description": "An Electron application with Solid and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://www.electronjs.org",
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:win": "npm run build && electron-builder --win --config",
"build:mac": "electron-vite build && electron-builder --mac --config",
"build:linux": "electron-vite build && electron-builder --linux --config"
},
"dependencies": {
"@electron-toolkit/preload": "^2.0.0",
"@electron-toolkit/utils": "^1.0.2",
"electron-updater": "^5.3.0",
"get-audio-duration": "^4.0.0"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron/notarize": "^1.2.3",
"@types/node": "^18.16.16",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^5.60.1",
"electron": "^24.4.1",
"electron-builder": "^23.6.0",
"electron-vite": "^1.0.23",
"eslint": "^8.42.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-solid": "^0.12.1",
"prettier": "^2.8.8",
"solid-js": "^1.7.6",
"typescript": "^5.1.3",
"vite": "^4.3.9",
"vite-plugin-solid": "^2.7.0"
}
}

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

96
src/@types.d.ts vendored Normal file
View File

@@ -0,0 +1,96 @@
export type Optional<T> = {
value: T,
isNone: false;
} | {
isNone: true
}
export type Result<T, E> = {
value: T,
isError: false,
} | {
error: E,
isError: true
}
export type WatchFile = {
/** Only file name, not absolute path */
fileName: string,
ctime: string
};
export type Song = {
/** Path to audio source (unique factor) */
id: string,
audio ?: WatchFile & { volume?: number },
bg ?: WatchFile,
config ?: WatchFile,
dir: string,
title: string,
artist: string,
creator: string,
bpm: number[][],
duration: number,
beatmapSetID ?: number,
mode?: number,
titleUnicode?: string,
artistUnicode?: string,
tags?: string[],
};
export type SongIndex = {
id: string,
t: string,
a: string,
c: string,
tags: string[],
bpm: number
};
export type Settings = {
volume: number,
osuDir: string,
};
type OmitPropsWithReturnType<O extends { [K: string]: (...args: unknown[]) => unknown }, V> = {
[K in keyof O as ReturnType<O[K]> extends V ? never : K]: O[K]
}
export type APIFunction<F extends (...args: unknown) => unknown> = (evt: Electron.IpcMainInvokeEvent, ...args: Parameters<F>) => ReturnType<F> | Promise<ReturnType<F>>;
export type API = {
queueCurrent: () => Song,
queueNext: () => void,
queuePrevious: () => void
}
export type PacketType = "DATA" | "ERROR"
export type Packet<T> = {
type: PacketType,
data: T,
token: string,
channel: string,
reason?: string,
}
export type APIListener<A, R> = (...args: A) => R

65
src/main/index.ts Normal file
View File

@@ -0,0 +1,65 @@
import { app, BrowserWindow } from 'electron';
import { join } from 'path';
import { electronApp, is, optimizer } from '@electron-toolkit/utils';
import icon from '../../resources/icon.png?asset';
import { Router } from './lib/route-pass/Router';
async function createWindow() {
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
});
mainWindow.on('ready-to-show', () => {
mainWindow.show();
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
await mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else {
await mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
Router.dispatch(mainWindow, "log", {
packed: "bytes",
payload: 69420n
}).then(console.log);
Router.dispatch(mainWindow, "non-existent", "bruh").catch(console.log);
}
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron');
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
createWindow();
app.on('activate', function() {
// On macOS, it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// routers

23
src/main/lib/Signal.ts Normal file
View File

@@ -0,0 +1,23 @@
type SignalListener<T> = (value: T) => any;
export class Signal<T> {
#value: T;
#listeners: SignalListener<T>[] = [];
constructor(startValue?: T) {
this.#value = startValue;
}
listen(listener: SignalListener<T>): void {
this.#listeners.push(listener);
}
set value(value: T) {
this.#value = value;
for (let i = 0; i < this.#listeners.length; i++) {
this.#listeners[i](value);
}
}
}

View File

@@ -0,0 +1,266 @@
import { Optional, Result, Song } from '../../../@types';
import fs from 'fs';
import readline from 'readline';
import path from 'path';
import getAudioDurationInSeconds from 'get-audio-duration';
import { WatchFile } from './WatchFile';
import { none, some } from '../rust-like-utils-backend/Optional';
import { fail, ok } from '../rust-like-utils-backend/Result';
import { Signal } from '../Signal';
const bgFileNameRegex = /.*"(?<!Video.*)(.*)".*/;
const beatmapSetIDRegex = /([0-9]+) .*/;
const propertiesMap: Map<string, string> = new Map([
['AudioFilename', 'audioSrc'],
['BeatmapSetID', 'beatmapSetID'],
['Title', 'title'],
['TitleUnicode', 'titleUnicode'],
['Artist', 'artist'],
['ArtistUnicode', 'artistUnicode'],
['Creator', 'creator'],
['Tags', 'tags'],
['Mode', 'mode']
]);
type FileState =
'Initial'
| 'General'
| 'Editor'
| 'Metadata'
| 'Difficulty'
| 'Events'
| 'TimingPoints'
| 'Colours'
| 'HitObjects';
type BPM = number[];
const OFFSET = 0;
const BPM = 1;
const audioSourceNotFound = 'Audio does not exists.' as const;
export class OsuFileParser {
private readonly file: string;
private audioSourceToken = 'AudioFilename: ';
private constructor(file: string) {
this.file = file;
}
static new(file: string): Optional<OsuFileParser> {
if (!fs.existsSync(file)) {
return none();
}
return some(new OsuFileParser(file));
}
static async parseSong(osuFile: string, obj: any): Promise<Result<Song, string>> {
const config = WatchFile.new(osuFile);
if (config.isError) {
return fail(config.error);
}
obj.config = config.value;
if (obj.audioSrc === undefined) {
return fail(audioSourceNotFound);
}
obj.dir = path.dirname(osuFile);
obj.id = path.join(obj.dir, obj.audioSrc);
const audio = WatchFile.new(obj.id);
if (audio.isError) {
return fail(audio.error);
}
obj.audio = audio.value;
delete obj.audioSrc;
if (obj.bgSrc !== undefined) {
const bg = WatchFile.new(path.join(obj.dir, obj.bgSrc));
if (!bg.isError) {
obj.bg = bg.value;
delete obj.bgSrc;
}
}
if (obj.mode !== undefined) {
obj.mode = Number(obj.mode);
}
if (obj.beatmapSetID !== undefined) {
obj.beatmapSetID = Number(obj.beatmapSetID);
}
if (typeof obj.tags === 'string') {
obj.tags = obj.tags.split(' ');
}
if (obj.beatmapSetID === undefined) {
const beatmapSetID = beatmapSetIDRegex.exec(path.basename(obj.dir));
if (beatmapSetID !== null) {
obj.beatmapSetID = Number(beatmapSetID[1]);
}
}
obj.duration = await getAudioDurationInSeconds(obj.id);
return ok(obj as Song);
}
static async parseDir(dir: string, update?: Signal<{
i: number,
total: number
}>): Promise<Result<Map<string, Song>, string>> {
if (!fs.existsSync(dir)) {
return fail('Directory does not exists.');
}
const dirs = fs.readdirSync(dir);
const audioSources: Set<string> = new Set();
const songs = new Map<string, Song>();
for (let i = 0; i < dirs.length; i++) {
if (update !== undefined) {
update.value = {
i: i + 1,
total: dirs.length
};
}
const subDirPath = path.join(dir, dirs[i]);
if (!fs.lstatSync(subDirPath).isDirectory()) {
continue;
}
const files = fs.readdirSync(subDirPath);
for (let j = 0; j < files.length; j++) {
if (!files[j].endsWith('.osu')) {
continue;
}
const parser = OsuFileParser.new(path.join(subDirPath, files[j]));
if (parser.isNone) {
continue;
}
const audioSource = parser.value.getAudioSource();
if (audioSource.isNone || audioSources.has(audioSource.value)) {
continue;
}
const song = await parser.value.parseFile();
if (song.isError) {
continue;
}
songs.set(song.value.id, song.value);
audioSources.add(audioSource.value);
}
}
return ok(songs);
}
getAudioSource(): Optional<string> {
const content = fs.readFileSync(this.file, { encoding: 'utf8' });
const start = content.indexOf(this.audioSourceToken) + this.audioSourceToken.length;
for (let i = start; i < content.length; i++) {
if (content[i] === '\n'
|| content[i] === '\r'
|| (content[i] === '\r' && content[i + 1] === '\n')) {
return some(content.substring(start, i));
}
}
return none();
}
async parseFile(): Promise<Result<Song, string>> {
const fileLines = readline.createInterface({
input: fs.createReadStream(this.file),
crlfDelay: Infinity
});
let state: FileState = 'Initial';
const song: any & { bpm: number[][] } = {};
song.bpm = [];
for await (const line of fileLines) {
const trimmed = line.trim();
if (trimmed === '') {
continue;
}
if (trimmed[0] === '[' && trimmed[trimmed.length - 1] === ']') {
state = trimmed.substring(1, trimmed.length - 1) as FileState;
if (state === 'HitObjects') {
break;
}
continue;
}
if (state === 'Initial' || state === 'Editor' || state === 'Difficulty' || state === 'Colours') {
continue;
}
if (state === 'Events') {
const bg = bgFileNameRegex.exec(trimmed);
if (bg !== null) {
song['bgSrc'] = bg[1];
}
continue;
}
if (state === 'TimingPoints') {
const timingPoint = trimmed.split(',').map(x => Number(x));
if (timingPoint.length === 2) {
song.bpm.push(timingPoint);
continue;
}
if (timingPoint[timingPoint.length - 2] === 0) {
continue;
}
if (song.bpm.length !== 0 && song.bpm[song.bpm.length - 1][BPM] === timingPoint[BPM]) {
continue;
}
song.bpm.push([timingPoint[OFFSET], timingPoint[BPM]]);
}
const useSpaceAfterColon = state === 'General';
const split = trimmed.split(useSpaceAfterColon ? ': ' : ':');
if (split.length !== 2) {
continue;
}
const property = propertiesMap.get(split[0]);
if (property === undefined) {
continue;
}
song[property] = split[1];
}
return await OsuFileParser.parseSong(this.file, song);
}
}

View File

@@ -0,0 +1,25 @@
import fs from "fs";
import path from "path";
import { Result } from "../../../@types";
import { fail, ok } from "../rust-like-utils-backend/Result";
export class WatchFile {
fileName: string;
ctime: string;
private constructor(fileName: string, ctime: string) {
this.fileName = fileName;
this.ctime = ctime;
}
static new(fullyQualifiedPath: string): Result<WatchFile, string> {
if (!fs.existsSync(fullyQualifiedPath)) {
return fail("File does not exists.");
}
return ok(new WatchFile(
path.basename(fullyQualifiedPath),
new Date(fs.lstatSync(fullyQualifiedPath).ctimeMs).toISOString()
));
}
}

View File

@@ -0,0 +1,7 @@
import type { PacketType, Packet } from "../../../@types";
export function cratePacket<T>(channel: string, token: string, data: T, type: PacketType = "DATA"): Packet<T> {
return {
channel, token, data, type
}
}

6
src/main/lib/route-pass/Router.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
import type { API, APIFunction } from '../../../@types';
import { BrowserWindow } from 'electron';
export declare class Router {
static respond<E extends keyof API>(event: E, fn: APIFunction<API[E]>): void;
static dispatch(window: BrowserWindow, channel: string, data: any): Promise<any>;
}

View File

@@ -0,0 +1,46 @@
import { ipcMain } from 'electron';
import { TokenNamespace } from '../tungsten/token';
import { assertNever } from '../tungsten/assertNever';
import { cratePacket } from './Packet';
const tokens = new TokenNamespace();
const pending = [];
ipcMain.on("communication/main", (_evt, packet) => {
for (let i = 0; i < pending.length; i++) {
if (pending[i].packet.token !== packet.token) {
continue;
}
switch (packet.type) {
case "DATA": {
pending[i].resolve(packet.data);
break;
}
case "ERROR": {
pending[i].reject(packet.reason);
break;
}
default: assertNever(packet.type);
}
}
});
export class Router {
static respond(event, fn) {
ipcMain.handle(event, fn);
}
static dispatch(window, channel, data) {
const packet = cratePacket(channel, tokens.create(), data);
const promise = new Promise((resolve, reject) => {
pending.push({
packet,
reject,
resolve
});
});
if (window.isVisible()) {
window.webContents.send("communication/renderer", packet);
}
else {
window.on("ready-to-show", () => window.webContents.send("communication/renderer", packet));
}
return promise;
}
}

View File

@@ -0,0 +1,63 @@
import type { API, APIFunction, Packet } from '../../../@types';
import { BrowserWindow, ipcMain } from 'electron';
import { TokenNamespace } from '../tungsten/token';
import { assertNever } from '../tungsten/assertNever';
import { cratePacket } from './Packet';
type Pending = {
resolve: (value: any) => void,
reject: (reason?: any) => void,
packet: Packet<any>
}
const tokens = new TokenNamespace();
const pending: Pending[] = [];
ipcMain.on("communication/main", (_evt, packet: Packet<any>) => {
for (let i = 0; i < pending.length; i++) {
if (pending[i].packet.token !== packet.token) {
continue;
}
switch (packet.type) {
case "DATA": {
pending[i].resolve(packet.data);
break;
}
case "ERROR": {
pending[i].reject(packet.reason);
break;
}
default: assertNever(packet.type);
}
}
});
export class Router {
static respond<E extends keyof API>(event: E, fn: APIFunction<API[E]>): void {
ipcMain.handle(event, fn as any);
}
static dispatch(window: BrowserWindow, channel: string, data: any): Promise<any> {
const packet = cratePacket(channel, tokens.create(), data);
const promise = new Promise((resolve, reject) => {
pending.push({
packet,
reject,
resolve
});
});
if (window.isVisible()) {
window.webContents.send("communication/renderer", packet);
} else {
window.on("ready-to-show", () => window.webContents.send("communication/renderer", packet));
}
return promise;
}
}

View File

@@ -0,0 +1,16 @@
import { Optional } from "../../../@types";
export function none(): Optional<any> {
return {
isNone: true
};
}
export function some<V>(value: V): Optional<V> {
return {
value,
isNone: false
};
}

View File

@@ -0,0 +1,17 @@
import { Result } from "../../../@types";
export function ok<V>(value: V): Result<V, any> {
return {
value,
isError: false
};
}
export function fail<E>(error: E): Result<any, E> {
return {
error,
isError: true
};
}

View File

@@ -0,0 +1,39 @@
import fs from "fs";
import path from "path";
import { app } from "electron";
import { Song, Settings } from "../../../@types";
import { Table } from "./Table";
type TableMap = {
"songs": { [key: string]: Song },
"playlists": { [key: string]: Song[] },
"settings": Settings
}
export class Storage {
private static cache: Map<string, Table<any>> = new Map();
static getTable<T extends keyof TableMap>(name: T): Table<TableMap[T]> {
const hit = this.cache.get(name);
if (hit !== undefined) {
return hit;
}
const tablePath = path.join(app.getPath("userData"), `/storage/${name}.json`);
if (!fs.existsSync(tablePath)) {
fs.mkdirSync(path.join(app.getPath("userData"), "/storage"), { recursive: true });
fs.writeFileSync(tablePath, "{}");
return new Table(tablePath, {} as unknown);
}
const table = new Table(tablePath, JSON.parse(fs.readFileSync(tablePath, { encoding: "utf8" })));
this.cache.set(name, table);
return table;
}
}

View File

@@ -0,0 +1,33 @@
import fs from "fs";
import { none, some } from "../rust-like-utils-backend/Optional";
import { Optional } from "../../../@types";
export class Table<Struct> {
private readonly path: string;
private readonly struct: Struct;
constructor(path: string, struct: Struct) {
this.path = path;
this.struct = struct;
}
get<K extends keyof Struct>(key: K): Optional<Struct[K]> {
return this.struct[key] === undefined
? none()
: some(this.struct[key]);
}
write<K extends keyof Struct>(key: K, content?: Struct[K]): void {
this.struct[key] = content;
fs.writeFileSync(this.path, JSON.stringify(this.struct), { encoding: "utf8" });
}
delete<K extends keyof Struct>(key: K): void {
delete this.struct[key];
fs.writeFileSync(this.path, JSON.stringify(this.struct), { encoding: "utf8" });
}
filePath(): string {
return this.path;
}
}

View File

@@ -0,0 +1 @@
export function assertNever(_value: never) {}

View File

@@ -0,0 +1,24 @@
export function clamp(min: number, max: number, number: number): number {
if (number < min) return min;
return number > max ? max : number;
}
export function map(value: number, fromStart: number, fromEnd: number, toStart: number, toEnd: number): number {
return ((value - fromStart) / (fromEnd - fromStart)) * (toEnd - toStart) + toStart;
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function rng(from: number, to: number): number {
return Math.random() * (Math.max(from, to) - Math.min(from, to)) + from;
}
export function flatRNG(from: number, to: number): number {
return Math.floor(rng(from, to));
}

View File

@@ -0,0 +1,56 @@
import { flatRNG } from "./math";
const TOKEN_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const TOKEN_CHARSET_ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
export type Token = string;
const globalTokens: Set<Token> = new Set();
export function generateToken(forceFirstLetter = false, length = 8, set: Set<string> = undefined as any): Token {
let id;
const MAX_RETRIES = 10_000;
let retry = 0;
do {
if (forceFirstLetter === true) {
id = TOKEN_CHARSET_ALPHA[flatRNG(0, TOKEN_CHARSET_ALPHA.length)];
length--;
}
for (let i = 0; i < length - 1; i++) {
id += TOKEN_CHARSET[flatRNG(0, TOKEN_CHARSET.length)];
}
if (++retry === MAX_RETRIES) break;
} while ((set ?? globalTokens).has(id));
if (retry === MAX_RETRIES) {
return generateToken(forceFirstLetter, length + 1, set);
}
(set ?? globalTokens).add(id);
return id;
}
export function freeToken(token: Token, set: Set<Token> = undefined as any): void {
(set ?? globalTokens).delete(token);
}
export class TokenNamespace {
private readonly set: Set<Token>;
constructor() {
this.set = new Set();
}
create(forceFirstLetter = false, length = 8): Token {
return generateToken(forceFirstLetter, length, this.set);
}
destroy(token: Token): void {
freeToken(token, this.set);
}
}

100
src/main/lib/utils.ts Normal file
View File

@@ -0,0 +1,100 @@
import { Song, SongIndex } from '../../@types';
import path from "path";
import fs from "fs";
export function averageBPM(bpm: number[][], durationMS: number): number {
if (bpm.length === 0) {
return NaN;
}
if (bpm.length === 1) {
return bpm[0][1];
}
const lookup = new Map<number, number[]>();
let highestEntry = [-Infinity, NaN];
for (let i = 0; i < bpm.length; i++) {
const end = i + 1 === bpm.length
? durationMS
: bpm[i + 1][0];
const entry = lookup.get(bpm[i][1]);
if (entry === undefined) {
lookup.set(bpm[i][1], [end - bpm[i][0], bpm[i][1]]);
continue;
}
entry[0] += end - bpm[i][0];
if (entry[0] > highestEntry[0]) {
highestEntry = entry;
}
}
return highestEntry[1];
}
export function filterTags(tags: string[], includes: string[], excludes: string[]): boolean {
if (tags.length === 0 || includes.length === 0 && excludes.length === 0) {
return true;
}
let init = 0;
for (let i = 0; i < tags.length; i++) {
if (includes.includes(tags[i])) {
init++;
continue;
}
if (excludes.includes(tags[i])) {
return false;
}
}
return init === includes.length;
}
export function indexSongs(songs: { [id: string]: Song }): [SongIndex[], Map<string, string[]>] {
const indexes: SongIndex[] = [];
const tags = new Map<string, string[]>();
for (const id in songs) {
const s = songs[id];
indexes.push({
id,
t: s.beatmapSetID + s.title + (s.titleUnicode ?? ""),
a: s.artist + (s.artistUnicode ?? ""),
c: s.creator,
tags: s.tags,
bpm: averageBPM(s.bpm, s.duration * 1_000)
});
for (let i = 0; i < s.tags.length; i++) {
const entry = tags.get(s.tags[i]);
if (entry === undefined) {
tags.set(s.tags[i], [id]);
continue;
}
entry.push(id);
}
}
return [indexes, tags];
}
export function checkConfigChanges(songs: { [id: string]: Song }): void {
let count = 0;
const total = Object.values(songs).length;
for (const id in songs) {
const s = songs[id];
const configSource = path.join(s.dir, "/" + s.config.fileName);
if (!(fs.existsSync(configSource) && fs.lstatSync(configSource).ctime.toISOString() === s.config.ctime)) {
count++;
process.stdout.write("\r\x1b[KNeed to update: " + count + "/" + total);
}
}
}

35
src/main/router/queue.ts Normal file
View File

@@ -0,0 +1,35 @@
import { Router } from "../lib/route-pass/Router";
import { Song } from '../../@types';
let queue: Song[];
let index = 0;
Router.respond("queueCurrent", () => {
return queue[index];
});
Router.respond("queuePrevious", () => {
if (--index < 0) {
index = queue.length;
}
});
Router.respond("queueNext", () => {
if (++index === queue.length) {
Queue.rewind();
}
});
export class Queue {
static rewind(): void {
index = 0;
}
static load(songs: Song[]): void {
queue = songs;
}
}

11
src/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import { ElectronAPI } from '@electron-toolkit/preload';
import { API } from '../@types';
declare global {
interface Window {
api: {
request<E extends keyof API>(event: E, ...data: Parameters<API[E]>): Promise<ReturnType<API[E]>>,
listen(channel: string, listener: APIListener<any, any>): void
}
}
}

64
src/preload/index.ts Normal file
View File

@@ -0,0 +1,64 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { API, APIListener, Packet, PacketType } from '../@types';
function createPacketPreload<T>(channel: string, token: string, data: T, type: PacketType = "DATA"): Packet<T> {
return {
channel, token, data, type
}
}
const apiListeners = new Map<string, APIListener<any, any>[]>();
ipcRenderer.on("communication/renderer", async (_evt, packet: Packet<any>) => {
const p = createPacketPreload(packet.channel, packet.token, undefined as any);
const listeners = apiListeners.get(packet.channel);
if (listeners === undefined) {
p.type = "ERROR";
p.reason = "No listeners found for channel: '" + packet.channel + "'";
ipcRenderer.send("communication/main", p);
return;
}
const responses: any[] = [];
const promises: Promise<any>[] = [];
for (let i = 0; i < listeners.length; i++) {
const got = listeners[i](packet.data);
if (got instanceof Promise) {
promises.push(got);
got.then(x => {
responses.push(x);
});
continue;
}
responses.push(got);
}
await Promise.all(promises);
p.data = responses;
ipcRenderer.send("communication/main", p);
});
const api = {
request<E extends keyof API>(event: E, ...args: Parameters<API[E]>): Promise<ReturnType<API[E]>> {
return ipcRenderer.invoke(event, ...args) as any;
},
listen(channel: string, listener: APIListener<any, any>): void {
const entry = apiListeners.get(channel);
if (entry === undefined) {
apiListeners.set(channel, [listener]);
return;
}
entry.push(listener);
}
};
contextBridge.exposeInMainWorld('api', api);

17
src/renderer/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2
src/renderer/src/App.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
import type { JSX } from 'solid-js';
export default function App(): JSX.Element;

175
src/renderer/src/App.jsx Normal file
View File

@@ -0,0 +1,175 @@
import Gradient from './components/Gradient';
import image from './assets/hololive IDOL PROJECT - BLUE CLAPPER_2761277.jpg';
import { createSignal, onMount } from 'solid-js';
export default function App() {
const [topColor, setTopColor] = createSignal("dodgerblue");
onMount(() => {
window.addEventListener("click", () => {
setTopColor("teal");
});
});
window.api.listen("log", (...args) => {
console.log(args);
return new Promise((resolve) => {
setTimeout(() => resolve("poggers"), 500);
});
});
return (<Gradient bottomColor="crimson" topColor={topColor()}>
<div class="app">
<nav>
<button class="icon">Aa</button>
<button class="icon">Aa</button>
<button class="icon">Aa</button>
<button class="icon">Aa</button>
<button class="icon">Aa</button>
</nav>
<div class="side-pane">
<div class="search">
<h1>Search...</h1>
</div>
<div class="list">
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art"/>
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
</div>
</div>
<main class="center">
<div class="container">
<div class="song">
<img src={image} alt="art"/>
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
<div class="seeker">
<div class="bar">
<div class="filling"></div>
</div>
<div class="time">
<span class="currently">0:01:09</span><span class="duration">0:04:20</span>
</div>
</div>
<div class="controls">
<div class="wrapper">
<button class="icon"><span>||</span></button>
<button class="icon"><span>|&lt;</span></button>
<button class="icon"><span>&gt;|</span></button>
<div class="dropdown">
<button class="icon"><span>Vol</span></button>
<div class="menu">
<div class="menu-wrapper">
<div class="selectors local">
<span>Local</span>
<div class="bar vertical">
<div class="filling"></div>
</div>
</div>
<div class="selectors global">
<span>Global</span>
<div class="bar vertical">
<div class="filling"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</Gradient>);
}

182
src/renderer/src/App.tsx Normal file
View File

@@ -0,0 +1,182 @@
import type { JSX } from 'solid-js';
import Gradient from './components/Gradient';
import image from './assets/hololive IDOL PROJECT - BLUE CLAPPER_2761277.jpg';
import { createSignal, onMount } from 'solid-js';
export default function App(): JSX.Element {
const [topColor, setTopColor] = createSignal("dodgerblue");
onMount(() => {
window.addEventListener("click", () => {
setTopColor("teal");
});
});
window.api.listen("log", (...args) => {
console.log(args);
return new Promise((resolve) => {
setTimeout(() => resolve("poggers"), 500);
});
});
return (
<Gradient bottomColor="crimson" topColor={topColor()}>
<div class="app">
<nav>
<button class="icon">Aa</button>
<button class="icon">Aa</button>
<button class="icon">Aa</button>
<button class="icon">Aa</button>
<button class="icon">Aa</button>
</nav>
<div class="side-pane">
<div class="search">
<h1>Search...</h1>
</div>
<div class="list">
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
<div class="item">
<img src={image} alt="art" />
<div class="column">
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
</div>
</div>
</div>
<main class="center">
<div class="container">
<div class="song">
<img src={image} alt="art" />
<h3>BLUE CLAPPER</h3>
<span>hololive IDOL PROJECT</span>
</div>
<div class="seeker">
<div class="bar">
<div class="filling"></div>
</div>
<div class="time">
<span class="currently">0:01:09</span><span class="duration">0:04:20</span>
</div>
</div>
<div class="controls">
<div class="wrapper">
<button class="icon"><span>||</span></button>
<button class="icon"><span>|&lt;</span></button>
<button class="icon"><span>&gt;|</span></button>
<div class="dropdown">
<button class="icon"><span>Vol</span></button>
<div class="menu">
<div class="menu-wrapper">
<div class="selectors local">
<span>Local</span>
<div class="bar vertical">
<div class="filling"></div>
</div>
</div>
<div class="selectors global">
<span>Global</span>
<div class="bar vertical">
<div class="filling"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</Gradient>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

View File

@@ -0,0 +1,189 @@
body {
display: flex;
flex-direction: column;
font-family: Roboto, -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', 'Oxygen',
'Ubuntu', 'Cantarell', 'Open Sans', sans-serif;
color: #86a5b1;
background-color: #2f3241;
}
* {
padding: 0;
margin: 0;
}
ul {
list-style: none;
}
code {
font-weight: 600;
padding: 3px 5px;
border-radius: 2px;
background-color: #26282e;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-size: 85%;
}
a {
color: #9feaf9;
font-weight: 600;
cursor: pointer;
text-decoration: none;
outline: none;
}
a:hover {
border-bottom: 1px solid;
}
.container {
flex: 1;
display: flex;
flex-direction: column;
max-width: 840px;
margin: 0 auto;
padding: 15px 30px 0 30px;
}
.versions {
margin: 0 auto;
float: none;
clear: both;
overflow: hidden;
font-family: 'Menlo', 'Lucida Console', monospace;
color: #c2f5ff;
line-height: 1;
transition: all 0.3s;
}
.versions li {
display: block;
float: left;
border-right: 1px solid rgba(194, 245, 255, 0.4);
padding: 0 20px;
font-size: 13px;
opacity: 0.8;
}
.versions li:last-child {
border: none;
}
.hero-logo {
margin-top: -0.4rem;
transition: all 0.3s;
}
@media (max-width: 840px) {
.versions {
display: none;
}
.hero-logo {
margin-top: -1.5rem;
}
}
.hero-text {
font-weight: 400;
color: #c2f5ff;
text-align: center;
margin-top: -0.5rem;
margin-bottom: 10px;
}
@media (max-width: 660px) {
.hero-logo {
display: none;
}
.hero-text {
margin-top: 20px;
}
}
.hero-tagline {
text-align: center;
margin-bottom: 14px;
}
.links {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
font-size: 18px;
font-weight: 500;
}
.links a {
font-weight: 500;
}
.links .link-item {
padding: 0 4px;
}
.features {
display: flex;
flex-wrap: wrap;
margin: -6px;
}
.features .feature-item {
width: 33.33%;
box-sizing: border-box;
padding: 6px;
}
.features article {
background-color: rgba(194, 245, 255, 0.1);
border-radius: 8px;
box-sizing: border-box;
padding: 12px;
height: 100%;
}
.features span {
color: #d4e8ef;
word-break: break-all;
}
.features .title {
font-size: 17px;
font-weight: 500;
color: #c2f5ff;
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.features .detail {
font-size: 14px;
font-weight: 500;
line-height: 22px;
margin-top: 6px;
}
@media (max-width: 660px) {
.features .feature-item {
width: 50%;
}
}
@media (max-width: 480px) {
.links {
flex-direction: column;
line-height: 32px;
}
.links .link-dot {
display: none;
}
.features .feature-item {
width: 100%;
}
}

View File

@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 900 300">
<g fill="none" fill-rule="evenodd">
<g class="hero-apps" style="fill: #71abb7;">
<path d="M15 138l-4.9-.64L8 133l-2.1 4.36L1 138l3.6 3.26-.93 4.74L8 143.67l4.33 2.33-.93-4.74z"></path>
<path d="M897.2 114.0912l-5.2 3.63v-2.72c0-.55-.45-1-1-1h-8c-.55 0-1 .45-1 1v9c0 .55.45 1 1 1h8c.55 0 1-.45 1-1v-2.72l5.2 3.63c.33.23.8 0 .8-.41v-10c0-.41-.47-.64-.8-.41z"></path>
<path d="M65.4 188.625h-1.6c.88 0 1.6-.7313 1.6-1.625v-1.625c0-.8937-.72-1.625-1.6-1.625h-1.6c-.88 0-1.6.7313-1.6 1.625V187c0 .8937.72 1.625 1.6 1.625h-1.6c-.88 0-1.6.7313-1.6 1.625v3.25h1.6v4.875c0 .8937.72 1.625 1.6 1.625h1.6c.88 0 1.6-.7313 1.6-1.625V193.5H67v-3.25c0-.8937-.72-1.625-1.6-1.625zm-3.2-3.25h1.6V187h-1.6v-1.625zm3.2 6.5h-1.6v6.5h-1.6v-6.5h-1.6v-1.625h4.8v1.625zm3.344-5.6875c0-3.2175-2.576-5.8337-5.744-5.8337-3.168 0-5.744 2.6162-5.744 5.8337 0 .455.048.8937.144 1.3162v3.2175c-.976-1.2512-1.6-2.8112-1.6-4.55 0-4.03 3.232-7.3125 7.2-7.3125s7.2 3.2825 7.2 7.3125c0 1.7225-.624 3.2988-1.6 4.55v-3.2175c.096-.4387.144-.8612.144-1.3162zm6.256 0c0 4.68-2.608 8.7425-6.4 10.7738v-1.7063c2.976-1.885 4.944-5.2325 4.944-9.0675 0-5.915-4.72-10.7087-10.544-10.7087-5.824 0-10.544 4.7937-10.544 10.7087 0 3.835 1.968 7.1825 4.944 9.0675v1.7063c-3.792-2.0313-6.4-6.0938-6.4-10.7738C51 179.46 56.376 174 63 174s12 5.46 12 12.1875z"></path>
<path d="M830.7143 142.3333c-.8643 0-1.5714.7125-1.5714 1.5834v3.1666c0 .871.707 1.5834 1.5713 1.5834h12.5714c.8643 0 1.5714-.7125 1.5714-1.5834v-3.1666c0-.871-.707-1.5834-1.5713-1.5834h-12.5714zm12.5714 2.771l-1.9643 1.979h-2.357L837 145.1043l-1.9643 1.979h-2.357l-1.9644-1.979v-1.1876h1.1786l1.964 1.979 1.9644-1.979h2.3572l1.9643 1.979 1.964-1.979h1.1787v1.1875zm-9.4286 5.1457h6.286v1.5833h-6.286V150.25zM837 136c-6.0657 0-11 4.6075-11 10.2917v7.125c0 .8708.707 1.5833 1.5714 1.5833h18.8572c.8643 0 1.5714-.7125 1.5714-1.5833v-7.125C848 140.6075 843.0657 136 837 136zm9.4286 17.4167h-18.8572v-7.125c0-4.8925 4.1486-8.851 9.4286-8.851 5.28 0 9.4286 3.9585 9.4286 8.851v7.125z"></path>
<path d="M75 91.8065V96h4.1935L90.376 84.8174l-4.1934-4.1935L75 91.8064zm4.1935 2.7957h-2.7957v-2.7957h1.398v1.3978h1.3977v1.398zM93.591 81.6024l-1.817 1.817-4.1935-4.1934 1.817-1.817c.5453-.5453 1.426-.5453 1.971 0l2.2226 2.2224c.5453.5452.5453 1.4258 0 1.971z"></path>
<path d="M797 187h4v4h-4v-4zm12-1v19c0 1.1-.9 2-2 2h-20c-1.1 0-2-.9-2-2v-24c0-1.1.9-2 2-2h15l7 7zm-2 1l-6-6h-14v22l6-10 4 8 4-4 6 6v-16z"></path>
<path d="M138 125c-6.62 0-12 5-12 11 0 9.04 12 21 12 21s12-11.96 12-21c0-6-5.38-11-12-11zm0 29.1c-3.72-4.06-10-12.22-10-18.1 0-4.96 4.5-9 10-9 2.68 0 5.22.96 7.12 2.72 1.84 1.72 2.88 3.94 2.88 6.28 0 5.88-6.28 14.04-10 18.1zm4-18.1c0 2.22-1.78 4-4 4-2.22 0-4-1.78-4-4 0-2.22 1.78-4 4-4 2.22 0 4 1.78 4 4z"></path>
<path d="M771 82h8v2h-8v-2zm0 6h8v-2h-8v2zm0 4h8v-2h-8v2zm22-10h-8v2h8v-2zm0 4h-8v2h8v-2zm0 4h-8v2h8v-2zm4-12v18c0 1.1-.9 2-2 2h-11l-2 2-2-2h-11c-1.1 0-2-.9-2-2V78c0-1.1.9-2 2-2h11l2 2 2-2h11c1.1 0 2 .9 2 2zm-16 1l-1-1h-11v18h12V79zm14-1h-11l-1 1v17h12V78z"></path>
<path d="M176 203h-24c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h4v7l7-7h13c1.1 0 2-.9 2-2v-16c0-1.1-.9-2-2-2zm0 18h-14l-4 4v-4h-6v-16h24v16z"></path>
<path d="M673 88.921c0 2.18-.9 4.18-2.34 5.66l-1.34-1.34c1.1-1.12 1.78-2.62 1.78-4.32 0-1.7-.68-3.22-1.78-4.32l1.34-1.34c1.44 1.44 2.34 3.44 2.34 5.66zm-8.56-11.48l-7.44 7.44h-4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h4l7.44 7.44c.94.94 2.56.28 2.56-1.06v-20.76c0-1.34-1.62-2-2.56-1.06zm11.88.16l-1.34 1.34c2.56 2.56 4.12 6.06 4.12 9.96 0 3.88-1.56 7.4-4.12 9.96l1.34 1.34c2.9-2.9 4.68-6.9 4.68-11.32 0-4.44-1.78-8.44-4.68-11.32v.04zm-2.82 2.82l-1.38 1.34c1.84 1.84 2.96 4.38 2.96 7.16 0 2.78-1.12 5.32-2.96 7.12l1.38 1.34c2.16-2.16 3.5-5.16 3.5-8.46 0-3.3-1.34-6.32-3.5-8.5z"></path>
<path d="M226 79h-16c0-1.1-.9-2-2-2h-8c-1.1 0-2 .9-2 2-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V81c0-1.1-.9-2-2-2zm-18 4h-8v-2h8v2zm9 14c-3.88 0-7-3.12-7-7s3.12-7 7-7 7 3.12 7 7-3.12 7-7 7zm5-7c0 2.76-2.26 5-5 5s-5-2.26-5-5 2.26-5 5-5 5 2.26 5 5z"></path>
<path d="M725.8393 157h-15.6498c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2h15.6298C727 153 727 153.82 727 155c0 1.18 0 2-1.1807 2h.02zm-11.6473-10c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2h11.6273C727 143 727 143.82 727 145c0 1.18 0 2-1.1807 2H714.192zM695 146.82l2.8218-2.6 3.182 3.18 8.185-8.4 2.8218 2.82-11.0068 11-6.0038-6zM710.1895 163h15.6298C727 163 727 163.82 727 165c0 1.18 0 2-1.1807 2h-15.6298c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2z"></path>
<path d="M223 152v24c0 1.65 1.35 3 3 3h36c1.65 0 3-1.35 3-3v-24c0-1.65-1.35-3-3-3h-36c-1.65 0-3 1.35-3 3zm39 0l-18 15-18-15h36zm-36 4.5l12 9-12 9v-18zm3 19.5l10.5-9 4.5 4.5 4.5-4.5 10.5 9h-30zm33-1.5l-12-9 12-9v18z"></path>
<path d="M648 182h-3v4.5c0 .84-.66 1.5-1.5 1.5h-6c-.84 0-1.5-.66-1.5-1.5V182h-9v4.5c0 .84-.66 1.5-1.5 1.5h-6c-.84 0-1.5-.66-1.5-1.5V182h-3c-1.65 0-3 1.35-3 3v33c0 1.65 1.35 3 3 3h33c1.65 0 3-1.35 3-3v-33c0-1.65-1.35-3-3-3zm0 36h-33v-27h33v27zm-24-33h-3v-6h3v6zm18 0h-3v-6h3v6zm-15 12h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3z"></path>
</g>
<g class="hero-icons" style="fill: #c2f5ff;">
<path d="M441.1132 69.724c7.681 0 13.9075-6.207 13.9075-13.8636 0-7.6565-6.2266-13.8634-13.9075-13.8634-7.681 0-13.9076 6.207-13.9076 13.8634 0 7.6566 6.2266 13.8635 13.9076 13.8635zm0-5.7932c-4.4713 0-8.096-3.6132-8.096-8.0704 0-4.457 3.6247-8.0703 8.096-8.0703 4.4712 0 8.096 3.6133 8.096 8.0704 0 4.4572-3.6248 8.0704-8.096 8.0704z"></path>
<path d="M354.8995 220.2693c7.681 0 13.9075-6.207 13.9075-13.8635s-6.2266-13.8634-13.9075-13.8634c-7.681 0-13.9075 6.207-13.9075 13.8634 0 7.6566 6.2266 13.8635 13.9075 13.8635zm0-5.793c-4.4713 0-8.096-3.6133-8.096-8.0705 0-4.457 3.6247-8.0703 8.096-8.0703s8.096 3.6132 8.096 8.0703c0 4.4572-3.6247 8.0704-8.096 8.0704z"></path>
<path d="M541.0343 206.4058c0-7.6565-6.2266-13.8634-13.9075-13.8634-7.681 0-13.9075 6.207-13.9075 13.8634 0 7.6566 6.2266 13.8635 13.9075 13.8635 7.681 0 13.9075-6.207 13.9075-13.8635zm-5.8115 0c0 4.4572-3.6247 8.0704-8.096 8.0704s-8.096-3.6132-8.096-8.0704c0-4.457 3.6247-8.0703 8.096-8.0703s8.096 3.6132 8.096 8.0703z"></path>
<path d="M397.6943 214.5258c9.7012 27.0033 25.5723 43.629 43.419 43.629 13.0157 0 25.0578-8.8443 34.4482-24.4154.827-1.371.3822-3.1507-.9932-3.975-1.3755-.824-3.1607-.3808-3.9876.9902-8.439 13.9938-18.8052 21.6072-29.4675 21.6072-14.8247 0-28.9803-14.8288-37.9476-39.7892-.541-1.506-2.2044-2.2897-3.7153-1.7504-1.511.5394-2.297 2.1975-1.756 3.7036z"></path>
<path d="M514.124 163.4733c18.5545-21.85 25.033-43.826 16.122-59.2117-6.557-11.321-20.419-17.2982-38.841-17.537-1.6047-.021-2.9225 1.259-2.9434 2.8586-.0208 1.5996 1.263 2.9132 2.8678 2.934 16.5683.2148 28.5106 5.3642 33.8836 14.641 7.4018 12.7797 1.6243 32.3774-15.5247 52.5722-1.037 1.221-.8844 3.0487.3405 4.0822 1.2248 1.0336 3.0584.8817 4.0952-.3393z"></path>
<path d="M411.5672 88.457c-28.3373-5.1448-50.7424.24-59.672 15.6575-6.6635 11.505-4.7588 26.7585 4.6193 43.0637.7982 1.3878 2.574 1.8678 3.966 1.072 1.3923-.7956 1.874-2.5656 1.0756-3.9534-8.4477-14.688-10.0915-27.8524-4.628-37.2857 7.418-12.8074 27.403-17.6105 53.5978-12.8546 1.579.2866 3.092-.7568 3.3794-2.3307.2876-1.5738-.7592-3.082-2.338-3.3687z"></path>
<path d="M486.3075 209.2436c5.022-15.998 7.7194-34.453 7.7194-53.6842 0-47.9875-16.849-89.3545-40.8478-99.977-1.4667-.649-3.1837.0098-3.835 1.472-.6512 1.462.01 3.1735 1.4766 3.8227 21.404 9.474 37.3945 48.7337 37.3945 94.6824 0 18.6574-2.612 36.5297-7.454 51.954-.4794 1.5268.3736 3.1518 1.9052 3.6295s3.1617-.3727 3.641-1.8994z"></path>
<path d="M466.439 89.4215c-16.7763 3.583-34.6332 10.5886-51.7827 20.4585-42.434 24.4216-70.1147 60.4323-66.2703 86.5432.233 1.5828 1.709 2.6776 3.297 2.4453 1.5877-.2323 2.686-1.7037 2.453-3.2865-3.4135-23.1838 22.825-57.3183 63.426-80.685 16.6365-9.5746 33.9267-16.3578 50.0946-19.811 1.5692-.335 2.5687-1.8748 2.2325-3.439-.336-1.5642-1.8807-2.5606-3.45-2.2255z"></path>
<path d="M371.2508 166.997c11.458 12.5516 26.3438 24.3243 43.3203 34.0947 41.106 23.6572 84.866 29.9805 106.4328 15.3217 1.326-.9013 1.668-2.7033.7638-4.025-.904-1.3217-2.712-1.6626-4.0378-.7614-19.302 13.1195-60.871 7.1128-100.253-15.5523-16.469-9.4783-30.8834-20.8782-41.9277-32.9767-1.08-1.1832-2.9178-1.2695-4.1048-.1928-1.187 1.0766-1.2735 2.9086-.1934 4.0918z"></path>
<path d="M443.2374 165.3634c-5.432 1.17-10.7838-2.2712-11.9598-7.686-1.1714-5.415 2.2785-10.7498 7.7106-11.922 5.432-1.17 10.7838 2.2712 11.9598 7.686 1.1737 5.415-2.2785 10.7498-7.7106 11.922z"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -0,0 +1,400 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--FIXED: 0;
--NORMAL_CONTENT: 1;
--border-radius: 10px;
--circle-0: dodgerblue;
--circle-1: crimson;
--color-fg: 0, 0, 0;
--color-bg: 255, 255, 255;
--level-bg: 0.64;
--level-0: 0.08;
--level-1: 0.16;
--level-2: 0.24;
--font-size: 14px;
--font-default: 'Inter', sans-serif;
--font-monospace: 'JetBrains Mono', monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--color-fg: 255, 255, 255;
--color-bg: 0, 0, 0;
}
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(var(--color-fg), var(--level-0));
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(var(--color-fg), var(--level-1));
}
.center {
display: flex;
justify-content: center;
align-items: center;
}
button {
background-color: rgba(var(--color-fg), var(--level-0));
color: rgba(var(--color-fg));
border: unset;
padding: 8px;
border-radius: var(--border-radius);
outline: 1px solid rgba(var(--color-fg), 0);
transition: background-color 250ms, outline-color 250ms;
}
button:focus {
outline: 1px solid rgba(var(--color-fg), var(--level-2));
}
button:hover {
background-color: rgba(var(--color-fg), var(--level-1));
cursor: pointer;
transition: background-color 0ms;
}
button.icon {
aspect-ratio: 1/1;
font-family: var(--font-monospace);
}
.bar {
--fill-per: 0%;
min-height: 0.5em;
min-width: 100%;
border-radius: var(--border-radius);
background-color: rgba(var(--color-fg), var(--level-0));
overflow: hidden;
}
.bar.vertical {
min-height: 100%;
min-width: 0.5em;
display: flex;
align-items: flex-end;
}
.bar .filling {
background-color: rgba(var(--color-fg), var(--level-1));
}
.bar:not(.vertical) .filling {
min-height: 0.5em;
width: var(--fill-per);
}
.bar.vertical .filling {
min-width: 0.5em;
height: var(--fill-per);
}
.dropdown {
position: relative;
}
.dropdown .menu {
display: none;
position: absolute;
padding-bottom: 8px;
bottom: 100%;
right: 0;
}
.dropdown:hover .menu {
display: block;
}
.dropdown .menu .menu-wrapper {
display: flex;
background-color: rgba(var(--color-fg), var(--level-0));
backdrop-filter: blur(0.5em);
padding: 8px;
gap: 8px;
border-radius: var(--border-radius);
}
.controls .dropdown .menu-wrapper {
width: calc(44px + 8px + 44px);
justify-content: space-between;
}
.controls .dropdown .menu-wrapper .selectors {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.controls .dropdown .menu-wrapper .selectors span {
font-size: 0.8em;
}
.controls .dropdown .menu-wrapper .selectors .bar.vertical {
width: 0.5em;
height: 80px;
min-height: unset;
min-width: unset;
}
html {
background-color: rgba(var(--color-fg));
color: rgba(var(--color-bg));
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: var(--font-default);
user-select: none;
font-size: var(--font-size);
}
#root {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.gradient {
width: 100%;
height: 100%;
overflow: hidden;
background: radial-gradient(
circle at top left,
var(--circle-0),
transparent max(125vw, 125vh)
), radial-gradient(
circle at bottom right,
var(--circle-1),
transparent max(125vw, 125vh)
);
transition: background 500ms;
}
.app {
width: 100%;
height: 100%;
z-index: var(--NORMAL_CONTENT);
overflow: hidden;
display: grid;
grid-template-columns: 64px 320px 1fr;
color: rgba(var(--color-fg));
background-color: rgba(var(--color-bg), var(--level-bg));
/* backdrop-filter: blur(max(25vh, 25vw)); */
}
.app > * {
overflow: auto;
}
.app * {
z-index: inherit;
}
nav {
background-color: rgba(var(--color-fg), var(--level-1));
display: flex;
flex-direction: column;
padding: 8px;
gap: 8px;
}
nav > .icon {
width: 100%;
aspect-ratio: 1/1;
background-color: rgba(var(--color-fg), var(--level-0));
border-radius: var(--border-radius);
transition: background-color 250ms;
}
nav > .icon:hover {
background-color: rgba(var(--color-fg), var(--level-1));
cursor: pointer;
transition: background-color 0ms;
}
nav > div.icon {
display: grid;
place-items: center;
}
.side-pane {
background-color: rgba(var(--color-fg), var(--level-0));
display: grid;
grid-template-rows: 64px 1fr;
overflow: hidden;
}
.side-pane > :not(.no-pd) {
padding: 16px;
}
.side-pane .list {
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
}
.side-pane .list .item {
min-height: 80px;
width: 100%;
display: grid;
gap: 4px;
grid-template-columns: 80px 1fr;
overflow: hidden;
font-size: 0.85em;
border-radius: var(--border-radius);
transition: background-color 250ms;
}
.side-pane .list .item:hover {
background-color: rgba(var(--color-fg), var(--level-0));
transition: background-color 0ms;
}
.side-pane .list .item img {
height: 80px;
padding: 4px;
border-radius: var(--border-radius);
aspect-ratio: 1/1;
place-self: center;
object-fit: cover;
}
.side-pane .list .item .column {
display: flex;
flex-direction: column;
align-self: center;
}
main {
background-color: rgba(var(--color-fg), 0);
}
main > .container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
main .song {
padding: 16px;
width: max(232px, 25vw);
border-radius: var(--border-radius);
transition: background-color 250ms;
}
main .song:hover {
background-color: rgba(var(--color-fg), var(--level-0));
transition: background-color 0ms;
}
main .song img {
width: 100%;
border-radius: var(--border-radius);
aspect-ratio: 1/1;
object-fit: cover;
}
main .song h3 {
margin-top: 16px;
}
main .seeker {
width: calc(max(232px, 25vw) - 32px);
display: flex;
flex-direction: column;
gap: 8px;
}
main .seeker span {
font-size: 0.8em;
}
main .seeker .time {
display: flex;
justify-content: space-between;
}
main .controls {
max-width: max(200px, 25vw);
}
main .controls .wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
main .controls button {
width: 44px;
}

View File

@@ -0,0 +1,15 @@
import { Component, createEffect } from 'solid-js';
const Gradient: Component<{ topColor: string, bottomColor: string, children }> = (props) => {
createEffect(() => {
document.documentElement.style.setProperty("--circle-0", props.topColor);
});
return (
<div class="gradient">
{props.children}
</div>
);
}
export default Gradient;

1
src/renderer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,5 @@
import { render } from 'solid-js/web';
import './assets/style.css';
import App from './App';
render(() => <App />, document.getElementById('root') as HTMLElement);

4
tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}

8
tsconfig.node.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}

19
tsconfig.web.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,
"jsxImportSource": "solid-js",
"baseUrl": ".",
"paths": {
"@renderer/*": [
"src/renderer/src/*"
]
}
}
}