Lint all files

This commit is contained in:
jeffvli 2023-07-01 19:10:05 -07:00
parent 22af76b4d6
commit 30e52ebb54
334 changed files with 76519 additions and 75932 deletions

View File

@ -1,90 +1,90 @@
module.exports = {
extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
ignorePatterns: ['.erb/*', 'server'],
parser: '@typescript-eslint/parser',
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 12,
extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
ignorePatterns: ['.erb/*', 'server'],
parser: '@typescript-eslint/parser',
project: './tsconfig.json',
sourceType: 'module',
tsconfigRootDir: './',
},
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['off'],
'default-case': 'off',
'import/extensions': 'off',
'import/no-absolute-path': 'off',
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error',
'import/order': [
'error',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: ['builtin', 'external', 'internal', ['parent', 'sibling']],
'newlines-between': 'never',
pathGroups: [
{
group: 'external',
pattern: 'react',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['react'],
},
],
'import/prefer-default-export': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/interactive-supports-focus': 'off',
'jsx-a11y/media-has-caption': 'off',
'no-await-in-loop': 'off',
'no-console': 'off',
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'no-underscore-dangle': 'off',
'prefer-destructuring': 'off',
'react/jsx-props-no-spreading': 'off',
'react/jsx-sort-props': [
'error',
{
callbacksLast: true,
ignoreCase: false,
noSortAlphabetically: false,
reservedFirst: true,
shorthandFirst: true,
shorthandLast: false,
},
],
'react/no-array-index-key': 'off',
'react/react-in-jsx-scope': 'off',
'react/require-default-props': 'off',
'sort-keys-fix/sort-keys-fix': 'warn',
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {
alwaysTryTypes: true,
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
project: './tsconfig.json',
},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
sourceType: 'module',
tsconfigRootDir: './',
},
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['off'],
'default-case': 'off',
'import/extensions': 'off',
'import/no-absolute-path': 'off',
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error',
'import/order': [
'error',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: ['builtin', 'external', 'internal', ['parent', 'sibling']],
'newlines-between': 'never',
pathGroups: [
{
group: 'external',
pattern: 'react',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['react'],
},
],
'import/prefer-default-export': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/interactive-supports-focus': 'off',
'jsx-a11y/media-has-caption': 'off',
'no-await-in-loop': 'off',
'no-console': 'off',
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'no-underscore-dangle': 'off',
'prefer-destructuring': 'off',
'react/jsx-props-no-spreading': 'off',
'react/jsx-sort-props': [
'error',
{
callbacksLast: true,
ignoreCase: false,
noSortAlphabetically: false,
reservedFirst: true,
shorthandFirst: true,
shorthandLast: false,
},
],
'react/no-array-index-key': 'off',
'react/react-in-jsx-scope': 'off',
'react/require-default-props': 'off',
'sort-keys-fix/sort-keys-fix': 'warn',
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json',
},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
},
},
},
};

View File

@ -39,7 +39,7 @@ labels: 'bug'
<!--- Include as many relevant details about the environment you experienced the bug in -->
- Application version (e.g. v0.1.0) :
- Operating System and version (e.g. Windows 10) :
- Server and version (e.g. Navidrome v0.48.0) :
- Node version (if developing locally) :
- Application version (e.g. v0.1.0) :
- Operating System and version (e.g. Windows 10) :
- Server and version (e.g. Navidrome v0.48.0) :
- Node version (if developing locally) :

10
.github/config.yml vendored
View File

@ -1,6 +1,6 @@
requiredHeaders:
- Prerequisites
- Expected Behavior
- Current Behavior
- Possible Solution
- Your Environment
- Prerequisites
- Expected Behavior
- Current Behavior
- Possible Solution
- Your Environment

10
.github/stale.yml vendored
View File

@ -4,14 +4,14 @@ daysUntilStale: 60
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- discussion
- security
- discussion
- security
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@ -3,37 +3,37 @@ name: Publish Linux (Manual)
on: workflow_dispatch
jobs:
publish:
runs-on: ${{ matrix.os }}
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
strategy:
matrix:
os: [ubuntu-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v1
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --linux
on_retry_command: npm cache clean --force
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --linux
on_retry_command: npm cache clean --force

View File

@ -3,37 +3,37 @@ name: Publish Windows and macOS (Manual)
on: workflow_dispatch
jobs:
publish:
runs-on: ${{ matrix.os }}
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest]
strategy:
matrix:
os: [macos-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v1
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --win --mac
on_retry_command: npm cache clean --force
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --win --mac
on_retry_command: npm cache clean --force

View File

@ -1,54 +1,54 @@
name: Comment on pull request
on:
workflow_run:
workflows: ['Publish (PR)']
types: [completed]
workflow_run:
workflows: ['Publish (PR)']
types: [completed]
jobs:
pr_comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
script: |
async function upsertComment(owner, repo, issue_number, purpose, body) {
const {data: comments} = await github.rest.issues.listComments(
{owner, repo, issue_number});
const marker = `<!-- bot: ${purpose} -->`;
body = marker + "\n" + body;
const existing = comments.filter((c) => c.body.includes(marker));
if (existing.length > 0) {
const last = existing[existing.length - 1];
core.info(`Updating comment ${last.id}`);
await github.rest.issues.updateComment({
owner, repo,
body,
comment_id: last.id,
});
} else {
core.info(`Creating a comment in issue / PR #${issue_number}`);
await github.rest.issues.createComment({issue_number, body, owner, repo});
}
}
const {owner, repo} = context.repo;
const run_id = ${{github.event.workflow_run.id}};
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
if (!pull_requests.length) {
return core.error("This workflow doesn't match any pull requests!");
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
core.info("Review thread message body:", body);
for (const pr of pull_requests) {
await upsertComment(owner, repo, pr.number,
"nightly-link", body);
}
pr_comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
script: |
async function upsertComment(owner, repo, issue_number, purpose, body) {
const {data: comments} = await github.rest.issues.listComments(
{owner, repo, issue_number});
const marker = `<!-- bot: ${purpose} -->`;
body = marker + "\n" + body;
const existing = comments.filter((c) => c.body.includes(marker));
if (existing.length > 0) {
const last = existing[existing.length - 1];
core.info(`Updating comment ${last.id}`);
await github.rest.issues.updateComment({
owner, repo,
body,
comment_id: last.id,
});
} else {
core.info(`Creating a comment in issue / PR #${issue_number}`);
await github.rest.issues.createComment({issue_number, body, owner, repo});
}
}
const {owner, repo} = context.repo;
const run_id = ${{github.event.workflow_run.id}};
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
if (!pull_requests.length) {
return core.error("This workflow doesn't match any pull requests!");
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
core.info("Review thread message body:", body);
for (const pr of pull_requests) {
await upsertComment(owner, repo, pr.number,
"nightly-link", body);
}

View File

@ -1,60 +1,60 @@
name: Publish (PR)
on:
pull_request:
branches:
- development
pull_request:
branches:
- development
jobs:
publish:
runs-on: ${{ matrix.os }}
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest]
strategy:
matrix:
os: [macos-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v3
steps:
- name: Checkout git repo
uses: actions/checkout@v3
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 16
cache: npm
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Build releases
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm run package:pr
on_retry_command: npm cache clean --force
- name: Build releases
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm run package:pr
on_retry_command: npm cache clean --force
- uses: actions/upload-artifact@v3
with:
name: windows-binaries
path: |
release/build/*.exe
- uses: actions/upload-artifact@v3
with:
name: windows-binaries
path: |
release/build/*.exe
- uses: actions/upload-artifact@v3
with:
name: linux-binaries
path: |
release/build/*.AppImage
release/build/*.deb
release/build/*.rpm
- uses: actions/upload-artifact@v3
with:
name: linux-binaries
path: |
release/build/*.AppImage
release/build/*.deb
release/build/*.rpm
- uses: actions/upload-artifact@v3
with:
name: macos-binaries
path: |
release/build/*.dmg
- uses: actions/upload-artifact@v3
with:
name: macos-binaries
path: |
release/build/*.dmg

View File

@ -3,32 +3,32 @@ name: Test
on: [push, pull_request]
jobs:
release:
runs-on: ${{ matrix.os }}
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v1
steps:
- name: Check out Git repository
uses: actions/checkout@v1
- name: Install Node.js and NPM
uses: actions/setup-node@v2
with:
node-version: 16
cache: npm
- name: Install Node.js and NPM
uses: actions/setup-node@v2
with:
node-version: 16
cache: npm
- name: npm install
run: |
npm install --legacy-peer-deps
- name: npm install
run: |
npm install --legacy-peer-deps
- name: npm test
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run package
npm run lint
npm exec tsc
npm test
- name: npm test
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run lint
npm run package
npm exec tsc
npm test

View File

@ -1,31 +1,28 @@
{
"processors": ["stylelint-processor-styled-components"],
"customSyntax": "postcss-scss",
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-styled-components",
"stylelint-config-rational-order"
],
"rules": {
"color-function-notation": ["legacy"],
"declaration-empty-line-before": null,
"order/properties-order": [],
"plugin/rational-order": [
true,
{
"border-in-box-model": false,
"empty-line-between-groups": false
}
"processors": ["stylelint-processor-styled-components"],
"customSyntax": "postcss-scss",
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-styled-components",
"stylelint-config-rational-order"
],
"string-quotes": "single",
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
"selector-type-no-unknown": [
true,
{ "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }
],
"value-keyword-case": ["lower", { "ignoreKeywords": ["dummyValue"] }],
"declaration-colon-newline-after": null
}
"rules": {
"color-function-notation": ["legacy"],
"declaration-empty-line-before": null,
"order/properties-order": [],
"plugin/rational-order": [
true,
{
"border-in-box-model": false,
"empty-line-between-groups": false
}
],
"string-quotes": "single",
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
"selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }],
"value-keyword-case": ["lower", { "ignoreKeywords": ["dummyValue"] }],
"declaration-colon-newline-after": null
}
}

View File

@ -1,8 +1,8 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"stylelint.vscode-stylelint",
"esbenp.prettier-vscode"
]
"recommendations": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"stylelint.vscode-stylelint",
"esbenp.prettier-vscode"
]
}

54
.vscode/launch.json vendored
View File

@ -1,30 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"protocol": "inspector",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run start:main --inspect=5858 --remote-debugging-port=9223"
],
"preLaunchTask": "Start Webpack Dev"
},
{
"name": "Electron: Renderer",
"type": "chrome",
"request": "attach",
"port": 9223,
"webRoot": "${workspaceFolder}",
"timeout": 15000
}
],
"compounds": [
{
"name": "Electron: All",
"configurations": ["Electron: Main", "Electron: Renderer"]
}
]
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"protocol": "inspector",
"runtimeExecutable": "npm",
"runtimeArgs": ["run start:main --inspect=5858 --remote-debugging-port=9223"],
"preLaunchTask": "Start Webpack Dev"
},
{
"name": "Electron: Renderer",
"type": "chrome",
"request": "attach",
"port": 9223,
"webRoot": "${workspaceFolder}",
"timeout": 15000
}
],
"compounds": [
{
"name": "Electron: All",
"configurations": ["Electron: Main", "Electron: Renderer"]
}
]
}

44
.vscode/tasks.json vendored
View File

@ -1,25 +1,25 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"label": "Start Webpack Dev",
"script": "start:renderer",
"options": {
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"pattern": {
"regexp": "____________"
},
"background": {
"activeOnStart": true,
"beginsPattern": "Compiling\\.\\.\\.$",
"endsPattern": "(Compiled successfully|Failed to compile)\\.$"
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"label": "Start Webpack Dev",
"script": "start:renderer",
"options": {
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"pattern": {
"regexp": "____________"
},
"background": {
"activeOnStart": true,
"beginsPattern": "Compiling\\.\\.\\.$",
"endsPattern": "(Compiled successfully|Failed to compile)\\.$"
}
}
}
}
}
]
]
}

24
assets/assets.d.ts vendored
View File

@ -1,31 +1,31 @@
type Styles = Record<string, string>;
declare module '*.svg' {
const content: string;
export default content;
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
const content: string;
export default content;
}
declare module '*.scss' {
const content: Styles;
export default content;
const content: Styles;
export default content;
}
declare module '*.sass' {
const content: Styles;
export default content;
const content: Styles;
export default content;
}
declare module '*.css' {
const content: Styles;
export default content;
const content: Styles;
export default content;
}

82060
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,321 +1,321 @@
{
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.2.0",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
"test": "jest",
"prepare": "husky install",
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"cross-env NODE_ENV=development eslint --cache"
],
"*.json,.{eslintrc,prettierrc}": [
"prettier --ignore-path .eslintignore --parser json --write"
],
"*.{css,scss}": [
"prettier --ignore-path .eslintignore --single-quote --write"
],
"*.{html,md,yml}": [
"prettier --ignore-path .eslintignore --single-quote --write"
]
},
"build": {
"name": "feishin",
"productName": "Feishin",
"appId": "org.jeffvli.feishin",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"asar": true,
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
"node_modules",
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "22.3.1",
"mac": {
"target": {
"target": "default",
"arch": [
"arm64",
"x64"
"description": "Feishin music server",
"version": "0.2.0",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
"test": "jest",
"prepare": "husky install",
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"cross-env NODE_ENV=development eslint --cache"
],
"*.json,.{eslintrc,prettierrc}": [
"prettier --ignore-path .eslintignore --parser json --write"
],
"*.{css,scss}": [
"prettier --ignore-path .eslintignore --single-quote --write"
],
"*.{html,md,yml}": [
"prettier --ignore-path .eslintignore --single-quote --write"
]
},
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
"build": {
"productName": "Feishin",
"appId": "org.jeffvli.feishin",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"asar": true,
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
"node_modules",
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "22.3.1",
"mac": {
"target": {
"target": "default",
"arch": [
"arm64",
"x64"
]
},
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
"nsis",
"zip"
]
},
"linux": {
"target": [
"AppImage",
"tar.xz"
],
"icon": "assets/icons/placeholder.png",
"category": "Development"
},
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
},
"extraResources": [
"./assets/**"
],
"publish": {
"provider": "github",
"owner": "jeffvli",
"repo": "feishin"
}
]
},
"win": {
"target": [
"nsis",
"zip"
]
"repository": {
"type": "git",
"url": "git+https://github.com/jeffvli/feishin.git"
},
"linux": {
"target": [
"AppImage",
"tar.xz"
],
"icon": "assets/icons/placeholder.png",
"category": "Development"
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
"contributors": [],
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/jeffvli/feishin/issues"
},
"extraResources": [
"./assets/**"
"keywords": [
"subsonic",
"navidrome",
"airsonic",
"jellyfin",
"react",
"electron"
],
"publish": {
"provider": "github",
"owner": "jeffvli",
"repo": "feishin"
"homepage": "https://github.com/jeffvli/feishin",
"jest": {
"testURL": "http://localhost/",
"testEnvironment": "jsdom",
"transform": {
"\\.(ts|tsx|js|jsx)$": "ts-jest"
},
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"json"
],
"moduleDirectories": [
"node_modules",
"release/app/node_modules"
],
"testPathIgnorePatterns": [
"release/app/dist"
],
"setupFiles": [
"./.erb/scripts/check-build-exists.ts"
]
},
"devDependencies": {
"@electron/rebuild": "^3.2.10",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.188",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.23",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/styled-components": "^5.1.26",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^7.1.0",
"core-js": "^3.21.1",
"cross-env": "^7.0.3",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^22.3.1",
"electron-builder": "^24.0.0-alpha.13",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
"electronmon": "^2.0.2",
"eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-typescript-sort-keys": "^2.1.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.3.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
"mini-css-extract-plugin": "^2.6.0",
"postcss-scss": "^4.0.4",
"postcss-syntax": "^0.36.2",
"prettier": "^2.6.2",
"react-refresh": "^0.12.0",
"react-refresh-typescript": "^2.0.4",
"react-test-renderer": "^18.0.0",
"rimraf": "^3.0.2",
"sass": "^1.49.11",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"stylelint": "^14.9.1",
"stylelint-config-rational-order": "^0.1.2",
"stylelint-config-standard-scss": "^4.0.0",
"stylelint-config-styled-components": "^0.1.1",
"stylelint-order": "^5.0.0",
"stylelint-processor-styled-components": "^1.10.0",
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.8.4",
"typescript-plugin-styled-components": "^2.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.71.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.0",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@ag-grid-community/client-side-row-model": "^28.2.1",
"@ag-grid-community/core": "^28.2.1",
"@ag-grid-community/infinite-row-model": "^28.2.1",
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.13",
"@mantine/dates": "^6.0.13",
"@mantine/form": "^6.0.13",
"@mantine/hooks": "^6.0.13",
"@mantine/modals": "^6.0.13",
"@mantine/notifications": "^6.0.13",
"@mantine/utils": "^6.0.13",
"@tanstack/react-query": "^4.29.5",
"@tanstack/react-query-devtools": "^4.29.6",
"@ts-rest/core": "^3.23.0",
"axios": "^1.4.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^4.4.6",
"electron-store": "^8.1.0",
"electron-updater": "^4.6.5",
"fast-average-color": "^9.3.0",
"format-duration": "^2.0.0",
"framer-motion": "^9.1.7",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"i18next": "^21.6.16",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"nanoid": "^3.3.3",
"net": "^1.0.2",
"node-mpv": "github:jeffvli/Node-MPV",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-icons": "^4.8.0",
"react-player": "^2.11.0",
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"styled-components": "^5.3.11",
"swiper": "^9.3.1",
"zod": "^3.21.4",
"zustand": "^4.3.8"
},
"resolutions": {
"styled-components": "^5"
},
"devEngines": {
"node": ">=14.x",
"npm": ">=7.x"
},
"browserslist": [],
"electronmon": {
"patterns": [
"!server",
"!src/renderer"
]
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/jeffvli/feishin.git"
},
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"contributors": [],
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/jeffvli/feishin/issues"
},
"keywords": [
"subsonic",
"navidrome",
"airsonic",
"jellyfin",
"react",
"electron"
],
"homepage": "https://github.com/jeffvli/feishin",
"jest": {
"testURL": "http://localhost/",
"testEnvironment": "jsdom",
"transform": {
"\\.(ts|tsx|js|jsx)$": "ts-jest"
},
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"json"
],
"moduleDirectories": [
"node_modules",
"release/app/node_modules"
],
"testPathIgnorePatterns": [
"release/app/dist"
],
"setupFiles": [
"./.erb/scripts/check-build-exists.ts"
]
},
"devDependencies": {
"@electron/rebuild": "^3.2.10",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.188",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.23",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/styled-components": "^5.1.26",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^7.1.0",
"core-js": "^3.21.1",
"cross-env": "^7.0.3",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^22.3.1",
"electron-builder": "^24.0.0-alpha.13",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
"electronmon": "^2.0.2",
"eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-typescript-sort-keys": "^2.1.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.3.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
"mini-css-extract-plugin": "^2.6.0",
"postcss-scss": "^4.0.4",
"postcss-syntax": "^0.36.2",
"prettier": "^2.6.2",
"react-refresh": "^0.12.0",
"react-refresh-typescript": "^2.0.4",
"react-test-renderer": "^18.0.0",
"rimraf": "^3.0.2",
"sass": "^1.49.11",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"stylelint": "^14.9.1",
"stylelint-config-rational-order": "^0.1.2",
"stylelint-config-standard-scss": "^4.0.0",
"stylelint-config-styled-components": "^0.1.1",
"stylelint-order": "^5.0.0",
"stylelint-processor-styled-components": "^1.10.0",
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.8.4",
"typescript-plugin-styled-components": "^2.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.71.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.0",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@ag-grid-community/client-side-row-model": "^28.2.1",
"@ag-grid-community/core": "^28.2.1",
"@ag-grid-community/infinite-row-model": "^28.2.1",
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.13",
"@mantine/dates": "^6.0.13",
"@mantine/form": "^6.0.13",
"@mantine/hooks": "^6.0.13",
"@mantine/modals": "^6.0.13",
"@mantine/notifications": "^6.0.13",
"@mantine/utils": "^6.0.13",
"@tanstack/react-query": "^4.29.5",
"@tanstack/react-query-devtools": "^4.29.6",
"@ts-rest/core": "^3.23.0",
"axios": "^1.4.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^4.4.6",
"electron-store": "^8.1.0",
"electron-updater": "^4.6.5",
"fast-average-color": "^9.3.0",
"format-duration": "^2.0.0",
"framer-motion": "^9.1.7",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"i18next": "^21.6.16",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"nanoid": "^3.3.3",
"net": "^1.0.2",
"node-mpv": "github:jeffvli/Node-MPV",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-icons": "^4.8.0",
"react-player": "^2.11.0",
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"styled-components": "^5.3.11",
"swiper": "^9.3.1",
"zod": "^3.21.4",
"zustand": "^4.3.8"
},
"resolutions": {
"styled-components": "^5"
},
"devEngines": {
"node": ">=14.x",
"npm": ">=7.x"
},
"browserslist": [],
"electronmon": {
"patterns": [
"!server",
"!src/renderer"
]
}
}

View File

@ -3,7 +3,7 @@ import { render } from '@testing-library/react';
import { App } from '../renderer/app';
describe('App', () => {
it('should render', () => {
expect(render(<App />)).toBeTruthy();
});
it('should render', () => {
expect(render(<App />)).toBeTruthy();
});
});

View File

@ -1,10 +1,10 @@
import axios, { AxiosResponse } from 'axios';
import { load } from 'cheerio';
import {
LyricSource,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '../../../../renderer/api/types';
import { orderSearchResults } from './shared';
@ -13,197 +13,197 @@ const SEARCH_URL = 'https://genius.com/api/search/song';
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/genius.ts
export interface GeniusResponse {
meta: Meta;
response: Response;
meta: Meta;
response: Response;
}
export interface Meta {
status: number;
status: number;
}
export interface Response {
next_page: number;
sections: Section[];
next_page: number;
sections: Section[];
}
export interface Section {
hits: Hit[];
type: string;
hits: Hit[];
type: string;
}
export interface Hit {
highlights: any[];
index: string;
result: Result;
type: string;
highlights: any[];
index: string;
result: Result;
type: string;
}
export interface Result {
_type: string;
annotation_count: number;
api_path: string;
artist_names: string;
featured_artists: any[];
full_title: string;
header_image_thumbnail_url: string;
header_image_url: string;
id: number;
instrumental: boolean;
language: string;
lyrics_owner_id: number;
lyrics_state: string;
lyrics_updated_at: number;
path: string;
primary_artist: PrimaryArtist;
pyongs_count: null;
relationships_index_url: string;
release_date_components: ReleaseDateComponents;
release_date_for_display: string;
release_date_with_abbreviated_month_for_display: string;
song_art_image_thumbnail_url: string;
song_art_image_url: string;
stats: Stats;
title: string;
title_with_featured: string;
updated_by_human_at: number;
url: string;
_type: string;
annotation_count: number;
api_path: string;
artist_names: string;
featured_artists: any[];
full_title: string;
header_image_thumbnail_url: string;
header_image_url: string;
id: number;
instrumental: boolean;
language: string;
lyrics_owner_id: number;
lyrics_state: string;
lyrics_updated_at: number;
path: string;
primary_artist: PrimaryArtist;
pyongs_count: null;
relationships_index_url: string;
release_date_components: ReleaseDateComponents;
release_date_for_display: string;
release_date_with_abbreviated_month_for_display: string;
song_art_image_thumbnail_url: string;
song_art_image_url: string;
stats: Stats;
title: string;
title_with_featured: string;
updated_by_human_at: number;
url: string;
}
export interface PrimaryArtist {
_type: string;
api_path: string;
header_image_url: string;
id: number;
image_url: string;
index_character: string;
is_meme_verified: boolean;
is_verified: boolean;
name: string;
slug: string;
url: string;
_type: string;
api_path: string;
header_image_url: string;
id: number;
image_url: string;
index_character: string;
is_meme_verified: boolean;
is_verified: boolean;
name: string;
slug: string;
url: string;
}
export interface ReleaseDateComponents {
day: number;
month: number;
year: number;
day: number;
month: number;
year: number;
}
export interface Stats {
hot: boolean;
unreviewed_annotations: number;
hot: boolean;
unreviewed_annotations: number;
}
export async function getSearchResults(
params: LyricSearchQuery,
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<GeniusResponse>;
let result: AxiosResponse<GeniusResponse>;
const searchQuery = [params.artist, params.name].join(' ');
const searchQuery = [params.artist, params.name].join(' ');
if (!searchQuery) {
return null;
}
if (!searchQuery) {
return null;
}
try {
result = await axios.get(SEARCH_URL, {
params: {
per_page: '5',
q: searchQuery,
},
try {
result = await axios.get(SEARCH_URL, {
params: {
per_page: '5',
q: searchQuery,
},
});
} catch (e) {
console.error('Genius search request got an error!', e);
return null;
}
const rawSongsResult = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result);
if (!rawSongsResult) return null;
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
return {
artist: song.artist_names,
id: song.url,
name: song.full_title,
source: LyricSource.GENIUS,
};
});
} catch (e) {
console.error('Genius search request got an error!', e);
return null;
}
const rawSongsResult = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result);
if (!rawSongsResult) return null;
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
return {
artist: song.artist_names,
id: song.url,
name: song.full_title,
source: LyricSource.GENIUS,
};
});
return orderSearchResults({ params, results: songResults });
return orderSearchResults({ params, results: songResults });
}
async function getSongId(
params: LyricSearchQuery,
params: LyricSearchQuery,
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
let result: AxiosResponse<GeniusResponse>;
try {
result = await axios.get(SEARCH_URL, {
params: {
per_page: '1',
q: `${params.artist} ${params.name}`,
},
});
} catch (e) {
console.error('Genius search request got an error!', e);
return null;
}
let result: AxiosResponse<GeniusResponse>;
try {
result = await axios.get(SEARCH_URL, {
params: {
per_page: '1',
q: `${params.artist} ${params.name}`,
},
});
} catch (e) {
console.error('Genius search request got an error!', e);
return null;
}
const hit = result.data.response?.sections?.[0]?.hits?.[0]?.result;
const hit = result.data.response?.sections?.[0]?.hits?.[0]?.result;
if (!hit) {
return null;
}
if (!hit) {
return null;
}
return {
artist: hit.artist_names,
id: hit.url,
name: hit.full_title,
source: LyricSource.GENIUS,
};
return {
artist: hit.artist_names,
id: hit.url,
name: hit.full_title,
source: LyricSource.GENIUS,
};
}
export async function getLyricsBySongId(url: string): Promise<string | null> {
let result: AxiosResponse<string, any>;
try {
result = await axios.get<string>(url, { responseType: 'text' });
} catch (e) {
console.error('Genius lyrics request got an error!', e);
return null;
}
let result: AxiosResponse<string, any>;
try {
result = await axios.get<string>(url, { responseType: 'text' });
} catch (e) {
console.error('Genius lyrics request got an error!', e);
return null;
}
const $ = load(result.data.split('<br/>').join('\n'));
const lyricsDiv = $('div.lyrics');
const $ = load(result.data.split('<br/>').join('\n'));
const lyricsDiv = $('div.lyrics');
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
const lyricSections = $('div[class^=Lyrics__Container]')
.map((_, e) => $(e).text())
.toArray()
.join('\n');
return lyricSections;
const lyricSections = $('div[class^=Lyrics__Container]')
.map((_, e) => $(e).text())
.toArray()
.join('\n');
return lyricSections;
}
export async function query(
params: LyricSearchQuery,
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
const response = await getSongId(params);
if (!response) {
console.error('Could not find the song on Genius!');
return null;
}
const response = await getSongId(params);
if (!response) {
console.error('Could not find the song on Genius!');
return null;
}
const lyrics = await getLyricsBySongId(response.id);
if (!lyrics) {
console.error('Could not get lyrics on Genius!');
return null;
}
const lyrics = await getLyricsBySongId(response.id);
if (!lyrics) {
console.error('Could not get lyrics on Genius!');
return null;
}
return {
artist: response.artist,
id: response.id,
lyrics,
name: response.name,
source: LyricSource.GENIUS,
};
return {
artist: response.artist,
id: response.id,
lyrics,
name: response.name,
source: LyricSource.GENIUS,
};
}

View File

@ -1,53 +1,53 @@
import { ipcMain } from 'electron';
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
QueueSong,
LyricGetQuery,
LyricSource,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
QueueSong,
LyricGetQuery,
LyricSource,
} from '../../../../renderer/api/types';
import { store } from '../settings/index';
import {
query as queryGenius,
getSearchResults as searchGenius,
getLyricsBySongId as getGenius,
query as queryGenius,
getSearchResults as searchGenius,
getLyricsBySongId as getGenius,
} from './genius';
import {
query as queryLrclib,
getSearchResults as searchLrcLib,
getLyricsBySongId as getLrcLib,
query as queryLrclib,
getSearchResults as searchLrcLib,
getLyricsBySongId as getLrcLib,
} from './lrclib';
import {
query as queryNetease,
getSearchResults as searchNetease,
getLyricsBySongId as getNetease,
query as queryNetease,
getSearchResults as searchNetease,
getLyricsBySongId as getNetease,
} from './netease';
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
type SearchFetcher = (
params: LyricSearchQuery,
params: LyricSearchQuery,
) => Promise<InternetProviderLyricSearchResponse[] | null>;
type GetFetcher = (id: string) => Promise<string | null>;
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
const FETCHERS: Record<LyricSource, SongFetcher> = {
[LyricSource.GENIUS]: queryGenius,
[LyricSource.LRCLIB]: queryLrclib,
[LyricSource.NETEASE]: queryNetease,
[LyricSource.GENIUS]: queryGenius,
[LyricSource.LRCLIB]: queryLrclib,
[LyricSource.NETEASE]: queryNetease,
};
const SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {
[LyricSource.GENIUS]: searchGenius,
[LyricSource.LRCLIB]: searchLrcLib,
[LyricSource.NETEASE]: searchNetease,
[LyricSource.GENIUS]: searchGenius,
[LyricSource.LRCLIB]: searchLrcLib,
[LyricSource.NETEASE]: searchNetease,
};
const GET_FETCHERS: Record<LyricSource, GetFetcher> = {
[LyricSource.GENIUS]: getGenius,
[LyricSource.LRCLIB]: getLrcLib,
[LyricSource.NETEASE]: getNetease,
[LyricSource.GENIUS]: getGenius,
[LyricSource.LRCLIB]: getLrcLib,
[LyricSource.NETEASE]: getNetease,
};
const MAX_CACHED_ITEMS = 10;
@ -55,95 +55,95 @@ const MAX_CACHED_ITEMS = 10;
const lyricCache = new Map<string, CachedLyrics>();
const getRemoteLyrics = async (song: QueueSong) => {
const sources = store.get('lyrics', []) as LyricSource[];
const sources = store.get('lyrics', []) as LyricSource[];
const cached = lyricCache.get(song.id);
const cached = lyricCache.get(song.id);
if (cached) {
for (const source of sources) {
const data = cached[source];
if (data) return data;
}
}
let lyricsFromSource = null;
if (cached) {
for (const source of sources) {
const data = cached[source];
if (data) return data;
const params = {
album: song.album || song.name,
artist: song.artistName,
duration: song.duration,
name: song.name,
};
const response = await FETCHERS[source](params);
if (response) {
const newResult = cached
? {
...cached,
[source]: response,
}
: ({ [source]: response } as CachedLyrics);
if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {
const toRemove = lyricCache.keys().next().value;
lyricCache.delete(toRemove);
}
lyricCache.set(song.id, newResult);
lyricsFromSource = response;
break;
}
}
}
let lyricsFromSource = null;
for (const source of sources) {
const params = {
album: song.album || song.name,
artist: song.artistName,
duration: song.duration,
name: song.name,
};
const response = await FETCHERS[source](params);
if (response) {
const newResult = cached
? {
...cached,
[source]: response,
}
: ({ [source]: response } as CachedLyrics);
if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {
const toRemove = lyricCache.keys().next().value;
lyricCache.delete(toRemove);
}
lyricCache.set(song.id, newResult);
lyricsFromSource = response;
break;
}
}
return lyricsFromSource;
return lyricsFromSource;
};
const searchRemoteLyrics = async (params: LyricSearchQuery) => {
const sources = store.get('lyrics', []) as LyricSource[];
const sources = store.get('lyrics', []) as LyricSource[];
const results: Record<LyricSource, InternetProviderLyricSearchResponse[]> = {
[LyricSource.GENIUS]: [],
[LyricSource.LRCLIB]: [],
[LyricSource.NETEASE]: [],
};
const results: Record<LyricSource, InternetProviderLyricSearchResponse[]> = {
[LyricSource.GENIUS]: [],
[LyricSource.LRCLIB]: [],
[LyricSource.NETEASE]: [],
};
for (const source of sources) {
const response = await SEARCH_FETCHERS[source](params);
for (const source of sources) {
const response = await SEARCH_FETCHERS[source](params);
if (response) {
response.forEach((result) => {
results[source].push(result);
});
if (response) {
response.forEach((result) => {
results[source].push(result);
});
}
}
}
return results;
return results;
};
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null> => {
const { remoteSongId, remoteSource } = params;
const response = await GET_FETCHERS[remoteSource](remoteSongId);
const { remoteSongId, remoteSource } = params;
const response = await GET_FETCHERS[remoteSource](remoteSongId);
if (!response) {
return null;
}
if (!response) {
return null;
}
return response;
return response;
};
ipcMain.handle('lyric-by-song', async (_event, song: QueueSong) => {
const lyric = await getRemoteLyrics(song);
return lyric;
const lyric = await getRemoteLyrics(song);
return lyric;
});
ipcMain.handle('lyric-search', async (_event, params: LyricSearchQuery) => {
const lyricResults = await searchRemoteLyrics(params);
return lyricResults;
const lyricResults = await searchRemoteLyrics(params);
return lyricResults;
});
ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
const lyricResults = await getRemoteLyricsById(params);
return lyricResults;
const lyricResults = await getRemoteLyricsById(params);
return lyricResults;
});

View File

@ -1,10 +1,10 @@
// Credits to https://github.com/tranxuanthang/lrcget for API implementation
import axios, { AxiosResponse } from 'axios';
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
} from '../../../../renderer/api/types';
import { orderSearchResults } from './shared';
@ -14,106 +14,106 @@ const SEEARCH_URL = 'https://lrclib.net/api/search';
const TIMEOUT_MS = 5000;
export interface LrcLibSearchResponse {
albumName: string;
artistName: string;
id: number;
name: string;
albumName: string;
artistName: string;
id: number;
name: string;
}
export interface LrcLibTrackResponse {
albumName: string;
artistName: string;
duration: number;
id: number;
instrumental: boolean;
isrc: string;
lang: string;
name: string;
plainLyrics: string | null;
releaseDate: string;
spotifyId: string;
syncedLyrics: string | null;
albumName: string;
artistName: string;
duration: number;
id: number;
instrumental: boolean;
isrc: string;
lang: string;
name: string;
plainLyrics: string | null;
releaseDate: string;
spotifyId: string;
syncedLyrics: string | null;
}
export async function getSearchResults(
params: LyricSearchQuery,
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<LrcLibSearchResponse[]>;
let result: AxiosResponse<LrcLibSearchResponse[]>;
if (!params.name) {
return null;
}
if (!params.name) {
return null;
}
try {
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
params: {
q: params.name,
},
try {
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
params: {
q: params.name,
},
});
} catch (e) {
console.error('LrcLib search request got an error!', e);
return null;
}
if (!result.data) return null;
const songResults: InternetProviderLyricSearchResponse[] = result.data.map((song) => {
return {
artist: song.artistName,
id: String(song.id),
name: song.name,
source: LyricSource.LRCLIB,
};
});
} catch (e) {
console.error('LrcLib search request got an error!', e);
return null;
}
if (!result.data) return null;
const songResults: InternetProviderLyricSearchResponse[] = result.data.map((song) => {
return {
artist: song.artistName,
id: String(song.id),
name: song.name,
source: LyricSource.LRCLIB,
};
});
return orderSearchResults({ params, results: songResults });
return orderSearchResults({ params, results: songResults });
}
export async function getLyricsBySongId(songId: string): Promise<string | null> {
let result: AxiosResponse<LrcLibTrackResponse, any>;
let result: AxiosResponse<LrcLibTrackResponse, any>;
try {
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
} catch (e) {
console.error('LrcLib lyrics request got an error!', e);
return null;
}
try {
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
} catch (e) {
console.error('LrcLib lyrics request got an error!', e);
return null;
}
return result.data.syncedLyrics || result.data.plainLyrics || null;
return result.data.syncedLyrics || result.data.plainLyrics || null;
}
export async function query(
params: LyricSearchQuery,
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
let result: AxiosResponse<LrcLibTrackResponse, any>;
let result: AxiosResponse<LrcLibTrackResponse, any>;
try {
result = await axios.get<LrcLibTrackResponse>(FETCH_URL, {
params: {
album_name: params.album,
artist_name: params.artist,
duration: params.duration,
track_name: params.name,
},
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('LrcLib search request got an error!', e);
return null;
}
try {
result = await axios.get<LrcLibTrackResponse>(FETCH_URL, {
params: {
album_name: params.album,
artist_name: params.artist,
duration: params.duration,
track_name: params.name,
},
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('LrcLib search request got an error!', e);
return null;
}
const lyrics = result.data.syncedLyrics || result.data.plainLyrics || null;
const lyrics = result.data.syncedLyrics || result.data.plainLyrics || null;
if (!lyrics) {
console.error(`Could not get lyrics on LrcLib!`);
return null;
}
if (!lyrics) {
console.error(`Could not get lyrics on LrcLib!`);
return null;
}
return {
artist: result.data.artistName,
id: String(result.data.id),
lyrics,
name: result.data.name,
source: LyricSource.LRCLIB,
};
return {
artist: result.data.artistName,
id: String(result.data.id),
lyrics,
name: result.data.name,
source: LyricSource.LRCLIB,
};
}

View File

@ -2,9 +2,9 @@ import axios, { AxiosResponse } from 'axios';
import { LyricSource } from '../../../../renderer/api/types';
import { orderSearchResults } from './shared';
import type {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '/@/renderer/api/types';
const SEARCH_URL = 'https://music.163.com/api/search/get';
@ -13,155 +13,155 @@ const LYRICS_URL = 'https://music.163.com/api/song/lyric';
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/netease.ts
export interface NetEaseResponse {
code: number;
result: Result;
code: number;
result: Result;
}
export interface Result {
hasMore: boolean;
songCount: number;
songs: Song[];
hasMore: boolean;
songCount: number;
songs: Song[];
}
export interface Song {
album: Album;
alias: string[];
artists: Artist[];
copyrightId: number;
duration: number;
fee: number;
ftype: number;
id: number;
mark: number;
mvid: number;
name: string;
rUrl: null;
rtype: number;
status: number;
transNames?: string[];
album: Album;
alias: string[];
artists: Artist[];
copyrightId: number;
duration: number;
fee: number;
ftype: number;
id: number;
mark: number;
mvid: number;
name: string;
rUrl: null;
rtype: number;
status: number;
transNames?: string[];
}
export interface Album {
artist: Artist;
copyrightId: number;
id: number;
mark: number;
name: string;
picId: number;
publishTime: number;
size: number;
status: number;
transNames?: string[];
artist: Artist;
copyrightId: number;
id: number;
mark: number;
name: string;
picId: number;
publishTime: number;
size: number;
status: number;
transNames?: string[];
}
export interface Artist {
albumSize: number;
alias: any[];
fansGroup: null;
id: number;
img1v1: number;
img1v1Url: string;
name: string;
picId: number;
picUrl: null;
trans: null;
albumSize: number;
alias: any[];
fansGroup: null;
id: number;
img1v1: number;
img1v1Url: string;
name: string;
picId: number;
picUrl: null;
trans: null;
}
export async function getSearchResults(
params: LyricSearchQuery,
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<NetEaseResponse>;
let result: AxiosResponse<NetEaseResponse>;
const searchQuery = [params.artist, params.name].join(' ');
const searchQuery = [params.artist, params.name].join(' ');
if (!searchQuery) {
return null;
}
if (!searchQuery) {
return null;
}
try {
result = await axios.get(SEARCH_URL, {
params: {
limit: 5,
offset: 0,
s: searchQuery,
type: '1',
},
try {
result = await axios.get(SEARCH_URL, {
params: {
limit: 5,
offset: 0,
s: searchQuery,
type: '1',
},
});
} catch (e) {
console.error('NetEase search request got an error!', e);
return null;
}
const rawSongsResult = result?.data.result?.songs;
if (!rawSongsResult) return null;
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
const artist = song.artists ? song.artists.map((artist) => artist.name).join(', ') : '';
return {
artist,
id: String(song.id),
name: song.name,
source: LyricSource.NETEASE,
};
});
} catch (e) {
console.error('NetEase search request got an error!', e);
return null;
}
const rawSongsResult = result?.data.result?.songs;
if (!rawSongsResult) return null;
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
const artist = song.artists ? song.artists.map((artist) => artist.name).join(', ') : '';
return {
artist,
id: String(song.id),
name: song.name,
source: LyricSource.NETEASE,
};
});
return orderSearchResults({ params, results: songResults });
return orderSearchResults({ params, results: songResults });
}
async function getMatchedLyrics(
params: LyricSearchQuery,
params: LyricSearchQuery,
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
const results = await getSearchResults(params);
const results = await getSearchResults(params);
const firstMatch = results?.[0];
const firstMatch = results?.[0];
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
return null;
}
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
return null;
}
return firstMatch;
return firstMatch;
}
export async function getLyricsBySongId(songId: string): Promise<string | null> {
let result: AxiosResponse<any, any>;
try {
result = await axios.get(LYRICS_URL, {
params: {
id: songId,
kv: '-1',
lv: '-1',
},
});
} catch (e) {
console.error('NetEase lyrics request got an error!', e);
return null;
}
let result: AxiosResponse<any, any>;
try {
result = await axios.get(LYRICS_URL, {
params: {
id: songId,
kv: '-1',
lv: '-1',
},
});
} catch (e) {
console.error('NetEase lyrics request got an error!', e);
return null;
}
return result.data.klyric?.lyric || result.data.lrc?.lyric;
return result.data.klyric?.lyric || result.data.lrc?.lyric;
}
export async function query(
params: LyricSearchQuery,
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
const lyricsMatch = await getMatchedLyrics(params);
if (!lyricsMatch) {
console.error('Could not find the song on NetEase!');
return null;
}
const lyricsMatch = await getMatchedLyrics(params);
if (!lyricsMatch) {
console.error('Could not find the song on NetEase!');
return null;
}
const lyrics = await getLyricsBySongId(lyricsMatch.id);
if (!lyrics) {
console.error('Could not get lyrics on NetEase!');
return null;
}
const lyrics = await getLyricsBySongId(lyricsMatch.id);
if (!lyrics) {
console.error('Could not get lyrics on NetEase!');
return null;
}
return {
artist: lyricsMatch.artist,
id: lyricsMatch.id,
lyrics,
name: lyricsMatch.name,
source: LyricSource.NETEASE,
};
return {
artist: lyricsMatch.artist,
id: lyricsMatch.id,
lyrics,
name: lyricsMatch.name,
source: LyricSource.NETEASE,
};
}

View File

@ -1,34 +1,34 @@
import Fuse from 'fuse.js';
import {
InternetProviderLyricSearchResponse,
LyricSearchQuery,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '../../../../renderer/api/types';
export const orderSearchResults = (args: {
params: LyricSearchQuery;
results: InternetProviderLyricSearchResponse[];
params: LyricSearchQuery;
results: InternetProviderLyricSearchResponse[];
}) => {
const { params, results } = args;
const { params, results } = args;
const options: Fuse.IFuseOptions<InternetProviderLyricSearchResponse> = {
fieldNormWeight: 1,
includeScore: true,
keys: [
{ getFn: (song) => song.name, name: 'name', weight: 3 },
{ getFn: (song) => song.artist, name: 'artist' },
],
threshold: 1.0,
};
const options: Fuse.IFuseOptions<InternetProviderLyricSearchResponse> = {
fieldNormWeight: 1,
includeScore: true,
keys: [
{ getFn: (song) => song.name, name: 'name', weight: 3 },
{ getFn: (song) => song.artist, name: 'artist' },
],
threshold: 1.0,
};
const fuse = new Fuse(results, options);
const fuse = new Fuse(results, options);
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }),
});
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }),
});
return searchResults.map((result) => ({
...result.item,
score: result.score,
}));
return searchResults.map((result) => ({
...result.item,
score: result.score,
}));
};

View File

@ -6,215 +6,215 @@ import { PlayerData } from '/@/renderer/store';
declare module 'node-mpv';
function wait(timeout: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve('resolved');
}, timeout);
});
return new Promise((resolve) => {
setTimeout(() => {
resolve('resolved');
}, timeout);
});
}
ipcMain.handle('player-is-running', async () => {
return getMpvInstance()?.isRunning();
return getMpvInstance()?.isRunning();
});
ipcMain.handle('player-clean-up', async () => {
getMpvInstance()?.stop();
getMpvInstance()?.clearPlaylist();
getMpvInstance()?.stop();
getMpvInstance()?.clearPlaylist();
});
ipcMain.on('player-start', async () => {
await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
});
// Starts the player
ipcMain.on('player-play', async () => {
await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
});
// Pauses the player
ipcMain.on('player-pause', async () => {
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
});
// Stops the player
ipcMain.on('player-stop', async () => {
await getMpvInstance()
?.stop()
.catch((err) => {
console.log('MPV failed to stop', err);
});
await getMpvInstance()
?.stop()
.catch((err) => {
console.log('MPV failed to stop', err);
});
});
// Goes to the next track in the playlist
ipcMain.on('player-next', async () => {
await getMpvInstance()
?.next()
.catch((err) => {
console.log('MPV failed to go to next', err);
});
await getMpvInstance()
?.next()
.catch((err) => {
console.log('MPV failed to go to next', err);
});
});
// Goes to the previous track in the playlist
ipcMain.on('player-previous', async () => {
await getMpvInstance()
?.prev()
.catch((err) => {
console.log('MPV failed to go to previous', err);
});
await getMpvInstance()
?.prev()
.catch((err) => {
console.log('MPV failed to go to previous', err);
});
});
// Seeks forward or backward by the given amount of seconds
ipcMain.on('player-seek', async (_event, time: number) => {
await getMpvInstance()
?.seek(time)
.catch((err) => {
console.log('MPV failed to seek', err);
});
await getMpvInstance()
?.seek(time)
.catch((err) => {
console.log('MPV failed to seek', err);
});
});
// Seeks to the given time in seconds
ipcMain.on('player-seek-to', async (_event, time: number) => {
await getMpvInstance()
?.goToPosition(time)
.catch((err) => {
console.log(`MPV failed to seek to ${time}`, err);
});
await getMpvInstance()
?.goToPosition(time)
.catch((err) => {
console.log(`MPV failed to seek to ${time}`, err);
});
});
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
if (!data.queue.current && !data.queue.next) {
await getMpvInstance()
?.clearPlaylist()
.catch((err) => {
console.log('MPV failed to clear playlist', err);
});
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
return;
}
let complete = false;
let tryAttempts = 0;
while (!complete) {
if (tryAttempts > 3) {
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
complete = true;
} else {
try {
if (data.queue.current) {
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
if (!data.queue.current && !data.queue.next) {
await getMpvInstance()
?.clearPlaylist()
.catch((err) => {
console.log('MPV failed to load song', err);
console.log('MPV failed to clear playlist', err);
});
}
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to load next song', err);
console.log('MPV failed to pause', err);
});
}
complete = true;
} catch (err) {
console.error(err);
tryAttempts += 1;
await wait(500);
}
return;
}
}
if (pause) {
await getMpvInstance()?.pause();
}
let complete = false;
let tryAttempts = 0;
while (!complete) {
if (tryAttempts > 3) {
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
complete = true;
} else {
try {
if (data.queue.current) {
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
.catch((err) => {
console.log('MPV failed to load song', err);
});
}
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
}
complete = true;
} catch (err) {
console.error(err);
tryAttempts += 1;
await wait(500);
}
}
}
if (pause) {
await getMpvInstance()?.pause();
}
});
// Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
const size = await getMpvInstance()
?.getPlaylistSize()
.catch((err) => {
console.log('MPV failed to get playlist size', err);
});
const size = await getMpvInstance()
?.getPlaylistSize()
.catch((err) => {
console.log('MPV failed to get playlist size', err);
});
if (!size) {
return;
}
if (!size) {
return;
}
if (size > 1) {
await getMpvInstance()
?.playlistRemove(1)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
});
}
if (size > 1) {
await getMpvInstance()
?.playlistRemove(1)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
});
}
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
}
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
}
});
// Sets the next song in the queue when reaching the end of the queue
ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
// Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
await getMpvInstance()
?.playlistRemove(0)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
});
if (data.queue.next) {
// Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
}
?.playlistRemove(0)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
});
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
}
});
// Sets the volume to the given value (0-100)
ipcMain.on('player-volume', async (_event, value: number) => {
await getMpvInstance()
?.volume(value)
.catch((err) => {
console.log('MPV failed to set volume', err);
});
await getMpvInstance()
?.volume(value)
.catch((err) => {
console.log('MPV failed to set volume', err);
});
});
// Toggles the mute status
ipcMain.on('player-mute', async () => {
await getMpvInstance()
?.mute()
.catch((err) => {
console.log('MPV failed to toggle mute', err);
});
await getMpvInstance()
?.mute()
.catch((err) => {
console.log('MPV failed to toggle mute', err);
});
});
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
return getMpvInstance()?.getTimePosition();
return getMpvInstance()?.getTimePosition();
});

View File

@ -2,26 +2,26 @@
import { BrowserWindow, globalShortcut } from 'electron';
export const enableMediaKeys = (window: BrowserWindow | null) => {
globalShortcut.register('MediaStop', () => {
window?.webContents.send('renderer-player-stop');
});
globalShortcut.register('MediaStop', () => {
window?.webContents.send('renderer-player-stop');
});
globalShortcut.register('MediaPlayPause', () => {
window?.webContents.send('renderer-player-play-pause');
});
globalShortcut.register('MediaPlayPause', () => {
window?.webContents.send('renderer-player-play-pause');
});
globalShortcut.register('MediaNextTrack', () => {
window?.webContents.send('renderer-player-next');
});
globalShortcut.register('MediaNextTrack', () => {
window?.webContents.send('renderer-player-next');
});
globalShortcut.register('MediaPreviousTrack', () => {
window?.webContents.send('renderer-player-previous');
});
globalShortcut.register('MediaPreviousTrack', () => {
window?.webContents.send('renderer-player-previous');
});
};
export const disableMediaKeys = () => {
globalShortcut.unregister('MediaStop');
globalShortcut.unregister('MediaPlayPause');
globalShortcut.unregister('MediaNextTrack');
globalShortcut.unregister('MediaPreviousTrack');
globalShortcut.unregister('MediaStop');
globalShortcut.unregister('MediaPlayPause');
globalShortcut.unregister('MediaNextTrack');
globalShortcut.unregister('MediaPreviousTrack');
};

View File

@ -4,47 +4,47 @@ import Store from 'electron-store';
export const store = new Store();
ipcMain.handle('settings-get', (_event, data: { property: string }) => {
return store.get(`${data.property}`);
return store.get(`${data.property}`);
});
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
store.set(`${data.property}`, data.value);
store.set(`${data.property}`, data.value);
});
ipcMain.handle('password-get', (_event, server: string): string | null => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
if (!servers) {
return null;
if (!servers) {
return null;
}
const encrypted = servers[server];
if (!encrypted) return null;
const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex'));
return decrypted;
}
const encrypted = servers[server];
if (!encrypted) return null;
const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex'));
return decrypted;
}
return null;
return null;
});
ipcMain.on('password-remove', (_event, server: string) => {
const passwords = store.get('server', {}) as Record<string, string>;
if (server in passwords) {
delete passwords[server];
}
store.set({ server: passwords });
const passwords = store.get('server', {}) as Record<string, string>;
if (server in passwords) {
delete passwords[server];
}
store.set({ server: passwords });
});
ipcMain.handle('password-set', (_event, password: string, server: string) => {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(password);
const passwords = store.get('server', {}) as Record<string, string>;
passwords[server] = encrypted.toString('hex');
store.set({ server: passwords });
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(password);
const passwords = store.get('server', {}) as Record<string, string>;
passwords[server] = encrypted.toString('hex');
store.set({ server: passwords });
return true;
}
return false;
return true;
}
return false;
});

View File

@ -5,164 +5,166 @@ import { getMainWindow } from '../../main';
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
const mprisPlayer = Player({
identity: 'Feishin',
maximumRate: 1.0,
minimumRate: 1.0,
name: 'Feishin',
rate: 1.0,
supportedInterfaces: ['player'],
supportedMimeTypes: ['audio/mpeg', 'application/ogg'],
supportedUriSchemes: ['file'],
identity: 'Feishin',
maximumRate: 1.0,
minimumRate: 1.0,
name: 'Feishin',
rate: 1.0,
supportedInterfaces: ['player'],
supportedMimeTypes: ['audio/mpeg', 'application/ogg'],
supportedUriSchemes: ['file'],
});
mprisPlayer.on('quit', () => {
process.exit();
process.exit();
});
mprisPlayer.on('stop', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
mprisPlayer.playbackStatus = 'Paused';
getMainWindow()?.webContents.send('renderer-player-stop');
mprisPlayer.playbackStatus = 'Paused';
});
mprisPlayer.on('pause', () => {
getMainWindow()?.webContents.send('renderer-player-pause');
mprisPlayer.playbackStatus = 'Paused';
getMainWindow()?.webContents.send('renderer-player-pause');
mprisPlayer.playbackStatus = 'Paused';
});
mprisPlayer.on('play', () => {
getMainWindow()?.webContents.send('renderer-player-play');
mprisPlayer.playbackStatus = 'Playing';
getMainWindow()?.webContents.send('renderer-player-play');
mprisPlayer.playbackStatus = 'Playing';
});
mprisPlayer.on('playpause', () => {
getMainWindow()?.webContents.send('renderer-player-play-pause');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
} else {
mprisPlayer.playbackStatus = 'Paused';
}
getMainWindow()?.webContents.send('renderer-player-play-pause');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
} else {
mprisPlayer.playbackStatus = 'Paused';
}
});
mprisPlayer.on('next', () => {
getMainWindow()?.webContents.send('renderer-player-next');
getMainWindow()?.webContents.send('renderer-player-next');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
}
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
}
});
mprisPlayer.on('previous', () => {
getMainWindow()?.webContents.send('renderer-player-previous');
getMainWindow()?.webContents.send('renderer-player-previous');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;
}
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;
}
});
mprisPlayer.on('volume', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-volume', {
volume: event,
});
getMainWindow()?.webContents.send('mpris-request-volume', {
volume: event,
});
});
mprisPlayer.on('shuffle', (event: boolean) => {
getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });
mprisPlayer.shuffle = event;
getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });
mprisPlayer.shuffle = event;
});
mprisPlayer.on('loopStatus', (event: string) => {
getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });
mprisPlayer.loopStatus = event;
getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });
mprisPlayer.loopStatus = event;
});
mprisPlayer.on('position', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-position', {
position: event.position / 1e6,
});
getMainWindow()?.webContents.send('mpris-request-position', {
position: event.position / 1e6,
});
});
mprisPlayer.on('seek', (event: number) => {
getMainWindow()?.webContents.send('mpris-request-seek', {
offset: event / 1e6,
});
getMainWindow()?.webContents.send('mpris-request-seek', {
offset: event / 1e6,
});
});
ipcMain.on('mpris-update-position', (_event, arg) => {
mprisPlayer.getPosition = () => arg * 1e6;
mprisPlayer.getPosition = () => arg * 1e6;
});
ipcMain.on('mpris-update-seek', (_event, arg) => {
mprisPlayer.seeked(arg * 1e6);
mprisPlayer.seeked(arg * 1e6);
});
ipcMain.on('mpris-update-volume', (_event, arg) => {
mprisPlayer.volume = Number(arg);
mprisPlayer.volume = Number(arg);
});
ipcMain.on('mpris-update-repeat', (_event, arg) => {
mprisPlayer.loopStatus = arg;
mprisPlayer.loopStatus = arg;
});
ipcMain.on('mpris-update-shuffle', (_event, arg) => {
mprisPlayer.shuffle = arg;
mprisPlayer.shuffle = arg;
});
ipcMain.on(
'mpris-update-song',
(
_event,
args: {
currentTime: number;
repeat: PlayerRepeat;
shuffle: PlayerShuffle;
song: QueueSong;
status: PlayerStatus;
'mpris-update-song',
(
_event,
args: {
currentTime: number;
repeat: PlayerRepeat;
shuffle: PlayerShuffle;
song: QueueSong;
status: PlayerStatus;
},
) => {
const { song, status, repeat, shuffle } = args || {};
try {
mprisPlayer.playbackStatus = status;
if (repeat) {
mprisPlayer.loopStatus =
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
}
if (shuffle) {
mprisPlayer.shuffle = shuffle !== 'none';
}
if (!song) return;
const upsizedImageUrl = song.imageUrl
? song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300')
: null;
mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
'mpris:trackid': song?.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '',
'xesam:album': song.album || null,
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null,
'xesam:artist':
song.artists?.length !== 0
? song.artists?.map((artist: RelatedArtist) => artist.name)
: null,
'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length
? song.genres.map((genre: any) => genre.name)
: null,
'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
'xesam:useCount':
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
};
} catch (err) {
console.log(err);
}
},
) => {
const { song, status, repeat, shuffle } = args || {};
try {
mprisPlayer.playbackStatus = status;
if (repeat) {
mprisPlayer.loopStatus =
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
}
if (shuffle) {
mprisPlayer.shuffle = shuffle !== 'none';
}
if (!song) return;
const upsizedImageUrl = song.imageUrl
? song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300')
: null;
mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
'mpris:trackid': song?.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '',
'xesam:album': song.album || null,
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null,
'xesam:artist':
song.artists?.length !== 0
? song.artists?.map((artist: RelatedArtist) => artist.name)
: null,
'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
'xesam:useCount':
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
};
} catch (err) {
console.log(err);
}
},
);

File diff suppressed because it is too large Load Diff

View File

@ -1,269 +1,279 @@
import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions } from 'electron';
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
export default class MenuBuilder {
mainWindow: BrowserWindow;
mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
const template =
process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate();
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
return menu;
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
return menu;
}
Menu.buildFromTemplate([
{
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
label: 'Inspect element',
},
]).popup({ window: this.mainWindow });
});
}
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
{
label: 'About ElectronReact',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
accelerator: 'Command+H',
label: 'Hide ElectronReact',
selector: 'hide:',
},
{
accelerator: 'Command+Shift+H',
label: 'Hide Others',
selector: 'hideOtherApplications:',
},
{ label: 'Show All', selector: 'unhideAllApplications:' },
{ type: 'separator' },
{
accelerator: 'Command+Q',
click: () => {
app.quit();
},
label: 'Quit',
},
],
};
const subMenuEdit: DarwinMenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ accelerator: 'Command+Z', label: 'Undo', selector: 'undo:' },
{ accelerator: 'Shift+Command+Z', label: 'Redo', selector: 'redo:' },
{ type: 'separator' },
{ accelerator: 'Command+X', label: 'Cut', selector: 'cut:' },
{ accelerator: 'Command+C', label: 'Copy', selector: 'copy:' },
{ accelerator: 'Command+V', label: 'Paste', selector: 'paste:' },
{
accelerator: 'Command+A',
label: 'Select All',
selector: 'selectAll:',
},
],
};
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: 'Reload',
},
{
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
{
accelerator: 'Alt+Command+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle Developer Tools',
},
],
};
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
],
};
const subMenuWindow: DarwinMenuItemConstructorOptions = {
label: 'Window',
submenu: [
{
accelerator: 'Command+M',
label: 'Minimize',
selector: 'performMiniaturize:',
},
{ accelerator: 'Command+W', label: 'Close', selector: 'performClose:' },
{ type: 'separator' },
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
};
const subMenuView =
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate() {
const templateDefault = [
{
label: '&File',
submenu: [
{
accelerator: 'Ctrl+O',
label: '&Open',
},
{
accelerator: 'Ctrl+W',
click: () => {
this.mainWindow.close();
},
label: '&Close',
},
],
},
{
label: '&View',
submenu:
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? [
Menu.buildFromTemplate([
{
accelerator: 'Ctrl+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: '&Reload',
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
label: 'Inspect element',
},
]).popup({ window: this.mainWindow });
});
}
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
{
label: 'About ElectronReact',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
accelerator: 'Command+H',
label: 'Hide ElectronReact',
selector: 'hide:',
},
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle &Full Screen',
accelerator: 'Command+Shift+H',
label: 'Hide Others',
selector: 'hideOtherApplications:',
},
{ label: 'Show All', selector: 'unhideAllApplications:' },
{ type: 'separator' },
{
accelerator: 'Command+Q',
click: () => {
app.quit();
},
label: 'Quit',
},
],
};
const subMenuEdit: DarwinMenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ accelerator: 'Command+Z', label: 'Undo', selector: 'undo:' },
{ accelerator: 'Shift+Command+Z', label: 'Redo', selector: 'redo:' },
{ type: 'separator' },
{ accelerator: 'Command+X', label: 'Cut', selector: 'cut:' },
{ accelerator: 'Command+C', label: 'Copy', selector: 'copy:' },
{ accelerator: 'Command+V', label: 'Paste', selector: 'paste:' },
{
accelerator: 'Command+A',
label: 'Select All',
selector: 'selectAll:',
},
],
};
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: 'Reload',
},
{
accelerator: 'Alt+Ctrl+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle &Developer Tools',
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
]
: [
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle &Full Screen',
accelerator: 'Alt+Command+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle Developer Tools',
},
],
},
{
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
},
];
],
};
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
],
};
const subMenuWindow: DarwinMenuItemConstructorOptions = {
label: 'Window',
submenu: [
{
accelerator: 'Command+M',
label: 'Minimize',
selector: 'performMiniaturize:',
},
{ accelerator: 'Command+W', label: 'Close', selector: 'performClose:' },
{ type: 'separator' },
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
};
return templateDefault;
}
const subMenuView =
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate() {
const templateDefault = [
{
label: '&File',
submenu: [
{
accelerator: 'Ctrl+O',
label: '&Open',
},
{
accelerator: 'Ctrl+W',
click: () => {
this.mainWindow.close();
},
label: '&Close',
},
],
},
{
label: '&View',
submenu:
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? [
{
accelerator: 'Ctrl+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: '&Reload',
},
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
},
label: 'Toggle &Full Screen',
},
{
accelerator: 'Alt+Ctrl+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle &Developer Tools',
},
]
: [
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
},
label: 'Toggle &Full Screen',
},
],
},
{
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
},
];
return templateDefault;
}
}

View File

@ -8,12 +8,12 @@ import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', {
browser,
ipc,
localSettings,
lyrics,
mpris,
mpvPlayer,
mpvPlayerListener,
utils,
browser,
ipc,
localSettings,
lyrics,
mpris,
mpvPlayer,
mpvPlayerListener,
utils,
});

View File

@ -1,26 +1,26 @@
import { ipcRenderer } from 'electron';
const exit = () => {
ipcRenderer.send('window-close');
ipcRenderer.send('window-close');
};
const maximize = () => {
ipcRenderer.send('window-maximize');
ipcRenderer.send('window-maximize');
};
const minimize = () => {
ipcRenderer.send('window-minimize');
ipcRenderer.send('window-minimize');
};
const unmaximize = () => {
ipcRenderer.send('window-unmaximize');
ipcRenderer.send('window-unmaximize');
};
const devtools = () => {
ipcRenderer.send('window-dev-tools');
ipcRenderer.send('window-dev-tools');
};
export const browser = {
devtools,
exit,
maximize,
minimize,
unmaximize,
devtools,
exit,
maximize,
minimize,
unmaximize,
};

View File

@ -1,14 +1,14 @@
import { ipcRenderer } from 'electron';
const removeAllListeners = (channel: string) => {
ipcRenderer.removeAllListeners(channel);
ipcRenderer.removeAllListeners(channel);
};
const send = (channel: string, ...args: any[]) => {
ipcRenderer.send(channel, ...args);
ipcRenderer.send(channel, ...args);
};
export const ipc = {
removeAllListeners,
send,
removeAllListeners,
send,
};

View File

@ -4,49 +4,49 @@ import Store from 'electron-store';
const store = new Store();
const set = (property: string, value: string | Record<string, unknown> | boolean | string[]) => {
store.set(`${property}`, value);
store.set(`${property}`, value);
};
const get = (property: string) => {
return store.get(`${property}`);
return store.get(`${property}`);
};
const restart = () => {
ipcRenderer.send('app-restart');
ipcRenderer.send('app-restart');
};
const enableMediaKeys = () => {
ipcRenderer.send('global-media-keys-enable');
ipcRenderer.send('global-media-keys-enable');
};
const disableMediaKeys = () => {
ipcRenderer.send('global-media-keys-disable');
ipcRenderer.send('global-media-keys-disable');
};
const passwordGet = async (server: string): Promise<string | null> => {
return ipcRenderer.invoke('password-get', server);
return ipcRenderer.invoke('password-get', server);
};
const passwordRemove = (server: string) => {
ipcRenderer.send('password-remove', server);
ipcRenderer.send('password-remove', server);
};
const passwordSet = async (password: string, server: string): Promise<boolean> => {
return ipcRenderer.invoke('password-set', password, server);
return ipcRenderer.invoke('password-set', password, server);
};
const setZoomFactor = (zoomFactor: number) => {
webFrame.setZoomFactor(zoomFactor / 100);
webFrame.setZoomFactor(zoomFactor / 100);
};
export const localSettings = {
disableMediaKeys,
enableMediaKeys,
get,
passwordGet,
passwordRemove,
passwordSet,
restart,
set,
setZoomFactor,
disableMediaKeys,
enableMediaKeys,
get,
passwordGet,
passwordRemove,
passwordSet,
restart,
set,
setZoomFactor,
};

View File

@ -2,22 +2,22 @@ import { ipcRenderer } from 'electron';
import { LyricSearchQuery, QueueSong } from '/@/renderer/api/types';
const getRemoteLyricsBySong = (song: QueueSong) => {
const result = ipcRenderer.invoke('lyric-by-song', song);
return result;
const result = ipcRenderer.invoke('lyric-by-song', song);
return result;
};
const searchRemoteLyrics = (params: LyricSearchQuery) => {
const result = ipcRenderer.invoke('lyric-search', params);
return result;
const result = ipcRenderer.invoke('lyric-search', params);
return result;
};
const getRemoteLyricsByRemoteId = (id: string) => {
const result = ipcRenderer.invoke('lyric-by-remote-id', id);
return result;
const result = ipcRenderer.invoke('lyric-by-remote-id', id);
return result;
};
export const lyrics = {
getRemoteLyricsByRemoteId,
getRemoteLyricsBySong,
searchRemoteLyrics,
getRemoteLyricsByRemoteId,
getRemoteLyricsBySong,
searchRemoteLyrics,
};

View File

@ -2,69 +2,69 @@ import { IpcRendererEvent, ipcRenderer } from 'electron';
import { QueueSong } from '/@/renderer/api/types';
const updateSong = (args: { currentTime: number; song: QueueSong }) => {
ipcRenderer.send('mpris-update-song', args);
ipcRenderer.send('mpris-update-song', args);
};
const updatePosition = (timeSec: number) => {
ipcRenderer.send('mpris-update-position', timeSec);
ipcRenderer.send('mpris-update-position', timeSec);
};
const updateSeek = (timeSec: number) => {
ipcRenderer.send('mpris-update-seek', timeSec);
ipcRenderer.send('mpris-update-seek', timeSec);
};
const updateVolume = (volume: number) => {
ipcRenderer.send('mpris-update-volume', volume);
ipcRenderer.send('mpris-update-volume', volume);
};
const updateRepeat = (repeat: string) => {
ipcRenderer.send('mpris-update-repeat', repeat);
ipcRenderer.send('mpris-update-repeat', repeat);
};
const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('mpris-update-shuffle', shuffle);
ipcRenderer.send('mpris-update-shuffle', shuffle);
};
const toggleRepeat = () => {
ipcRenderer.send('mpris-toggle-repeat');
ipcRenderer.send('mpris-toggle-repeat');
};
const toggleShuffle = () => {
ipcRenderer.send('mpris-toggle-shuffle');
ipcRenderer.send('mpris-toggle-shuffle');
};
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
ipcRenderer.on('mpris-request-position', cb);
ipcRenderer.on('mpris-request-position', cb);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('mpris-request-seek', cb);
ipcRenderer.on('mpris-request-seek', cb);
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('mpris-request-volume', cb);
ipcRenderer.on('mpris-request-volume', cb);
};
const requestToggleRepeat = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('mpris-request-toggle-repeat', cb);
ipcRenderer.on('mpris-request-toggle-repeat', cb);
};
const requestToggleShuffle = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
};
export const mpris = {
requestPosition,
requestSeek,
requestToggleRepeat,
requestToggleShuffle,
requestVolume,
toggleRepeat,
toggleShuffle,
updatePosition,
updateRepeat,
updateSeek,
updateShuffle,
updateSong,
updateVolume,
requestPosition,
requestSeek,
requestToggleRepeat,
requestToggleShuffle,
requestVolume,
toggleRepeat,
toggleShuffle,
updatePosition,
updateRepeat,
updateSeek,
updateShuffle,
updateSong,
updateVolume,
};

View File

@ -2,213 +2,213 @@ import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '/@/renderer/store';
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-initialize', data);
ipcRenderer.send('player-initialize', data);
};
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-restart', data);
ipcRenderer.send('player-restart', data);
};
const isRunning = () => {
return ipcRenderer.invoke('player-is-running');
return ipcRenderer.invoke('player-is-running');
};
const cleanup = () => {
return ipcRenderer.invoke('player-clean-up');
return ipcRenderer.invoke('player-clean-up');
};
const setProperties = (data: Record<string, any>) => {
console.log('Setting property :>>', data);
ipcRenderer.send('player-set-properties', data);
console.log('Setting property :>>', data);
ipcRenderer.send('player-set-properties', data);
};
const autoNext = (data: PlayerData) => {
ipcRenderer.send('player-auto-next', data);
ipcRenderer.send('player-auto-next', data);
};
const currentTime = () => {
ipcRenderer.send('player-current-time');
ipcRenderer.send('player-current-time');
};
const mute = () => {
ipcRenderer.send('player-mute');
ipcRenderer.send('player-mute');
};
const next = () => {
ipcRenderer.send('player-next');
ipcRenderer.send('player-next');
};
const pause = () => {
ipcRenderer.send('player-pause');
ipcRenderer.send('player-pause');
};
const play = () => {
ipcRenderer.send('player-play');
ipcRenderer.send('player-play');
};
const previous = () => {
ipcRenderer.send('player-previous');
ipcRenderer.send('player-previous');
};
const restoreQueue = () => {
ipcRenderer.send('player-restore-queue');
ipcRenderer.send('player-restore-queue');
};
const saveQueue = (data: Record<string, any>) => {
ipcRenderer.send('player-save-queue', data);
ipcRenderer.send('player-save-queue', data);
};
const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds);
ipcRenderer.send('player-seek', seconds);
};
const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds);
ipcRenderer.send('player-seek-to', seconds);
};
const setQueue = (data: PlayerData, pause?: boolean) => {
ipcRenderer.send('player-set-queue', data, pause);
ipcRenderer.send('player-set-queue', data, pause);
};
const setQueueNext = (data: PlayerData) => {
ipcRenderer.send('player-set-queue-next', data);
ipcRenderer.send('player-set-queue-next', data);
};
const stop = () => {
ipcRenderer.send('player-stop');
ipcRenderer.send('player-stop');
};
const volume = (value: number) => {
ipcRenderer.send('player-volume', value);
ipcRenderer.send('player-volume', value);
};
const quit = () => {
ipcRenderer.send('player-quit');
ipcRenderer.send('player-quit');
};
const getCurrentTime = async () => {
return ipcRenderer.invoke('player-get-time');
return ipcRenderer.invoke('player-get-time');
};
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', cb);
ipcRenderer.on('renderer-player-auto-next', cb);
};
const rendererCurrentTime = (cb: (event: IpcRendererEvent, data: number) => void) => {
ipcRenderer.on('renderer-player-current-time', cb);
ipcRenderer.on('renderer-player-current-time', cb);
};
const rendererNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-next', cb);
ipcRenderer.on('renderer-player-next', cb);
};
const rendererPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-pause', cb);
ipcRenderer.on('renderer-player-pause', cb);
};
const rendererPlay = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play', cb);
ipcRenderer.on('renderer-player-play', cb);
};
const rendererPlayPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play-pause', cb);
ipcRenderer.on('renderer-player-play-pause', cb);
};
const rendererPrevious = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-previous', cb);
ipcRenderer.on('renderer-player-previous', cb);
};
const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-stop', cb);
ipcRenderer.on('renderer-player-stop', cb);
};
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-forward', cb);
ipcRenderer.on('renderer-player-skip-forward', cb);
};
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-backward', cb);
ipcRenderer.on('renderer-player-skip-backward', cb);
};
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-up', cb);
ipcRenderer.on('renderer-player-volume-up', cb);
};
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-down', cb);
ipcRenderer.on('renderer-player-volume-down', cb);
};
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-mute', cb);
ipcRenderer.on('renderer-player-volume-mute', cb);
};
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-repeat', cb);
ipcRenderer.on('renderer-player-toggle-repeat', cb);
};
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
};
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-quit', cb);
ipcRenderer.on('renderer-player-quit', cb);
};
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-save-queue', cb);
ipcRenderer.on('renderer-player-save-queue', cb);
};
const rendererRestoreQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-restore-queue', cb);
ipcRenderer.on('renderer-player-restore-queue', cb);
};
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
ipcRenderer.on('renderer-player-error', cb);
ipcRenderer.on('renderer-player-error', cb);
};
export const mpvPlayer = {
autoNext,
cleanup,
currentTime,
getCurrentTime,
initialize,
isRunning,
mute,
next,
pause,
play,
previous,
quit,
restart,
restoreQueue,
saveQueue,
seek,
seekTo,
setProperties,
setQueue,
setQueueNext,
stop,
volume,
autoNext,
cleanup,
currentTime,
getCurrentTime,
initialize,
isRunning,
mute,
next,
pause,
play,
previous,
quit,
restart,
restoreQueue,
saveQueue,
seek,
seekTo,
setProperties,
setQueue,
setQueueNext,
stop,
volume,
};
export const mpvPlayerListener = {
rendererAutoNext,
rendererCurrentTime,
rendererError,
rendererNext,
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPrevious,
rendererQuit,
rendererRestoreQueue,
rendererSaveQueue,
rendererSkipBackward,
rendererSkipForward,
rendererStop,
rendererToggleRepeat,
rendererToggleShuffle,
rendererVolumeDown,
rendererVolumeMute,
rendererVolumeUp,
rendererAutoNext,
rendererCurrentTime,
rendererError,
rendererNext,
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPrevious,
rendererQuit,
rendererRestoreQueue,
rendererSaveQueue,
rendererSkipBackward,
rendererSkipForward,
rendererStop,
rendererToggleRepeat,
rendererToggleShuffle,
rendererVolumeDown,
rendererVolumeMute,
rendererVolumeUp,
};

View File

@ -1,7 +1,7 @@
import { isMacOS, isWindows, isLinux } from '../utils';
export const utils = {
isLinux,
isMacOS,
isWindows,
isLinux,
isMacOS,
isWindows,
};

View File

@ -6,47 +6,47 @@ import { URL } from 'url';
export let resolveHtmlPath: (htmlFileName: string) => string;
if (process.env.NODE_ENV === 'development') {
const port = process.env.PORT || 4343;
resolveHtmlPath = (htmlFileName: string) => {
const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName;
return url.href;
};
const port = process.env.PORT || 4343;
resolveHtmlPath = (htmlFileName: string) => {
const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName;
return url.href;
};
} else {
resolveHtmlPath = (htmlFileName: string) => {
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
};
resolveHtmlPath = (htmlFileName: string) => {
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
};
}
export const isMacOS = () => {
return process.platform === 'darwin';
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
return process.platform === 'win32';
};
export const isLinux = () => {
return process.platform === 'linux';
return process.platform === 'linux';
};
export const hotkeyToElectronAccelerator = (hotkey: string) => {
let accelerator = hotkey;
let accelerator = hotkey;
const replacements = {
mod: 'CmdOrCtrl',
numpad: 'num',
numpadadd: 'numadd',
numpaddecimal: 'numdec',
numpaddivide: 'numdiv',
numpadenter: 'numenter',
numpadmultiply: 'nummult',
numpadsubtract: 'numsub',
};
const replacements = {
mod: 'CmdOrCtrl',
numpad: 'num',
numpadadd: 'numadd',
numpaddecimal: 'numdec',
numpaddivide: 'numdiv',
numpadenter: 'numenter',
numpadmultiply: 'nummult',
numpadsubtract: 'numsub',
};
Object.keys(replacements).forEach((key) => {
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
});
Object.keys(replacements).forEach((key) => {
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
});
return accelerator;
return accelerator;
};

View File

@ -1,53 +1,53 @@
import { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast/index';
import type {
AlbumDetailArgs,
AlbumListArgs,
SongListArgs,
SongDetailArgs,
AlbumArtistDetailArgs,
AlbumArtistListArgs,
SetRatingArgs,
GenreListArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
PlaylistDetailArgs,
PlaylistListArgs,
MusicFolderListArgs,
PlaylistSongListArgs,
ArtistListArgs,
UpdatePlaylistArgs,
UserListArgs,
FavoriteArgs,
TopSongListArgs,
AddToPlaylistArgs,
AddToPlaylistResponse,
RemoveFromPlaylistArgs,
RemoveFromPlaylistResponse,
ScrobbleArgs,
ScrobbleResponse,
AlbumArtistDetailResponse,
FavoriteResponse,
CreatePlaylistResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
ArtistListResponse,
GenreListResponse,
MusicFolderListResponse,
PlaylistDetailResponse,
PlaylistListResponse,
RatingResponse,
SongDetailResponse,
SongListResponse,
TopSongListResponse,
UpdatePlaylistResponse,
UserListResponse,
AuthenticationResponse,
SearchArgs,
SearchResponse,
LyricsArgs,
LyricsResponse,
AlbumDetailArgs,
AlbumListArgs,
SongListArgs,
SongDetailArgs,
AlbumArtistDetailArgs,
AlbumArtistListArgs,
SetRatingArgs,
GenreListArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
PlaylistDetailArgs,
PlaylistListArgs,
MusicFolderListArgs,
PlaylistSongListArgs,
ArtistListArgs,
UpdatePlaylistArgs,
UserListArgs,
FavoriteArgs,
TopSongListArgs,
AddToPlaylistArgs,
AddToPlaylistResponse,
RemoveFromPlaylistArgs,
RemoveFromPlaylistResponse,
ScrobbleArgs,
ScrobbleResponse,
AlbumArtistDetailResponse,
FavoriteResponse,
CreatePlaylistResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
ArtistListResponse,
GenreListResponse,
MusicFolderListResponse,
PlaylistDetailResponse,
PlaylistListResponse,
RatingResponse,
SongDetailResponse,
SongListResponse,
TopSongListResponse,
UpdatePlaylistResponse,
UserListResponse,
AuthenticationResponse,
SearchArgs,
SearchResponse,
LyricsArgs,
LyricsResponse,
} from '/@/renderer/api/types';
import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
@ -56,436 +56,445 @@ import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { password: string; username: string },
) => Promise<AuthenticationResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
getFolderList: () => void;
getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { password: string; username: string },
) => Promise<AuthenticationResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
getFolderList: () => void;
getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>;
type ApiController = {
jellyfin: ControllerEndpoint;
navidrome: ControllerEndpoint;
subsonic: ControllerEndpoint;
jellyfin: ControllerEndpoint;
navidrome: ControllerEndpoint;
subsonic: ControllerEndpoint;
};
const endpoints: ApiController = {
jellyfin: {
addToPlaylist: jfController.addToPlaylist,
authenticate: jfController.authenticate,
clearPlaylist: undefined,
createFavorite: jfController.createFavorite,
createPlaylist: jfController.createPlaylist,
deleteFavorite: jfController.deleteFavorite,
deletePlaylist: jfController.deletePlaylist,
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
getAlbumArtistList: jfController.getAlbumArtistList,
getAlbumDetail: jfController.getAlbumDetail,
getAlbumList: jfController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: jfController.getGenreList,
getLyrics: jfController.getLyrics,
getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getSongDetail: undefined,
getSongList: jfController.getSongList,
getTopSongs: jfController.getTopSongList,
getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined,
updatePlaylist: jfController.updatePlaylist,
},
navidrome: {
addToPlaylist: ndController.addToPlaylist,
authenticate: ndController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: ndController.createPlaylist,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: ndController.deletePlaylist,
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
getAlbumArtistList: ndController.getAlbumArtistList,
getAlbumDetail: ndController.getAlbumDetail,
getAlbumList: ndController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: ndController.getGenreList,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating,
updatePlaylist: ndController.updatePlaylist,
},
subsonic: {
authenticate: ssController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: undefined,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: undefined,
getAlbumArtistDetail: undefined,
getAlbumArtistList: undefined,
getAlbumDetail: undefined,
getAlbumList: undefined,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: undefined,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined,
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
getTopSongs: ssController.getTopSongList,
getUserList: undefined,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined,
updatePlaylist: undefined,
},
jellyfin: {
addToPlaylist: jfController.addToPlaylist,
authenticate: jfController.authenticate,
clearPlaylist: undefined,
createFavorite: jfController.createFavorite,
createPlaylist: jfController.createPlaylist,
deleteFavorite: jfController.deleteFavorite,
deletePlaylist: jfController.deletePlaylist,
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
getAlbumArtistList: jfController.getAlbumArtistList,
getAlbumDetail: jfController.getAlbumDetail,
getAlbumList: jfController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: jfController.getGenreList,
getLyrics: jfController.getLyrics,
getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getSongDetail: undefined,
getSongList: jfController.getSongList,
getTopSongs: jfController.getTopSongList,
getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined,
updatePlaylist: jfController.updatePlaylist,
},
navidrome: {
addToPlaylist: ndController.addToPlaylist,
authenticate: ndController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: ndController.createPlaylist,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: ndController.deletePlaylist,
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
getAlbumArtistList: ndController.getAlbumArtistList,
getAlbumDetail: ndController.getAlbumDetail,
getAlbumList: ndController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: ndController.getGenreList,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating,
updatePlaylist: ndController.updatePlaylist,
},
subsonic: {
authenticate: ssController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: undefined,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: undefined,
getAlbumArtistDetail: undefined,
getAlbumArtistList: undefined,
getAlbumDetail: undefined,
getAlbumList: undefined,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: undefined,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined,
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
getTopSongs: ssController.getTopSongList,
getUserList: undefined,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined,
updatePlaylist: undefined,
},
};
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
const serverType = type || useAuthStore.getState().currentServer?.type;
const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' });
throw new Error(`No server selected`);
}
if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' });
throw new Error(`No server selected`);
}
const controllerFn = endpoints?.[serverType]?.[endpoint];
const controllerFn = endpoints?.[serverType]?.[endpoint];
if (typeof controllerFn !== 'function') {
toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request',
});
if (typeof controllerFn !== 'function') {
toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request',
});
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
}
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
}
return endpoints[serverType][endpoint];
return endpoints[serverType][endpoint];
};
const authenticate = async (
url: string,
body: { legacy?: boolean; password: string; username: string },
type: ServerType,
url: string,
body: { legacy?: boolean; password: string; username: string },
type: ServerType,
) => {
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body);
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body);
};
const getAlbumList = async (args: AlbumListArgs) => {
return (
apiController(
'getAlbumList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumList']
)?.(args);
return (
apiController(
'getAlbumList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumList']
)?.(args);
};
const getAlbumDetail = async (args: AlbumDetailArgs) => {
return (
apiController(
'getAlbumDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumDetail']
)?.(args);
return (
apiController(
'getAlbumDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumDetail']
)?.(args);
};
const getSongList = async (args: SongListArgs) => {
return (
apiController(
'getSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongList']
)?.(args);
return (
apiController(
'getSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongList']
)?.(args);
};
const getSongDetail = async (args: SongDetailArgs) => {
return (
apiController(
'getSongDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongDetail']
)?.(args);
return (
apiController(
'getSongDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongDetail']
)?.(args);
};
const getMusicFolderList = async (args: MusicFolderListArgs) => {
return (
apiController(
'getMusicFolderList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getMusicFolderList']
)?.(args);
return (
apiController(
'getMusicFolderList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getMusicFolderList']
)?.(args);
};
const getGenreList = async (args: GenreListArgs) => {
return (
apiController(
'getGenreList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getGenreList']
)?.(args);
return (
apiController(
'getGenreList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getGenreList']
)?.(args);
};
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
return (
apiController(
'getAlbumArtistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistDetail']
)?.(args);
return (
apiController(
'getAlbumArtistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistDetail']
)?.(args);
};
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
return (
apiController(
'getAlbumArtistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistList']
)?.(args);
return (
apiController(
'getAlbumArtistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistList']
)?.(args);
};
const getArtistList = async (args: ArtistListArgs) => {
return (
apiController(
'getArtistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getArtistList']
)?.(args);
return (
apiController(
'getArtistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getArtistList']
)?.(args);
};
const getPlaylistList = async (args: PlaylistListArgs) => {
return (
apiController(
'getPlaylistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistList']
)?.(args);
return (
apiController(
'getPlaylistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistList']
)?.(args);
};
const createPlaylist = async (args: CreatePlaylistArgs) => {
return (
apiController(
'createPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createPlaylist']
)?.(args);
return (
apiController(
'createPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createPlaylist']
)?.(args);
};
const updatePlaylist = async (args: UpdatePlaylistArgs) => {
return (
apiController(
'updatePlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['updatePlaylist']
)?.(args);
return (
apiController(
'updatePlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['updatePlaylist']
)?.(args);
};
const deletePlaylist = async (args: DeletePlaylistArgs) => {
return (
apiController(
'deletePlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deletePlaylist']
)?.(args);
return (
apiController(
'deletePlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deletePlaylist']
)?.(args);
};
const addToPlaylist = async (args: AddToPlaylistArgs) => {
return (
apiController(
'addToPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['addToPlaylist']
)?.(args);
return (
apiController(
'addToPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['addToPlaylist']
)?.(args);
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return (
apiController(
'removeFromPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['removeFromPlaylist']
)?.(args);
return (
apiController(
'removeFromPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['removeFromPlaylist']
)?.(args);
};
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
return (
apiController(
'getPlaylistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistDetail']
)?.(args);
return (
apiController(
'getPlaylistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistDetail']
)?.(args);
};
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
return (
apiController(
'getPlaylistSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistSongList']
)?.(args);
return (
apiController(
'getPlaylistSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistSongList']
)?.(args);
};
const getUserList = async (args: UserListArgs) => {
return (
apiController(
'getUserList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getUserList']
)?.(args);
return (
apiController(
'getUserList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getUserList']
)?.(args);
};
const createFavorite = async (args: FavoriteArgs) => {
return (
apiController(
'createFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createFavorite']
)?.(args);
return (
apiController(
'createFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createFavorite']
)?.(args);
};
const deleteFavorite = async (args: FavoriteArgs) => {
return (
apiController(
'deleteFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deleteFavorite']
)?.(args);
return (
apiController(
'deleteFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deleteFavorite']
)?.(args);
};
const updateRating = async (args: SetRatingArgs) => {
return (
apiController('setRating', args.apiClientProps.server?.type) as ControllerEndpoint['setRating']
)?.(args);
return (
apiController(
'setRating',
args.apiClientProps.server?.type,
) as ControllerEndpoint['setRating']
)?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (
apiController(
'getTopSongs',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getTopSongs']
)?.(args);
return (
apiController(
'getTopSongs',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getTopSongs']
)?.(args);
};
const scrobble = async (args: ScrobbleArgs) => {
return (
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble']
)?.(args);
return (
apiController(
'scrobble',
args.apiClientProps.server?.type,
) as ControllerEndpoint['scrobble']
)?.(args);
};
const search = async (args: SearchArgs) => {
return (
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
)?.(args);
return (
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
)?.(args);
};
const getRandomSongList = async (args: RandomSongListArgs) => {
return (
apiController(
'getRandomSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getRandomSongList']
)?.(args);
return (
apiController(
'getRandomSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getRandomSongList']
)?.(args);
};
const getLyrics = async (args: LyricsArgs) => {
return (
apiController('getLyrics', args.apiClientProps.server?.type) as ControllerEndpoint['getLyrics']
)?.(args);
return (
apiController(
'getLyrics',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getLyrics']
)?.(args);
};
export const controller = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistList,
getGenreList,
getLyrics,
getMusicFolderList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getSongDetail,
getSongList,
getTopSongList,
getUserList,
removeFromPlaylist,
scrobble,
search,
updatePlaylist,
updateRating,
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistList,
getGenreList,
getLyrics,
getMusicFolderList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getSongDetail,
getSongList,
getTopSongList,
getUserList,
removeFromPlaylist,
scrobble,
search,
updatePlaylist,
updateRating,
};

View File

@ -1,5 +1,5 @@
import { controller } from '/@/renderer/api/controller';
export const api = {
controller,
controller,
};

File diff suppressed because it is too large Load Diff

View File

@ -11,340 +11,340 @@ import { authenticationFailure } from '/@/renderer/api/utils';
const c = initContract();
export const contract = c.router({
addToPlaylist: {
body: z.null(),
method: 'POST',
path: 'playlists/:id/items',
query: jfType._parameters.addToPlaylist,
responses: {
204: jfType._response.addToPlaylist,
400: jfType._response.error,
addToPlaylist: {
body: z.null(),
method: 'POST',
path: 'playlists/:id/items',
query: jfType._parameters.addToPlaylist,
responses: {
204: jfType._response.addToPlaylist,
400: jfType._response.error,
},
},
},
authenticate: {
body: jfType._parameters.authenticate,
headers: z.object({
'X-Emby-Authorization': z.string(),
}),
method: 'POST',
path: 'users/authenticatebyname',
responses: {
200: jfType._response.authenticate,
400: jfType._response.error,
authenticate: {
body: jfType._parameters.authenticate,
headers: z.object({
'X-Emby-Authorization': z.string(),
}),
method: 'POST',
path: 'users/authenticatebyname',
responses: {
200: jfType._response.authenticate,
400: jfType._response.error,
},
},
},
createFavorite: {
body: jfType._parameters.favorite,
method: 'POST',
path: 'users/:userId/favoriteitems/:id',
responses: {
200: jfType._response.favorite,
400: jfType._response.error,
createFavorite: {
body: jfType._parameters.favorite,
method: 'POST',
path: 'users/:userId/favoriteitems/:id',
responses: {
200: jfType._response.favorite,
400: jfType._response.error,
},
},
},
createPlaylist: {
body: jfType._parameters.createPlaylist,
method: 'POST',
path: 'playlists',
responses: {
200: jfType._response.createPlaylist,
400: jfType._response.error,
createPlaylist: {
body: jfType._parameters.createPlaylist,
method: 'POST',
path: 'playlists',
responses: {
200: jfType._response.createPlaylist,
400: jfType._response.error,
},
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
path: 'items/:id',
responses: {
204: jfType._response.deletePlaylist,
400: jfType._response.error,
deletePlaylist: {
body: null,
method: 'DELETE',
path: 'items/:id',
responses: {
204: jfType._response.deletePlaylist,
400: jfType._response.error,
},
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.albumArtistDetail,
responses: {
200: jfType._response.albumArtist,
400: jfType._response.error,
getAlbumArtistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.albumArtistDetail,
responses: {
200: jfType._response.albumArtist,
400: jfType._response.error,
},
},
},
getAlbumArtistList: {
method: 'GET',
path: 'artists/albumArtists',
query: jfType._parameters.albumArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
getAlbumArtistList: {
method: 'GET',
path: 'artists/albumArtists',
query: jfType._parameters.albumArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
},
},
},
getAlbumDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.albumDetail,
responses: {
200: jfType._response.album,
400: jfType._response.error,
getAlbumDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.albumDetail,
responses: {
200: jfType._response.album,
400: jfType._response.error,
},
},
},
getAlbumList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.albumList,
responses: {
200: jfType._response.albumList,
400: jfType._response.error,
getAlbumList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.albumList,
responses: {
200: jfType._response.albumList,
400: jfType._response.error,
},
},
},
getArtistList: {
method: 'GET',
path: 'artists',
query: jfType._parameters.albumArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
getArtistList: {
method: 'GET',
path: 'artists',
query: jfType._parameters.albumArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
},
},
},
getGenreList: {
method: 'GET',
path: 'genres',
responses: {
200: jfType._response.genreList,
400: jfType._response.error,
getGenreList: {
method: 'GET',
path: 'genres',
responses: {
200: jfType._response.genreList,
400: jfType._response.error,
},
},
},
getMusicFolderList: {
method: 'GET',
path: 'users/:userId/items',
responses: {
200: jfType._response.musicFolderList,
400: jfType._response.error,
getMusicFolderList: {
method: 'GET',
path: 'users/:userId/items',
responses: {
200: jfType._response.musicFolderList,
400: jfType._response.error,
},
},
},
getPlaylistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.playlistDetail,
responses: {
200: jfType._response.playlist,
400: jfType._response.error,
getPlaylistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.playlistDetail,
responses: {
200: jfType._response.playlist,
400: jfType._response.error,
},
},
},
getPlaylistList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.playlistList,
responses: {
200: jfType._response.playlistList,
400: jfType._response.error,
getPlaylistList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.playlistList,
responses: {
200: jfType._response.playlistList,
400: jfType._response.error,
},
},
},
getPlaylistSongList: {
method: 'GET',
path: 'playlists/:id/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.playlistSongList,
400: jfType._response.error,
getPlaylistSongList: {
method: 'GET',
path: 'playlists/:id/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.playlistSongList,
400: jfType._response.error,
},
},
},
getSimilarArtistList: {
method: 'GET',
path: 'artists/:id/similar',
query: jfType._parameters.similarArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
getSimilarArtistList: {
method: 'GET',
path: 'artists/:id/similar',
query: jfType._parameters.similarArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
},
},
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
responses: {
200: jfType._response.song,
400: jfType._response.error,
getSongDetail: {
method: 'GET',
path: 'song/:id',
responses: {
200: jfType._response.song,
400: jfType._response.error,
},
},
},
getSongList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.songList,
400: jfType._response.error,
getSongList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.songList,
400: jfType._response.error,
},
},
},
getSongLyrics: {
method: 'GET',
path: 'users/:userId/Items/:id/Lyrics',
responses: {
200: jfType._response.lyrics,
404: jfType._response.error,
getSongLyrics: {
method: 'GET',
path: 'users/:userId/Items/:id/Lyrics',
responses: {
200: jfType._response.lyrics,
404: jfType._response.error,
},
},
},
getTopSongsList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.topSongsList,
400: jfType._response.error,
getTopSongsList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.topSongsList,
400: jfType._response.error,
},
},
},
removeFavorite: {
body: jfType._parameters.favorite,
method: 'DELETE',
path: 'users/:userId/favoriteitems/:id',
responses: {
200: jfType._response.favorite,
400: jfType._response.error,
removeFavorite: {
body: jfType._parameters.favorite,
method: 'DELETE',
path: 'users/:userId/favoriteitems/:id',
responses: {
200: jfType._response.favorite,
400: jfType._response.error,
},
},
},
removeFromPlaylist: {
body: null,
method: 'DELETE',
path: 'playlists/:id/items',
query: jfType._parameters.removeFromPlaylist,
responses: {
200: jfType._response.removeFromPlaylist,
400: jfType._response.error,
removeFromPlaylist: {
body: null,
method: 'DELETE',
path: 'playlists/:id/items',
query: jfType._parameters.removeFromPlaylist,
responses: {
200: jfType._response.removeFromPlaylist,
400: jfType._response.error,
},
},
},
scrobblePlaying: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
scrobblePlaying: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
},
},
},
scrobbleProgress: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing/progress',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
scrobbleProgress: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing/progress',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
},
},
},
scrobbleStopped: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing/stopped',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
scrobbleStopped: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing/stopped',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
},
},
},
search: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.search,
responses: {
200: jfType._response.search,
400: jfType._response.error,
search: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.search,
responses: {
200: jfType._response.search,
400: jfType._response.error,
},
},
},
updatePlaylist: {
body: jfType._parameters.updatePlaylist,
method: 'PUT',
path: 'items/:id',
responses: {
200: jfType._response.updatePlaylist,
400: jfType._response.error,
updatePlaylist: {
body: jfType._parameters.updatePlaylist,
method: 'PUT',
path: 'items/:id',
responses: {
200: jfType._response.updatePlaylist,
400: jfType._response.error,
},
},
},
});
const axiosClient = axios.create({});
axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' });
return qs.stringify(params, { arrayFormat: 'repeat' });
};
axiosClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer;
(response) => {
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer;
authenticationFailure(currentServer);
}
authenticationFailure(currentServer);
}
return Promise.reject(error);
},
return Promise.reject(error);
},
);
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
return {
params: notNilParams,
path,
};
return {
params: notNilParams,
path,
};
};
export const jfApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
}) => {
const { server, url, signal } = args;
const { server, url, signal } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
let token: string | undefined;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
let token: string | undefined;
const { params, path: api } = parsePath(path);
const { params, path: api } = parsePath(path);
if (server) {
baseUrl = `${server?.url}`;
token = server?.credential;
} else {
baseUrl = url;
}
if (server) {
baseUrl = `${server?.url}`;
token = server?.credential;
} else {
baseUrl = url;
}
try {
const result = await axiosClient.request({
data: body,
headers: {
...headers,
...(token && { 'X-MediaBrowser-Token': token }),
},
method: method as Method,
params,
signal,
url: `${baseUrl}/${api}`,
});
return {
body: result.data,
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
return {
body: response?.data,
headers: response?.headers as any,
status: response.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
jsonQuery: false,
});
try {
const result = await axiosClient.request({
data: body,
headers: {
...headers,
...(token && { 'X-MediaBrowser-Token': token }),
},
method: method as Method,
params,
signal,
url: `${baseUrl}/${api}`,
});
return {
body: result.data,
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
return {
body: response?.data,
headers: response?.headers as any,
status: response.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
jsonQuery: false,
});
};

File diff suppressed because it is too large Load Diff

View File

@ -3,312 +3,316 @@ import { z } from 'zod';
import { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import {
Song,
LibraryItem,
Album,
AlbumArtist,
Playlist,
MusicFolder,
Genre,
Song,
LibraryItem,
Album,
AlbumArtist,
Playlist,
MusicFolder,
Genre,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
const getStreamUrl = (args: {
container?: string;
deviceId: string;
eTag?: string;
id: string;
mediaSourceId?: string;
server: ServerListItem | null;
container?: string;
deviceId: string;
eTag?: string;
id: string;
mediaSourceId?: string;
server: ServerListItem | null;
}) => {
const { id, server, deviceId } = args;
const { id, server, deviceId } = args;
return (
`${server?.url}/audio` +
`/${id}/universal` +
`?userId=${server?.userId}` +
`&deviceId=${deviceId}` +
'&audioCodec=aac' +
`&api_key=${server?.credential}` +
`&playSessionId=${deviceId}` +
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
'&transcodingContainer=ts' +
'&transcodingProtocol=hls'
);
return (
`${server?.url}/audio` +
`/${id}/universal` +
`?userId=${server?.userId}` +
`&deviceId=${deviceId}` +
'&audioCodec=aac' +
`&api_key=${server?.credential}` +
`&playSessionId=${deviceId}` +
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
'&transcodingContainer=ts' +
'&transcodingProtocol=hls'
);
};
const getAlbumArtistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>;
size: number;
baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>;
size: number;
}) => {
const size = args.size ? args.size : 300;
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => {
const size = args.size ? args.size : 300;
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
return null;
}
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
const getSongCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.song>;
size: number;
baseUrl: string;
item: z.infer<typeof jfType._response.song>;
size: number;
}) => {
const size = args.size ? args.size : 100;
const size = args.size ? args.size : 100;
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
if (args.item?.AlbumPrimaryImageTag) {
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
if (args.item?.AlbumPrimaryImageTag) {
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
return null;
return null;
};
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
const size = args.size ? args.size : 300;
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
const normalizeSong = (
item: z.infer<typeof jfType._response.song>,
server: ServerListItem | null,
deviceId: string,
imageSize?: number,
item: z.infer<typeof jfType._response.song>,
server: ServerListItem | null,
deviceId: string,
imageSize?: number,
): Song => {
return {
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
albumId: item.AlbumId,
artistName: item?.ArtistItems?.[0]?.Name,
artists: item?.ArtistItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
bpm: null,
channels: null,
comment: null,
compilation: null,
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
duration: item.RunTimeTicks / 10000000,
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
itemType: LibraryItem.SONG,
lastPlayedAt: null,
lyrics: null,
name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: item.MediaSources && item.MediaSources[0]?.Size,
streamUrl: getStreamUrl({
container: item.MediaSources?.[0]?.Container,
deviceId,
eTag: item.MediaSources?.[0]?.ETag,
id: item.Id,
mediaSourceId: item.MediaSources?.[0]?.Id,
server,
}),
trackNumber: item.IndexNumber,
uniqueId: nanoid(),
updatedAt: item.DateCreated,
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
userRating: null,
};
return {
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
albumId: item.AlbumId,
artistName: item?.ArtistItems?.[0]?.Name,
artists: item?.ArtistItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
bpm: null,
channels: null,
comment: null,
compilation: null,
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
duration: item.RunTimeTicks / 10000000,
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
itemType: LibraryItem.SONG,
lastPlayedAt: null,
lyrics: null,
name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: item.MediaSources && item.MediaSources[0]?.Size,
streamUrl: getStreamUrl({
container: item.MediaSources?.[0]?.Container,
deviceId,
eTag: item.MediaSources?.[0]?.ETag,
id: item.Id,
mediaSourceId: item.MediaSources?.[0]?.Id,
server,
}),
trackNumber: item.IndexNumber,
uniqueId: nanoid(),
updatedAt: item.DateCreated,
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
userRating: null,
};
};
const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>,
server: ServerListItem | null,
imageSize?: number,
item: z.infer<typeof jfType._response.album>,
server: ServerListItem | null,
imageSize?: number,
): Album => {
return {
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})) || [],
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
backdropImageUrl: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getAlbumCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear || null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
uniqueId: nanoid(),
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
return {
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})) || [],
artists: item.ArtistItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
backdropImageUrl: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getAlbumCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear || null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
uniqueId: nanoid(),
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
};
const normalizeAlbumArtist = (
item: z.infer<typeof jfType._response.albumArtist> & {
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
},
server: ServerListItem | null,
imageSize?: number,
item: z.infer<typeof jfType._response.albumArtist> & {
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
},
server: ServerListItem | null,
imageSize?: number,
): AlbumArtist => {
const similarArtists =
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
(entry) => ({
id: entry.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item: entry,
size: imageSize || 300,
}),
name: entry.Name,
}),
) || [];
const similarArtists =
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
(entry) => ({
id: entry.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item: entry,
size: imageSize || 300,
}),
name: entry.Name,
}),
) || [];
return {
albumCount: null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
similarArtists,
songCount: null,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
return {
albumCount: null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
similarArtists,
songCount: null,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
};
const normalizePlaylist = (
item: z.infer<typeof jfType._response.playlist>,
server: ServerListItem | null,
imageSize?: number,
item: z.infer<typeof jfType._response.playlist>,
server: ServerListItem | null,
imageSize?: number,
): Playlist => {
const imageUrl = getPlaylistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
});
const imageUrl = getPlaylistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
const imagePlaceholderUrl = null;
return {
description: item.Overview || null,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl,
imageUrl: imageUrl || null,
itemType: LibraryItem.PLAYLIST,
name: item.Name,
owner: null,
ownerId: null,
public: null,
rules: null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
sync: null,
};
return {
description: item.Overview || null,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl,
imageUrl: imageUrl || null,
itemType: LibraryItem.PLAYLIST,
name: item.Name,
owner: null,
ownerId: null,
public: null,
rules: null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
sync: null,
};
};
const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
return {
id: item.Id,
name: item.Name,
};
return {
id: item.Id,
name: item.Name,
};
};
// const normalizeArtist = (item: any) => {
@ -332,12 +336,12 @@ const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
// };
const normalizeGenre = (item: JFGenre): Genre => {
return {
albumCount: undefined,
id: item.Id,
name: item.Name,
songCount: undefined,
};
return {
albumCount: undefined,
id: item.Id,
name: item.Name,
songCount: undefined,
};
};
// const normalizeFolder = (item: any) => {
@ -360,10 +364,10 @@ const normalizeGenre = (item: JFGenre): Genre => {
// };
export const jfNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
song: normalizeSong,
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
song: normalizeSong,
};

File diff suppressed because it is too large Load Diff

View File

@ -1,143 +1,143 @@
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
};
export type NDUser = {
createdAt: string;
email: string;
id: string;
isAdmin: boolean;
lastAccessAt: string;
lastLoginAt: string;
name: string;
updatedAt: string;
userName: string;
createdAt: string;
email: string;
id: string;
isAdmin: boolean;
lastAccessAt: string;
lastLoginAt: string;
name: string;
updatedAt: string;
userName: string;
};
export type NDGenre = {
id: string;
name: string;
id: string;
name: string;
};
export type NDAlbum = {
albumArtist: string;
albumArtistId: string;
allArtistIds: string;
artist: string;
artistId: string;
compilation: boolean;
coverArtId?: string; // Removed after v0.48.0
coverArtPath?: string; // Removed after v0.48.0
createdAt: string;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
id: string;
maxYear: number;
mbzAlbumArtistId: string;
mbzAlbumId: string;
minYear: number;
name: string;
orderAlbumArtistName: string;
orderAlbumName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
songCount: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
updatedAt: string;
albumArtist: string;
albumArtistId: string;
allArtistIds: string;
artist: string;
artistId: string;
compilation: boolean;
coverArtId?: string; // Removed after v0.48.0
coverArtPath?: string; // Removed after v0.48.0
createdAt: string;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
id: string;
maxYear: number;
mbzAlbumArtistId: string;
mbzAlbumId: string;
minYear: number;
name: string;
orderAlbumArtistName: string;
orderAlbumName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
songCount: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
updatedAt: string;
} & { songs?: NDSong[] };
export type NDSong = {
album: string;
albumArtist: string;
albumArtistId: string;
albumId: string;
artist: string;
artistId: string;
bitRate: number;
bookmarkPosition: number;
bpm?: number;
channels?: number;
comment?: string;
compilation: boolean;
createdAt: string;
discNumber: number;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
hasCoverArt: boolean;
id: string;
lyrics?: string;
mbzAlbumArtistId: string;
mbzAlbumId: string;
mbzArtistId: string;
mbzTrackId: string;
orderAlbumArtistName: string;
orderAlbumName: string;
orderArtistName: string;
orderTitle: string;
path: string;
playCount: number;
playDate: string;
rating: number;
size: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
suffix: string;
title: string;
trackNumber: number;
updatedAt: string;
year: number;
album: string;
albumArtist: string;
albumArtistId: string;
albumId: string;
artist: string;
artistId: string;
bitRate: number;
bookmarkPosition: number;
bpm?: number;
channels?: number;
comment?: string;
compilation: boolean;
createdAt: string;
discNumber: number;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
hasCoverArt: boolean;
id: string;
lyrics?: string;
mbzAlbumArtistId: string;
mbzAlbumId: string;
mbzArtistId: string;
mbzTrackId: string;
orderAlbumArtistName: string;
orderAlbumName: string;
orderArtistName: string;
orderTitle: string;
path: string;
playCount: number;
playDate: string;
rating: number;
size: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
suffix: string;
title: string;
trackNumber: number;
updatedAt: string;
year: number;
};
export type NDAlbumArtist = {
albumCount: number;
biography: string;
externalInfoUpdatedAt: string;
externalUrl: string;
fullText: string;
genres: NDGenre[];
id: string;
largeImageUrl?: string;
mbzArtistId: string;
mediumImageUrl?: string;
name: string;
orderArtistName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
smallImageUrl?: string;
songCount: number;
starred: boolean;
starredAt: string;
albumCount: number;
biography: string;
externalInfoUpdatedAt: string;
externalUrl: string;
fullText: string;
genres: NDGenre[];
id: string;
largeImageUrl?: string;
mbzArtistId: string;
mediumImageUrl?: string;
name: string;
orderArtistName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
smallImageUrl?: string;
songCount: number;
starred: boolean;
starredAt: string;
} & {
similarArtists?: SSArtistInfo['similarArtist'];
similarArtists?: SSArtistInfo['similarArtist'];
};
export type NDAuthenticationResponse = NDAuthenticate;
export type NDAlbumArtistList = {
items: NDAlbumArtist[];
startIndex: number;
totalRecordCount: number;
items: NDAlbumArtist[];
startIndex: number;
totalRecordCount: number;
};
export type NDAlbumArtistDetail = NDAlbumArtist;
@ -155,9 +155,9 @@ export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
export type NDAlbumListResponse = NDAlbum[];
export type NDAlbumList = {
items: NDAlbum[];
startIndex: number;
totalRecordCount: number;
items: NDAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type NDSongDetail = NDSong;
@ -167,142 +167,142 @@ export type NDSongDetailResponse = NDSong;
export type NDSongListResponse = NDSong[];
export type NDSongList = {
items: NDSong[];
startIndex: number;
totalRecordCount: number;
items: NDSong[];
startIndex: number;
totalRecordCount: number;
};
export type NDArtistListResponse = NDAlbumArtist[];
export type NDPagination = {
_end?: number;
_start?: number;
_end?: number;
_start?: number;
};
export enum NDSortOrder {
ASC = 'ASC',
DESC = 'DESC',
ASC = 'ASC',
DESC = 'DESC',
}
export type NDOrder = {
_order?: NDSortOrder;
_order?: NDSortOrder;
};
export enum NDGenreListSort {
NAME = 'name',
NAME = 'name',
}
export type NDGenreListParams = {
_sort?: NDGenreListSort;
id?: string;
_sort?: NDGenreListSort;
id?: string;
} & NDPagination &
NDOrder;
NDOrder;
export enum NDAlbumListSort {
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
DURATION = 'duration',
NAME = 'name',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'play_date',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount',
STARRED = 'starred',
YEAR = 'max_year',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
DURATION = 'duration',
NAME = 'name',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'play_date',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount',
STARRED = 'starred',
YEAR = 'max_year',
}
export type NDAlbumListParams = {
_sort?: NDAlbumListSort;
album_id?: string;
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
id?: string;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
_sort?: NDAlbumListSort;
album_id?: string;
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
id?: string;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
} & NDPagination &
NDOrder;
NDOrder;
export enum NDSongListSort {
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS = 'album, discNumber, trackNumber',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
COMMENT = 'comment',
DURATION = 'duration',
FAVORITED = 'starred ASC, starredAt ASC',
GENRE = 'genre',
ID = 'id',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year, album, discNumber, trackNumber',
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS = 'album, discNumber, trackNumber',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
COMMENT = 'comment',
DURATION = 'duration',
FAVORITED = 'starred ASC, starredAt ASC',
GENRE = 'genre',
ID = 'id',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year, album, discNumber, trackNumber',
}
export type NDSongListParams = {
_sort?: NDSongListSort;
album_id?: string[];
artist_id?: string[];
genre_id?: string;
starred?: boolean;
_sort?: NDSongListSort;
album_id?: string[];
artist_id?: string[];
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
NDOrder;
export enum NDAlbumArtistListSort {
ALBUM_COUNT = 'albumCount',
FAVORITED = 'starred ASC, starredAt ASC',
NAME = 'name',
PLAY_COUNT = 'playCount',
RATING = 'rating',
SONG_COUNT = 'songCount',
ALBUM_COUNT = 'albumCount',
FAVORITED = 'starred ASC, starredAt ASC',
NAME = 'name',
PLAY_COUNT = 'playCount',
RATING = 'rating',
SONG_COUNT = 'songCount',
}
export type NDAlbumArtistListParams = {
_sort?: NDAlbumArtistListSort;
genre_id?: string;
starred?: boolean;
_sort?: NDAlbumArtistListSort;
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
NDOrder;
export type NDAddToPlaylistResponse = {
added: number;
added: number;
};
export type NDAddToPlaylistBody = {
ids: string[];
ids: string[];
};
export type NDAddToPlaylist = null;
export type NDRemoveFromPlaylistResponse = {
ids: string[];
ids: string[];
};
export type NDRemoveFromPlaylistParams = {
id: string[];
id: string[];
};
export type NDRemoveFromPlaylist = null;
export type NDCreatePlaylistParams = {
comment?: string;
name: string;
public?: boolean;
rules?: Record<string, any> | null;
comment?: string;
name: string;
public?: boolean;
rules?: Record<string, any> | null;
};
export type NDCreatePlaylistResponse = {
id: string;
id: string;
};
export type NDCreatePlaylist = NDCreatePlaylistResponse;
@ -312,7 +312,7 @@ export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
export type NDUpdatePlaylistResponse = NDPlaylist;
export type NDDeletePlaylistParams = {
id: string;
id: string;
};
export type NDDeletePlaylistResponse = null;
@ -320,21 +320,21 @@ export type NDDeletePlaylistResponse = null;
export type NDDeletePlaylist = NDDeletePlaylistResponse;
export type NDPlaylist = {
comment: string;
createdAt: string;
duration: number;
evaluatedAt: string;
id: string;
name: string;
ownerId: string;
ownerName: string;
path: string;
public: boolean;
rules: Record<string, any> | null;
size: number;
songCount: number;
sync: boolean;
updatedAt: string;
comment: string;
createdAt: string;
duration: number;
evaluatedAt: string;
id: string;
name: string;
ownerId: string;
ownerName: string;
path: string;
public: boolean;
rules: Record<string, any> | null;
size: number;
songCount: number;
sync: boolean;
updatedAt: string;
};
export type NDPlaylistDetail = NDPlaylist;
@ -342,125 +342,125 @@ export type NDPlaylistDetail = NDPlaylist;
export type NDPlaylistDetailResponse = NDPlaylist;
export type NDPlaylistList = {
items: NDPlaylist[];
startIndex: number;
totalRecordCount: number;
items: NDPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export type NDPlaylistListResponse = NDPlaylist[];
export enum NDPlaylistListSort {
DURATION = 'duration',
NAME = 'name',
OWNER = 'ownerName',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
DURATION = 'duration',
NAME = 'name',
OWNER = 'ownerName',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
}
export type NDPlaylistListParams = {
_sort?: NDPlaylistListSort;
owner_id?: string;
_sort?: NDPlaylistListSort;
owner_id?: string;
} & NDPagination &
NDOrder;
NDOrder;
export type NDPlaylistSong = NDSong & {
mediaFileId: string;
playlistId: string;
mediaFileId: string;
playlistId: string;
};
export type NDPlaylistSongListResponse = NDPlaylistSong[];
export type NDPlaylistSongList = {
items: NDPlaylistSong[];
startIndex: number;
totalRecordCount: number;
items: NDPlaylistSong[];
startIndex: number;
totalRecordCount: number;
};
export const NDSongQueryFields = [
{ label: 'Album', type: 'string', value: 'album' },
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
{ label: 'Album Type', type: 'string', value: 'albumtype' },
{ label: 'Artist', type: 'string', value: 'artist' },
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
{ label: 'BPM', type: 'number', value: 'bpm' },
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' },
{ label: 'Channels', type: 'number', value: 'channels' },
{ label: 'Comment', type: 'string', value: 'comment' },
{ label: 'Date Added', type: 'date', value: 'dateadded' },
{ label: 'Date Favorited', type: 'date', value: 'dateloved' },
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' },
{ label: 'Date Modified', type: 'date', value: 'datemodified' },
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
{ label: 'Disc Number', type: 'number', value: 'discnumber' },
{ label: 'Duration', type: 'number', value: 'duration' },
{ label: 'File Path', type: 'string', value: 'filepath' },
{ label: 'File Type', type: 'string', value: 'filetype' },
{ label: 'Genre', type: 'string', value: 'genre' },
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
{ label: 'Name', type: 'string', value: 'title' },
{ label: 'Play Count', type: 'number', value: 'playcount' },
{ label: 'Rating', type: 'number', value: 'rating' },
{ label: 'Size', type: 'number', value: 'size' },
{ label: 'Sort Album', type: 'string', value: 'sortalbum' },
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
{ label: 'Sort Artist', type: 'string', value: 'sortartist' },
{ label: 'Sort Name', type: 'string', value: 'sorttitle' },
{ label: 'Track Number', type: 'number', value: 'tracknumber' },
{ label: 'Year', type: 'number', value: 'year' },
{ label: 'Album', type: 'string', value: 'album' },
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
{ label: 'Album Type', type: 'string', value: 'albumtype' },
{ label: 'Artist', type: 'string', value: 'artist' },
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
{ label: 'BPM', type: 'number', value: 'bpm' },
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' },
{ label: 'Channels', type: 'number', value: 'channels' },
{ label: 'Comment', type: 'string', value: 'comment' },
{ label: 'Date Added', type: 'date', value: 'dateadded' },
{ label: 'Date Favorited', type: 'date', value: 'dateloved' },
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' },
{ label: 'Date Modified', type: 'date', value: 'datemodified' },
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
{ label: 'Disc Number', type: 'number', value: 'discnumber' },
{ label: 'Duration', type: 'number', value: 'duration' },
{ label: 'File Path', type: 'string', value: 'filepath' },
{ label: 'File Type', type: 'string', value: 'filetype' },
{ label: 'Genre', type: 'string', value: 'genre' },
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
{ label: 'Name', type: 'string', value: 'title' },
{ label: 'Play Count', type: 'number', value: 'playcount' },
{ label: 'Rating', type: 'number', value: 'rating' },
{ label: 'Size', type: 'number', value: 'size' },
{ label: 'Sort Album', type: 'string', value: 'sortalbum' },
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
{ label: 'Sort Artist', type: 'string', value: 'sortartist' },
{ label: 'Sort Name', type: 'string', value: 'sorttitle' },
{ label: 'Track Number', type: 'number', value: 'tracknumber' },
{ label: 'Year', type: 'number', value: 'year' },
];
export const NDSongQueryDateOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'is before', value: 'before' },
{ label: 'is after', value: 'after' },
{ label: 'is in the last', value: 'inTheLast' },
{ label: 'is not in the last', value: 'notInTheLast' },
{ label: 'is in the range', value: 'inTheRange' },
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'is before', value: 'before' },
{ label: 'is after', value: 'after' },
{ label: 'is in the last', value: 'inTheLast' },
{ label: 'is not in the last', value: 'notInTheLast' },
{ label: 'is in the range', value: 'inTheRange' },
];
export const NDSongQueryStringOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'starts with', value: 'startsWith' },
{ label: 'ends with', value: 'endsWith' },
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'starts with', value: 'startsWith' },
{ label: 'ends with', value: 'endsWith' },
];
export const NDSongQueryBooleanOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
];
export const NDSongQueryNumberOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'is greater than', value: 'gt' },
{ label: 'is less than', value: 'lt' },
{ label: 'is in the range', value: 'inTheRange' },
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'is greater than', value: 'gt' },
{ label: 'is less than', value: 'lt' },
{ label: 'is in the range', value: 'inTheRange' },
];
export type NDUserListParams = {
_sort?: NDUserListSort;
_sort?: NDUserListSort;
} & NDPagination &
NDOrder;
NDOrder;
export type NDUserListResponse = NDUser[];
export type NDUserList = {
items: NDUser[];
startIndex: number;
totalRecordCount: number;
items: NDUser[];
startIndex: number;
totalRecordCount: number;
};
export enum NDUserListSort {
NAME = 'name',
NAME = 'name',
}

View File

@ -15,187 +15,188 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
const c = initContract();
export const contract = c.router({
addToPlaylist: {
body: ndType._parameters.addToPlaylist,
method: 'POST',
path: 'playlist/:id/tracks',
responses: {
200: resultWithHeaders(ndType._response.addToPlaylist),
500: resultWithHeaders(ndType._response.error),
addToPlaylist: {
body: ndType._parameters.addToPlaylist,
method: 'POST',
path: 'playlist/:id/tracks',
responses: {
200: resultWithHeaders(ndType._response.addToPlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
},
authenticate: {
body: ndType._parameters.authenticate,
method: 'POST',
path: 'auth/login',
responses: {
200: resultWithHeaders(ndType._response.authenticate),
500: resultWithHeaders(ndType._response.error),
authenticate: {
body: ndType._parameters.authenticate,
method: 'POST',
path: 'auth/login',
responses: {
200: resultWithHeaders(ndType._response.authenticate),
500: resultWithHeaders(ndType._response.error),
},
},
},
createPlaylist: {
body: ndType._parameters.createPlaylist,
method: 'POST',
path: 'playlist',
responses: {
200: resultWithHeaders(ndType._response.createPlaylist),
500: resultWithHeaders(ndType._response.error),
createPlaylist: {
body: ndType._parameters.createPlaylist,
method: 'POST',
path: 'playlist',
responses: {
200: resultWithHeaders(ndType._response.createPlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.deletePlaylist),
500: resultWithHeaders(ndType._response.error),
deletePlaylist: {
body: null,
method: 'DELETE',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.deletePlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'artist/:id',
responses: {
200: resultWithHeaders(ndType._response.albumArtist),
500: resultWithHeaders(ndType._response.error),
getAlbumArtistDetail: {
method: 'GET',
path: 'artist/:id',
responses: {
200: resultWithHeaders(ndType._response.albumArtist),
500: resultWithHeaders(ndType._response.error),
},
},
},
getAlbumArtistList: {
method: 'GET',
path: 'artist',
query: ndType._parameters.albumArtistList,
responses: {
200: resultWithHeaders(ndType._response.albumArtistList),
500: resultWithHeaders(ndType._response.error),
getAlbumArtistList: {
method: 'GET',
path: 'artist',
query: ndType._parameters.albumArtistList,
responses: {
200: resultWithHeaders(ndType._response.albumArtistList),
500: resultWithHeaders(ndType._response.error),
},
},
},
getAlbumDetail: {
method: 'GET',
path: 'album/:id',
responses: {
200: resultWithHeaders(ndType._response.album),
500: resultWithHeaders(ndType._response.error),
getAlbumDetail: {
method: 'GET',
path: 'album/:id',
responses: {
200: resultWithHeaders(ndType._response.album),
500: resultWithHeaders(ndType._response.error),
},
},
},
getAlbumList: {
method: 'GET',
path: 'album',
query: ndType._parameters.albumList,
responses: {
200: resultWithHeaders(ndType._response.albumList),
500: resultWithHeaders(ndType._response.error),
getAlbumList: {
method: 'GET',
path: 'album',
query: ndType._parameters.albumList,
responses: {
200: resultWithHeaders(ndType._response.albumList),
500: resultWithHeaders(ndType._response.error),
},
},
},
getGenreList: {
method: 'GET',
path: 'genre',
responses: {
200: resultWithHeaders(ndType._response.genreList),
500: resultWithHeaders(ndType._response.error),
getGenreList: {
method: 'GET',
path: 'genre',
responses: {
200: resultWithHeaders(ndType._response.genreList),
500: resultWithHeaders(ndType._response.error),
},
},
},
getPlaylistDetail: {
method: 'GET',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.playlist),
500: resultWithHeaders(ndType._response.error),
getPlaylistDetail: {
method: 'GET',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.playlist),
500: resultWithHeaders(ndType._response.error),
},
},
},
getPlaylistList: {
method: 'GET',
path: 'playlist',
query: ndType._parameters.playlistList,
responses: {
200: resultWithHeaders(ndType._response.playlistList),
500: resultWithHeaders(ndType._response.error),
getPlaylistList: {
method: 'GET',
path: 'playlist',
query: ndType._parameters.playlistList,
responses: {
200: resultWithHeaders(ndType._response.playlistList),
500: resultWithHeaders(ndType._response.error),
},
},
},
getPlaylistSongList: {
method: 'GET',
path: 'playlist/:id/tracks',
query: ndType._parameters.songList,
responses: {
200: resultWithHeaders(ndType._response.playlistSongList),
500: resultWithHeaders(ndType._response.error),
getPlaylistSongList: {
method: 'GET',
path: 'playlist/:id/tracks',
query: ndType._parameters.songList,
responses: {
200: resultWithHeaders(ndType._response.playlistSongList),
500: resultWithHeaders(ndType._response.error),
},
},
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
responses: {
200: resultWithHeaders(ndType._response.song),
500: resultWithHeaders(ndType._response.error),
getSongDetail: {
method: 'GET',
path: 'song/:id',
responses: {
200: resultWithHeaders(ndType._response.song),
500: resultWithHeaders(ndType._response.error),
},
},
},
getSongList: {
method: 'GET',
path: 'song',
query: ndType._parameters.songList,
responses: {
200: resultWithHeaders(ndType._response.songList),
500: resultWithHeaders(ndType._response.error),
getSongList: {
method: 'GET',
path: 'song',
query: ndType._parameters.songList,
responses: {
200: resultWithHeaders(ndType._response.songList),
500: resultWithHeaders(ndType._response.error),
},
},
},
getUserList: {
method: 'GET',
path: 'user',
query: ndType._parameters.userList,
responses: {
200: resultWithHeaders(ndType._response.userList),
500: resultWithHeaders(ndType._response.error),
getUserList: {
method: 'GET',
path: 'user',
query: ndType._parameters.userList,
responses: {
200: resultWithHeaders(ndType._response.userList),
500: resultWithHeaders(ndType._response.error),
},
},
},
removeFromPlaylist: {
body: null,
method: 'DELETE',
path: 'playlist/:id/tracks',
query: ndType._parameters.removeFromPlaylist,
responses: {
200: resultWithHeaders(ndType._response.removeFromPlaylist),
500: resultWithHeaders(ndType._response.error),
removeFromPlaylist: {
body: null,
method: 'DELETE',
path: 'playlist/:id/tracks',
query: ndType._parameters.removeFromPlaylist,
responses: {
200: resultWithHeaders(ndType._response.removeFromPlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
},
updatePlaylist: {
body: ndType._parameters.updatePlaylist,
method: 'PUT',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.updatePlaylist),
500: resultWithHeaders(ndType._response.error),
updatePlaylist: {
body: ndType._parameters.updatePlaylist,
method: 'PUT',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.updatePlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
},
});
const axiosClient = axios.create({});
axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' });
return qs.stringify(params, { arrayFormat: 'repeat' });
};
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params);
const parsedParams = qs.parse(params);
// Convert indexed object to array
const newParams: Record<string, any> = {};
Object.keys(parsedParams).forEach((key) => {
const isIndexedArrayObject =
typeof parsedParams[key] === 'object' && Object.keys(parsedParams[key] || {}).includes('0');
// Convert indexed object to array
const newParams: Record<string, any> = {};
Object.keys(parsedParams).forEach((key) => {
const isIndexedArrayObject =
typeof parsedParams[key] === 'object' &&
Object.keys(parsedParams[key] || {}).includes('0');
if (!isIndexedArrayObject) {
newParams[key] = parsedParams[key];
} else {
newParams[key] = Object.values(parsedParams[key] || {});
}
});
if (!isIndexedArrayObject) {
newParams[key] = parsedParams[key];
} else {
newParams[key] = Object.values(parsedParams[key] || {});
}
});
const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');
const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');
return {
params: notNilParams,
path,
};
return {
params: notNilParams,
path,
};
};
let authSuccess = true;
@ -205,184 +206,186 @@ const RETRY_DELAY_MS = 1000;
const MAX_RETRIES = 5;
const waitForResult = async (count = 0): Promise<void> => {
return new Promise((resolve) => {
if (count === MAX_RETRIES || !shouldDelay) resolve();
return new Promise((resolve) => {
if (count === MAX_RETRIES || !shouldDelay) resolve();
setTimeout(() => {
waitForResult(count + 1)
.then(resolve)
.catch(resolve);
}, RETRY_DELAY_MS);
});
setTimeout(() => {
waitForResult(count + 1)
.then(resolve)
.catch(resolve);
}, RETRY_DELAY_MS);
});
};
const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);
const TIMEOUT_ERROR = Error();
axiosClient.interceptors.response.use(
(response) => {
const serverId = useAuthStore.getState().currentServer?.id;
(response) => {
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
if (serverId) {
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
if (headerCredential) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: headerCredential,
});
}
}
if (headerCredential) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: headerCredential,
});
}
}
authSuccess = true;
authSuccess = true;
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer;
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer;
if (localSettings && currentServer?.savePassword) {
// eslint-disable-next-line promise/no-promise-in-callback
return localSettings
.passwordGet(currentServer.id)
.then(async (password: string | null) => {
authSuccess = false;
if (localSettings && currentServer?.savePassword) {
// eslint-disable-next-line promise/no-promise-in-callback
return localSettings
.passwordGet(currentServer.id)
.then(async (password: string | null) => {
authSuccess = false;
if (password === null) {
throw error;
if (password === null) {
throw error;
}
if (shouldDelay) {
await waitForResult();
// Hopefully the delay was sufficient for authentication.
// Otherwise, it will require manual intervention
if (authSuccess) {
return axiosClient.request(error.config);
}
throw error;
}
shouldDelay = true;
// Do not use axiosClient. Instead, manually make a post
const res = await axios.post(`${currentServer.url}/auth/login`, {
password,
username: currentServer.username,
});
if (res.status === 429) {
toast.error({
message:
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
title: 'Your session has expired.',
});
const serverId = currentServer.id;
useAuthStore
.getState()
.actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
// special error to prevent sending a second message, and stop other messages that could be enqueued
limitedFail.cancel();
throw TIMEOUT_ERROR;
}
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
const newCredential = res.data.token;
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
useAuthStore.getState().actions.updateServer(currentServer.id, {
credential: subsonicCredential,
ndCredential: newCredential,
});
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
authSuccess = true;
return axiosClient.request(error.config);
})
.catch((newError: any) => {
if (newError !== TIMEOUT_ERROR) {
console.error('Error when trying to reauthenticate: ', newError);
limitedFail(currentServer);
}
// make sure to pass the error so axios will error later on
throw newError;
})
.finally(() => {
shouldDelay = false;
});
}
if (shouldDelay) {
await waitForResult();
limitedFail(currentServer);
}
// Hopefully the delay was sufficient for authentication.
// Otherwise, it will require manual intervention
if (authSuccess) {
return axiosClient.request(error.config);
}
throw error;
}
shouldDelay = true;
// Do not use axiosClient. Instead, manually make a post
const res = await axios.post(`${currentServer.url}/auth/login`, {
password,
username: currentServer.username,
});
if (res.status === 429) {
toast.error({
message:
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
title: 'Your session has expired.',
});
const serverId = currentServer.id;
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
// special error to prevent sending a second message, and stop other messages that could be enqueued
limitedFail.cancel();
throw TIMEOUT_ERROR;
}
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
const newCredential = res.data.token;
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
useAuthStore.getState().actions.updateServer(currentServer.id, {
credential: subsonicCredential,
ndCredential: newCredential,
});
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
authSuccess = true;
return axiosClient.request(error.config);
})
.catch((newError: any) => {
if (newError !== TIMEOUT_ERROR) {
console.error('Error when trying to reauthenticate: ', newError);
limitedFail(currentServer);
}
// make sure to pass the error so axios will error later on
throw newError;
})
.finally(() => {
shouldDelay = false;
});
}
limitedFail(currentServer);
}
return Promise.reject(error);
},
return Promise.reject(error);
},
);
export const ndApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
}) => {
const { server, url, signal } = args;
const { server, url, signal } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
let token: string | undefined;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
let token: string | undefined;
const { params, path: api } = parsePath(path);
const { params, path: api } = parsePath(path);
if (server) {
baseUrl = `${server?.url}/api`;
token = server?.ndCredential;
} else {
baseUrl = url;
}
if (server) {
baseUrl = `${server?.url}/api`;
token = server?.ndCredential;
} else {
baseUrl = url;
}
try {
if (shouldDelay) await waitForResult();
try {
if (shouldDelay) await waitForResult();
const result = await axiosClient.request({
data: body,
headers: {
...headers,
...(token && { 'x-nd-authorization': `Bearer ${token}` }),
},
method: method as Method,
params,
signal,
url: `${baseUrl}/${api}`,
});
return {
body: { data: result.data, headers: result.headers },
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
return {
body: { data: response.data, headers: response.headers },
headers: response.headers as any,
status: response.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
jsonQuery: false,
});
const result = await axiosClient.request({
data: body,
headers: {
...headers,
...(token && { 'x-nd-authorization': `Bearer ${token}` }),
},
method: method as Method,
params,
signal,
url: `${baseUrl}/${api}`,
});
return {
body: { data: result.data, headers: result.headers },
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
return {
body: { data: response.data, headers: response.headers },
headers: response.headers as any,
status: response.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
jsonQuery: false,
});
};

View File

@ -1,43 +1,43 @@
import {
AlbumArtistDetailArgs,
AlbumArtistDetailResponse,
AddToPlaylistArgs,
AddToPlaylistResponse,
CreatePlaylistResponse,
CreatePlaylistArgs,
DeletePlaylistArgs,
DeletePlaylistResponse,
AlbumArtistListResponse,
AlbumArtistListArgs,
albumArtistListSortMap,
sortOrderMap,
AuthenticationResponse,
UserListResponse,
UserListArgs,
userListSortMap,
GenreListArgs,
GenreListResponse,
AlbumDetailResponse,
AlbumDetailArgs,
AlbumListArgs,
albumListSortMap,
AlbumListResponse,
SongListResponse,
SongListArgs,
songListSortMap,
SongDetailResponse,
SongDetailArgs,
UpdatePlaylistArgs,
UpdatePlaylistResponse,
PlaylistListResponse,
PlaylistDetailArgs,
PlaylistListArgs,
playlistListSortMap,
PlaylistDetailResponse,
PlaylistSongListArgs,
PlaylistSongListResponse,
RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs,
AlbumArtistDetailArgs,
AlbumArtistDetailResponse,
AddToPlaylistArgs,
AddToPlaylistResponse,
CreatePlaylistResponse,
CreatePlaylistArgs,
DeletePlaylistArgs,
DeletePlaylistResponse,
AlbumArtistListResponse,
AlbumArtistListArgs,
albumArtistListSortMap,
sortOrderMap,
AuthenticationResponse,
UserListResponse,
UserListArgs,
userListSortMap,
GenreListArgs,
GenreListResponse,
AlbumDetailResponse,
AlbumDetailArgs,
AlbumListArgs,
albumListSortMap,
AlbumListResponse,
SongListResponse,
SongListArgs,
songListSortMap,
SongDetailResponse,
SongDetailArgs,
UpdatePlaylistArgs,
UpdatePlaylistResponse,
PlaylistListResponse,
PlaylistDetailArgs,
PlaylistListArgs,
playlistListSortMap,
PlaylistDetailResponse,
PlaylistSongListArgs,
PlaylistSongListResponse,
RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs,
} from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
@ -45,428 +45,430 @@ import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
const authenticate = async (
url: string,
body: { password: string; username: string },
url: string,
body: { password: string; username: string },
): Promise<AuthenticationResponse> => {
const cleanServerUrl = url.replace(/\/$/, '');
const cleanServerUrl = url.replace(/\/$/, '');
const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
body: {
password: body.password,
username: body.username,
},
});
const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
body: {
password: body.password,
username: body.username,
},
});
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
return {
credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
ndCredential: res.body.data.token,
userId: res.body.data.id,
username: res.body.data.username,
};
return {
credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
ndCredential: res.body.data.token,
userId: res.body.data.id,
username: res.body.data.username,
};
};
const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getUserList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: userListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query._custom?.navidrome,
},
});
const res = await ndApiClient(apiClientProps).getUserList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: userListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get user list');
}
if (res.status !== 200) {
throw new Error('Failed to get user list');
}
return {
items: res.body.data.map((user) => ndNormalize.user(user)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return {
items: res.body.data.map((user) => ndNormalize.user(user)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
const { apiClientProps } = args;
const { apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getGenreList({});
const res = await ndApiClient(apiClientProps).getGenreList({});
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
return {
items: res.body.data,
startIndex: 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return {
items: res.body.data,
startIndex: 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getAlbumArtistDetail = async (
args: AlbumArtistDetailArgs,
args: AlbumArtistDetailArgs,
): Promise<AlbumArtistDetailResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
params: {
id: query.id,
},
});
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
params: {
id: query.id,
},
});
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
query: {
count: 10,
id: query.id,
},
});
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
query: {
count: 10,
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist detail');
}
if (res.status !== 200) {
throw new Error('Failed to get album artist detail');
}
if (!apiClientProps.server) {
throw new Error('Server is required');
}
if (!apiClientProps.server) {
throw new Error('Server is required');
}
return ndNormalize.albumArtist(
{
...res.body.data,
...(artistInfoRes.status === 200 && {
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
...(!res.body.data.largeImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
}),
...(!res.body.data.mediumImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
}),
...(!res.body.data.smallImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
}),
}),
},
apiClientProps.server,
);
return ndNormalize.albumArtist(
{
...res.body.data,
...(artistInfoRes.status === 200 && {
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
...(!res.body.data.largeImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
}),
...(!res.body.data.mediumImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
}),
...(!res.body.data.smallImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
}),
}),
},
apiClientProps.server,
);
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumArtistListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
...query._custom?.navidrome,
},
});
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumArtistListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist list');
}
if (res.status !== 200) {
throw new Error('Failed to get album artist list');
}
return {
items: res.body.data.map((albumArtist) =>
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
),
startIndex: query.startIndex,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return {
items: res.body.data.map((albumArtist) =>
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
),
startIndex: query.startIndex,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
params: {
id: query.id,
},
});
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
params: {
id: query.id,
},
});
const songsData = await ndApiClient(apiClientProps).getSongList({
query: {
_end: 0,
_order: 'ASC',
_sort: 'album',
_start: 0,
album_id: [query.id],
},
});
const songsData = await ndApiClient(apiClientProps).getSongList({
query: {
_end: 0,
_order: 'ASC',
_sort: 'album',
_start: 0,
album_id: [query.id],
},
});
if (albumRes.status !== 200 || songsData.status !== 200) {
throw new Error('Failed to get album detail');
}
if (albumRes.status !== 200 || songsData.status !== 200) {
throw new Error('Failed to get album detail');
}
return ndNormalize.album(
{ ...albumRes.body.data, songs: songsData.body.data },
apiClientProps.server,
);
return ndNormalize.album(
{ ...albumRes.body.data, songs: songsData.body.data },
apiClientProps.server,
);
};
const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
artist_id: query.artistIds?.[0],
name: query.searchTerm,
...query._custom?.navidrome,
},
});
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
artist_id: query.artistIds?.[0],
name: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
return {
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return {
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getSongList({
query: {
_end: query.startIndex + (query.limit || -1),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_artist_id: query.artistIds,
album_id: query.albumIds,
title: query.searchTerm,
...query._custom?.navidrome,
},
});
const res = await ndApiClient(apiClientProps).getSongList({
query: {
_end: query.startIndex + (query.limit || -1),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_artist_id: query.artistIds,
album_id: query.albumIds,
title: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
return {
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return {
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getSongDetail({
params: {
id: query.id,
},
});
const res = await ndApiClient(apiClientProps).getSongDetail({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song detail');
}
if (res.status !== 200) {
throw new Error('Failed to get song detail');
}
return ndNormalize.song(res.body.data, apiClientProps.server, '');
return ndNormalize.song(res.body.data, apiClientProps.server, '');
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { body, apiClientProps } = args;
const { body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).createPlaylist({
body: {
comment: body.comment,
name: body.name,
public: body._custom?.navidrome?.public,
rules: body._custom?.navidrome?.rules,
sync: body._custom?.navidrome?.sync,
},
});
const res = await ndApiClient(apiClientProps).createPlaylist({
body: {
comment: body.comment,
name: body.name,
public: body._custom?.navidrome?.public,
rules: body._custom?.navidrome?.rules,
sync: body._custom?.navidrome?.sync,
},
});
if (res.status !== 200) {
throw new Error('Failed to create playlist');
}
if (res.status !== 200) {
throw new Error('Failed to create playlist');
}
return {
id: res.body.data.id,
};
return {
id: res.body.data.id,
};
};
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
const { query, body, apiClientProps } = args;
const { query, body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).updatePlaylist({
body: {
comment: body.comment || '',
name: body.name,
public: body._custom?.navidrome?.public || false,
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
sync: body._custom?.navidrome?.sync || undefined,
},
params: {
id: query.id,
},
});
const res = await ndApiClient(apiClientProps).updatePlaylist({
body: {
comment: body.comment || '',
name: body.name,
public: body._custom?.navidrome?.public || false,
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
sync: body._custom?.navidrome?.sync || undefined,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to update playlist');
}
if (res.status !== 200) {
throw new Error('Failed to update playlist');
}
return null;
return null;
};
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).deletePlaylist({
body: null,
params: {
id: query.id,
},
});
const res = await ndApiClient(apiClientProps).deletePlaylist({
body: null,
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete playlist');
}
if (res.status !== 200) {
throw new Error('Failed to delete playlist');
}
return null;
return null;
};
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
...query._custom?.navidrome,
},
});
const res = await ndApiClient(apiClientProps).getPlaylistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist list');
}
if (res.status !== 200) {
throw new Error('Failed to get playlist list');
}
return {
items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return {
items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
},
});
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist detail');
}
if (res.status !== 200) {
throw new Error('Failed to get playlist detail');
}
return ndNormalize.playlist(res.body.data, apiClientProps.server);
return ndNormalize.playlist(res.body.data, apiClientProps.server);
};
const getPlaylistSongList = async (
args: PlaylistSongListArgs,
args: PlaylistSongListArgs,
): Promise<PlaylistSongListResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID,
_start: query.startIndex,
},
});
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
_sort: query.sortBy
? songListSortMap.navidrome[query.sortBy]
: ndType._enum.songList.ID,
_start: query.startIndex,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist song list');
}
if (res.status !== 200) {
throw new Error('Failed to get playlist song list');
}
return {
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return {
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
const { body, query, apiClientProps } = args;
const { body, query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).addToPlaylist({
body: {
ids: body.songId,
},
params: {
id: query.id,
},
});
const res = await ndApiClient(apiClientProps).addToPlaylist({
body: {
ids: body.songId,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to add to playlist');
}
if (res.status !== 200) {
throw new Error('Failed to add to playlist');
}
return null;
return null;
};
const removeFromPlaylist = async (
args: RemoveFromPlaylistArgs,
args: RemoveFromPlaylistArgs,
): Promise<RemoveFromPlaylistResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
query: {
id: query.songId,
},
});
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
query: {
id: query.songId,
},
});
if (res.status !== 200) {
throw new Error('Failed to remove from playlist');
}
if (res.status !== 200) {
throw new Error('Failed to remove from playlist');
}
return null;
return null;
};
export const ndController = {
addToPlaylist,
authenticate,
createPlaylist,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getGenreList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
addToPlaylist,
authenticate,
createPlaylist,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getGenreList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
};

View File

@ -6,226 +6,226 @@ import { ndType } from './navidrome-types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
coverArtId: string;
credential: string | undefined;
size: number;
baseUrl: string | undefined;
coverArtId: string;
credential: string | undefined;
size: number;
}) => {
const size = args.size ? args.size : 250;
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
};
const normalizeSong = (
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
server: ServerListItem | null,
deviceId: string,
imageSize?: number,
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
server: ServerListItem | null,
deviceId: string,
imageSize?: number,
): Song => {
let id;
let playlistItemId;
let id;
let playlistItemId;
// Dynamically determine the id field based on whether or not the item is a playlist song
if ('mediaFileId' in item) {
id = item.mediaFileId;
playlistItemId = item.id;
} else {
id = item.id;
}
// Dynamically determine the id field based on whether or not the item is a playlist song
if ('mediaFileId' in item) {
id = item.mediaFileId;
playlistItemId = item.id;
} else {
id = item.id;
}
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: id,
credential: server?.credential,
size: imageSize || 100,
});
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: id,
credential: server?.credential,
size: imageSize || 100,
});
const imagePlaceholderUrl = null;
const imagePlaceholderUrl = null;
return {
album: item.album,
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : null,
channels: item.channels ? item.channels : null,
comment: item.comment ? item.comment : null,
compilation: item.compilation,
container: item.suffix,
createdAt: item.createdAt.split('T')[0],
discNumber: item.discNumber,
duration: item.duration,
genres: item.genres,
id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
lyrics: item.lyrics ? item.lyrics : null,
name: item.title,
path: item.path,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`,
trackNumber: item.trackNumber,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred || false,
userRating: item.rating || null,
};
return {
album: item.album,
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : null,
channels: item.channels ? item.channels : null,
comment: item.comment ? item.comment : null,
compilation: item.compilation,
container: item.suffix,
createdAt: item.createdAt.split('T')[0],
discNumber: item.discNumber,
duration: item.duration,
genres: item.genres,
id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
lyrics: item.lyrics ? item.lyrics : null,
name: item.title,
path: item.path,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`,
trackNumber: item.trackNumber,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred || false,
userRating: item.rating || null,
};
};
const normalizeAlbum = (
item: z.infer<typeof ndType._response.album> & {
songs?: z.infer<typeof ndType._response.songList>;
},
server: ServerListItem | null,
imageSize?: number,
item: z.infer<typeof ndType._response.album> & {
songs?: z.infer<typeof ndType._response.songList>;
},
server: ServerListItem | null,
imageSize?: number,
): Album => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArtId || item.id,
credential: server?.credential,
size: imageSize || 300,
});
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArtId || item.id,
credential: server?.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
const imagePlaceholderUrl = null;
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
genres: item.genres,
id: item.id,
imagePlaceholderUrl,
imageUrl,
isCompilation: item.compilation,
itemType: LibraryItem.ALBUM,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
releaseYear: item.minYear,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred,
userRating: item.rating || null,
};
return {
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
genres: item.genres,
id: item.id,
imagePlaceholderUrl,
imageUrl,
isCompilation: item.compilation,
itemType: LibraryItem.ALBUM,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
releaseYear: item.minYear,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred,
userRating: item.rating || null,
};
};
const normalizeAlbumArtist = (
item: z.infer<typeof ndType._response.albumArtist> & {
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
},
server: ServerListItem | null,
item: z.infer<typeof ndType._response.albumArtist> & {
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
},
server: ServerListItem | null,
): AlbumArtist => {
const imageUrl =
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
const imageUrl =
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
return {
albumCount: item.albumCount,
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
genres: item.genres,
id: item.id,
imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.songCount,
userFavorite: item.starred,
userRating: item.rating,
};
return {
albumCount: item.albumCount,
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
genres: item.genres,
id: item.id,
imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.songCount,
userFavorite: item.starred,
userRating: item.rating,
};
};
const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>,
server: ServerListItem | null,
imageSize?: number,
item: z.infer<typeof ndType._response.playlist>,
server: ServerListItem | null,
imageSize?: number,
): Playlist => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.id,
credential: server?.credential,
size: imageSize || 300,
});
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.id,
credential: server?.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
const imagePlaceholderUrl = null;
return {
description: item.comment,
duration: item.duration * 1000,
genres: [],
id: item.id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
public: item.public,
rules: item?.rules || null,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
sync: item.sync,
};
return {
description: item.comment,
duration: item.duration * 1000,
genres: [],
id: item.id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
public: item.public,
rules: item?.rules || null,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
sync: item.sync,
};
};
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
return {
createdAt: item.createdAt,
email: item.email || null,
id: item.id,
isAdmin: item.isAdmin,
lastLoginAt: item.lastLoginAt,
name: item.userName,
updatedAt: item.updatedAt,
};
return {
createdAt: item.createdAt,
email: item.email || null,
id: item.id,
isAdmin: item.isAdmin,
lastLoginAt: item.lastLoginAt,
name: item.userName,
updatedAt: item.updatedAt,
};
};
export const ndNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong,
user: normalizeUser,
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong,
user: normalizeUser,
};

View File

@ -5,294 +5,294 @@ const sortOrderValues = ['ASC', 'DESC'] as const;
const error = z.string();
const paginationParameters = z.object({
_end: z.number().optional(),
_order: z.enum(sortOrderValues),
_start: z.number().optional(),
_end: z.number().optional(),
_order: z.enum(sortOrderValues),
_start: z.number().optional(),
});
const authenticate = z.object({
id: z.string(),
isAdmin: z.boolean(),
name: z.string(),
subsonicSalt: z.string(),
subsonicToken: z.string(),
token: z.string(),
username: z.string(),
id: z.string(),
isAdmin: z.boolean(),
name: z.string(),
subsonicSalt: z.string(),
subsonicToken: z.string(),
token: z.string(),
username: z.string(),
});
const authenticateParameters = z.object({
password: z.string(),
username: z.string(),
password: z.string(),
username: z.string(),
});
const user = z.object({
createdAt: z.string(),
email: z.string().optional(),
id: z.string(),
isAdmin: z.boolean(),
lastAccessAt: z.string(),
lastLoginAt: z.string(),
name: z.string(),
updatedAt: z.string(),
userName: z.string(),
createdAt: z.string(),
email: z.string().optional(),
id: z.string(),
isAdmin: z.boolean(),
lastAccessAt: z.string(),
lastLoginAt: z.string(),
name: z.string(),
updatedAt: z.string(),
userName: z.string(),
});
const userList = z.array(user);
const ndUserListSort = {
NAME: 'name',
NAME: 'name',
} as const;
const userListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndUserListSort).optional(),
_sort: z.nativeEnum(ndUserListSort).optional(),
});
const genre = z.object({
id: z.string(),
name: z.string(),
id: z.string(),
name: z.string(),
});
const genreList = z.array(genre);
const albumArtist = z.object({
albumCount: z.number(),
biography: z.string(),
externalInfoUpdatedAt: z.string(),
externalUrl: z.string(),
fullText: z.string(),
genres: z.array(genre),
id: z.string(),
largeImageUrl: z.string().optional(),
mbzArtistId: z.string().optional(),
mediumImageUrl: z.string().optional(),
name: z.string(),
orderArtistName: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number(),
size: z.number(),
smallImageUrl: z.string().optional(),
songCount: z.number(),
starred: z.boolean(),
starredAt: z.string(),
albumCount: z.number(),
biography: z.string(),
externalInfoUpdatedAt: z.string(),
externalUrl: z.string(),
fullText: z.string(),
genres: z.array(genre),
id: z.string(),
largeImageUrl: z.string().optional(),
mbzArtistId: z.string().optional(),
mediumImageUrl: z.string().optional(),
name: z.string(),
orderArtistName: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number(),
size: z.number(),
smallImageUrl: z.string().optional(),
songCount: z.number(),
starred: z.boolean(),
starredAt: z.string(),
});
const albumArtistList = z.array(albumArtist);
const ndAlbumArtistListSort = {
ALBUM_COUNT: 'albumCount',
FAVORITED: 'starred ASC, starredAt ASC',
NAME: 'name',
PLAY_COUNT: 'playCount',
RATING: 'rating',
SONG_COUNT: 'songCount',
ALBUM_COUNT: 'albumCount',
FAVORITED: 'starred ASC, starredAt ASC',
NAME: 'name',
PLAY_COUNT: 'playCount',
RATING: 'rating',
SONG_COUNT: 'songCount',
} as const;
const albumArtistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
genre_id: z.string().optional(),
name: z.string().optional(),
starred: z.boolean().optional(),
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
genre_id: z.string().optional(),
name: z.string().optional(),
starred: z.boolean().optional(),
});
const album = z.object({
albumArtist: z.string(),
albumArtistId: z.string(),
allArtistIds: z.string(),
artist: z.string(),
artistId: z.string(),
compilation: z.boolean(),
coverArtId: z.string().optional(), // Removed after v0.48.0
coverArtPath: z.string().optional(), // Removed after v0.48.0
createdAt: z.string(),
duration: z.number(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
id: z.string(),
maxYear: z.number(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
minYear: z.number(),
name: z.string(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number().optional(),
size: z.number(),
songCount: z.number(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
starredAt: z.string().optional(),
updatedAt: z.string(),
albumArtist: z.string(),
albumArtistId: z.string(),
allArtistIds: z.string(),
artist: z.string(),
artistId: z.string(),
compilation: z.boolean(),
coverArtId: z.string().optional(), // Removed after v0.48.0
coverArtPath: z.string().optional(), // Removed after v0.48.0
createdAt: z.string(),
duration: z.number(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
id: z.string(),
maxYear: z.number(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
minYear: z.number(),
name: z.string(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number().optional(),
size: z.number(),
songCount: z.number(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
starredAt: z.string().optional(),
updatedAt: z.string(),
});
const albumList = z.array(album);
const ndAlbumListSort = {
ALBUM_ARTIST: 'albumArtist',
ARTIST: 'artist',
DURATION: 'duration',
NAME: 'name',
PLAY_COUNT: 'playCount',
PLAY_DATE: 'play_date',
RANDOM: 'random',
RATING: 'rating',
RECENTLY_ADDED: 'recently_added',
SONG_COUNT: 'songCount',
STARRED: 'starred',
YEAR: 'max_year',
ALBUM_ARTIST: 'albumArtist',
ARTIST: 'artist',
DURATION: 'duration',
NAME: 'name',
PLAY_COUNT: 'playCount',
PLAY_DATE: 'play_date',
RANDOM: 'random',
RATING: 'rating',
RECENTLY_ADDED: 'recently_added',
SONG_COUNT: 'songCount',
STARRED: 'starred',
YEAR: 'max_year',
} as const;
const albumListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndAlbumListSort).optional(),
album_id: z.string().optional(),
artist_id: z.string().optional(),
compilation: z.boolean().optional(),
genre_id: z.string().optional(),
has_rating: z.boolean().optional(),
id: z.string().optional(),
name: z.string().optional(),
recently_added: z.boolean().optional(),
recently_played: z.boolean().optional(),
starred: z.boolean().optional(),
year: z.number().optional(),
_sort: z.nativeEnum(ndAlbumListSort).optional(),
album_id: z.string().optional(),
artist_id: z.string().optional(),
compilation: z.boolean().optional(),
genre_id: z.string().optional(),
has_rating: z.boolean().optional(),
id: z.string().optional(),
name: z.string().optional(),
recently_added: z.boolean().optional(),
recently_played: z.boolean().optional(),
starred: z.boolean().optional(),
year: z.number().optional(),
});
const song = z.object({
album: z.string(),
albumArtist: z.string(),
albumArtistId: z.string(),
albumId: z.string(),
artist: z.string(),
artistId: z.string(),
bitRate: z.number(),
bookmarkPosition: z.number(),
bpm: z.number().optional(),
channels: z.number().optional(),
comment: z.string().optional(),
compilation: z.boolean(),
createdAt: z.string(),
discNumber: z.number(),
duration: z.number(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
hasCoverArt: z.boolean(),
id: z.string(),
lyrics: z.string().optional(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
mbzArtistId: z.string().optional(),
mbzTrackId: z.string().optional(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
orderArtistName: z.string(),
orderTitle: z.string(),
path: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number().optional(),
size: z.number(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
starredAt: z.string().optional(),
suffix: z.string(),
title: z.string(),
trackNumber: z.number(),
updatedAt: z.string(),
year: z.number(),
album: z.string(),
albumArtist: z.string(),
albumArtistId: z.string(),
albumId: z.string(),
artist: z.string(),
artistId: z.string(),
bitRate: z.number(),
bookmarkPosition: z.number(),
bpm: z.number().optional(),
channels: z.number().optional(),
comment: z.string().optional(),
compilation: z.boolean(),
createdAt: z.string(),
discNumber: z.number(),
duration: z.number(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
hasCoverArt: z.boolean(),
id: z.string(),
lyrics: z.string().optional(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
mbzArtistId: z.string().optional(),
mbzTrackId: z.string().optional(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
orderArtistName: z.string(),
orderTitle: z.string(),
path: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number().optional(),
size: z.number(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
starredAt: z.string().optional(),
suffix: z.string(),
title: z.string(),
trackNumber: z.number(),
updatedAt: z.string(),
year: z.number(),
});
const songList = z.array(song);
const ndSongListSort = {
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS: 'album, discNumber, trackNumber',
ARTIST: 'artist',
BPM: 'bpm',
CHANNELS: 'channels',
COMMENT: 'comment',
DURATION: 'duration',
FAVORITED: 'starred ASC, starredAt ASC',
GENRE: 'genre',
ID: 'id',
PLAY_COUNT: 'playCount',
PLAY_DATE: 'playDate',
RATING: 'rating',
RECENTLY_ADDED: 'createdAt',
TITLE: 'title',
TRACK: 'track',
YEAR: 'year, album, discNumber, trackNumber',
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS: 'album, discNumber, trackNumber',
ARTIST: 'artist',
BPM: 'bpm',
CHANNELS: 'channels',
COMMENT: 'comment',
DURATION: 'duration',
FAVORITED: 'starred ASC, starredAt ASC',
GENRE: 'genre',
ID: 'id',
PLAY_COUNT: 'playCount',
PLAY_DATE: 'playDate',
RATING: 'rating',
RECENTLY_ADDED: 'createdAt',
TITLE: 'title',
TRACK: 'track',
YEAR: 'year, album, discNumber, trackNumber',
};
const songListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndSongListSort).optional(),
album_artist_id: z.array(z.string()).optional(),
album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(),
genre_id: z.string().optional(),
starred: z.boolean().optional(),
title: z.string().optional(),
year: z.number().optional(),
_sort: z.nativeEnum(ndSongListSort).optional(),
album_artist_id: z.array(z.string()).optional(),
album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(),
genre_id: z.string().optional(),
starred: z.boolean().optional(),
title: z.string().optional(),
year: z.number().optional(),
});
const playlist = z.object({
comment: z.string(),
createdAt: z.string(),
duration: z.number(),
evaluatedAt: z.string(),
id: z.string(),
name: z.string(),
ownerId: z.string(),
ownerName: z.string(),
path: z.string(),
public: z.boolean(),
rules: z.record(z.string(), z.any()),
size: z.number(),
songCount: z.number(),
sync: z.boolean(),
updatedAt: z.string(),
comment: z.string(),
createdAt: z.string(),
duration: z.number(),
evaluatedAt: z.string(),
id: z.string(),
name: z.string(),
ownerId: z.string(),
ownerName: z.string(),
path: z.string(),
public: z.boolean(),
rules: z.record(z.string(), z.any()),
size: z.number(),
songCount: z.number(),
sync: z.boolean(),
updatedAt: z.string(),
});
const playlistList = z.array(playlist);
const ndPlaylistListSort = {
DURATION: 'duration',
NAME: 'name',
OWNER: 'ownerName',
PUBLIC: 'public',
SONG_COUNT: 'songCount',
UPDATED_AT: 'updatedAt',
DURATION: 'duration',
NAME: 'name',
OWNER: 'ownerName',
PUBLIC: 'public',
SONG_COUNT: 'songCount',
UPDATED_AT: 'updatedAt',
} as const;
const playlistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
owner_id: z.string().optional(),
smart: z.boolean().optional(),
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
owner_id: z.string().optional(),
smart: z.boolean().optional(),
});
const playlistSong = song.extend({
mediaFileId: z.string(),
playlistId: z.string(),
mediaFileId: z.string(),
playlistId: z.string(),
});
const playlistSongList = z.array(playlistSong);
const createPlaylist = playlist.pick({
id: true,
id: true,
});
const createPlaylistParameters = z.object({
comment: z.string().optional(),
name: z.string(),
public: z.boolean().optional(),
rules: z.record(z.any()).optional(),
sync: z.boolean().optional(),
comment: z.string().optional(),
name: z.string(),
public: z.boolean().optional(),
rules: z.record(z.any()).optional(),
sync: z.boolean().optional(),
});
const updatePlaylist = playlist;
@ -302,62 +302,62 @@ const updatePlaylistParameters = createPlaylistParameters.partial();
const deletePlaylist = z.null();
const addToPlaylist = z.object({
added: z.number(),
added: z.number(),
});
const addToPlaylistParameters = z.object({
ids: z.array(z.string()),
ids: z.array(z.string()),
});
const removeFromPlaylist = z.object({
ids: z.array(z.string()),
ids: z.array(z.string()),
});
const removeFromPlaylistParameters = z.object({
id: z.array(z.string()),
id: z.array(z.string()),
});
export const ndType = {
_enum: {
albumArtistList: ndAlbumArtistListSort,
albumList: ndAlbumListSort,
playlistList: ndPlaylistListSort,
songList: ndSongListSort,
userList: ndUserListSort,
},
_parameters: {
addToPlaylist: addToPlaylistParameters,
albumArtistList: albumArtistListParameters,
albumList: albumListParameters,
authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
userList: userListParameters,
},
_response: {
addToPlaylist,
album,
albumArtist,
albumArtistList,
albumList,
authenticate,
createPlaylist,
deletePlaylist,
error,
genre,
genreList,
playlist,
playlistList,
playlistSong,
playlistSongList,
removeFromPlaylist,
song,
songList,
updatePlaylist,
user,
userList,
},
_enum: {
albumArtistList: ndAlbumArtistListSort,
albumList: ndAlbumListSort,
playlistList: ndPlaylistListSort,
songList: ndSongListSort,
userList: ndUserListSort,
},
_parameters: {
addToPlaylist: addToPlaylistParameters,
albumArtistList: albumArtistListParameters,
albumList: albumListParameters,
authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
userList: userListParameters,
},
_response: {
addToPlaylist,
album,
albumArtist,
albumArtistList,
albumList,
authenticate,
createPlaylist,
deletePlaylist,
error,
genre,
genreList,
playlist,
playlistList,
playlistSong,
playlistSongList,
removeFromPlaylist,
song,
songList,
updatePlaylist,
user,
userList,
},
};

View File

@ -1,132 +1,132 @@
import { QueryFunctionContext } from '@tanstack/react-query';
import { LyricSource } from './types';
import type {
AlbumListQuery,
SongListQuery,
AlbumDetailQuery,
AlbumArtistListQuery,
ArtistListQuery,
PlaylistListQuery,
PlaylistDetailQuery,
PlaylistSongListQuery,
UserListQuery,
AlbumArtistDetailQuery,
TopSongListQuery,
SearchQuery,
SongDetailQuery,
RandomSongListQuery,
LyricsQuery,
LyricSearchQuery,
AlbumListQuery,
SongListQuery,
AlbumDetailQuery,
AlbumArtistListQuery,
ArtistListQuery,
PlaylistListQuery,
PlaylistDetailQuery,
PlaylistSongListQuery,
UserListQuery,
AlbumArtistDetailQuery,
TopSongListQuery,
SearchQuery,
SongDetailQuery,
RandomSongListQuery,
LyricsQuery,
LyricSearchQuery,
} from './types';
export const queryKeys: Record<
string,
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
string,
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
> = {
albumArtists: {
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] as const;
albumArtists: {
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] as const;
},
list: (serverId: string, query?: AlbumArtistListQuery) => {
if (query) return [serverId, 'albumArtists', 'list', query] as const;
return [serverId, 'albumArtists', 'list'] as const;
},
root: (serverId: string) => [serverId, 'albumArtists'] as const,
topSongs: (serverId: string, query?: TopSongListQuery) => {
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
return [serverId, 'albumArtists', 'topSongs'] as const;
},
},
list: (serverId: string, query?: AlbumArtistListQuery) => {
if (query) return [serverId, 'albumArtists', 'list', query] as const;
return [serverId, 'albumArtists', 'list'] as const;
albums: {
detail: (serverId: string, query?: AlbumDetailQuery) =>
[serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query?: AlbumListQuery) => {
if (query) return [serverId, 'albums', 'list', query] as const;
return [serverId, 'albums', 'list'] as const;
},
root: (serverId: string) => [serverId, 'albums'],
serverRoot: (serverId: string) => [serverId, 'albums'],
songs: (serverId: string, query: SongListQuery) =>
[serverId, 'albums', 'songs', query] as const,
},
root: (serverId: string) => [serverId, 'albumArtists'] as const,
topSongs: (serverId: string, query?: TopSongListQuery) => {
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
return [serverId, 'albumArtists', 'topSongs'] as const;
artists: {
list: (serverId: string, query?: ArtistListQuery) => {
if (query) return [serverId, 'artists', 'list', query] as const;
return [serverId, 'artists', 'list'] as const;
},
root: (serverId: string) => [serverId, 'artists'] as const,
},
},
albums: {
detail: (serverId: string, query?: AlbumDetailQuery) =>
[serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query?: AlbumListQuery) => {
if (query) return [serverId, 'albums', 'list', query] as const;
return [serverId, 'albums', 'list'] as const;
genres: {
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
root: (serverId: string) => [serverId, 'genres'] as const,
},
root: (serverId: string) => [serverId, 'albums'],
serverRoot: (serverId: string) => [serverId, 'albums'],
songs: (serverId: string, query: SongListQuery) =>
[serverId, 'albums', 'songs', query] as const,
},
artists: {
list: (serverId: string, query?: ArtistListQuery) => {
if (query) return [serverId, 'artists', 'list', query] as const;
return [serverId, 'artists', 'list'] as const;
musicFolders: {
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
},
root: (serverId: string) => [serverId, 'artists'] as const,
},
genres: {
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
root: (serverId: string) => [serverId, 'genres'] as const,
},
musicFolders: {
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
},
playlists: {
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
if (query) return [serverId, 'playlists', id, 'detail', query] as const;
if (id) return [serverId, 'playlists', id, 'detail'] as const;
return [serverId, 'playlists', 'detail'] as const;
playlists: {
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
if (query) return [serverId, 'playlists', id, 'detail', query] as const;
if (id) return [serverId, 'playlists', id, 'detail'] as const;
return [serverId, 'playlists', 'detail'] as const;
},
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const;
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
return [serverId, 'playlists', 'detailSongList'] as const;
},
list: (serverId: string, query?: PlaylistListQuery) => {
if (query) return [serverId, 'playlists', 'list', query] as const;
return [serverId, 'playlists', 'list'] as const;
},
root: (serverId: string) => [serverId, 'playlists'] as const,
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
if (query && id) return [serverId, 'playlists', id, 'songList', query] as const;
if (id) return [serverId, 'playlists', id, 'songList'] as const;
return [serverId, 'playlists', 'songList'] as const;
},
},
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const;
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
return [serverId, 'playlists', 'detailSongList'] as const;
search: {
list: (serverId: string, query?: SearchQuery) => {
if (query) return [serverId, 'search', 'list', query] as const;
return [serverId, 'search', 'list'] as const;
},
root: (serverId: string) => [serverId, 'search'] as const,
},
list: (serverId: string, query?: PlaylistListQuery) => {
if (query) return [serverId, 'playlists', 'list', query] as const;
return [serverId, 'playlists', 'list'] as const;
server: {
root: (serverId: string) => [serverId] as const,
},
root: (serverId: string) => [serverId, 'playlists'] as const,
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
if (query && id) return [serverId, 'playlists', id, 'songList', query] as const;
if (id) return [serverId, 'playlists', id, 'songList'] as const;
return [serverId, 'playlists', 'songList'] as const;
songs: {
detail: (serverId: string, query?: SongDetailQuery) => {
if (query) return [serverId, 'songs', 'detail', query] as const;
return [serverId, 'songs', 'detail'] as const;
},
list: (serverId: string, query?: SongListQuery) => {
if (query) return [serverId, 'songs', 'list', query] as const;
return [serverId, 'songs', 'list'] as const;
},
lyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', query] as const;
return [serverId, 'song', 'lyrics'] as const;
},
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
return ['song', 'lyrics', 'remote', searchQuery] as const;
},
lyricsSearch: (query?: LyricSearchQuery) => {
if (query) return ['lyrics', 'search', query] as const;
return ['lyrics', 'search'] as const;
},
randomSongList: (serverId: string, query?: RandomSongListQuery) => {
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
return [serverId, 'songs', 'randomSongList'] as const;
},
root: (serverId: string) => [serverId, 'songs'] as const,
},
},
search: {
list: (serverId: string, query?: SearchQuery) => {
if (query) return [serverId, 'search', 'list', query] as const;
return [serverId, 'search', 'list'] as const;
users: {
list: (serverId: string, query?: UserListQuery) => {
if (query) return [serverId, 'users', 'list', query] as const;
return [serverId, 'users', 'list'] as const;
},
root: (serverId: string) => [serverId, 'users'] as const,
},
root: (serverId: string) => [serverId, 'search'] as const,
},
server: {
root: (serverId: string) => [serverId] as const,
},
songs: {
detail: (serverId: string, query?: SongDetailQuery) => {
if (query) return [serverId, 'songs', 'detail', query] as const;
return [serverId, 'songs', 'detail'] as const;
},
list: (serverId: string, query?: SongListQuery) => {
if (query) return [serverId, 'songs', 'list', query] as const;
return [serverId, 'songs', 'list'] as const;
},
lyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', query] as const;
return [serverId, 'song', 'lyrics'] as const;
},
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
return ['song', 'lyrics', 'remote', searchQuery] as const;
},
lyricsSearch: (query?: LyricSearchQuery) => {
if (query) return ['lyrics', 'search', query] as const;
return ['lyrics', 'search'] as const;
},
randomSongList: (serverId: string, query?: RandomSongListQuery) => {
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
return [serverId, 'songs', 'randomSongList'] as const;
},
root: (serverId: string) => [serverId, 'songs'] as const,
},
users: {
list: (serverId: string, query?: UserListQuery) => {
if (query) return [serverId, 'users', 'list', query] as const;
return [serverId, 'users', 'list'] as const;
},
root: (serverId: string) => [serverId, 'users'] as const,
},
};

View File

@ -1,190 +1,190 @@
export type SSBaseResponse = {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
};
export type SSMusicFolderList = SSMusicFolder[];
export type SSMusicFolderListResponse = {
musicFolders: {
musicFolder: SSMusicFolder[];
};
musicFolders: {
musicFolder: SSMusicFolder[];
};
};
export type SSGenreList = SSGenre[];
export type SSGenreListResponse = {
genres: {
genre: SSGenre[];
};
genres: {
genre: SSGenre[];
};
};
export type SSAlbumArtistDetailParams = {
id: string;
id: string;
};
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
export type SSAlbumArtistDetailResponse = {
artist: SSAlbumArtistListEntry & {
album: SSAlbumListEntry[];
};
artist: SSAlbumArtistListEntry & {
album: SSAlbumListEntry[];
};
};
export type SSAlbumArtistList = {
items: SSAlbumArtistListEntry[];
startIndex: number;
totalRecordCount: number | null;
items: SSAlbumArtistListEntry[];
startIndex: number;
totalRecordCount: number | null;
};
export type SSAlbumArtistListResponse = {
artists: {
ignoredArticles: string;
index: SSArtistIndex[];
lastModified: number;
};
artists: {
ignoredArticles: string;
index: SSArtistIndex[];
lastModified: number;
};
};
export type SSAlbumList = {
items: SSAlbumListEntry[];
startIndex: number;
totalRecordCount: number | null;
items: SSAlbumListEntry[];
startIndex: number;
totalRecordCount: number | null;
};
export type SSAlbumListResponse = {
albumList2: {
album: SSAlbumListEntry[];
};
albumList2: {
album: SSAlbumListEntry[];
};
};
export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] };
export type SSAlbumDetailResponse = {
album: SSAlbum;
album: SSAlbum;
};
export type SSArtistInfoParams = {
count?: number;
id: string;
includeNotPresent?: boolean;
count?: number;
id: string;
includeNotPresent?: boolean;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
artistInfo2: SSArtistInfo;
};
export type SSArtistInfo = {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
similarArtist?: {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
similarArtist?: {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string;
};
export type SSMusicFolder = {
id: number;
name: string;
};
export type SSGenre = {
albumCount?: number;
songCount?: number;
value: string;
};
export type SSArtistIndex = {
artist: SSAlbumArtistListEntry[];
name: string;
};
export type SSAlbumArtistListEntry = {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string;
};
export type SSMusicFolder = {
id: number;
name: string;
};
export type SSGenre = {
albumCount?: number;
songCount?: number;
value: string;
};
export type SSArtistIndex = {
artist: SSAlbumArtistListEntry[];
name: string;
};
export type SSAlbumArtistListEntry = {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
};
export type SSAlbumListEntry = {
album: string;
artist: string;
artistId: string;
coverArt: string;
created: string;
duration: number;
genre?: string;
id: string;
isDir: boolean;
isVideo: boolean;
name: string;
parent: string;
songCount: number;
starred?: boolean;
title: string;
userRating?: number;
year: number;
album: string;
artist: string;
artistId: string;
coverArt: string;
created: string;
duration: number;
genre?: string;
id: string;
isDir: boolean;
isVideo: boolean;
name: string;
parent: string;
songCount: number;
starred?: boolean;
title: string;
userRating?: number;
year: number;
};
export type SSAlbum = {
song: SSSong[];
song: SSSong[];
} & SSAlbumListEntry;
export type SSSong = {
album: string;
albumId: string;
artist: string;
artistId?: string;
bitRate: number;
contentType: string;
coverArt: string;
created: string;
discNumber?: number;
duration: number;
genre: string;
id: string;
isDir: boolean;
isVideo: boolean;
parent: string;
path: string;
playCount: number;
size: number;
starred?: boolean;
suffix: string;
title: string;
track: number;
type: string;
userRating?: number;
year: number;
album: string;
albumId: string;
artist: string;
artistId?: string;
bitRate: number;
contentType: string;
coverArt: string;
created: string;
discNumber?: number;
duration: number;
genre: string;
id: string;
isDir: boolean;
isVideo: boolean;
parent: string;
path: string;
playCount: number;
size: number;
starred?: boolean;
suffix: string;
title: string;
track: number;
type: string;
userRating?: number;
year: number;
};
export type SSAlbumListParams = {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
};
export type SSAlbumArtistListParams = {
musicFolderId?: string;
musicFolderId?: string;
};
export type SSFavoriteParams = {
albumId?: string;
artistId?: string;
id?: string;
albumId?: string;
artistId?: string;
id?: string;
};
export type SSFavorite = null;
@ -192,8 +192,8 @@ export type SSFavorite = null;
export type SSFavoriteResponse = null;
export type SSRatingParams = {
id: string;
rating: number;
id: string;
rating: number;
};
export type SSRating = null;
@ -201,24 +201,24 @@ export type SSRating = null;
export type SSRatingResponse = null;
export type SSTopSongListParams = {
artist: string;
count?: number;
artist: string;
count?: number;
};
export type SSTopSongListResponse = {
topSongs: {
song: SSSong[];
};
topSongs: {
song: SSSong[];
};
};
export type SSTopSongList = {
items: SSSong[];
startIndex: number;
totalRecordCount: number | null;
items: SSSong[];
startIndex: number;
totalRecordCount: number | null;
};
export type SSScrobbleParams = {
id: string;
submission?: boolean;
time?: number;
id: string;
submission?: boolean;
time?: number;
};

View File

@ -10,196 +10,198 @@ import { toast } from '/@/renderer/components/toast/index';
const c = initContract();
export const contract = c.router({
authenticate: {
method: 'GET',
path: 'ping.view',
query: ssType._parameters.authenticate,
responses: {
200: ssType._response.authenticate,
authenticate: {
method: 'GET',
path: 'ping.view',
query: ssType._parameters.authenticate,
responses: {
200: ssType._response.authenticate,
},
},
},
createFavorite: {
method: 'GET',
path: 'star.view',
query: ssType._parameters.createFavorite,
responses: {
200: ssType._response.createFavorite,
createFavorite: {
method: 'GET',
path: 'star.view',
query: ssType._parameters.createFavorite,
responses: {
200: ssType._response.createFavorite,
},
},
},
getArtistInfo: {
method: 'GET',
path: 'getArtistInfo.view',
query: ssType._parameters.artistInfo,
responses: {
200: ssType._response.artistInfo,
getArtistInfo: {
method: 'GET',
path: 'getArtistInfo.view',
query: ssType._parameters.artistInfo,
responses: {
200: ssType._response.artistInfo,
},
},
},
getMusicFolderList: {
method: 'GET',
path: 'getMusicFolders.view',
responses: {
200: ssType._response.musicFolderList,
getMusicFolderList: {
method: 'GET',
path: 'getMusicFolders.view',
responses: {
200: ssType._response.musicFolderList,
},
},
},
getRandomSongList: {
method: 'GET',
path: 'getRandomSongs.view',
query: ssType._parameters.randomSongList,
responses: {
200: ssType._response.randomSongList,
getRandomSongList: {
method: 'GET',
path: 'getRandomSongs.view',
query: ssType._parameters.randomSongList,
responses: {
200: ssType._response.randomSongList,
},
},
},
getTopSongsList: {
method: 'GET',
path: 'getTopSongs.view',
query: ssType._parameters.topSongsList,
responses: {
200: ssType._response.topSongsList,
getTopSongsList: {
method: 'GET',
path: 'getTopSongs.view',
query: ssType._parameters.topSongsList,
responses: {
200: ssType._response.topSongsList,
},
},
},
removeFavorite: {
method: 'GET',
path: 'unstar.view',
query: ssType._parameters.removeFavorite,
responses: {
200: ssType._response.removeFavorite,
removeFavorite: {
method: 'GET',
path: 'unstar.view',
query: ssType._parameters.removeFavorite,
responses: {
200: ssType._response.removeFavorite,
},
},
},
scrobble: {
method: 'GET',
path: 'scrobble.view',
query: ssType._parameters.scrobble,
responses: {
200: ssType._response.scrobble,
scrobble: {
method: 'GET',
path: 'scrobble.view',
query: ssType._parameters.scrobble,
responses: {
200: ssType._response.scrobble,
},
},
},
search3: {
method: 'GET',
path: 'search3.view',
query: ssType._parameters.search3,
responses: {
200: ssType._response.search3,
search3: {
method: 'GET',
path: 'search3.view',
query: ssType._parameters.search3,
responses: {
200: ssType._response.search3,
},
},
},
setRating: {
method: 'GET',
path: 'setRating.view',
query: ssType._parameters.setRating,
responses: {
200: ssType._response.setRating,
setRating: {
method: 'GET',
path: 'setRating.view',
query: ssType._parameters.setRating,
responses: {
200: ssType._response.setRating,
},
},
},
});
const axiosClient = axios.create({});
axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' });
return qs.stringify(params, { arrayFormat: 'repeat' });
};
axiosClient.interceptors.response.use(
(response) => {
const data = response.data;
(response) => {
const data = response.data;
if (data['subsonic-response'].status !== 'ok') {
// Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
});
}
}
if (data['subsonic-response'].status !== 'ok') {
// Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
});
}
}
return response;
},
(error) => {
return Promise.reject(error);
},
return response;
},
(error) => {
return Promise.reject(error);
},
);
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
return {
params: notNilParams,
path,
};
return {
params: notNilParams,
path,
};
};
export const ssApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
}) => {
const { server, url, signal } = args;
const { server, url, signal } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
const authParams: Record<string, any> = {};
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
const authParams: Record<string, any> = {};
const { params, path: api } = parsePath(path);
const { params, path: api } = parsePath(path);
if (server) {
baseUrl = `${server.url}/rest`;
const token = server.credential;
const params = token.split(/&?\w=/gm);
if (server) {
baseUrl = `${server.url}/rest`;
const token = server.credential;
const params = token.split(/&?\w=/gm);
authParams.u = server.username;
if (params?.length === 4) {
authParams.s = params[2];
authParams.t = params[3];
} else if (params?.length === 3) {
authParams.p = params[2];
}
} else {
baseUrl = url;
}
authParams.u = server.username;
if (params?.length === 4) {
authParams.s = params[2];
authParams.t = params[3];
} else if (params?.length === 3) {
authParams.p = params[2];
}
} else {
baseUrl = url;
}
try {
const result = await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>({
data: body,
headers,
method: method as Method,
params: {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
},
signal,
url: `${baseUrl}/${api}`,
});
try {
const result = await axiosClient.request<
z.infer<typeof ssType._response.baseResponse>
>({
data: body,
headers,
method: method as Method,
params: {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
},
signal,
url: `${baseUrl}/${api}`,
});
return {
body: result.data['subsonic-response'],
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
console.log('CATCH ERR');
return {
body: result.data['subsonic-response'],
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
console.log('CATCH ERR');
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
return {
body: response?.data,
headers: response.headers as any,
status: response?.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
});
return {
body: response?.data,
headers: response.headers as any,
status: response?.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
});
};

View File

@ -4,91 +4,91 @@ import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import {
ArtistInfoArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
LibraryItem,
MusicFolderListArgs,
MusicFolderListResponse,
SetRatingArgs,
RatingResponse,
ScrobbleArgs,
ScrobbleResponse,
SongListResponse,
TopSongListArgs,
SearchArgs,
SearchResponse,
RandomSongListResponse,
RandomSongListArgs,
ArtistInfoArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
LibraryItem,
MusicFolderListArgs,
MusicFolderListResponse,
SetRatingArgs,
RatingResponse,
ScrobbleArgs,
ScrobbleResponse,
SongListResponse,
TopSongListArgs,
SearchArgs,
SearchResponse,
RandomSongListResponse,
RandomSongListArgs,
} from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils';
const authenticate = async (
url: string,
body: {
legacy?: boolean;
password: string;
username: string;
},
): Promise<AuthenticationResponse> => {
let credential: string;
let credentialParams: {
p?: string;
s?: string;
t?: string;
u: string;
};
const cleanServerUrl = url.replace(/\/$/, '');
if (body.legacy) {
credential = `u=${body.username}&p=${body.password}`;
credentialParams = {
p: body.password,
u: body.username,
};
} else {
const salt = randomString(12);
const hash = md5(body.password + salt);
credential = `u=${body.username}&s=${salt}&t=${hash}`;
credentialParams = {
s: salt,
t: hash,
u: body.username,
};
}
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
query: {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...credentialParams,
url: string,
body: {
legacy?: boolean;
password: string;
username: string;
},
});
): Promise<AuthenticationResponse> => {
let credential: string;
let credentialParams: {
p?: string;
s?: string;
t?: string;
u: string;
};
return {
credential,
userId: null,
username: body.username,
};
const cleanServerUrl = url.replace(/\/$/, '');
if (body.legacy) {
credential = `u=${body.username}&p=${body.password}`;
credentialParams = {
p: body.password,
u: body.username,
};
} else {
const salt = randomString(12);
const hash = md5(body.password + salt);
credential = `u=${body.username}&s=${salt}&t=${hash}`;
credentialParams = {
s: salt,
t: hash,
u: body.username,
};
}
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
query: {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...credentialParams,
},
});
return {
credential,
userId: null,
username: body.username,
};
};
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
const { apiClientProps } = args;
const { apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getMusicFolderList({});
const res = await ssApiClient(apiClientProps).getMusicFolderList({});
if (res.status !== 200) {
throw new Error('Failed to get music folder list');
}
if (res.status !== 200) {
throw new Error('Failed to get music folder list');
}
return {
items: res.body.musicFolders.musicFolder,
startIndex: 0,
totalRecordCount: res.body.musicFolders.musicFolder.length,
};
return {
items: res.body.musicFolders.musicFolder,
startIndex: 0,
totalRecordCount: res.body.musicFolders.musicFolder.length,
};
};
// export const getAlbumArtistDetail = async (
@ -198,184 +198,185 @@ const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolde
// };
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).createFavorite({
query: {
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
},
});
const res = await ssApiClient(apiClientProps).createFavorite({
query: {
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to create favorite');
}
if (res.status !== 200) {
throw new Error('Failed to create favorite');
}
return null;
return null;
};
const removeFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).removeFavorite({
query: {
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
},
});
const res = await ssApiClient(apiClientProps).removeFavorite({
query: {
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete favorite');
}
if (res.status !== 200) {
throw new Error('Failed to delete favorite');
}
return null;
return null;
};
const setRating = async (args: SetRatingArgs): Promise<RatingResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const itemIds = query.item.map((item) => item.id);
const itemIds = query.item.map((item) => item.id);
for (const id of itemIds) {
await ssApiClient(apiClientProps).setRating({
query: {
id,
rating: query.rating,
},
});
}
for (const id of itemIds) {
await ssApiClient(apiClientProps).setRating({
query: {
id,
rating: query.rating,
},
});
}
return null;
return null;
};
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getTopSongsList({
query: {
artist: query.artist,
count: query.limit,
},
});
const res = await ssApiClient(apiClientProps).getTopSongsList({
query: {
artist: query.artist,
count: query.limit,
},
});
if (res.status !== 200) {
throw new Error('Failed to get top songs');
}
if (res.status !== 200) {
throw new Error('Failed to get top songs');
}
return {
items:
res.body.topSongs?.song?.map((song) => ssNormalize.song(song, apiClientProps.server, '')) ||
[],
startIndex: 0,
totalRecordCount: res.body.topSongs?.song?.length || 0,
};
return {
items:
res.body.topSongs?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
) || [],
startIndex: 0,
totalRecordCount: res.body.topSongs?.song?.length || 0,
};
};
const getArtistInfo = async (
args: ArtistInfoArgs,
args: ArtistInfoArgs,
): Promise<z.infer<typeof ssType._response.artistInfo>> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getArtistInfo({
query: {
count: query.limit,
id: query.artistId,
},
});
const res = await ssApiClient(apiClientProps).getArtistInfo({
query: {
count: query.limit,
id: query.artistId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist info');
}
if (res.status !== 200) {
throw new Error('Failed to get artist info');
}
return res.body;
return res.body;
};
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
submission: query.submission,
},
});
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
submission: query.submission,
},
});
if (res.status !== 200) {
throw new Error('Failed to scrobble');
}
if (res.status !== 200) {
throw new Error('Failed to scrobble');
}
return null;
return null;
};
const search3 = async (args: SearchArgs): Promise<SearchResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).search3({
query: {
albumCount: query.albumLimit,
albumOffset: query.albumStartIndex,
artistCount: query.albumArtistLimit,
artistOffset: query.albumArtistStartIndex,
query: query.query,
songCount: query.songLimit,
songOffset: query.songStartIndex,
},
});
const res = await ssApiClient(apiClientProps).search3({
query: {
albumCount: query.albumLimit,
albumOffset: query.albumStartIndex,
artistCount: query.albumArtistLimit,
artistOffset: query.albumArtistStartIndex,
query: query.query,
songCount: query.songLimit,
songOffset: query.songStartIndex,
},
});
if (res.status !== 200) {
throw new Error('Failed to search');
}
if (res.status !== 200) {
throw new Error('Failed to search');
}
return {
albumArtists: res.body.searchResult3?.artist?.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server),
),
albums: res.body.searchResult3?.album?.map((album) =>
ssNormalize.album(album, apiClientProps.server),
),
songs: res.body.searchResult3?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
),
};
return {
albumArtists: res.body.searchResult3?.artist?.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server),
),
albums: res.body.searchResult3?.album?.map((album) =>
ssNormalize.album(album, apiClientProps.server),
),
songs: res.body.searchResult3?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
),
};
};
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
const { query, apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getRandomSongList({
query: {
fromYear: query.minYear,
genre: query.genre,
musicFolderId: query.musicFolderId,
size: query.limit,
toYear: query.maxYear,
},
});
const res = await ssApiClient(apiClientProps).getRandomSongList({
query: {
fromYear: query.minYear,
genre: query.genre,
musicFolderId: query.musicFolderId,
size: query.limit,
toYear: query.maxYear,
},
});
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
return {
items: res.body.randomSongs?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
),
startIndex: 0,
totalRecordCount: res.body.randomSongs?.song?.length || 0,
};
return {
items: res.body.randomSongs?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
),
startIndex: 0,
totalRecordCount: res.body.randomSongs?.song?.length || 0,
};
};
export const ssController = {
authenticate,
createFavorite,
getArtistInfo,
getMusicFolderList,
getRandomSongList,
getTopSongList,
removeFavorite,
scrobble,
search3,
setRating,
authenticate,
createFavorite,
getArtistInfo,
getMusicFolderList,
getRandomSongList,
getTopSongList,
removeFavorite,
scrobble,
search3,
setRating,
};

View File

@ -5,176 +5,178 @@ import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/type
import { ServerListItem, ServerType } from '/@/renderer/types';
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
coverArtId?: string;
credential: string | undefined;
size: number;
baseUrl: string | undefined;
coverArtId?: string;
credential: string | undefined;
size: number;
}) => {
const size = args.size ? args.size : 250;
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
};
const normalizeSong = (
item: z.infer<typeof ssType._response.song>,
server: ServerListItem | null,
deviceId: string,
item: z.infer<typeof ssType._response.song>,
server: ServerListItem | null,
deviceId: string,
): QueueSong => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
}) || null;
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
}) || null;
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
return {
album: item.album || '',
albumArtists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist || '',
},
],
albumId: item.albumId || '',
artistName: item.artist || '',
artists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist || '',
},
],
bitRate: item.bitRate || 0,
bpm: null,
channels: null,
comment: null,
compilation: null,
container: item.contentType,
createdAt: item.created,
discNumber: item.discNumber || 1,
duration: item.duration || 0,
genres: item.genre
? [
{
id: item.genre,
name: item.genre,
},
]
: [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: null,
lyrics: null,
name: item.title,
path: item.path,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: item.size,
streamUrl,
trackNumber: item.track || 1,
uniqueId: nanoid(),
updatedAt: '',
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
return {
album: item.album || '',
albumArtists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist || '',
},
],
albumId: item.albumId || '',
artistName: item.artist || '',
artists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist || '',
},
],
bitRate: item.bitRate || 0,
bpm: null,
channels: null,
comment: null,
compilation: null,
container: item.contentType,
createdAt: item.created,
discNumber: item.discNumber || 1,
duration: item.duration || 0,
genres: item.genre
? [
{
id: item.genre,
name: item.genre,
},
]
: [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: null,
lyrics: null,
name: item.title,
path: item.path,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: item.size,
streamUrl,
trackNumber: item.track || 1,
uniqueId: nanoid(),
updatedAt: '',
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
const normalizeAlbumArtist = (
item: z.infer<typeof ssType._response.albumArtist>,
server: ServerListItem | null,
item: z.infer<typeof ssType._response.albumArtist>,
server: ServerListItem | null,
): AlbumArtist => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
}) || null;
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
}) || null;
return {
albumCount: item.albumCount ? Number(item.albumCount) : 0,
backgroundImageUrl: null,
biography: null,
duration: null,
genres: [],
id: item.id,
imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
name: item.name,
playCount: null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
similarArtists: [],
songCount: null,
userFavorite: false,
userRating: null,
};
return {
albumCount: item.albumCount ? Number(item.albumCount) : 0,
backgroundImageUrl: null,
biography: null,
duration: null,
genres: [],
id: item.id,
imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
name: item.name,
playCount: null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
similarArtists: [],
songCount: null,
userFavorite: false,
userRating: null,
};
};
const normalizeAlbum = (
item: z.infer<typeof ssType._response.album>,
server: ServerListItem | null,
item: z.infer<typeof ssType._response.album>,
server: ServerListItem | null,
): Album => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 300,
}) || null;
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 300,
}) || null;
return {
albumArtists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
backdropImageUrl: null,
createdAt: item.created,
duration: item.duration,
genres: item.genre ? [{ id: item.genre, name: item.genre }] : [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
name: item.name,
playCount: null,
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
releaseYear: item.year ? Number(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
songs: [],
uniqueId: nanoid(),
updatedAt: item.created,
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
return {
albumArtists: item.artistId
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
: [],
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
backdropImageUrl: null,
createdAt: item.created,
duration: item.duration,
genres: item.genre ? [{ id: item.genre, name: item.genre }] : [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
name: item.name,
playCount: null,
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
releaseYear: item.year ? Number(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
songs: [],
uniqueId: nanoid(),
updatedAt: item.created,
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
song: normalizeSong,
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
song: normalizeSong,
};

View File

@ -1,240 +1,240 @@
import { z } from 'zod';
const baseResponse = z.object({
'subsonic-response': z.object({
status: z.string(),
version: z.string(),
}),
'subsonic-response': z.object({
status: z.string(),
version: z.string(),
}),
});
const authenticate = z.null();
const authenticateParameters = z.object({
c: z.string(),
f: z.string(),
p: z.string().optional(),
s: z.string().optional(),
t: z.string().optional(),
u: z.string(),
v: z.string(),
c: z.string(),
f: z.string(),
p: z.string().optional(),
s: z.string().optional(),
t: z.string().optional(),
u: z.string(),
v: z.string(),
});
const createFavoriteParameters = z.object({
albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(),
albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(),
});
const createFavorite = z.null();
const removeFavoriteParameters = z.object({
albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(),
albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(),
});
const removeFavorite = z.null();
const setRatingParameters = z.object({
id: z.string(),
rating: z.number(),
id: z.string(),
rating: z.number(),
});
const setRating = z.null();
const musicFolder = z.object({
id: z.string(),
name: z.string(),
id: z.string(),
name: z.string(),
});
const musicFolderList = z.object({
musicFolders: z.object({
musicFolder: z.array(musicFolder),
}),
musicFolders: z.object({
musicFolder: z.array(musicFolder),
}),
});
const song = z.object({
album: z.string().optional(),
albumId: z.string().optional(),
artist: z.string().optional(),
artistId: z.string().optional(),
averageRating: z.number().optional(),
bitRate: z.number().optional(),
contentType: z.string(),
coverArt: z.string().optional(),
created: z.string(),
discNumber: z.number(),
duration: z.number().optional(),
genre: z.string().optional(),
id: z.string(),
isDir: z.boolean(),
isVideo: z.boolean(),
parent: z.string(),
path: z.string(),
playCount: z.number().optional(),
size: z.number(),
starred: z.boolean().optional(),
suffix: z.string(),
title: z.string(),
track: z.number().optional(),
type: z.string(),
userRating: z.number().optional(),
year: z.number().optional(),
album: z.string().optional(),
albumId: z.string().optional(),
artist: z.string().optional(),
artistId: z.string().optional(),
averageRating: z.number().optional(),
bitRate: z.number().optional(),
contentType: z.string(),
coverArt: z.string().optional(),
created: z.string(),
discNumber: z.number(),
duration: z.number().optional(),
genre: z.string().optional(),
id: z.string(),
isDir: z.boolean(),
isVideo: z.boolean(),
parent: z.string(),
path: z.string(),
playCount: z.number().optional(),
size: z.number(),
starred: z.boolean().optional(),
suffix: z.string(),
title: z.string(),
track: z.number().optional(),
type: z.string(),
userRating: z.number().optional(),
year: z.number().optional(),
});
const album = z.object({
album: z.string(),
artist: z.string(),
artistId: z.string(),
coverArt: z.string(),
created: z.string(),
duration: z.number(),
genre: z.string().optional(),
id: z.string(),
isDir: z.boolean(),
isVideo: z.boolean(),
name: z.string(),
parent: z.string(),
song: z.array(song),
songCount: z.number(),
starred: z.boolean().optional(),
title: z.string(),
userRating: z.number().optional(),
year: z.number().optional(),
album: z.string(),
artist: z.string(),
artistId: z.string(),
coverArt: z.string(),
created: z.string(),
duration: z.number(),
genre: z.string().optional(),
id: z.string(),
isDir: z.boolean(),
isVideo: z.boolean(),
name: z.string(),
parent: z.string(),
song: z.array(song),
songCount: z.number(),
starred: z.boolean().optional(),
title: z.string(),
userRating: z.number().optional(),
year: z.number().optional(),
});
const albumListParameters = z.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
type: z.string().optional(),
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
type: z.string().optional(),
});
const albumList = z.array(album.omit({ song: true }));
const albumArtist = z.object({
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id: z.string(),
name: z.string(),
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id: z.string(),
name: z.string(),
});
const albumArtistList = z.object({
artist: z.array(albumArtist),
name: z.string(),
artist: z.array(albumArtist),
name: z.string(),
});
const artistInfoParameters = z.object({
count: z.number().optional(),
id: z.string(),
includeNotPresent: z.boolean().optional(),
count: z.number().optional(),
id: z.string(),
includeNotPresent: z.boolean().optional(),
});
const artistInfo = z.object({
artistInfo: z.object({
biography: z.string().optional(),
largeImageUrl: z.string().optional(),
lastFmUrl: z.string().optional(),
mediumImageUrl: z.string().optional(),
musicBrainzId: z.string().optional(),
similarArtist: z.array(
z.object({
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id: z.string(),
name: z.string(),
}),
),
smallImageUrl: z.string().optional(),
}),
artistInfo: z.object({
biography: z.string().optional(),
largeImageUrl: z.string().optional(),
lastFmUrl: z.string().optional(),
mediumImageUrl: z.string().optional(),
musicBrainzId: z.string().optional(),
similarArtist: z.array(
z.object({
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id: z.string(),
name: z.string(),
}),
),
smallImageUrl: z.string().optional(),
}),
});
const topSongsListParameters = z.object({
artist: z.string(), // The name of the artist, not the artist ID
count: z.number().optional(),
artist: z.string(), // The name of the artist, not the artist ID
count: z.number().optional(),
});
const topSongsList = z.object({
topSongs: z.object({
song: z.array(song),
}),
topSongs: z.object({
song: z.array(song),
}),
});
const scrobbleParameters = z.object({
id: z.string(),
submission: z.boolean().optional(),
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
id: z.string(),
submission: z.boolean().optional(),
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
});
const scrobble = z.null();
const search3 = z.object({
searchResult3: z.object({
album: z.array(album),
artist: z.array(albumArtist),
song: z.array(song),
}),
searchResult3: z.object({
album: z.array(album),
artist: z.array(albumArtist),
song: z.array(song),
}),
});
const search3Parameters = z.object({
albumCount: z.number().optional(),
albumOffset: z.number().optional(),
artistCount: z.number().optional(),
artistOffset: z.number().optional(),
musicFolderId: z.string().optional(),
query: z.string().optional(),
songCount: z.number().optional(),
songOffset: z.number().optional(),
albumCount: z.number().optional(),
albumOffset: z.number().optional(),
artistCount: z.number().optional(),
artistOffset: z.number().optional(),
musicFolderId: z.string().optional(),
query: z.string().optional(),
songCount: z.number().optional(),
songOffset: z.number().optional(),
});
const randomSongListParameters = z.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
});
const randomSongList = z.object({
randomSongs: z.object({
song: z.array(song),
}),
randomSongs: z.object({
song: z.array(song),
}),
});
export const ssType = {
_parameters: {
albumList: albumListParameters,
artistInfo: artistInfoParameters,
authenticate: authenticateParameters,
createFavorite: createFavoriteParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters,
topSongsList: topSongsListParameters,
},
_response: {
album,
albumArtist,
albumArtistList,
albumList,
artistInfo,
authenticate,
baseResponse,
createFavorite,
musicFolderList,
randomSongList,
removeFavorite,
scrobble,
search3,
setRating,
song,
topSongsList,
},
_parameters: {
albumList: albumListParameters,
artistInfo: artistInfoParameters,
authenticate: authenticateParameters,
createFavorite: createFavoriteParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters,
topSongsList: topSongsListParameters,
},
_response: {
album,
albumArtist,
albumArtistList,
albumList,
artistInfo,
authenticate,
baseResponse,
createFavorite,
musicFolderList,
randomSongList,
removeFavorite,
scrobble,
search3,
setRating,
song,
topSongsList,
},
};

File diff suppressed because it is too large Load Diff

View File

@ -6,35 +6,35 @@ import { ServerListItem } from '/@/renderer/types';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
return z.object({
data: itemSchema,
headers: z.instanceof(AxiosHeaders),
});
return z.object({
data: itemSchema,
headers: z.instanceof(AxiosHeaders),
});
};
export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
itemSchema: ItemType,
itemSchema: ItemType,
) => {
return z.object({
'subsonic-response': z
.object({
status: z.string(),
version: z.string(),
})
.extend(itemSchema),
});
return z.object({
'subsonic-response': z
.object({
status: z.string(),
version: z.string(),
})
.extend(itemSchema),
});
};
export const authenticationFailure = (currentServer: ServerListItem | null) => {
toast.error({
message: 'Your session has expired.',
});
toast.error({
message: 'Your session has expired.',
});
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
}
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
}
};

View File

@ -29,161 +29,161 @@ const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : nul
const ipc = isElectron() ? window.electron.ipc : null;
export const App = () => {
const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent);
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls();
const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent);
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls();
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--content-font-family', contentFont);
}, [contentFont]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--content-font-family', contentFont);
}, [contentFont]);
// Start the mpv instance on startup
useEffect(() => {
const initializeMpv = async () => {
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
// Start the mpv instance on startup
useEffect(() => {
const initializeMpv = async () => {
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
if (!isRunning) {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties = {
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
if (!isRunning) {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties = {
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
};
mpvPlayer?.initialize({
extraParameters,
properties,
});
mpvPlayer?.volume(properties.volume);
}
};
mpvPlayer?.initialize({
extraParameters,
properties,
});
mpvPlayer?.volume(properties.volume);
}
};
if (isElectron() && playbackType === PlaybackType.LOCAL) {
initializeMpv();
}
return () => {
clearQueue();
mpvPlayer?.stop();
mpvPlayer?.cleanup();
};
}, [clearQueue, playbackType]);
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
useEffect(() => {
if (isElectron()) {
mpvPlayer.restoreQueue();
mpvPlayerListener.rendererSaveQueue(() => {
const { current, queue } = usePlayerStore.getState();
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
current: {
...current,
status: PlayerStatus.PAUSED,
},
queue,
};
mpvPlayer.saveQueue(stateToSave);
});
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
const playerData = restoreQueue(data);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData, true);
if (isElectron() && playbackType === PlaybackType.LOCAL) {
initializeMpv();
}
});
}
return () => {
ipc?.removeAllListeners('renderer-player-restore-queue');
ipc?.removeAllListeners('renderer-player-save-queue');
};
}, [playbackType, restoreQueue]);
return () => {
clearQueue();
mpvPlayer?.stop();
mpvPlayer?.cleanup();
};
}, [clearQueue, playbackType]);
return (
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: theme as 'light' | 'dark',
components: {
Modal: {
styles: {
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
close: { marginRight: '0.5rem' },
content: { borderRadius: '5px' },
header: {
background: 'var(--modal-header-bg)',
paddingBottom: '1rem',
},
title: { fontSize: 'medium', fontWeight: 500 },
},
},
},
defaultRadius: 'xs',
dir: 'ltr',
focusRing: 'auto',
focusRingStyles: {
inputStyles: () => ({
border: '1px solid var(--primary-color)',
}),
resetStyles: () => ({ outline: 'none' }),
styles: () => ({
outline: '1px solid var(--primary-color)',
outlineOffset: '-1px',
}),
},
fontFamily: 'var(--content-font-family)',
fontSizes: {
lg: '1.1rem',
md: '1rem',
sm: '0.9rem',
xl: '1.5rem',
xs: '0.8rem',
},
headings: {
fontFamily: 'var(--content-font-family)',
fontWeight: 700,
},
other: {},
spacing: {
lg: '2rem',
md: '1rem',
sm: '0.5rem',
xl: '4rem',
xs: '0rem',
},
}}
>
<ModalsProvider
modalProps={{
centered: true,
styles: {
body: { position: 'relative' },
content: { overflow: 'auto' },
},
transitionProps: {
duration: 300,
exitDuration: 300,
transition: 'fade',
},
}}
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
>
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
<ContextMenuProvider>
<AppRouter />
</ContextMenuProvider>
</PlayQueueHandlerContext.Provider>
</ModalsProvider>
</MantineProvider>
);
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
useEffect(() => {
if (isElectron()) {
mpvPlayer.restoreQueue();
mpvPlayerListener.rendererSaveQueue(() => {
const { current, queue } = usePlayerStore.getState();
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
current: {
...current,
status: PlayerStatus.PAUSED,
},
queue,
};
mpvPlayer.saveQueue(stateToSave);
});
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
const playerData = restoreQueue(data);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData, true);
}
});
}
return () => {
ipc?.removeAllListeners('renderer-player-restore-queue');
ipc?.removeAllListeners('renderer-player-save-queue');
};
}, [playbackType, restoreQueue]);
return (
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: theme as 'light' | 'dark',
components: {
Modal: {
styles: {
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
close: { marginRight: '0.5rem' },
content: { borderRadius: '5px' },
header: {
background: 'var(--modal-header-bg)',
paddingBottom: '1rem',
},
title: { fontSize: 'medium', fontWeight: 500 },
},
},
},
defaultRadius: 'xs',
dir: 'ltr',
focusRing: 'auto',
focusRingStyles: {
inputStyles: () => ({
border: '1px solid var(--primary-color)',
}),
resetStyles: () => ({ outline: 'none' }),
styles: () => ({
outline: '1px solid var(--primary-color)',
outlineOffset: '-1px',
}),
},
fontFamily: 'var(--content-font-family)',
fontSizes: {
lg: '1.1rem',
md: '1rem',
sm: '0.9rem',
xl: '1.5rem',
xs: '0.8rem',
},
headings: {
fontFamily: 'var(--content-font-family)',
fontWeight: 700,
},
other: {},
spacing: {
lg: '2rem',
md: '1rem',
sm: '0.5rem',
xl: '4rem',
xs: '0rem',
},
}}
>
<ModalsProvider
modalProps={{
centered: true,
styles: {
body: { position: 'relative' },
content: { overflow: 'auto' },
},
transitionProps: {
duration: 300,
exitDuration: 300,
transition: 'fade',
},
}}
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
>
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
<ContextMenuProvider>
<AppRouter />
</ContextMenuProvider>
</PlayQueueHandlerContext.Provider>
</ModalsProvider>
</MantineProvider>
);
};

View File

@ -5,17 +5,17 @@ import styled from 'styled-components';
type AccordionProps = MantineAccordionProps;
const StyledAccordion = styled(MantineAccordion)`
& .mantine-Accordion-panel {
background: var(--paper-bg);
}
& .mantine-Accordion-panel {
background: var(--paper-bg);
}
.mantine-Accordion-control {
background: var(--paper-bg);
}
.mantine-Accordion-control {
background: var(--paper-bg);
}
`;
export const Accordion = ({ children, ...props }: AccordionProps) => {
return <StyledAccordion {...props}>{children}</StyledAccordion>;
return <StyledAccordion {...props}>{children}</StyledAccordion>;
};
Accordion.Control = StyledAccordion.Control;

View File

@ -4,188 +4,192 @@ import type { ReactPlayerProps } from 'react-player';
import ReactPlayer from 'react-player';
import type { Song } from '/@/renderer/api/types';
import {
crossfadeHandler,
gaplessHandler,
crossfadeHandler,
gaplessHandler,
} from '/@/renderer/components/audio-player/utils/list-handlers';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
interface AudioPlayerProps extends ReactPlayerProps {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: 1 | 2;
playbackStyle: PlaybackStyle;
player1: Song;
player2: Song;
status: PlayerStatus;
volume: number;
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: 1 | 2;
playbackStyle: PlaybackStyle;
player1: Song;
player2: Song;
status: PlayerStatus;
volume: number;
}
export type AudioPlayerProgress = {
loaded: number;
loadedSeconds: number;
played: number;
playedSeconds: number;
loaded: number;
loadedSeconds: number;
played: number;
playedSeconds: number;
};
const getDuration = (ref: any) => {
return ref.current?.player?.player?.player?.duration;
return ref.current?.player?.player?.player?.duration;
};
export const AudioPlayer = forwardRef(
(
{
status,
playbackStyle,
crossfadeStyle,
crossfadeDuration,
currentPlayer,
autoNext,
player1,
player2,
muted,
volume,
}: AudioPlayerProps,
ref: any,
) => {
const player1Ref = useRef<any>(null);
const player2Ref = useRef<any>(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
(
{
status,
playbackStyle,
crossfadeStyle,
crossfadeDuration,
currentPlayer,
autoNext,
player1,
player2,
muted,
volume,
}: AudioPlayerProps,
ref: any,
) => {
const player1Ref = useRef<any>(null);
const player2Ref = useRef<any>(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
useImperativeHandle(ref, () => ({
get player1() {
return player1Ref?.current;
},
get player2() {
return player2Ref?.current;
},
}));
useImperativeHandle(ref, () => ({
get player1() {
return player1Ref?.current;
},
get player2() {
return player2Ref?.current;
},
}));
const handleOnEnded = () => {
autoNext();
setIsTransitioning(false);
};
const handleOnEnded = () => {
autoNext();
setIsTransitioning(false);
};
useEffect(() => {
if (status === PlayerStatus.PLAYING) {
if (currentPlayer === 1) {
player1Ref.current?.getInternalPlayer()?.play();
} else {
player2Ref.current?.getInternalPlayer()?.play();
}
} else {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
}
}, [currentPlayer, status]);
useEffect(() => {
if (status === PlayerStatus.PLAYING) {
if (currentPlayer === 1) {
player1Ref.current?.getInternalPlayer()?.play();
} else {
player2Ref.current?.getInternalPlayer()?.play();
}
} else {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
}
}, [currentPlayer, status]);
const handleCrossfade1 = useCallback(
(e: AudioPlayerProgress) => {
return crossfadeHandler({
currentPlayer,
currentPlayerRef: player1Ref,
currentTime: e.playedSeconds,
duration: getDuration(player1Ref),
fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle,
isTransitioning,
nextPlayerRef: player2Ref,
player: 1,
setIsTransitioning,
volume,
});
},
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
);
const handleCrossfade1 = useCallback(
(e: AudioPlayerProgress) => {
return crossfadeHandler({
currentPlayer,
currentPlayerRef: player1Ref,
currentTime: e.playedSeconds,
duration: getDuration(player1Ref),
fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle,
isTransitioning,
nextPlayerRef: player2Ref,
player: 1,
setIsTransitioning,
volume,
});
},
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
);
const handleCrossfade2 = useCallback(
(e: AudioPlayerProgress) => {
return crossfadeHandler({
currentPlayer,
currentPlayerRef: player2Ref,
currentTime: e.playedSeconds,
duration: getDuration(player2Ref),
fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle,
isTransitioning,
nextPlayerRef: player1Ref,
player: 2,
setIsTransitioning,
volume,
});
},
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
);
const handleCrossfade2 = useCallback(
(e: AudioPlayerProgress) => {
return crossfadeHandler({
currentPlayer,
currentPlayerRef: player2Ref,
currentTime: e.playedSeconds,
duration: getDuration(player2Ref),
fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle,
isTransitioning,
nextPlayerRef: player1Ref,
player: 2,
setIsTransitioning,
volume,
});
},
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
);
const handleGapless1 = useCallback(
(e: AudioPlayerProgress) => {
return gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(player1Ref),
isFlac: player1?.container === 'flac',
isTransitioning,
nextPlayerRef: player2Ref,
setIsTransitioning,
});
},
[isTransitioning, player1?.container],
);
const handleGapless1 = useCallback(
(e: AudioPlayerProgress) => {
return gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(player1Ref),
isFlac: player1?.container === 'flac',
isTransitioning,
nextPlayerRef: player2Ref,
setIsTransitioning,
});
},
[isTransitioning, player1?.container],
);
const handleGapless2 = useCallback(
(e: AudioPlayerProgress) => {
return gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(player2Ref),
isFlac: player2?.container === 'flac',
isTransitioning,
nextPlayerRef: player1Ref,
setIsTransitioning,
});
},
[isTransitioning, player2?.container],
);
const handleGapless2 = useCallback(
(e: AudioPlayerProgress) => {
return gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(player2Ref),
isFlac: player2?.container === 'flac',
isTransitioning,
nextPlayerRef: player1Ref,
setIsTransitioning,
});
},
[isTransitioning, player2?.container],
);
useEffect(() => {
if (isElectron()) {
if (audioDeviceId) {
player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
} else {
player1Ref.current?.getInternalPlayer()?.setSinkId('');
player2Ref.current?.getInternalPlayer()?.setSinkId('');
}
}
}, [audioDeviceId]);
useEffect(() => {
if (isElectron()) {
if (audioDeviceId) {
player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
} else {
player1Ref.current?.getInternalPlayer()?.setSinkId('');
player2Ref.current?.getInternalPlayer()?.setSinkId('');
}
}
}, [audioDeviceId]);
return (
<>
<ReactPlayer
ref={player1Ref}
height={0}
muted={muted}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player1?.streamUrl}
volume={volume}
width={0}
onEnded={handleOnEnded}
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1}
/>
<ReactPlayer
ref={player2Ref}
height={0}
muted={muted}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player2?.streamUrl}
volume={volume}
width={0}
onEnded={handleOnEnded}
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2}
/>
</>
);
},
return (
<>
<ReactPlayer
ref={player1Ref}
height={0}
muted={muted}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player1?.streamUrl}
volume={volume}
width={0}
onEnded={handleOnEnded}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
}
/>
<ReactPlayer
ref={player2Ref}
height={0}
muted={muted}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player2?.streamUrl}
volume={volume}
width={0}
onEnded={handleOnEnded}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
}
/>
</>
);
},
);

View File

@ -3,129 +3,131 @@ import type { Dispatch } from 'react';
import { CrossfadeStyle } from '/@/renderer/types';
export const gaplessHandler = (args: {
currentTime: number;
duration: number;
isFlac: boolean;
isTransitioning: boolean;
nextPlayerRef: any;
setIsTransitioning: Dispatch<boolean>;
currentTime: number;
duration: number;
isFlac: boolean;
isTransitioning: boolean;
nextPlayerRef: any;
setIsTransitioning: Dispatch<boolean>;
}) => {
const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } =
args;
const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } =
args;
if (!isTransitioning) {
if (currentTime > duration - 2) {
return setIsTransitioning(true);
if (!isTransitioning) {
if (currentTime > duration - 2) {
return setIsTransitioning(true);
}
return null;
}
const durationPadding = isFlac ? 0.065 : 0.116;
if (currentTime + durationPadding >= duration) {
return nextPlayerRef.current.getInternalPlayer().play();
}
return null;
}
const durationPadding = isFlac ? 0.065 : 0.116;
if (currentTime + durationPadding >= duration) {
return nextPlayerRef.current.getInternalPlayer().play();
}
return null;
};
export const crossfadeHandler = (args: {
currentPlayer: 1 | 2;
currentPlayerRef: any;
currentTime: number;
duration: number;
fadeDuration: number;
fadeType: CrossfadeStyle;
isTransitioning: boolean;
nextPlayerRef: any;
player: 1 | 2;
setIsTransitioning: Dispatch<boolean>;
volume: number;
currentPlayer: 1 | 2;
currentPlayerRef: any;
currentTime: number;
duration: number;
fadeDuration: number;
fadeType: CrossfadeStyle;
isTransitioning: boolean;
nextPlayerRef: any;
player: 1 | 2;
setIsTransitioning: Dispatch<boolean>;
volume: number;
}) => {
const {
currentTime,
player,
currentPlayer,
currentPlayerRef,
nextPlayerRef,
fadeDuration,
fadeType,
duration,
volume,
isTransitioning,
setIsTransitioning,
} = args;
const {
currentTime,
player,
currentPlayer,
currentPlayerRef,
nextPlayerRef,
fadeDuration,
fadeType,
duration,
volume,
isTransitioning,
setIsTransitioning,
} = args;
if (!isTransitioning || currentPlayer !== player) {
const shouldBeginTransition = currentTime >= duration - fadeDuration;
if (!isTransitioning || currentPlayer !== player) {
const shouldBeginTransition = currentTime >= duration - fadeDuration;
if (shouldBeginTransition) {
setIsTransitioning(true);
return nextPlayerRef.current.getInternalPlayer().play();
if (shouldBeginTransition) {
setIsTransitioning(true);
return nextPlayerRef.current.getInternalPlayer().play();
}
return null;
}
const timeLeft = duration - currentTime;
let currentPlayerVolumeCalculation;
let nextPlayerVolumeCalculation;
let percentageOfFadeLeft;
let n;
switch (fadeType) {
case 'equalPower':
// https://dsp.stackexchange.com/a/14755
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
currentPlayerVolumeCalculation = Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
nextPlayerVolumeCalculation = Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
break;
case 'linear':
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
break;
case 'dipped':
// https://math.stackexchange.com/a/4622
percentageOfFadeLeft = timeLeft / fadeDuration;
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
break;
case fadeType.match(/constantPower.*/)?.input:
// https://math.stackexchange.com/a/26159
n =
fadeType === 'constantPower'
? 0
: fadeType === 'constantPowerSlowFade'
? 1
: fadeType === 'constantPowerSlowCut'
? 3
: 10;
percentageOfFadeLeft = timeLeft / fadeDuration;
currentPlayerVolumeCalculation =
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)) *
volume;
nextPlayerVolumeCalculation =
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) *
volume;
break;
default:
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
break;
}
const currentPlayerVolume =
currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;
const nextPlayerVolume =
nextPlayerVolumeCalculation <= volume ? nextPlayerVolumeCalculation : volume;
if (currentPlayer === 1) {
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
} else {
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
}
// }
return null;
}
const timeLeft = duration - currentTime;
let currentPlayerVolumeCalculation;
let nextPlayerVolumeCalculation;
let percentageOfFadeLeft;
let n;
switch (fadeType) {
case 'equalPower':
// https://dsp.stackexchange.com/a/14755
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
currentPlayerVolumeCalculation = Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
nextPlayerVolumeCalculation = Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
break;
case 'linear':
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
break;
case 'dipped':
// https://math.stackexchange.com/a/4622
percentageOfFadeLeft = timeLeft / fadeDuration;
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
break;
case fadeType.match(/constantPower.*/)?.input:
// https://math.stackexchange.com/a/26159
n =
fadeType === 'constantPower'
? 0
: fadeType === 'constantPowerSlowFade'
? 1
: fadeType === 'constantPowerSlowCut'
? 3
: 10;
percentageOfFadeLeft = timeLeft / fadeDuration;
currentPlayerVolumeCalculation =
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)) * volume;
nextPlayerVolumeCalculation =
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) * volume;
break;
default:
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
break;
}
const currentPlayerVolume =
currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;
const nextPlayerVolume =
nextPlayerVolumeCalculation <= volume ? nextPlayerVolumeCalculation : volume;
if (currentPlayer === 1) {
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
} else {
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
}
// }
return null;
};

View File

@ -5,30 +5,30 @@ import styled from 'styled-components';
export type BadgeProps = MantineBadgeProps;
const StyledBadge = styled(MantineBadge)<BadgeProps>`
border-radius: var(--badge-radius);
border-radius: var(--badge-radius);
.mantine-Badge-root {
color: var(--badge-fg);
}
.mantine-Badge-root {
color: var(--badge-fg);
}
.mantine-Badge-inner {
color: var(--badge-fg);
}
.mantine-Badge-inner {
color: var(--badge-fg);
}
`;
const _Badge = ({ children, ...props }: BadgeProps) => {
return (
<StyledBadge
radius="md"
size="sm"
styles={{
root: { background: 'var(--badge-bg)' },
}}
{...props}
>
{children}
</StyledBadge>
);
return (
<StyledBadge
radius="md"
size="sm"
styles={{
root: { background: 'var(--badge-bg)' },
}}
{...props}
>
{children}
</StyledBadge>
);
};
export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge);

View File

@ -8,177 +8,177 @@ import { Spinner } from '/@/renderer/components/spinner';
import { Tooltip } from '/@/renderer/components/tooltip';
export interface ButtonProps extends MantineButtonProps {
children: React.ReactNode;
loading?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
tooltip?: Omit<TooltipProps, 'children'>;
children: React.ReactNode;
loading?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
tooltip?: Omit<TooltipProps, 'children'>;
}
interface StyledButtonProps extends ButtonProps {
ref: Ref<HTMLButtonElement>;
ref: Ref<HTMLButtonElement>;
}
const StyledButton = styled(MantineButton)<StyledButtonProps>`
color: ${(props) => `var(--btn-${props.variant}-fg)`};
background: ${(props) => `var(--btn-${props.variant}-bg)`};
border: ${(props) => `var(--btn-${props.variant}-border)`};
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
svg {
transition: fill 0.2s ease-in-out;
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
}
&:disabled {
color: ${(props) => `var(--btn-${props.variant}-fg)`};
background: ${(props) => `var(--btn-${props.variant}-bg)`};
opacity: 0.6;
}
&:not([data-disabled])&:hover {
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
border: ${(props) => `var(--btn-${props.variant}-border-hover)`};
border: ${(props) => `var(--btn-${props.variant}-border)`};
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
svg {
fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
transition: fill 0.2s ease-in-out;
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
}
}
&:not([data-disabled])&:focus-visible {
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
}
&:disabled {
color: ${(props) => `var(--btn-${props.variant}-fg)`};
background: ${(props) => `var(--btn-${props.variant}-bg)`};
&:active {
transform: none;
}
opacity: 0.6;
}
& .mantine-Button-centerLoader {
display: none;
}
&:not([data-disabled])&:hover {
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
border: ${(props) => `var(--btn-${props.variant}-border-hover)`};
& .mantine-Button-leftIcon {
display: flex;
height: 100%;
margin-right: 0.5rem;
transform: translateY(-0.1rem);
}
svg {
fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
}
}
.mantine-Button-rightIcon {
display: flex;
margin-left: 0.5rem;
}
&:not([data-disabled])&:focus-visible {
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
}
&:active {
transform: none;
}
& .mantine-Button-centerLoader {
display: none;
}
& .mantine-Button-leftIcon {
display: flex;
height: 100%;
margin-right: 0.5rem;
transform: translateY(-0.1rem);
}
.mantine-Button-rightIcon {
display: flex;
margin-left: 0.5rem;
}
`;
const ButtonChildWrapper = styled.span<{ $loading?: boolean }>`
color: ${(props) => props.$loading && 'transparent !important'};
color: ${(props) => props.$loading && 'transparent !important'};
`;
const SpinnerWrapper = styled.div`
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
`;
export const _Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, tooltip, ...props }: ButtonProps, ref) => {
if (tooltip) {
return (
<Tooltip
withinPortal
{...tooltip}
>
<StyledButton
ref={ref}
loaderPosition="center"
{...props}
>
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
{props.loading && (
<SpinnerWrapper>
<Spinner />
</SpinnerWrapper>
)}
</StyledButton>
</Tooltip>
);
}
({ children, tooltip, ...props }: ButtonProps, ref) => {
if (tooltip) {
return (
<Tooltip
withinPortal
{...tooltip}
>
<StyledButton
ref={ref}
loaderPosition="center"
{...props}
>
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
{props.loading && (
<SpinnerWrapper>
<Spinner />
</SpinnerWrapper>
)}
</StyledButton>
</Tooltip>
);
}
return (
<StyledButton
ref={ref}
loaderPosition="center"
{...props}
>
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
{props.loading && (
<SpinnerWrapper>
<Spinner />
</SpinnerWrapper>
)}
</StyledButton>
);
},
return (
<StyledButton
ref={ref}
loaderPosition="center"
{...props}
>
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
{props.loading && (
<SpinnerWrapper>
<Spinner />
</SpinnerWrapper>
)}
</StyledButton>
);
},
);
export const Button = createPolymorphicComponent<'button', ButtonProps>(_Button);
_Button.defaultProps = {
loading: undefined,
onClick: undefined,
tooltip: undefined,
loading: undefined,
onClick: undefined,
tooltip: undefined,
};
interface HoldButtonProps extends ButtonProps {
timeoutProps: {
callback: () => void;
duration: number;
};
timeoutProps: {
callback: () => void;
duration: number;
};
}
export const TimeoutButton = ({ timeoutProps, ...props }: HoldButtonProps) => {
const [, setTimeoutRemaining] = useState(timeoutProps.duration);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(0);
const [, setTimeoutRemaining] = useState(timeoutProps.duration);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(0);
const callback = () => {
timeoutProps.callback();
setTimeoutRemaining(timeoutProps.duration);
clearInterval(intervalRef.current);
setIsRunning(false);
};
const callback = () => {
timeoutProps.callback();
setTimeoutRemaining(timeoutProps.duration);
clearInterval(intervalRef.current);
setIsRunning(false);
};
const { start, clear } = useTimeout(callback, timeoutProps.duration);
const { start, clear } = useTimeout(callback, timeoutProps.duration);
const startTimeout = useCallback(() => {
if (isRunning) {
clearInterval(intervalRef.current);
setIsRunning(false);
clear();
} else {
setIsRunning(true);
start();
const startTimeout = useCallback(() => {
if (isRunning) {
clearInterval(intervalRef.current);
setIsRunning(false);
clear();
} else {
setIsRunning(true);
start();
const intervalId = window.setInterval(() => {
setTimeoutRemaining((prev) => prev - 100);
}, 100);
const intervalId = window.setInterval(() => {
setTimeoutRemaining((prev) => prev - 100);
}, 100);
intervalRef.current = intervalId;
}
}, [clear, isRunning, start]);
intervalRef.current = intervalId;
}
}, [clear, isRunning, start]);
return (
<Button
sx={{ color: 'var(--danger-color)' }}
onClick={startTimeout}
{...props}
>
{isRunning ? 'Cancel' : props.children}
</Button>
);
return (
<Button
sx={{ color: 'var(--danger-color)' }}
onClick={startTimeout}
{...props}
>
{isRunning ? 'Cancel' : props.children}
</Button>
);
};

View File

@ -11,208 +11,208 @@ import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
import { CardRows } from '/@/renderer/components/card/card-rows';
const CardWrapper = styled.div<{
link?: boolean;
link?: boolean;
}>`
padding: 1rem;
background: var(--card-default-bg);
border-radius: var(--card-default-radius);
cursor: ${({ link }) => link && 'pointer'};
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
padding: 1rem;
background: var(--card-default-bg);
border-radius: var(--card-default-radius);
cursor: ${({ link }) => link && 'pointer'};
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
&:hover {
background: var(--card-default-bg-hover);
}
&:hover div {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
&:hover {
background: var(--card-default-bg-hover);
}
}
&:focus-visible {
outline: 1px solid #fff;
}
&:hover div {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
}
}
&:focus-visible {
outline: 1px solid #fff;
}
`;
const StyledCard = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0;
border-radius: var(--card-default-radius);
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0;
border-radius: var(--card-default-radius);
`;
const ImageSection = styled.div`
position: relative;
display: flex;
justify-content: center;
border-radius: var(--card-default-radius);
position: relative;
display: flex;
justify-content: center;
border-radius: var(--card-default-radius);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
`;
const Image = styled(SimpleImg)`
border-radius: var(--card-default-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
border-radius: var(--card-default-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
`;
const ControlsContainer = styled.div`
position: absolute;
bottom: 0;
z-index: 50;
width: 100%;
opacity: 0;
transition: all 0.2s ease-in-out;
position: absolute;
bottom: 0;
z-index: 50;
width: 100%;
opacity: 0;
transition: all 0.2s ease-in-out;
`;
const DetailSection = styled.div`
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
`;
const Row = styled.div<{ $secondary?: boolean }>`
width: 100%;
max-width: 100%;
height: 22px;
padding: 0 0.2rem;
overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
width: 100%;
max-width: 100%;
height: 22px;
padding: 0 0.2rem;
overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
`;
interface BaseGridCardProps {
controls: {
cardRows: CardRow<Album | Artist | AlbumArtist>[];
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
loading?: boolean;
size: number;
controls: {
cardRows: CardRow<Album | Artist | AlbumArtist>[];
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
loading?: boolean;
size: number;
}
export const AlbumCard = ({
loading,
size,
handlePlayQueueAdd,
data,
controls,
loading,
size,
handlePlayQueueAdd,
data,
controls,
}: BaseGridCardProps) => {
const navigate = useNavigate();
const { itemType, cardRows, route } = controls;
const navigate = useNavigate();
const { itemType, cardRows, route } = controls;
const handleNavigate = useCallback(() => {
navigate(
generatePath(
route.route,
route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
),
);
}, [data, navigate, route.route, route.slugs]);
const handleNavigate = useCallback(() => {
navigate(
generatePath(
route.route,
route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
),
);
}, [data, navigate, route.route, route.slugs]);
if (!loading) {
return (
<CardWrapper
link
onClick={handleNavigate}
>
<StyledCard>
<ImageSection>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={size}
imgStyle={{ objectFit: 'cover' }}
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
width={size}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: `${size}px`,
width: `${size}px`,
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<ControlsContainer>
<CardControls
handlePlayQueueAdd={handlePlayQueueAdd}
itemData={data}
itemType={itemType}
/>
</ControlsContainer>
</ImageSection>
<DetailSection>
<CardRows
data={data}
rows={cardRows}
/>
</DetailSection>
</StyledCard>
</CardWrapper>
);
}
return (
<CardWrapper>
<StyledCard style={{ alignItems: 'center', display: 'flex' }}>
<Skeleton
visible
height={size}
radius="sm"
width={size}
>
<ImageSection />
</Skeleton>
<DetailSection style={{ width: '100%' }}>
{cardRows.map((_row: CardRow<Album>, index: number) => (
<Skeleton
visible
height={15}
my={3}
radius="md"
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
if (!loading) {
return (
<CardWrapper
link
onClick={handleNavigate}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
<StyledCard>
<ImageSection>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={size}
imgStyle={{ objectFit: 'cover' }}
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
width={size}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: `${size}px`,
width: `${size}px`,
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<ControlsContainer>
<CardControls
handlePlayQueueAdd={handlePlayQueueAdd}
itemData={data}
itemType={itemType}
/>
</ControlsContainer>
</ImageSection>
<DetailSection>
<CardRows
data={data}
rows={cardRows}
/>
</DetailSection>
</StyledCard>
</CardWrapper>
);
}
return (
<CardWrapper>
<StyledCard style={{ alignItems: 'center', display: 'flex' }}>
<Skeleton
visible
height={size}
radius="sm"
width={size}
>
<ImageSection />
</Skeleton>
<DetailSection style={{ width: '100%' }}>
{cardRows.map((_row: CardRow<Album>, index: number) => (
<Skeleton
visible
height={15}
my={3}
radius="md"
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
};

View File

@ -11,69 +11,69 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import {
ALBUM_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS,
ALBUM_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
const PlayButton = styled.button<PlayButtonType>`
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background-color: rgb(255, 255, 255);
border: none;
border-radius: 50%;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background-color: rgb(255, 255, 255);
border: none;
border-radius: 50%;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
&:hover {
opacity: 1;
scale: 1.1;
}
&:hover {
opacity: 1;
scale: 1.1;
}
&:active {
opacity: 1;
scale: 1;
}
&:active {
opacity: 1;
scale: 1;
}
svg {
fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0);
}
svg {
fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0);
}
`;
const SecondaryButton = styled(_Button)`
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
&:hover {
opacity: 1;
scale: 1.1;
}
&:hover {
opacity: 1;
scale: 1.1;
}
&:active {
opacity: 1;
scale: 1;
}
&:active {
opacity: 1;
scale: 1;
}
`;
const GridCardControlsContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
`;
const ControlsRow = styled.div`
width: 100%;
height: calc(100% / 3);
width: 100%;
height: calc(100% / 3);
`;
// const TopControls = styled(ControlsRow)`
@ -91,87 +91,87 @@ const ControlsRow = styled.div`
// `;
const BottomControls = styled(ControlsRow)`
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 1rem 0.5rem;
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 1rem 0.5rem;
`;
const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
svg {
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
}
svg {
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
}
`;
export const CardControls = ({
itemData,
itemType,
handlePlayQueueAdd,
}: {
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any;
itemType: LibraryItem;
}) => {
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
e.stopPropagation();
handlePlayQueueAdd?.({
byItemType: {
id: [itemData.id],
type: itemType,
},
playType: playType || playButtonBehavior,
});
};
const handleContextMenu = useHandleGeneralContextMenu(
itemData,
itemType,
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
);
handlePlayQueueAdd,
}: {
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any;
itemType: LibraryItem;
}) => {
const playButtonBehavior = usePlayButtonBehavior();
return (
<GridCardControlsContainer>
<BottomControls>
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<Group spacing="xs">
<SecondaryButton
disabled
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{itemData?.isFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
/>
)}
</FavoriteWrapper>
</SecondaryButton>
<SecondaryButton
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</Group>
</BottomControls>
</GridCardControlsContainer>
);
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
e.stopPropagation();
handlePlayQueueAdd?.({
byItemType: {
id: [itemData.id],
type: itemType,
},
playType: playType || playButtonBehavior,
});
};
const handleContextMenu = useHandleGeneralContextMenu(
itemType,
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
);
return (
<GridCardControlsContainer>
<BottomControls>
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<Group spacing="xs">
<SecondaryButton
disabled
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{itemData?.isFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
/>
)}
</FavoriteWrapper>
</SecondaryButton>
<SecondaryButton
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</Group>
</BottomControls>
</GridCardControlsContainer>
);
};

View File

@ -8,205 +8,208 @@ import { AppRoute } from '/@/renderer/router/routes';
import { CardRow } from '/@/renderer/types';
const Row = styled.div<{ $secondary?: boolean }>`
width: 100%;
max-width: 100%;
height: 22px;
padding: 0 0.2rem;
overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
width: 100%;
max-width: 100%;
height: 22px;
padding: 0 0.2rem;
overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
`;
interface CardRowsProps {
data: any;
rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
data: any;
rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
}
export const CardRows = ({ data, rows }: CardRowsProps) => {
return (
<>
{rows.map((row, index: number) => {
if (row.arrayProperty && row.route) {
return (
<Row
key={`row-${row.property}-${index}`}
$secondary={index > 0}
>
{data[row.property].map((item: any, itemIndex: number) => (
<React.Fragment key={`${data.id}-${item.id}`}>
{itemIndex > 0 && (
<Text
$noSelect
$secondary
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
}}
>
,
</Text>
)}{' '}
<Text
$link
$noSelect
$secondary={index > 0}
component={Link}
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
to={generatePath(
row.route!.route,
row.route!.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[row.property][itemIndex][slug.idProperty],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
</React.Fragment>
))}
</Row>
);
}
return (
<>
{rows.map((row, index: number) => {
if (row.arrayProperty && row.route) {
return (
<Row
key={`row-${row.property}-${index}`}
$secondary={index > 0}
>
{data[row.property].map((item: any, itemIndex: number) => (
<React.Fragment key={`${data.id}-${item.id}`}>
{itemIndex > 0 && (
<Text
$noSelect
$secondary
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
}}
>
,
</Text>
)}{' '}
<Text
$link
$noSelect
$secondary={index > 0}
component={Link}
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
to={generatePath(
row.route!.route,
row.route!.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]:
data[row.property][itemIndex][
slug.idProperty
],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
</React.Fragment>
))}
</Row>
);
}
if (row.arrayProperty) {
return (
<Row key={`row-${row.property}`}>
{data[row.property].map((item: any) => (
<Text
key={`${data.id}-${item.id}`}
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
))}
</Row>
);
}
if (row.arrayProperty) {
return (
<Row key={`row-${row.property}`}>
{data[row.property].map((item: any) => (
<Text
key={`${data.id}-${item.id}`}
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
))}
</Row>
);
}
return (
<Row key={`row-${row.property}`}>
{row.route ? (
<Text
$link
$noSelect
component={Link}
overflow="hidden"
to={generatePath(
row.route.route,
row.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{data && data[row.property]}
</Text>
) : (
<Text
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
>
{data && data[row.property]}
</Text>
)}
</Row>
);
})}
</>
);
return (
<Row key={`row-${row.property}`}>
{row.route ? (
<Text
$link
$noSelect
component={Link}
overflow="hidden"
to={generatePath(
row.route.route,
row.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{data && data[row.property]}
</Text>
) : (
<Text
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
>
{data && data[row.property]}
</Text>
)}
</Row>
);
})}
</>
);
};
export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
albumArtists: {
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
albumArtists: {
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
},
artists: {
arrayProperty: 'name',
property: 'artists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
artists: {
arrayProperty: 'name',
property: 'artists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
},
createdAt: {
property: 'createdAt',
},
duration: {
property: 'duration',
},
lastPlayedAt: {
property: 'lastPlayedAt',
},
name: {
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
createdAt: {
property: 'createdAt',
},
duration: {
property: 'duration',
},
lastPlayedAt: {
property: 'lastPlayedAt',
},
name: {
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
playCount: {
property: 'playCount',
},
rating: {
property: 'userRating',
},
releaseDate: {
property: 'releaseDate',
},
releaseYear: {
property: 'releaseYear',
},
songCount: {
property: 'songCount',
},
},
playCount: {
property: 'playCount',
},
rating: {
property: 'userRating',
},
releaseDate: {
property: 'releaseDate',
},
releaseYear: {
property: 'releaseYear',
},
songCount: {
property: 'songCount',
},
};
export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
albumCount: {
property: 'albumCount',
},
duration: {
property: 'duration',
},
genres: {
property: 'genres',
},
lastPlayedAt: {
property: 'lastPlayedAt',
},
name: {
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
albumCount: {
property: 'albumCount',
},
duration: {
property: 'duration',
},
genres: {
property: 'genres',
},
lastPlayedAt: {
property: 'lastPlayedAt',
},
name: {
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
playCount: {
property: 'playCount',
},
rating: {
property: 'userRating',
},
songCount: {
property: 'songCount',
},
},
playCount: {
property: 'playCount',
},
rating: {
property: 'userRating',
},
songCount: {
property: 'songCount',
},
};

View File

@ -10,197 +10,197 @@ import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
interface BaseGridCardProps {
controls: {
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
handleFavorite: (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
serverId: string;
}) => void;
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
isLoading?: boolean;
controls: {
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
handleFavorite: (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
serverId: string;
}) => void;
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
isLoading?: boolean;
}
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
.card-controls {
opacity: 0;
}
.card-controls {
opacity: 0;
}
`;
const ImageContainerStyles = css`
position: relative;
display: flex;
align-items: center;
aspect-ratio: 1/1;
overflow: hidden;
background: var(--card-default-bg);
border-radius: var(--card-poster-radius);
position: relative;
display: flex;
align-items: center;
aspect-ratio: 1/1;
overflow: hidden;
background: var(--card-default-bg);
border-radius: var(--card-poster-radius);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
&:hover {
&::before {
opacity: 0.5;
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
}
&:hover .card-controls {
opacity: 1;
}
&:hover {
&::before {
opacity: 0.5;
}
}
&:hover .card-controls {
opacity: 1;
}
`;
const ImageContainer = styled(Link)<{ $isFavorite?: boolean }>`
${ImageContainerStyles}
${ImageContainerStyles}
`;
const ImageContainerSkeleton = styled.div`
${ImageContainerStyles}
${ImageContainerStyles}
`;
const Image = styled(SimpleImg)`
width: 100%;
max-width: 100%;
height: 100% !important;
max-height: 100%;
border: 0;
width: 100%;
max-width: 100%;
height: 100% !important;
max-height: 100%;
border: 0;
img {
height: 100%;
object-fit: cover;
}
img {
height: 100%;
object-fit: cover;
}
`;
const DetailContainer = styled.div`
margin-top: 0.5rem;
margin-top: 0.5rem;
`;
export const PosterCard = ({
data,
controls,
isLoading,
uniqueId,
data,
controls,
isLoading,
uniqueId,
}: BaseGridCardProps & { uniqueId: string }) => {
if (!isLoading) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
if (!isLoading) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
let Placeholder = RiAlbumFill;
let Placeholder = RiAlbumFill;
switch (controls.itemType) {
case LibraryItem.ALBUM:
Placeholder = RiAlbumFill;
break;
case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill;
break;
default:
Placeholder = RiAlbumFill;
break;
switch (controls.itemType) {
case LibraryItem.ALBUM:
Placeholder = RiAlbumFill;
break;
case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill;
break;
default:
Placeholder = RiAlbumFill;
break;
}
return (
<PosterCardContainer key={`${uniqueId}-${data.id}`}>
<ImageContainer
$isFavorite={data?.userFavorite}
to={path}
>
{data?.imageUrl ? (
<Image
importance="auto"
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<Placeholder
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ImageContainer>
<DetailContainer>
<CardRows
data={data}
rows={controls.cardRows}
/>
</DetailContainer>
</PosterCardContainer>
);
}
return (
<PosterCardContainer key={`${uniqueId}-${data.id}`}>
<ImageContainer
$isFavorite={data?.userFavorite}
to={path}
>
{data?.imageUrl ? (
<Image
importance="auto"
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<Placeholder
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ImageContainer>
<DetailContainer>
<CardRows
data={data}
rows={controls.cardRows}
/>
</DetailContainer>
</PosterCardContainer>
);
}
return (
<PosterCardContainer key={`placeholder-${uniqueId}-${data.id}`}>
<Skeleton
visible
radius="sm"
>
<ImageContainerSkeleton />
</Skeleton>
<DetailContainer>
<Stack spacing="sm">
{controls.cardRows.map((row, index) => (
<PosterCardContainer key={`placeholder-${uniqueId}-${data.id}`}>
<Skeleton
key={`${index}-${row.arrayProperty}`}
visible
height={14}
radius="sm"
/>
))}
</Stack>
</DetailContainer>
</PosterCardContainer>
);
visible
radius="sm"
>
<ImageContainerSkeleton />
</Skeleton>
<DetailContainer>
<Stack spacing="sm">
{controls.cardRows.map((row, index) => (
<Skeleton
key={`${index}-${row.arrayProperty}`}
visible
height={14}
radius="sm"
/>
))}
</Stack>
</DetailContainer>
</PosterCardContainer>
);
};

View File

@ -3,30 +3,30 @@ import { Checkbox as MantineCheckbox, CheckboxProps } from '@mantine/core';
import styled from 'styled-components';
const StyledCheckbox = styled(MantineCheckbox)`
& .mantine-Checkbox-input {
background-color: var(--input-bg);
& .mantine-Checkbox-input {
background-color: var(--input-bg);
&:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
&:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
&:hover:not(:checked) {
background-color: var(--primary-color);
opacity: 0.5;
}
transition: none;
}
&:hover:not(:checked) {
background-color: var(--primary-color);
opacity: 0.5;
}
transition: none;
}
`;
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ ...props }: CheckboxProps, ref) => {
return (
<StyledCheckbox
ref={ref}
{...props}
/>
);
},
({ ...props }: CheckboxProps, ref) => {
return (
<StyledCheckbox
ref={ref}
{...props}
/>
);
},
);

View File

@ -4,124 +4,124 @@ import { motion, Variants } from 'framer-motion';
import styled from 'styled-components';
interface ContextMenuProps {
children: ReactNode;
maxWidth?: number;
minWidth?: number;
xPos: number;
yPos: number;
children: ReactNode;
maxWidth?: number;
minWidth?: number;
xPos: number;
yPos: number;
}
const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children'>>`
position: absolute;
top: ${({ yPos }) => yPos}px !important;
left: ${({ xPos }) => xPos}px !important;
z-index: 1000;
min-width: ${({ minWidth }) => minWidth}px;
max-width: ${({ maxWidth }) => maxWidth}px;
background: var(--dropdown-menu-bg);
border-radius: var(--dropdown-menu-border-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
position: absolute;
top: ${({ yPos }) => yPos}px !important;
left: ${({ xPos }) => xPos}px !important;
z-index: 1000;
min-width: ${({ minWidth }) => minWidth}px;
max-width: ${({ maxWidth }) => maxWidth}px;
background: var(--dropdown-menu-bg);
border-radius: var(--dropdown-menu-border-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
button:first-child {
border-top-left-radius: var(--dropdown-menu-border-radius);
border-top-right-radius: var(--dropdown-menu-border-radius);
}
button:first-child {
border-top-left-radius: var(--dropdown-menu-border-radius);
border-top-right-radius: var(--dropdown-menu-border-radius);
}
button:last-child {
border-bottom-right-radius: var(--dropdown-menu-border-radius);
border-bottom-left-radius: var(--dropdown-menu-border-radius);
}
button:last-child {
border-bottom-right-radius: var(--dropdown-menu-border-radius);
border-bottom-left-radius: var(--dropdown-menu-border-radius);
}
`;
export const StyledContextMenuButton = styled(UnstyledButton)`
padding: var(--dropdown-menu-item-padding);
color: var(--dropdown-menu-fg);
font-weight: 500;
font-family: var(--content-font-family);
text-align: left;
background: var(--dropdown-menu-bg);
border: none;
cursor: default;
padding: var(--dropdown-menu-item-padding);
color: var(--dropdown-menu-fg);
font-weight: 500;
font-family: var(--content-font-family);
text-align: left;
background: var(--dropdown-menu-bg);
border: none;
cursor: default;
& .mantine-Button-inner {
justify-content: flex-start;
}
& .mantine-Button-inner {
justify-content: flex-start;
}
&:hover {
background: var(--dropdown-menu-bg-hover);
}
&:hover {
background: var(--dropdown-menu-bg-hover);
}
&:disabled {
background: transparent;
opacity: 0.6;
}
&:disabled {
background: transparent;
opacity: 0.6;
}
`;
export const ContextMenuButton = forwardRef(
(
{
children,
rightIcon,
leftIcon,
...props
}: UnstyledButtonProps &
React.ComponentPropsWithoutRef<'button'> & {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
},
ref: any,
) => {
return (
<StyledContextMenuButton
{...props}
key={props.key}
ref={ref}
as="button"
disabled={props.disabled}
onClick={props.onClick}
>
<Group position="apart">
<Group spacing="md">
<Box>{leftIcon}</Box>
<Box mr="2rem">{children}</Box>
</Group>
<Box>{rightIcon}</Box>
</Group>
</StyledContextMenuButton>
);
},
(
{
children,
rightIcon,
leftIcon,
...props
}: UnstyledButtonProps &
React.ComponentPropsWithoutRef<'button'> & {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
},
ref: any,
) => {
return (
<StyledContextMenuButton
{...props}
key={props.key}
ref={ref}
as="button"
disabled={props.disabled}
onClick={props.onClick}
>
<Group position="apart">
<Group spacing="md">
<Box>{leftIcon}</Box>
<Box mr="2rem">{children}</Box>
</Group>
<Box>{rightIcon}</Box>
</Group>
</StyledContextMenuButton>
);
},
);
const variants: Variants = {
closed: {
opacity: 0,
transition: {
duration: 0.1,
closed: {
opacity: 0,
transition: {
duration: 0.1,
},
},
},
open: {
opacity: 1,
transition: {
duration: 0.1,
open: {
opacity: 1,
transition: {
duration: 0.1,
},
},
},
};
export const ContextMenu = forwardRef(
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
return (
<ContextMenuContainer
ref={ref}
animate="open"
initial="closed"
maxWidth={maxWidth}
minWidth={minWidth}
variants={variants}
xPos={xPos}
yPos={yPos}
>
{children}
</ContextMenuContainer>
);
},
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
return (
<ContextMenuContainer
ref={ref}
animate="open"
initial="closed"
maxWidth={maxWidth}
minWidth={minWidth}
variants={variants}
xPos={xPos}
yPos={yPos}
>
{children}
</ContextMenuContainer>
);
},
);

View File

@ -3,47 +3,47 @@ import { DatePicker as MantineDatePicker } from '@mantine/dates';
import styled from 'styled-components';
interface DatePickerProps extends MantineDatePickerProps {
maxWidth?: number | string;
width?: number | string;
maxWidth?: number | string;
width?: number | string;
}
const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
& .mantine-DatePicker-input {
color: var(--input-fg);
background: var(--input-bg);
& .mantine-DatePicker-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
}
& .mantine-DatePicker-icon {
color: var(--input-placeholder-fg);
}
& .mantine-DatePicker-icon {
color: var(--input-placeholder-fg);
}
& .mantine-DatePicker-required {
color: var(--secondary-color);
}
& .mantine-DatePicker-required {
color: var(--secondary-color);
}
& .mantine-DatePicker-label {
font-family: var(--label-font-family);
}
& .mantine-DatePicker-label {
font-family: var(--label-font-family);
}
& .mantine-DateRangePicker-disabled {
opacity: 0.6;
}
& .mantine-DateRangePicker-disabled {
opacity: 0.6;
}
`;
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
return (
<StyledDatePicker
{...props}
sx={{ maxWidth, width }}
/>
);
return (
<StyledDatePicker
{...props}
sx={{ maxWidth, width }}
/>
);
};
DatePicker.defaultProps = {
maxWidth: undefined,
width: undefined,
maxWidth: undefined,
width: undefined,
};

View File

@ -1,9 +1,9 @@
import type {
MenuProps as MantineMenuProps,
MenuItemProps as MantineMenuItemProps,
MenuLabelProps as MantineMenuLabelProps,
MenuDividerProps as MantineMenuDividerProps,
MenuDropdownProps as MantineMenuDropdownProps,
MenuProps as MantineMenuProps,
MenuItemProps as MantineMenuItemProps,
MenuLabelProps as MantineMenuLabelProps,
MenuDividerProps as MantineMenuDividerProps,
MenuDropdownProps as MantineMenuDropdownProps,
} from '@mantine/core';
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
import { RiArrowLeftSFill } from 'react-icons/ri';
@ -12,9 +12,9 @@ import styled from 'styled-components';
type MenuProps = MantineMenuProps;
type MenuLabelProps = MantineMenuLabelProps;
interface MenuItemProps extends MantineMenuItemProps {
$danger?: boolean;
$isActive?: boolean;
children: React.ReactNode;
$danger?: boolean;
$isActive?: boolean;
children: React.ReactNode;
}
type MenuDividerProps = MantineMenuDividerProps;
type MenuDropdownProps = MantineMenuDropdownProps;
@ -22,46 +22,46 @@ type MenuDropdownProps = MantineMenuDropdownProps;
const StyledMenu = styled(MantineMenu)<MenuProps>``;
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
padding: 0.5rem;
font-family: var(--content-font-family);
padding: 0.5rem;
font-family: var(--content-font-family);
`;
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
position: relative;
padding: var(--dropdown-menu-item-padding);
font-size: var(--dropdown-menu-item-font-size);
font-family: var(--content-font-family);
position: relative;
padding: var(--dropdown-menu-item-padding);
font-size: var(--dropdown-menu-item-font-size);
font-family: var(--content-font-family);
&:disabled {
opacity: 0.6;
}
&:disabled {
opacity: 0.6;
}
&:hover {
background-color: var(--dropdown-menu-bg-hover);
}
&:hover {
background-color: var(--dropdown-menu-bg-hover);
}
& .mantine-Menu-itemLabel {
margin-right: 2rem;
margin-left: 1rem;
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
}
& .mantine-Menu-itemLabel {
margin-right: 2rem;
margin-left: 1rem;
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
}
& .mantine-Menu-itemRightSection {
display: flex;
}
& .mantine-Menu-itemRightSection {
display: flex;
}
cursor: default;
cursor: default;
`;
const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
margin: 0;
padding: 0;
background: var(--dropdown-menu-bg);
border: var(--dropdown-menu-border);
border-radius: var(--dropdown-menu-border-radius);
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
margin: 0;
padding: 0;
background: var(--dropdown-menu-bg);
border: var(--dropdown-menu-border);
border-radius: var(--dropdown-menu-border-radius);
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
/* *:first-child {
/* *:first-child {
border-top-left-radius: var(--dropdown-menu-border-radius);
border-top-right-radius: var(--dropdown-menu-border-radius);
}
@ -73,51 +73,51 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
`;
const StyledMenuDivider = styled(MantineMenu.Divider)`
margin: 0;
padding: 0;
margin: 0;
padding: 0;
`;
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
return (
<StyledMenu
withinPortal
styles={{
dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
{...props}
>
{children}
</StyledMenu>
);
return (
<StyledMenu
withinPortal
styles={{
dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
{...props}
>
{children}
</StyledMenu>
);
};
const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
};
const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) => {
return (
<StyledMenuItem
$danger={$danger}
$isActive={$isActive}
rightSection={$isActive && <RiArrowLeftSFill size={15} />}
{...props}
>
{children}
</StyledMenuItem>
);
return (
<StyledMenuItem
$danger={$danger}
$isActive={$isActive}
rightSection={$isActive && <RiArrowLeftSFill size={15} />}
{...props}
>
{children}
</StyledMenuItem>
);
};
const MenuDropdown = ({ children, ...props }: MenuDropdownProps) => {
return <StyledMenuDropdown {...props}>{children}</StyledMenuDropdown>;
return <StyledMenuDropdown {...props}>{children}</StyledMenuDropdown>;
};
const MenuItem = createPolymorphicComponent<'button', MenuItemProps>(pMenuItem);
const MenuDivider = ({ ...props }: MenuDividerProps) => {
return <StyledMenuDivider {...props} />;
return <StyledMenuDivider {...props} />;
};
DropdownMenu.Label = MenuLabel;

View File

@ -15,245 +15,247 @@ import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue
import { Play } from '/@/renderer/types';
const Carousel = styled(motion.div)`
position: relative;
height: 35vh;
min-height: 250px;
padding: 2rem;
overflow: hidden;
background: linear-gradient(180deg, var(--main-bg), rgba(25, 26, 28, 60%));
border-radius: 1rem;
position: relative;
height: 35vh;
min-height: 250px;
padding: 2rem;
overflow: hidden;
background: linear-gradient(180deg, var(--main-bg), rgba(25, 26, 28, 60%));
border-radius: 1rem;
`;
const Grid = styled.div`
display: grid;
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: 200px minmax(0, 1fr);
width: 100%;
max-width: 100%;
height: 100%;
display: grid;
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: 200px minmax(0, 1fr);
width: 100%;
max-width: 100%;
height: 100%;
`;
const ImageColumn = styled.div`
z-index: 15;
display: flex;
grid-area: image;
align-items: flex-end;
z-index: 15;
display: flex;
grid-area: image;
align-items: flex-end;
`;
const InfoColumn = styled.div`
z-index: 15;
display: flex;
grid-area: info;
align-items: flex-end;
width: 100%;
max-width: 100%;
padding-left: 1rem;
z-index: 15;
display: flex;
grid-area: info;
align-items: flex-end;
width: 100%;
max-width: 100%;
padding-left: 1rem;
`;
const BackgroundImage = styled.img`
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 150%;
height: 150%;
object-fit: cover;
object-position: 0 30%;
filter: blur(24px);
user-select: none;
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 150%;
height: 150%;
object-fit: cover;
object-position: 0 30%;
filter: blur(24px);
user-select: none;
`;
const BackgroundImageOverlay = styled.div`
position: absolute;
top: 0;
left: 0;
z-index: 10;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(25, 26, 28, 30%), var(--main-bg));
position: absolute;
top: 0;
left: 0;
z-index: 10;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(25, 26, 28, 30%), var(--main-bg));
`;
const Wrapper = styled(Link)`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
`;
const TitleWrapper = styled.div`
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
`;
const variants: Variants = {
animate: {
opacity: 1,
transition: { opacity: { duration: 0.5 } },
},
exit: {
opacity: 0,
transition: { opacity: { duration: 0.5 } },
},
initial: {
opacity: 0,
},
animate: {
opacity: 1,
transition: { opacity: { duration: 0.5 } },
},
exit: {
opacity: 0,
transition: { opacity: { duration: 0.5 } },
},
initial: {
opacity: 0,
},
};
interface FeatureCarouselProps {
data: Album[] | undefined;
data: Album[] | undefined;
}
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
const handlePlayQueueAdd = usePlayQueueAdd();
const [itemIndex, setItemIndex] = useState(0);
const [direction, setDirection] = useState(0);
const handlePlayQueueAdd = usePlayQueueAdd();
const [itemIndex, setItemIndex] = useState(0);
const [direction, setDirection] = useState(0);
const currentItem = data?.[itemIndex];
const currentItem = data?.[itemIndex];
const handleNext = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setDirection(1);
if (itemIndex === (data?.length || 0) - 1 || 0) {
setItemIndex(0);
return;
}
const handleNext = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setDirection(1);
if (itemIndex === (data?.length || 0) - 1 || 0) {
setItemIndex(0);
return;
}
setItemIndex((prev) => prev + 1);
};
setItemIndex((prev) => prev + 1);
};
const handlePrevious = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setDirection(-1);
if (itemIndex === 0) {
setItemIndex((data?.length || 0) - 1);
return;
}
const handlePrevious = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setDirection(-1);
if (itemIndex === 0) {
setItemIndex((data?.length || 0) - 1);
return;
}
setItemIndex((prev) => prev - 1);
};
setItemIndex((prev) => prev - 1);
};
return (
<Wrapper to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })}>
<AnimatePresence
custom={direction}
initial={false}
mode="popLayout"
>
{data && (
<Carousel
key={`image-${itemIndex}`}
animate="animate"
custom={direction}
exit="exit"
initial="initial"
variants={variants}
>
<Grid>
<ImageColumn>
<Image
height={225}
placeholder="var(--card-default-bg)"
radius="md"
src={data[itemIndex]?.imageUrl}
sx={{ objectFit: 'cover' }}
width={225}
/>
</ImageColumn>
<InfoColumn>
<Stack
spacing="md"
sx={{ width: '100%' }}
>
<TitleWrapper>
<TextTitle
lh="3.5rem"
order={1}
overflow="hidden"
sx={{ fontSize: '3.5rem' }}
weight={900}
return (
<Wrapper
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })}
>
<AnimatePresence
custom={direction}
initial={false}
mode="popLayout"
>
{data && (
<Carousel
key={`image-${itemIndex}`}
animate="animate"
custom={direction}
exit="exit"
initial="initial"
variants={variants}
>
{currentItem?.name}
</TextTitle>
</TitleWrapper>
<TitleWrapper>
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
<TextTitle
key={`carousel-artist-${artist.id}`}
order={2}
weight={600}
>
{artist.name}
</TextTitle>
))}
</TitleWrapper>
<Group>
{currentItem?.genres?.slice(0, 1).map((genre) => (
<Badge
key={`carousel-genre-${genre.id}`}
size="lg"
>
{genre.name}
</Badge>
))}
<Badge size="lg">{currentItem?.releaseYear}</Badge>
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
</Group>
<Group position="apart">
<Button
size="lg"
style={{ borderRadius: '5rem' }}
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!currentItem) return;
<Grid>
<ImageColumn>
<Image
height={225}
placeholder="var(--card-default-bg)"
radius="md"
src={data[itemIndex]?.imageUrl}
sx={{ objectFit: 'cover' }}
width={225}
/>
</ImageColumn>
<InfoColumn>
<Stack
spacing="md"
sx={{ width: '100%' }}
>
<TitleWrapper>
<TextTitle
lh="3.5rem"
order={1}
overflow="hidden"
sx={{ fontSize: '3.5rem' }}
weight={900}
>
{currentItem?.name}
</TextTitle>
</TitleWrapper>
<TitleWrapper>
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
<TextTitle
key={`carousel-artist-${artist.id}`}
order={2}
weight={600}
>
{artist.name}
</TextTitle>
))}
</TitleWrapper>
<Group>
{currentItem?.genres?.slice(0, 1).map((genre) => (
<Badge
key={`carousel-genre-${genre.id}`}
size="lg"
>
{genre.name}
</Badge>
))}
<Badge size="lg">{currentItem?.releaseYear}</Badge>
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
</Group>
<Group position="apart">
<Button
size="lg"
style={{ borderRadius: '5rem' }}
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!currentItem) return;
handlePlayQueueAdd?.({
byItemType: {
id: [currentItem.id],
type: LibraryItem.ALBUM,
},
playType: Play.NOW,
});
}}
>
Play
</Button>
<Group spacing="sm">
<Button
radius="lg"
size="sm"
variant="outline"
onClick={handlePrevious}
>
<RiArrowLeftSLine size="2rem" />
</Button>
<Button
radius="lg"
size="sm"
variant="outline"
onClick={handleNext}
>
<RiArrowRightSLine size="2rem" />
</Button>
</Group>
</Group>
</Stack>
</InfoColumn>
</Grid>
<BackgroundImage
draggable="false"
src={currentItem?.imageUrl || undefined}
/>
<BackgroundImageOverlay />
</Carousel>
)}
</AnimatePresence>
</Wrapper>
);
handlePlayQueueAdd?.({
byItemType: {
id: [currentItem.id],
type: LibraryItem.ALBUM,
},
playType: Play.NOW,
});
}}
>
Play
</Button>
<Group spacing="sm">
<Button
radius="lg"
size="sm"
variant="outline"
onClick={handlePrevious}
>
<RiArrowLeftSLine size="2rem" />
</Button>
<Button
radius="lg"
size="sm"
variant="outline"
onClick={handleNext}
>
<RiArrowRightSLine size="2rem" />
</Button>
</Group>
</Group>
</Stack>
</InfoColumn>
</Grid>
<BackgroundImage
draggable="false"
src={currentItem?.imageUrl || undefined}
/>
<BackgroundImageOverlay />
</Carousel>
)}
</AnimatePresence>
</Wrapper>
);
};

View File

@ -17,262 +17,267 @@ import { usePlayButtonBehavior } from '/@/renderer/store';
import { CardRoute, CardRow } from '/@/renderer/types';
const getSlidesPerView = (windowWidth: number) => {
if (windowWidth < 400) return 2;
if (windowWidth < 700) return 3;
if (windowWidth < 900) return 4;
if (windowWidth < 1100) return 5;
if (windowWidth < 1300) return 6;
if (windowWidth < 1500) return 7;
if (windowWidth < 1920) return 8;
return 10;
if (windowWidth < 400) return 2;
if (windowWidth < 700) return 3;
if (windowWidth < 900) return 4;
if (windowWidth < 1100) return 5;
if (windowWidth < 1300) return 6;
if (windowWidth < 1500) return 7;
if (windowWidth < 1920) return 8;
return 10;
};
const CarouselContainer = styled(Stack)`
container-type: inline-size;
container-type: inline-size;
`;
interface TitleProps {
handleNext?: () => void;
handlePrev?: () => void;
label?: string | ReactNode;
pagination: {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
handleNext?: () => void;
handlePrev?: () => void;
label?: string | ReactNode;
pagination: {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
const Title = ({ label, handleNext, handlePrev, pagination }: TitleProps) => {
return (
<Group position="apart">
{isValidElement(label) ? (
label
) : (
<TextTitle
order={2}
weight={700}
>
{label}
</TextTitle>
)}
return (
<Group position="apart">
{isValidElement(label) ? (
label
) : (
<TextTitle
order={2}
weight={700}
>
{label}
</TextTitle>
)}
<Group spacing="sm">
<Button
compact
disabled={!pagination.hasPreviousPage}
size="lg"
variant="default"
onClick={handlePrev}
>
<RiArrowLeftSLine />
</Button>
<Button
compact
disabled={!pagination.hasNextPage}
size="lg"
variant="default"
onClick={handleNext}
>
<RiArrowRightSLine />
</Button>
</Group>
</Group>
);
<Group spacing="sm">
<Button
compact
disabled={!pagination.hasPreviousPage}
size="lg"
variant="default"
onClick={handlePrev}
>
<RiArrowLeftSLine />
</Button>
<Button
compact
disabled={!pagination.hasNextPage}
size="lg"
variant="default"
onClick={handleNext}
>
<RiArrowRightSLine />
</Button>
</Group>
</Group>
);
};
interface SwiperGridCarouselProps {
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined;
isLoading?: boolean;
itemType: LibraryItem;
route: CardRoute;
swiperProps?: SwiperOptions;
title?: {
children?: ReactNode;
hasPagination?: boolean;
icon?: ReactNode;
label: string | ReactNode;
};
uniqueId: string;
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined;
isLoading?: boolean;
itemType: LibraryItem;
route: CardRoute;
swiperProps?: SwiperOptions;
title?: {
children?: ReactNode;
hasPagination?: boolean;
icon?: ReactNode;
label: string | ReactNode;
};
uniqueId: string;
}
export const SwiperGridCarousel = ({
cardRows,
data,
itemType,
route,
swiperProps,
title,
isLoading,
uniqueId,
}: SwiperGridCarouselProps) => {
const swiperRef = useRef<SwiperCore | any>(null);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const [pagination, setPagination] = useState({
hasNextPage: (data?.length || 0) > Math.round(3),
hasPreviousPage: false,
});
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = useCallback(
(options: { id: string[]; isFavorite: boolean; itemType: LibraryItem; serverId: string }) => {
const { id, itemType, isFavorite, serverId } = options;
if (isFavorite) {
deleteFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId,
});
} else {
createFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId,
});
}
},
[createFavoriteMutation, deleteFavoriteMutation],
);
const slides = useMemo(() => {
if (!data) return [];
return data.map((el) => (
<PosterCard
controls={{
cardRows,
handleFavorite,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={el}
isLoading={isLoading}
uniqueId={uniqueId}
/>
));
}, [
cardRows,
data,
handleFavorite,
handlePlayQueueAdd,
isLoading,
itemType,
playButtonBehavior,
route,
swiperProps,
title,
isLoading,
uniqueId,
]);
}: SwiperGridCarouselProps) => {
const swiperRef = useRef<SwiperCore | any>(null);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const handleNext = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
}, [swiperProps?.slidesPerView]);
const handlePrev = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
}, [swiperProps?.slidesPerView]);
const handleOnSlideChange = useCallback((e: SwiperCore) => {
const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return;
setPagination({
hasNextPage: (params?.slidesPerView || 4) < slides.length,
hasPreviousPage: (params?.slidesPerView || 4) < slides.length,
const [pagination, setPagination] = useState({
hasNextPage: (data?.length || 0) > Math.round(3),
hasPreviousPage: false,
});
}, []);
const handleOnZoomChange = useCallback((e: SwiperCore) => {
const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return;
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length,
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
});
}, []);
const handleFavorite = useCallback(
(options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
serverId: string;
}) => {
const { id, itemType, isFavorite, serverId } = options;
if (isFavorite) {
deleteFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId,
});
} else {
createFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId,
});
}
},
[createFavoriteMutation, deleteFavoriteMutation],
);
const handleOnReachEnd = useCallback((e: SwiperCore) => {
const { slides, params } = e;
const slides = useMemo(() => {
if (!data) return [];
setPagination({
hasNextPage: false,
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
});
}, []);
return data.map((el) => (
<PosterCard
controls={{
cardRows,
handleFavorite,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={el}
isLoading={isLoading}
uniqueId={uniqueId}
/>
));
}, [
cardRows,
data,
handleFavorite,
handlePlayQueueAdd,
isLoading,
itemType,
playButtonBehavior,
route,
uniqueId,
]);
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
const { slides, params } = e;
const handleNext = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
}, [swiperProps?.slidesPerView]);
setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length,
hasPreviousPage: false,
});
}, []);
const handlePrev = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
}, [swiperProps?.slidesPerView]);
const handleOnResize = useCallback((e: SwiperCore) => {
if (!e) return;
const { width } = e;
const slidesPerView = getSlidesPerView(width);
if (!e.params) return;
e.params.slidesPerView = slidesPerView;
}, []);
const handleOnSlideChange = useCallback((e: SwiperCore) => {
const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return;
const throttledOnResize = throttle(handleOnResize, 200);
setPagination({
hasNextPage: (params?.slidesPerView || 4) < slides.length,
hasPreviousPage: (params?.slidesPerView || 4) < slides.length,
});
}, []);
return (
<CarouselContainer
className="grid-carousel"
spacing="md"
>
{title ? (
<Title
{...title}
handleNext={handleNext}
handlePrev={handlePrev}
pagination={pagination}
/>
) : null}
<Swiper
ref={swiperRef}
resizeObserver
modules={[Virtual]}
slidesPerView={4}
spaceBetween={20}
style={{ height: '100%', width: '100%' }}
onBeforeInit={(swiper) => {
swiperRef.current = swiper;
}}
onBeforeResize={handleOnResize}
onReachBeginning={handleOnReachBeginning}
onReachEnd={handleOnReachEnd}
onResize={throttledOnResize}
onSlideChange={handleOnSlideChange}
onZoomChange={handleOnZoomChange}
{...swiperProps}
>
{slides.map((slideContent, index) => {
return (
<SwiperSlide
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
virtualIndex={index}
const handleOnZoomChange = useCallback((e: SwiperCore) => {
const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return;
setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length,
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
});
}, []);
const handleOnReachEnd = useCallback((e: SwiperCore) => {
const { slides, params } = e;
setPagination({
hasNextPage: false,
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
});
}, []);
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
const { slides, params } = e;
setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length,
hasPreviousPage: false,
});
}, []);
const handleOnResize = useCallback((e: SwiperCore) => {
if (!e) return;
const { width } = e;
const slidesPerView = getSlidesPerView(width);
if (!e.params) return;
e.params.slidesPerView = slidesPerView;
}, []);
const throttledOnResize = throttle(handleOnResize, 200);
return (
<CarouselContainer
className="grid-carousel"
spacing="md"
>
{title ? (
<Title
{...title}
handleNext={handleNext}
handlePrev={handlePrev}
pagination={pagination}
/>
) : null}
<Swiper
ref={swiperRef}
resizeObserver
modules={[Virtual]}
slidesPerView={4}
spaceBetween={20}
style={{ height: '100%', width: '100%' }}
onBeforeInit={(swiper) => {
swiperRef.current = swiper;
}}
onBeforeResize={handleOnResize}
onReachBeginning={handleOnReachBeginning}
onReachEnd={handleOnReachEnd}
onResize={throttledOnResize}
onSlideChange={handleOnSlideChange}
onZoomChange={handleOnZoomChange}
{...swiperProps}
>
{slideContent}
</SwiperSlide>
);
})}
</Swiper>
</CarouselContainer>
);
{slides.map((slideContent, index) => {
return (
<SwiperSlide
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
virtualIndex={index}
>
{slideContent}
</SwiperSlide>
);
})}
</Swiper>
</CarouselContainer>
);
};

View File

@ -1,23 +1,23 @@
import { HoverCard as MantineHoverCard, HoverCardProps } from '@mantine/core';
export const HoverCard = ({ children, ...props }: HoverCardProps) => {
return (
<MantineHoverCard
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
border: 'none',
borderRadius: 'var(--dropdown-menu-border-radius)',
boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)',
margin: 0,
padding: 0,
},
}}
{...props}
>
{children}
</MantineHoverCard>
);
return (
<MantineHoverCard
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
border: 'none',
borderRadius: 'var(--dropdown-menu-border-radius)',
boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)',
margin: 0,
padding: 0,
},
}}
{...props}
>
{children}
</MantineHoverCard>
);
};
HoverCard.Target = MantineHoverCard.Target;

View File

@ -1,404 +1,404 @@
import React, { forwardRef } from 'react';
import type {
TextInputProps as MantineTextInputProps,
NumberInputProps as MantineNumberInputProps,
PasswordInputProps as MantinePasswordInputProps,
FileInputProps as MantineFileInputProps,
JsonInputProps as MantineJsonInputProps,
TextareaProps as MantineTextareaProps,
TextInputProps as MantineTextInputProps,
NumberInputProps as MantineNumberInputProps,
PasswordInputProps as MantinePasswordInputProps,
FileInputProps as MantineFileInputProps,
JsonInputProps as MantineJsonInputProps,
TextareaProps as MantineTextareaProps,
} from '@mantine/core';
import {
TextInput as MantineTextInput,
NumberInput as MantineNumberInput,
PasswordInput as MantinePasswordInput,
FileInput as MantineFileInput,
JsonInput as MantineJsonInput,
Textarea as MantineTextarea,
TextInput as MantineTextInput,
NumberInput as MantineNumberInput,
PasswordInput as MantinePasswordInput,
FileInput as MantineFileInput,
JsonInput as MantineJsonInput,
Textarea as MantineTextarea,
} from '@mantine/core';
import styled from 'styled-components';
interface TextInputProps extends MantineTextInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface NumberInputProps extends MantineNumberInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface PasswordInputProps extends MantinePasswordInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface FileInputProps extends MantineFileInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface JsonInputProps extends MantineJsonInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface TextareaProps extends MantineTextareaProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
& .mantine-TextInput-wrapper {
border-color: var(--primary-color);
}
& .mantine-TextInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
& .mantine-TextInput-wrapper {
border-color: var(--primary-color);
}
}
& .mantine-Input-icon {
color: var(--input-placeholder-fg);
}
& .mantine-TextInput-input {
color: var(--input-fg);
background: var(--input-bg);
& .mantine-TextInput-required {
color: var(--secondary-color);
}
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-TextInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-Input-icon {
color: var(--input-placeholder-fg);
}
& .mantine-TextInput-disabled {
opacity: 0.6;
}
& .mantine-TextInput-required {
color: var(--secondary-color);
}
& [data-disabled='true'] {
opacity: 0.6;
}
& .mantine-TextInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
transition: width 0.3s ease-in-out;
& .mantine-TextInput-disabled {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
`;
const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>`
& .mantine-NumberInput-wrapper {
border-color: var(--primary-color);
}
& .mantine-NumberInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
& .mantine-NumberInput-wrapper {
border-color: var(--primary-color);
}
}
& .mantine-NumberInput-controlUp {
svg {
color: var(--btn-default-fg);
fill: var(--btn-default-fg);
& .mantine-NumberInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
}
& .mantine-NumberInput-controlDown {
svg {
color: var(--btn-default-fg);
fill: var(--btn-default-fg);
& .mantine-NumberInput-controlUp {
svg {
color: var(--btn-default-fg);
fill: var(--btn-default-fg);
}
}
}
& .mantine-Input-icon {
color: var(--input-placeholder-fg);
}
& .mantine-NumberInput-controlDown {
svg {
color: var(--btn-default-fg);
fill: var(--btn-default-fg);
}
}
& .mantine-NumberInput-required {
color: var(--secondary-color);
}
& .mantine-Input-icon {
color: var(--input-placeholder-fg);
}
& .mantine-NumberInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-NumberInput-required {
color: var(--secondary-color);
}
& .mantine-NumberInput-disabled {
opacity: 0.6;
}
& .mantine-NumberInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& [data-disabled='true'] {
opacity: 0.6;
}
& .mantine-NumberInput-disabled {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
`;
const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
& .mantine-PasswordInput-input {
color: var(--input-fg);
background: var(--input-bg);
& .mantine-PasswordInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
}
& .mantine-PasswordInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-PasswordInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-PasswordInput-required {
color: var(--secondary-color);
}
& .mantine-PasswordInput-required {
color: var(--secondary-color);
}
& .mantine-PasswordInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-PasswordInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-PasswordInput-disabled {
opacity: 0.6;
}
& .mantine-PasswordInput-disabled {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
transition: width 0.3s ease-in-out;
`;
const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
& .mantine-FileInput-input {
color: var(--input-fg);
background: var(--input-bg);
& .mantine-FileInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
}
& .mantine-FileInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-FileInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-FileInput-required {
color: var(--secondary-color);
}
& .mantine-FileInput-required {
color: var(--secondary-color);
}
& .mantine-FileInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-FileInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-FileInput-disabled {
opacity: 0.6;
}
& .mantine-FileInput-disabled {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
transition: width 0.3s ease-in-out;
`;
const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
& .mantine-JsonInput-input {
color: var(--input-fg);
background: var(--input-bg);
& .mantine-JsonInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
}
& .mantine-JsonInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-JsonInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-JsonInput-required {
color: var(--secondary-color);
}
& .mantine-JsonInput-required {
color: var(--secondary-color);
}
& .mantine-JsonInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-JsonInput-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-JsonInput-disabled {
opacity: 0.6;
}
& .mantine-JsonInput-disabled {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
transition: width 0.3s ease-in-out;
`;
const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
& .mantine-Textarea-input {
color: var(--input-fg);
background: var(--input-bg);
}
& .mantine-Textarea-input {
color: var(--input-fg);
background: var(--input-bg);
}
& .mantine-Textarea-icon {
color: var(--input-placeholder-fg);
}
& .mantine-Textarea-icon {
color: var(--input-placeholder-fg);
}
& .mantine-Textarea-required {
color: var(--secondary-color);
}
& .mantine-Textarea-required {
color: var(--secondary-color);
}
& .mantine-Textarea-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-Textarea-label {
margin-bottom: 0.5rem;
font-family: var(--label-font-family);
}
& .mantine-Textarea-disabled {
opacity: 0.6;
}
& .mantine-Textarea-disabled {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
transition: width 0.3s ease-in-out;
`;
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ children, width, maxWidth, ...props }: TextInputProps, ref) => {
return (
<StyledTextInput
ref={ref}
spellCheck={false}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledTextInput>
);
},
({ children, width, maxWidth, ...props }: TextInputProps, ref) => {
return (
<StyledTextInput
ref={ref}
spellCheck={false}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledTextInput>
);
},
);
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
({ children, width, maxWidth, ...props }: NumberInputProps, ref) => {
return (
<StyledNumberInput
ref={ref}
hideControls
spellCheck={false}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledNumberInput>
);
},
({ children, width, maxWidth, ...props }: NumberInputProps, ref) => {
return (
<StyledNumberInput
ref={ref}
hideControls
spellCheck={false}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledNumberInput>
);
},
);
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ children, width, maxWidth, ...props }: PasswordInputProps, ref) => {
return (
<StyledPasswordInput
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledPasswordInput>
);
},
({ children, width, maxWidth, ...props }: PasswordInputProps, ref) => {
return (
<StyledPasswordInput
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledPasswordInput>
);
},
);
export const FileInput = forwardRef<HTMLButtonElement, FileInputProps>(
({ children, width, maxWidth, ...props }: FileInputProps, ref) => {
return (
<StyledFileInput
ref={ref}
{...props}
styles={{
placeholder: {
color: 'var(--input-placeholder-fg)',
},
}}
sx={{ maxWidth, width }}
>
{children}
</StyledFileInput>
);
},
({ children, width, maxWidth, ...props }: FileInputProps, ref) => {
return (
<StyledFileInput
ref={ref}
{...props}
styles={{
placeholder: {
color: 'var(--input-placeholder-fg)',
},
}}
sx={{ maxWidth, width }}
>
{children}
</StyledFileInput>
);
},
);
export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>(
({ children, width, maxWidth, ...props }: JsonInputProps, ref) => {
return (
<StyledJsonInput
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledJsonInput>
);
},
({ children, width, maxWidth, ...props }: JsonInputProps, ref) => {
return (
<StyledJsonInput
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledJsonInput>
);
},
);
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ children, width, maxWidth, ...props }: TextareaProps, ref) => {
return (
<StyledTextarea
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledTextarea>
);
},
({ children, width, maxWidth, ...props }: TextareaProps, ref) => {
return (
<StyledTextarea
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledTextarea>
);
},
);
TextInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
children: undefined,
maxWidth: undefined,
width: undefined,
};
NumberInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
children: undefined,
maxWidth: undefined,
width: undefined,
};
PasswordInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
children: undefined,
maxWidth: undefined,
width: undefined,
};
FileInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
children: undefined,
maxWidth: undefined,
width: undefined,
};
JsonInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
children: undefined,
maxWidth: undefined,
width: undefined,
};
Textarea.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
children: undefined,
maxWidth: undefined,
width: undefined,
};

View File

@ -1,99 +1,99 @@
import React, { ReactNode } from 'react';
import {
ModalProps as MantineModalProps,
Stack,
Modal as MantineModal,
Flex,
Group,
ModalProps as MantineModalProps,
Stack,
Modal as MantineModal,
Flex,
Group,
} from '@mantine/core';
import { closeAllModals, ContextModalProps } from '@mantine/modals';
import { Button } from '/@/renderer/components/button';
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
children?: ReactNode;
handlers: {
close: () => void;
open: () => void;
toggle: () => void;
};
children?: ReactNode;
handlers: {
close: () => void;
open: () => void;
toggle: () => void;
};
}
export const Modal = ({ children, handlers, ...rest }: ModalProps) => {
return (
<MantineModal
{...rest}
onClose={handlers.close}
>
{children}
</MantineModal>
);
return (
<MantineModal
{...rest}
onClose={handlers.close}
>
{children}
</MantineModal>
);
};
export type ContextModalVars = {
context: ContextModalProps['context'];
id: ContextModalProps['id'];
context: ContextModalProps['context'];
id: ContextModalProps['id'];
};
export const BaseContextModal = ({
context,
id,
innerProps,
context,
id,
innerProps,
}: ContextModalProps<{
modalBody: (vars: ContextModalVars) => React.ReactNode;
modalBody: (vars: ContextModalVars) => React.ReactNode;
}>) => <>{innerProps.modalBody({ context, id })}</>;
Modal.defaultProps = {
children: undefined,
children: undefined,
};
interface ConfirmModalProps {
children: ReactNode;
disabled?: boolean;
labels?: {
cancel?: string;
confirm?: string;
};
loading?: boolean;
onCancel?: () => void;
onConfirm: () => void;
children: ReactNode;
disabled?: boolean;
labels?: {
cancel?: string;
confirm?: string;
};
loading?: boolean;
onCancel?: () => void;
onConfirm: () => void;
}
export const ConfirmModal = ({
loading,
disabled,
labels,
onCancel,
onConfirm,
children,
loading,
disabled,
labels,
onCancel,
onConfirm,
children,
}: ConfirmModalProps) => {
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
closeAllModals();
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
closeAllModals();
}
};
return (
<Stack>
<Flex>{children}</Flex>
<Group position="right">
<Button
data-focus
variant="default"
onClick={handleCancel}
>
{labels?.cancel ? labels.cancel : 'Cancel'}
</Button>
<Button
disabled={disabled}
loading={loading}
variant="filled"
onClick={onConfirm}
>
{labels?.confirm ? labels.confirm : 'Confirm'}
</Button>
</Group>
</Stack>
);
return (
<Stack>
<Flex>{children}</Flex>
<Group position="right">
<Button
data-focus
variant="default"
onClick={handleCancel}
>
{labels?.cancel ? labels.cancel : 'Cancel'}
</Button>
<Button
disabled={disabled}
loading={loading}
variant="filled"
onClick={onConfirm}
>
{labels?.confirm ? labels.confirm : 'Confirm'}
</Button>
</Group>
</Stack>
);
};

View File

@ -2,30 +2,30 @@ import { ReactNode } from 'react';
import { Flex, Group } from '@mantine/core';
export const Option = ({ children }: any) => {
return (
<Group
grow
p="0.5rem"
>
{children}
</Group>
);
return (
<Group
grow
p="0.5rem"
>
{children}
</Group>
);
};
interface LabelProps {
children: ReactNode;
children: ReactNode;
}
const Label = ({ children }: LabelProps) => {
return <Flex align="flex-start">{children}</Flex>;
return <Flex align="flex-start">{children}</Flex>;
};
interface ControlProps {
children: ReactNode;
children: ReactNode;
}
const Control = ({ children }: ControlProps) => {
return <Flex justify="flex-end">{children}</Flex>;
return <Flex justify="flex-end">{children}</Flex>;
};
Option.Label = Label;

View File

@ -7,127 +7,127 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
const Container = styled(motion(Flex))<{
height?: string;
position?: string;
height?: string;
position?: string;
}>`
position: ${(props) => props.position || 'relative'};
z-index: 2000;
width: 100%;
height: ${(props) => props.height || '65px'};
background: var(--titlebar-bg);
position: ${(props) => props.position || 'relative'};
z-index: 2000;
width: 100%;
height: ${(props) => props.height || '65px'};
background: var(--titlebar-bg);
`;
const Header = styled(motion.div)<{
$isDraggable?: boolean;
$isHidden?: boolean;
$padRight?: boolean;
$isDraggable?: boolean;
$isHidden?: boolean;
$padRight?: boolean;
}>`
position: relative;
z-index: 15;
width: 100%;
height: 100%;
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
-webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
position: relative;
z-index: 15;
width: 100%;
height: 100%;
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
-webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
button {
-webkit-app-region: no-drag;
}
button {
-webkit-app-region: no-drag;
}
input {
-webkit-app-region: no-drag;
}
input {
-webkit-app-region: no-drag;
}
`;
const BackgroundImage = styled.div<{ background: string }>`
position: absolute;
top: 0;
z-index: 1;
width: 100%;
height: 100%;
background: ${(props) => props.background || 'var(--titlebar-bg)'};
position: absolute;
top: 0;
z-index: 1;
width: 100%;
height: 100%;
background: ${(props) => props.background || 'var(--titlebar-bg)'};
`;
const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
position: absolute;
top: 0;
left: 0;
z-index: 10;
width: 100%;
height: 100%;
background: ${(props) =>
props.theme === 'light'
? 'linear-gradient(rgba(255, 255, 255, 25%), rgba(255, 255, 255, 25%))'
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
position: absolute;
top: 0;
left: 0;
z-index: 10;
width: 100%;
height: 100%;
background: ${(props) =>
props.theme === 'light'
? 'linear-gradient(rgba(255, 255, 255, 25%), rgba(255, 255, 255, 25%))'
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
`;
export interface PageHeaderProps
extends Omit<FlexProps, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
backgroundColor?: string;
children?: React.ReactNode;
height?: string;
isHidden?: boolean;
position?: string;
extends Omit<FlexProps, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
backgroundColor?: string;
children?: React.ReactNode;
height?: string;
isHidden?: boolean;
position?: string;
}
const TitleWrapper = styled(motion.div)`
position: absolute;
display: flex;
width: 100%;
height: 100%;
position: absolute;
display: flex;
width: 100%;
height: 100%;
`;
const variants: Variants = {
animate: { opacity: 1 },
exit: { opacity: 0 },
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
initial: { opacity: 0 },
};
export const PageHeader = ({
position,
height,
backgroundColor,
isHidden,
children,
...props
position,
height,
backgroundColor,
isHidden,
children,
...props
}: PageHeaderProps) => {
const ref = useRef(null);
const padRight = useShouldPadTitlebar();
const { windowBarStyle } = useWindowSettings();
const theme = useTheme();
const ref = useRef(null);
const padRight = useShouldPadTitlebar();
const { windowBarStyle } = useWindowSettings();
const theme = useTheme();
return (
<Container
ref={ref}
height={height}
position={position}
{...props}
>
<Header
$isDraggable={windowBarStyle === Platform.WEB}
$isHidden={isHidden}
$padRight={padRight}
>
<AnimatePresence initial={false}>
{!isHidden && (
<TitleWrapper
animate="animate"
exit="exit"
initial="initial"
variants={variants}
return (
<Container
ref={ref}
height={height}
position={position}
{...props}
>
<Header
$isDraggable={windowBarStyle === Platform.WEB}
$isHidden={isHidden}
$padRight={padRight}
>
{children}
</TitleWrapper>
)}
</AnimatePresence>
</Header>
{backgroundColor && (
<>
<BackgroundImage background={backgroundColor || 'var(--titlebar-bg)'} />
<BackgroundImageOverlay theme={theme} />
</>
)}
</Container>
);
<AnimatePresence initial={false}>
{!isHidden && (
<TitleWrapper
animate="animate"
exit="exit"
initial="initial"
variants={variants}
>
{children}
</TitleWrapper>
)}
</AnimatePresence>
</Header>
{backgroundColor && (
<>
<BackgroundImage background={backgroundColor || 'var(--titlebar-bg)'} />
<BackgroundImageOverlay theme={theme} />
</>
)}
</Container>
);
};

View File

@ -1,52 +1,52 @@
import {
Pagination as MantinePagination,
PaginationProps as MantinePaginationProps,
Pagination as MantinePagination,
PaginationProps as MantinePaginationProps,
} from '@mantine/core';
import styled from 'styled-components';
const StyledPagination = styled(MantinePagination)<PaginationProps>`
& .mantine-Pagination-item {
color: var(--btn-default-fg);
background-color: var(--btn-default-bg);
border: none;
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
& .mantine-Pagination-item {
color: var(--btn-default-fg);
background-color: var(--btn-default-bg);
border: none;
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
&[data-active] {
color: var(--btn-primary-fg);
background-color: var(--btn-primary-bg);
&[data-active] {
color: var(--btn-primary-fg);
background-color: var(--btn-primary-bg);
}
&[data-dots] {
display: ${({ $hideDividers }) => ($hideDividers ? 'none' : 'block')};
background-color: transparent;
}
&:hover {
color: var(--btn-default-fg-hover);
background-color: var(--btn-default-bg-hover);
&[data-active] {
color: var(--btn-primary-fg-hover);
background-color: var(--btn-primary-bg-hover);
}
&[data-dots] {
background-color: transparent;
}
}
}
&[data-dots] {
display: ${({ $hideDividers }) => ($hideDividers ? 'none' : 'block')};
background-color: transparent;
}
&:hover {
color: var(--btn-default-fg-hover);
background-color: var(--btn-default-bg-hover);
&[data-active] {
color: var(--btn-primary-fg-hover);
background-color: var(--btn-primary-bg-hover);
}
&[data-dots] {
background-color: transparent;
}
}
}
`;
interface PaginationProps extends MantinePaginationProps {
$hideDividers?: boolean;
$hideDividers?: boolean;
}
export const Pagination = ({ $hideDividers, ...props }: PaginationProps) => {
return (
<StyledPagination
$hideDividers={$hideDividers}
radius="xl"
{...props}
/>
);
return (
<StyledPagination
$hideDividers={$hideDividers}
radius="xl"
{...props}
/>
);
};

View File

@ -3,13 +3,13 @@ import { Paper as MantinePaper } from '@mantine/core';
import styled from 'styled-components';
export interface PaperProps extends MantinePaperProps {
children: React.ReactNode;
children: React.ReactNode;
}
const StyledPaper = styled(MantinePaper)<PaperProps>`
background: var(--paper-bg);
background: var(--paper-bg);
`;
export const Paper = ({ children, ...props }: PaperProps) => {
return <StyledPaper {...props}>{children}</StyledPaper>;
return <StyledPaper {...props}>{children}</StyledPaper>;
};

View File

@ -1,6 +1,6 @@
import type {
PopoverProps as MantinePopoverProps,
PopoverDropdownProps as MantinePopoverDropdownProps,
PopoverProps as MantinePopoverProps,
PopoverDropdownProps as MantinePopoverDropdownProps,
} from '@mantine/core';
import { Popover as MantinePopover } from '@mantine/core';
import styled from 'styled-components';
@ -11,28 +11,28 @@ type PopoverDropdownProps = MantinePopoverDropdownProps;
const StyledPopover = styled(MantinePopover)``;
const StyledDropdown = styled(MantinePopover.Dropdown)<PopoverDropdownProps>`
padding: 0.5rem;
font-size: 0.9em;
font-family: var(--content-font-family);
background-color: var(--dropdown-menu-bg);
border: var(--dropdown-menu-border);
padding: 0.5rem;
font-size: 0.9em;
font-family: var(--content-font-family);
background-color: var(--dropdown-menu-bg);
border: var(--dropdown-menu-border);
`;
export const Popover = ({ children, ...props }: PopoverProps) => {
return (
<StyledPopover
withinPortal
styles={{
dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
transitionProps={{ transition: 'fade' }}
{...props}
>
{children}
</StyledPopover>
);
return (
<StyledPopover
withinPortal
styles={{
dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
transitionProps={{ transition: 'fade' }}
{...props}
>
{children}
</StyledPopover>
);
};
Popover.Target = MantinePopover.Target;

View File

@ -8,211 +8,211 @@ import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-b
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
const FILTER_GROUP_OPTIONS_DATA = [
{
label: 'Match all',
value: 'all',
},
{
label: 'Match any',
value: 'any',
},
{
label: 'Match all',
value: 'all',
},
{
label: 'Match any',
value: 'any',
},
];
type AddArgs = {
groupIndex: number[];
level: number;
groupIndex: number[];
level: number;
};
type DeleteArgs = {
groupIndex: number[];
level: number;
uniqueId: string;
groupIndex: number[];
level: number;
uniqueId: string;
};
interface QueryBuilderProps {
data: Record<string, any>;
filters: { label: string; type: string; value: string }[];
groupIndex: number[];
level: number;
onAddRule: (args: AddArgs) => void;
onAddRuleGroup: (args: AddArgs) => void;
onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void;
onChangeType: (args: any) => void;
onChangeValue: (args: any) => void;
onClearFilters: () => void;
onDeleteRule: (args: DeleteArgs) => void;
onDeleteRuleGroup: (args: DeleteArgs) => void;
onResetFilters: () => void;
operators: {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
uniqueId: string;
data: Record<string, any>;
filters: { label: string; type: string; value: string }[];
groupIndex: number[];
level: number;
onAddRule: (args: AddArgs) => void;
onAddRuleGroup: (args: AddArgs) => void;
onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void;
onChangeType: (args: any) => void;
onChangeValue: (args: any) => void;
onClearFilters: () => void;
onDeleteRule: (args: DeleteArgs) => void;
onDeleteRuleGroup: (args: DeleteArgs) => void;
onResetFilters: () => void;
operators: {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
uniqueId: string;
}
export const QueryBuilder = ({
data,
level,
onAddRule,
onDeleteRuleGroup,
onDeleteRule,
onAddRuleGroup,
onChangeType,
onChangeField,
operators,
onChangeOperator,
onChangeValue,
onClearFilters,
onResetFilters,
groupIndex,
uniqueId,
filters,
data,
level,
onAddRule,
onDeleteRuleGroup,
onDeleteRule,
onAddRuleGroup,
onChangeType,
onChangeField,
operators,
onChangeOperator,
onChangeValue,
onClearFilters,
onResetFilters,
groupIndex,
uniqueId,
filters,
}: QueryBuilderProps) => {
const handleAddRule = () => {
onAddRule({ groupIndex, level });
};
const handleAddRule = () => {
onAddRule({ groupIndex, level });
};
const handleAddRuleGroup = () => {
onAddRuleGroup({ groupIndex, level });
};
const handleAddRuleGroup = () => {
onAddRuleGroup({ groupIndex, level });
};
const handleDeleteRuleGroup = () => {
onDeleteRuleGroup({ groupIndex, level, uniqueId });
};
const handleDeleteRuleGroup = () => {
onDeleteRuleGroup({ groupIndex, level, uniqueId });
};
const handleChangeType = (value: string | null) => {
onChangeType({ groupIndex, level, value });
};
const handleChangeType = (value: string | null) => {
onChangeType({ groupIndex, level, value });
};
return (
<Stack
ml={`${level * 10}px`}
spacing="sm"
>
<Group spacing="sm">
<Select
data={FILTER_GROUP_OPTIONS_DATA}
maxWidth={175}
size="sm"
value={data.type}
width="20%"
onChange={handleChangeType}
/>
<Button
px={5}
size="sm"
tooltip={{ label: 'Add rule' }}
variant="default"
onClick={handleAddRule}
return (
<Stack
ml={`${level * 10}px`}
spacing="sm"
>
<RiAddLine size={20} />
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
p={0}
size="sm"
variant="subtle"
>
<RiMore2Line size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiAddFill />}
onClick={handleAddRuleGroup}
>
Add rule group
</DropdownMenu.Item>
<Group spacing="sm">
<Select
data={FILTER_GROUP_OPTIONS_DATA}
maxWidth={175}
size="sm"
value={data.type}
width="20%"
onChange={handleChangeType}
/>
<Button
px={5}
size="sm"
tooltip={{ label: 'Add rule' }}
variant="default"
onClick={handleAddRule}
>
<RiAddLine size={20} />
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
p={0}
size="sm"
variant="subtle"
>
<RiMore2Line size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiAddFill />}
onClick={handleAddRuleGroup}
>
Add rule group
</DropdownMenu.Item>
{level > 0 && (
<DropdownMenu.Item
icon={<RiDeleteBinFill />}
onClick={handleDeleteRuleGroup}
>
Remove rule group
</DropdownMenu.Item>
{level > 0 && (
<DropdownMenu.Item
icon={<RiDeleteBinFill />}
onClick={handleDeleteRuleGroup}
>
Remove rule group
</DropdownMenu.Item>
)}
{level === 0 && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
$danger
icon={<RiRestartLine color="var(--danger-color)" />}
onClick={onResetFilters}
>
Reset to default
</DropdownMenu.Item>
<DropdownMenu.Item
$danger
icon={<RiDeleteBinFill color="var(--danger-color)" />}
onClick={onClearFilters}
>
Clear filters
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<AnimatePresence initial={false}>
{data?.rules?.map((rule: QueryBuilderRule) => (
<motion.div
key={rule.uniqueId}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<QueryBuilderOption
data={rule}
filters={filters}
groupIndex={groupIndex || []}
level={level}
noRemove={data?.rules?.length === 1}
operators={operators}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue}
onDeleteRule={onDeleteRule}
/>
</motion.div>
))}
</AnimatePresence>
{data?.group && (
<AnimatePresence initial={false}>
{data.group?.map((group: QueryBuilderGroup, index: number) => (
<motion.div
key={group.uniqueId}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<QueryBuilder
data={group}
filters={filters}
groupIndex={[...(groupIndex || []), index]}
level={level + 1}
operators={operators}
uniqueId={group.uniqueId}
onAddRule={onAddRule}
onAddRuleGroup={onAddRuleGroup}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeType={onChangeType}
onChangeValue={onChangeValue}
onClearFilters={onClearFilters}
onDeleteRule={onDeleteRule}
onDeleteRuleGroup={onDeleteRuleGroup}
onResetFilters={onResetFilters}
/>
</motion.div>
))}
</AnimatePresence>
)}
{level === 0 && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
$danger
icon={<RiRestartLine color="var(--danger-color)" />}
onClick={onResetFilters}
>
Reset to default
</DropdownMenu.Item>
<DropdownMenu.Item
$danger
icon={<RiDeleteBinFill color="var(--danger-color)" />}
onClick={onClearFilters}
>
Clear filters
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<AnimatePresence initial={false}>
{data?.rules?.map((rule: QueryBuilderRule) => (
<motion.div
key={rule.uniqueId}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<QueryBuilderOption
data={rule}
filters={filters}
groupIndex={groupIndex || []}
level={level}
noRemove={data?.rules?.length === 1}
operators={operators}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue}
onDeleteRule={onDeleteRule}
/>
</motion.div>
))}
</AnimatePresence>
{data?.group && (
<AnimatePresence initial={false}>
{data.group?.map((group: QueryBuilderGroup, index: number) => (
<motion.div
key={group.uniqueId}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<QueryBuilder
data={group}
filters={filters}
groupIndex={[...(groupIndex || []), index]}
level={level + 1}
operators={operators}
uniqueId={group.uniqueId}
onAddRule={onAddRule}
onAddRuleGroup={onAddRuleGroup}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeType={onChangeType}
onChangeValue={onChangeValue}
onClearFilters={onClearFilters}
onDeleteRule={onDeleteRule}
onDeleteRuleGroup={onDeleteRuleGroup}
onResetFilters={onResetFilters}
/>
</motion.div>
))}
</AnimatePresence>
)}
</Stack>
);
</Stack>
);
};

View File

@ -7,233 +7,233 @@ import { Select } from '/@/renderer/components/select';
import { QueryBuilderRule } from '/@/renderer/types';
type DeleteArgs = {
groupIndex: number[];
level: number;
uniqueId: string;
groupIndex: number[];
level: number;
uniqueId: string;
};
interface QueryOptionProps {
data: QueryBuilderRule;
filters: { label: string; type: string; value: string }[];
groupIndex: number[];
level: number;
noRemove: boolean;
onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void;
onChangeValue: (args: any) => void;
onDeleteRule: (args: DeleteArgs) => void;
operators: {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
data: QueryBuilderRule;
filters: { label: string; type: string; value: string }[];
groupIndex: number[];
level: number;
noRemove: boolean;
onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void;
onChangeValue: (args: any) => void;
onDeleteRule: (args: DeleteArgs) => void;
operators: {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
}
const QueryValueInput = ({ onChange, type, ...props }: any) => {
const [numberRange, setNumberRange] = useState([0, 0]);
const [numberRange, setNumberRange] = useState([0, 0]);
switch (type) {
case 'string':
return (
<TextInput
size="sm"
onChange={onChange}
{...props}
/>
);
case 'number':
return (
<NumberInput
size="sm"
onChange={onChange}
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue)}
/>
);
case 'date':
return (
<TextInput
size="sm"
onChange={onChange}
{...props}
/>
);
switch (type) {
case 'string':
return (
<TextInput
size="sm"
onChange={onChange}
{...props}
/>
);
case 'number':
return (
<NumberInput
size="sm"
onChange={onChange}
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue)}
/>
);
case 'date':
return (
<TextInput
size="sm"
onChange={onChange}
{...props}
/>
);
case 'dateRange':
return (
<>
<NumberInput
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
maxWidth={81}
width="10%"
onChange={(e) => {
const newRange = [e || 0, numberRange[1]];
setNumberRange(newRange);
onChange(newRange);
}}
/>
<NumberInput
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
maxWidth={81}
width="10%"
onChange={(e) => {
const newRange = [numberRange[0], e || 0];
setNumberRange(newRange);
onChange(newRange);
}}
/>
</>
);
case 'dateRange':
return (
<>
<NumberInput
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
maxWidth={81}
width="10%"
onChange={(e) => {
const newRange = [e || 0, numberRange[1]];
setNumberRange(newRange);
onChange(newRange);
}}
/>
<NumberInput
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
maxWidth={81}
width="10%"
onChange={(e) => {
const newRange = [numberRange[0], e || 0];
setNumberRange(newRange);
onChange(newRange);
}}
/>
</>
);
case 'boolean':
return (
<Select
data={[
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]}
onChange={onChange}
{...props}
/>
);
case 'boolean':
return (
<Select
data={[
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]}
onChange={onChange}
{...props}
/>
);
default:
return <></>;
}
default:
return <></>;
}
};
export const QueryBuilderOption = ({
data,
filters,
level,
onDeleteRule,
operators,
groupIndex,
noRemove,
onChangeField,
onChangeOperator,
onChangeValue,
data,
filters,
level,
onDeleteRule,
operators,
groupIndex,
noRemove,
onChangeField,
onChangeOperator,
onChangeValue,
}: QueryOptionProps) => {
const { field, operator, uniqueId, value } = data;
const { field, operator, uniqueId, value } = data;
const handleDeleteRule = () => {
onDeleteRule({ groupIndex, level, uniqueId });
};
const handleDeleteRule = () => {
onDeleteRule({ groupIndex, level, uniqueId });
};
const handleChangeField = (e: any) => {
onChangeField({ groupIndex, level, uniqueId, value: e });
};
const handleChangeField = (e: any) => {
onChangeField({ groupIndex, level, uniqueId, value: e });
};
const handleChangeOperator = (e: any) => {
onChangeOperator({ groupIndex, level, uniqueId, value: e });
};
const handleChangeOperator = (e: any) => {
onChangeOperator({ groupIndex, level, uniqueId, value: e });
};
const handleChangeValue = (e: any) => {
const isDirectValue =
typeof e === 'string' ||
typeof e === 'number' ||
typeof e === 'undefined' ||
typeof e === null;
const handleChangeValue = (e: any) => {
const isDirectValue =
typeof e === 'string' ||
typeof e === 'number' ||
typeof e === 'undefined' ||
typeof e === null;
if (isDirectValue) {
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e,
});
}
if (isDirectValue) {
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e,
});
}
// const isDate = e instanceof Date;
// const isDate = e instanceof Date;
// if (isDate) {
// return onChangeValue({
// groupIndex,
// level,
// uniqueId,
// value: dayjs(e).format('YYYY-MM-DD'),
// });
// }
// if (isDate) {
// return onChangeValue({
// groupIndex,
// level,
// uniqueId,
// value: dayjs(e).format('YYYY-MM-DD'),
// });
// }
const isArray = Array.isArray(e);
const isArray = Array.isArray(e);
if (isArray) {
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e,
});
}
if (isArray) {
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e,
});
}
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e.currentTarget.value,
});
};
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e.currentTarget.value,
});
};
const fieldType = filters.find((f) => f.value === field)?.type;
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
const ml = (level + 1) * 10;
const fieldType = filters.find((f) => f.value === field)?.type;
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
const ml = (level + 1) * 10;
return (
<Group
ml={ml}
spacing="sm"
>
<Select
searchable
data={filters}
maxWidth={170}
size="sm"
value={field}
width="25%"
onChange={handleChangeField}
/>
<Select
searchable
data={operatorsByFieldType || []}
disabled={!field}
maxWidth={170}
size="sm"
value={operator}
width="25%"
onChange={handleChangeOperator}
/>
{field ? (
<QueryValueInput
defaultValue={value}
maxWidth={170}
size="sm"
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
width="25%"
onChange={handleChangeValue}
/>
) : (
<TextInput
disabled
defaultValue={value}
maxWidth={170}
size="sm"
width="25%"
onChange={handleChangeValue}
/>
)}
<Button
disabled={noRemove}
px={5}
size="sm"
tooltip={{ label: 'Remove rule' }}
variant="default"
onClick={handleDeleteRule}
>
<RiSubtractLine size={20} />
</Button>
</Group>
);
return (
<Group
ml={ml}
spacing="sm"
>
<Select
searchable
data={filters}
maxWidth={170}
size="sm"
value={field}
width="25%"
onChange={handleChangeField}
/>
<Select
searchable
data={operatorsByFieldType || []}
disabled={!field}
maxWidth={170}
size="sm"
value={operator}
width="25%"
onChange={handleChangeOperator}
/>
{field ? (
<QueryValueInput
defaultValue={value}
maxWidth={170}
size="sm"
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
width="25%"
onChange={handleChangeValue}
/>
) : (
<TextInput
disabled
defaultValue={value}
maxWidth={170}
size="sm"
width="25%"
onChange={handleChangeValue}
/>
)}
<Button
disabled={noRemove}
px={5}
size="sm"
tooltip={{ label: 'Remove rule' }}
variant="default"
onClick={handleDeleteRule}
>
<RiSubtractLine size={20} />
</Button>
</Group>
);
};

View File

@ -5,29 +5,29 @@ import styled from 'styled-components';
import { Tooltip } from '/@/renderer/components/tooltip';
interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
}
const StyledRating = styled(MantineRating)`
& .mantine-Rating-symbolBody {
svg {
stroke: var(--main-fg-secondary);
& .mantine-Rating-symbolBody {
svg {
stroke: var(--main-fg-secondary);
}
}
}
`;
export const Rating = ({ onClick, ...props }: RatingProps) => {
// const debouncedOnClick = debounce(onClick, 100);
// const debouncedOnClick = debounce(onClick, 100);
return (
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<StyledRating
{...props}
onDoubleClick={(e) => onClick(e, props.value)}
/>
</Tooltip>
);
return (
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<StyledRating
{...props}
onDoubleClick={(e) => onClick(e, props.value)}
/>
</Tooltip>
);
};

View File

@ -9,169 +9,169 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
interface ScrollAreaProps extends MantineScrollAreaProps {
children: React.ReactNode;
children: React.ReactNode;
}
const StyledScrollArea = styled(MantineScrollArea)`
& .mantine-ScrollArea-thumb {
background: var(--scrollbar-thumb-bg);
border-radius: 0;
}
& .mantine-ScrollArea-thumb {
background: var(--scrollbar-thumb-bg);
border-radius: 0;
}
& .mantine-ScrollArea-scrollbar {
padding: 0;
background: var(--scrollbar-track-bg);
}
& .mantine-ScrollArea-scrollbar {
padding: 0;
background: var(--scrollbar-track-bg);
}
& .mantine-ScrollArea-viewport > div {
display: block !important;
}
& .mantine-ScrollArea-viewport > div {
display: block !important;
}
`;
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>`
height: 100%;
overflow-y: overlay !important;
height: 100%;
overflow-y: overlay !important;
&::-webkit-scrollbar-track {
margin-top: ${(props) =>
props.windowBarStyle === Platform.WINDOWS ||
props.windowBarStyle === Platform.MACOS ||
props.windowBarStyle === Platform.LINUX
? '0px'
: props.scrollBarOffset || '65px'};
}
&::-webkit-scrollbar-track {
margin-top: ${(props) =>
props.windowBarStyle === Platform.WINDOWS ||
props.windowBarStyle === Platform.MACOS ||
props.windowBarStyle === Platform.LINUX
? '0px'
: props.scrollBarOffset || '65px'};
}
&::-webkit-scrollbar-thumb {
margin-top: ${(props) =>
props.windowBarStyle === Platform.WINDOWS ||
props.windowBarStyle === Platform.MACOS ||
props.windowBarStyle === Platform.LINUX
? '0px'
: props.scrollBarOffset || '65px'};
}
&::-webkit-scrollbar-thumb {
margin-top: ${(props) =>
props.windowBarStyle === Platform.WINDOWS ||
props.windowBarStyle === Platform.MACOS ||
props.windowBarStyle === Platform.LINUX
? '0px'
: props.scrollBarOffset || '65px'};
}
`;
export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, ref: Ref<any>) => {
return (
<StyledScrollArea
ref={ref}
scrollbarSize={12}
{...props}
>
{children}
</StyledScrollArea>
);
return (
<StyledScrollArea
ref={ref}
scrollbarSize={12}
{...props}
>
{children}
</StyledScrollArea>
);
});
interface NativeScrollAreaProps {
children: React.ReactNode;
debugScrollPosition?: boolean;
noHeader?: boolean;
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
scrollBarOffset?: string;
scrollHideDelay?: number;
style?: React.CSSProperties;
children: React.ReactNode;
debugScrollPosition?: boolean;
noHeader?: boolean;
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
scrollBarOffset?: string;
scrollHideDelay?: number;
style?: React.CSSProperties;
}
export const NativeScrollArea = forwardRef(
(
{
children,
pageHeaderProps,
debugScrollPosition,
scrollBarOffset,
scrollHideDelay,
noHeader,
...props
}: NativeScrollAreaProps,
ref: Ref<HTMLDivElement>,
) => {
const { windowBarStyle } = useWindowSettings();
const [hideScrollbar, setHideScrollbar] = useState(false);
const [hideHeader, setHideHeader] = useState(true);
const { start, clear } = useTimeout(
() => setHideScrollbar(true),
scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0,
);
(
{
children,
pageHeaderProps,
debugScrollPosition,
scrollBarOffset,
scrollHideDelay,
noHeader,
...props
}: NativeScrollAreaProps,
ref: Ref<HTMLDivElement>,
) => {
const { windowBarStyle } = useWindowSettings();
const [hideScrollbar, setHideScrollbar] = useState(false);
const [hideHeader, setHideHeader] = useState(true);
const { start, clear } = useTimeout(
() => setHideScrollbar(true),
scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0,
);
const containerRef = useRef(null);
const mergedRef = useMergedRef(ref, containerRef);
const containerRef = useRef(null);
const mergedRef = useMergedRef(ref, containerRef);
const { scrollYProgress } = useScroll({
container: containerRef,
offset: pageHeaderProps?.offset || ['center start', 'end start'],
target: pageHeaderProps?.target,
});
const { scrollYProgress } = useScroll({
container: containerRef,
offset: pageHeaderProps?.offset || ['center start', 'end start'],
target: pageHeaderProps?.target,
});
// Automatically hide the scrollbar after the timeout duration
useEffect(() => {
start();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const setHeaderVisibility = (v: number) => {
if (v === 1) {
return setHideHeader(false);
}
if (hideHeader === false) {
return setHideHeader(true);
}
return undefined;
};
const unsubscribe = scrollYProgress.on('change', setHeaderVisibility);
return () => {
unsubscribe();
};
}, [hideHeader, scrollYProgress]);
return (
<>
{!noHeader && (
<PageHeader
isHidden={hideHeader}
position="absolute"
style={{ opacity: scrollYProgress as any }}
{...pageHeaderProps}
/>
)}
<StyledNativeScrollArea
ref={mergedRef}
className={hideScrollbar ? 'hide-scrollbar' : undefined}
scrollBarOffset={scrollBarOffset}
windowBarStyle={windowBarStyle}
onMouseEnter={() => {
setHideScrollbar(false);
clear();
}}
onMouseLeave={() => {
// Automatically hide the scrollbar after the timeout duration
useEffect(() => {
start();
}}
{...props}
>
{children}
</StyledNativeScrollArea>
{debugScrollPosition && (
<motion.div
style={{
background: 'red',
height: '10px',
left: 0,
position: 'fixed',
right: 0,
scaleX: scrollYProgress,
top: 0,
transformOrigin: '0%',
width: '100%',
zIndex: 5000,
}}
/>
)}
</>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const setHeaderVisibility = (v: number) => {
if (v === 1) {
return setHideHeader(false);
}
if (hideHeader === false) {
return setHideHeader(true);
}
return undefined;
};
const unsubscribe = scrollYProgress.on('change', setHeaderVisibility);
return () => {
unsubscribe();
};
}, [hideHeader, scrollYProgress]);
return (
<>
{!noHeader && (
<PageHeader
isHidden={hideHeader}
position="absolute"
style={{ opacity: scrollYProgress as any }}
{...pageHeaderProps}
/>
)}
<StyledNativeScrollArea
ref={mergedRef}
className={hideScrollbar ? 'hide-scrollbar' : undefined}
scrollBarOffset={scrollBarOffset}
windowBarStyle={windowBarStyle}
onMouseEnter={() => {
setHideScrollbar(false);
clear();
}}
onMouseLeave={() => {
start();
}}
{...props}
>
{children}
</StyledNativeScrollArea>
{debugScrollPosition && (
<motion.div
style={{
background: 'red',
height: '10px',
left: 0,
position: 'fixed',
right: 0,
scaleX: scrollYProgress,
top: 0,
transformOrigin: '0%',
width: '100%',
zIndex: 5000,
}}
/>
)}
</>
);
},
);

View File

@ -7,64 +7,64 @@ import { useSettingsStore } from '/@/renderer/store';
import { shallow } from 'zustand/shallow';
interface SearchInputProps extends TextInputProps {
initialWidth?: number;
openedWidth?: number;
value?: string;
initialWidth?: number;
openedWidth?: number;
value?: string;
}
export const SearchInput = ({
initialWidth,
onChange,
openedWidth,
...props
initialWidth,
onChange,
openedWidth,
...props
}: SearchInputProps) => {
const { ref, focused } = useFocusWithin();
const mergedRef = useMergedRef<HTMLInputElement>(ref);
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
const { ref, focused } = useFocusWithin();
const mergedRef = useMergedRef<HTMLInputElement>(ref);
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
const isOpened = focused || ref.current?.value;
const showIcon = !isOpened || (openedWidth || 100) > 100;
const isOpened = focused || ref.current?.value;
const showIcon = !isOpened || (openedWidth || 100) > 100;
useHotkeys([[binding.hotkey, () => ref.current.select()]]);
useHotkeys([[binding.hotkey, () => ref.current.select()]]);
const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.code === 'Escape') {
onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);
ref.current.value = '';
ref.current.blur();
}
};
const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.code === 'Escape') {
onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);
ref.current.value = '';
ref.current.blur();
}
};
return (
<TextInput
ref={mergedRef}
{...props}
icon={showIcon && <RiSearchLine />}
rightSection={
isOpened ? (
<ActionIcon
onClick={() => {
ref.current.value = '';
ref.current.focus();
return (
<TextInput
ref={mergedRef}
{...props}
icon={showIcon && <RiSearchLine />}
rightSection={
isOpened ? (
<ActionIcon
onClick={() => {
ref.current.value = '';
ref.current.focus();
}}
>
<RiCloseFill />
</ActionIcon>
) : null
}
size="md"
styles={{
icon: { svg: { fill: 'var(--titlebar-fg)' } },
input: {
backgroundColor: isOpened ? 'inherit' : 'transparent !important',
border: 'none !important',
cursor: isOpened ? 'text' : 'pointer',
padding: isOpened ? '10px' : 0,
},
}}
>
<RiCloseFill />
</ActionIcon>
) : null
}
size="md"
styles={{
icon: { svg: { fill: 'var(--titlebar-fg)' } },
input: {
backgroundColor: isOpened ? 'inherit' : 'transparent !important',
border: 'none !important',
cursor: isOpened ? 'text' : 'pointer',
padding: isOpened ? '10px' : 0,
},
}}
width={isOpened ? openedWidth || 150 : initialWidth || 35}
onChange={onChange}
onKeyDown={handleEscape}
/>
);
width={isOpened ? openedWidth || 150 : initialWidth || 35}
onChange={onChange}
onKeyDown={handleEscape}
/>
);
};

View File

@ -6,37 +6,37 @@ import styled from 'styled-components';
type SegmentedControlProps = MantineSegmentedControlProps;
const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedControlProps>`
& .mantine-SegmentedControl-label {
color: var(--input-fg);
font-family: var(--content-font-family);
}
& .mantine-SegmentedControl-label {
color: var(--input-fg);
font-family: var(--content-font-family);
}
background-color: var(--input-bg);
background-color: var(--input-bg);
& .mantine-SegmentedControl-disabled {
opacity: 0.6;
}
& .mantine-SegmentedControl-disabled {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
& .mantine-SegmentedControl-active {
color: var(--input-active-fg);
background-color: var(--input-active-bg);
}
& .mantine-SegmentedControl-active {
color: var(--input-active-fg);
background-color: var(--input-active-bg);
}
`;
export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
({ ...props }: SegmentedControlProps, ref) => {
return (
<StyledSegmentedControl
ref={ref}
styles={{}}
transitionDuration={250}
transitionTimingFunction="linear"
{...props}
/>
);
},
({ ...props }: SegmentedControlProps, ref) => {
return (
<StyledSegmentedControl
ref={ref}
styles={{}}
transitionDuration={250}
transitionTimingFunction="linear"
{...props}
/>
);
},
);

View File

@ -1,142 +1,142 @@
import type {
SelectProps as MantineSelectProps,
MultiSelectProps as MantineMultiSelectProps,
SelectProps as MantineSelectProps,
MultiSelectProps as MantineMultiSelectProps,
} from '@mantine/core';
import { Select as MantineSelect, MultiSelect as MantineMultiSelect } from '@mantine/core';
import styled from 'styled-components';
interface SelectProps extends MantineSelectProps {
maxWidth?: number | string;
width?: number | string;
maxWidth?: number | string;
width?: number | string;
}
export interface MultiSelectProps extends MantineMultiSelectProps {
maxWidth?: number | string;
width?: number | string;
maxWidth?: number | string;
width?: number | string;
}
const StyledSelect = styled(MantineSelect)`
& [data-selected='true'] {
background: var(--input-bg);
}
& [data-disabled='true'] {
background: var(--input-bg);
opacity: 0.6;
}
& .mantine-Select-itemsWrapper {
& .mantine-Select-item {
padding: 40px;
& [data-selected='true'] {
background: var(--input-bg);
}
& [data-disabled='true'] {
background: var(--input-bg);
opacity: 0.6;
}
& .mantine-Select-itemsWrapper {
& .mantine-Select-item {
padding: 40px;
}
}
}
`;
export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
return (
<StyledSelect
withinPortal
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
},
input: {
background: 'var(--input-bg)',
color: 'var(--input-fg)',
},
item: {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-hovered]': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-selected="true"]': {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
background: 'none',
color: 'var(--primary-color)',
},
color: 'var(--dropdown-menu-fg)',
padding: '.3rem',
},
}}
sx={{ maxWidth, width }}
transitionProps={{ duration: 100, transition: 'fade' }}
{...props}
/>
);
return (
<StyledSelect
withinPortal
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
},
input: {
background: 'var(--input-bg)',
color: 'var(--input-fg)',
},
item: {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-hovered]': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-selected="true"]': {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
background: 'none',
color: 'var(--primary-color)',
},
color: 'var(--dropdown-menu-fg)',
padding: '.3rem',
},
}}
sx={{ maxWidth, width }}
transitionProps={{ duration: 100, transition: 'fade' }}
{...props}
/>
);
};
const StyledMultiSelect = styled(MantineMultiSelect)`
& [data-selected='true'] {
background: var(--input-select-bg);
}
& [data-disabled='true'] {
background: var(--input-bg);
opacity: 0.6;
}
& .mantine-MultiSelect-itemsWrapper {
& .mantine-Select-item {
padding: 40px;
& [data-selected='true'] {
background: var(--input-select-bg);
}
& [data-disabled='true'] {
background: var(--input-bg);
opacity: 0.6;
}
& .mantine-MultiSelect-itemsWrapper {
& .mantine-Select-item {
padding: 40px;
}
}
}
`;
export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) => {
return (
<StyledMultiSelect
withinPortal
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
},
input: {
background: 'var(--input-bg)',
color: 'var(--input-fg)',
},
item: {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-hovered]': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-selected="true"]': {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
background: 'none',
color: 'var(--primary-color)',
},
color: 'var(--dropdown-menu-fg)',
padding: '.5rem .1rem',
},
value: {
margin: '.2rem',
paddingBottom: '1rem',
paddingLeft: '1rem',
paddingTop: '1rem',
},
}}
sx={{ maxWidth, width }}
transitionProps={{ duration: 100, transition: 'fade' }}
{...props}
/>
);
return (
<StyledMultiSelect
withinPortal
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
},
input: {
background: 'var(--input-bg)',
color: 'var(--input-fg)',
},
item: {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-hovered]': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-selected="true"]': {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
background: 'none',
color: 'var(--primary-color)',
},
color: 'var(--dropdown-menu-fg)',
padding: '.5rem .1rem',
},
value: {
margin: '.2rem',
paddingBottom: '1rem',
paddingLeft: '1rem',
paddingTop: '1rem',
},
}}
sx={{ maxWidth, width }}
transitionProps={{ duration: 100, transition: 'fade' }}
{...props}
/>
);
};
Select.defaultProps = {
maxWidth: undefined,
width: undefined,
maxWidth: undefined,
width: undefined,
};
MultiSelect.defaultProps = {
maxWidth: undefined,
width: undefined,
maxWidth: undefined,
width: undefined,
};

View File

@ -3,16 +3,16 @@ import { Skeleton as MantineSkeleton } from '@mantine/core';
import styled from 'styled-components';
const StyledSkeleton = styled(MantineSkeleton)`
&::after {
background: var(--placeholder-bg);
}
&::after {
background: var(--placeholder-bg);
}
`;
export const Skeleton = ({ ...props }: MantineSkeletonProps) => {
return (
<StyledSkeleton
animate={false}
{...props}
/>
);
return (
<StyledSkeleton
animate={false}
{...props}
/>
);
};

View File

@ -5,30 +5,30 @@ import styled from 'styled-components';
type SliderProps = MantineSliderProps;
const StyledSlider = styled(MantineSlider)`
& .mantine-Slider-track {
height: 0.5rem;
background-color: var(--slider-track-bg);
}
& .mantine-Slider-track {
height: 0.5rem;
background-color: var(--slider-track-bg);
}
& .mantine-Slider-bar {
background-color: var(--primary-color);
}
& .mantine-Slider-bar {
background-color: var(--primary-color);
}
& .mantine-Slider-thumb {
width: 1rem;
height: 1rem;
background: var(--slider-thumb-bg);
border: none;
}
& .mantine-Slider-thumb {
width: 1rem;
height: 1rem;
background: var(--slider-thumb-bg);
border: none;
}
& .mantine-Slider-label {
padding: 0 1rem;
color: var(--tooltip-fg);
font-size: 1em;
background: var(--tooltip-bg);
}
& .mantine-Slider-label {
padding: 0 1rem;
color: var(--tooltip-fg);
font-size: 1em;
background: var(--tooltip-bg);
}
`;
export const Slider = ({ ...props }: SliderProps) => {
return <StyledSlider {...props} />;
return <StyledSlider {...props} />;
};

View File

@ -5,35 +5,35 @@ import styled from 'styled-components';
import { rotating } from '/@/renderer/styles';
interface SpinnerProps extends IconType {
color?: string;
container?: boolean;
size?: number;
color?: string;
container?: boolean;
size?: number;
}
export const SpinnerIcon = styled(RiLoader5Fill)`
${rotating}
animation: rotating 1s ease-in-out infinite;
${rotating}
animation: rotating 1s ease-in-out infinite;
`;
export const Spinner = ({ ...props }: SpinnerProps) => {
if (props.container) {
return (
<Center
h="100%"
w="100%"
>
<SpinnerIcon
color={props.color}
size={props.size}
/>
</Center>
);
}
if (props.container) {
return (
<Center
h="100%"
w="100%"
>
<SpinnerIcon
color={props.color}
size={props.size}
/>
</Center>
);
}
return <SpinnerIcon {...props} />;
return <SpinnerIcon {...props} />;
};
Spinner.defaultProps = {
color: undefined,
size: 15,
color: undefined,
size: 15,
};

View File

@ -5,24 +5,24 @@ import styled from 'styled-components';
type SwitchProps = MantineSwitchProps;
const StyledSwitch = styled(MantineSwitch)`
display: flex;
display: flex;
& .mantine-Switch-track {
background-color: var(--switch-track-bg);
border: none;
}
& .mantine-Switch-input {
&:checked + .mantine-Switch-track {
background-color: var(--switch-track-enabled-bg);
& .mantine-Switch-track {
background-color: var(--switch-track-bg);
border: none;
}
}
& .mantine-Switch-thumb {
background-color: var(--switch-thumb-bg);
}
& .mantine-Switch-input {
&:checked + .mantine-Switch-track {
background-color: var(--switch-track-enabled-bg);
}
}
& .mantine-Switch-thumb {
background-color: var(--switch-thumb-bg);
}
`;
export const Switch = ({ ...props }: SwitchProps) => {
return <StyledSwitch {...props} />;
return <StyledSwitch {...props} />;
};

View File

@ -5,57 +5,57 @@ import styled from 'styled-components';
type TabsProps = MantineTabsProps;
const StyledTabs = styled(MantineTabs)`
height: 100%;
height: 100%;
& .mantine-Tabs-tabsList {
padding-right: 1rem;
}
&.mantine-Tabs-tab {
padding: 0.5rem 1rem;
font-weight: 600;
font-size: 1rem;
background-color: var(--main-bg);
}
& .mantine-Tabs-panel {
padding: 1.5rem 0.5rem;
}
& .mantine-Tabs-tab {
padding: 1rem;
color: var(--btn-subtle-fg);
border-radius: 0;
&:hover {
color: var(--btn-subtle-fg-hover);
background: var(--btn-subtle-bg-hover);
& .mantine-Tabs-tabsList {
padding-right: 1rem;
}
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
}
button[data-active] {
color: var(--btn-subtle-fg);
background: none;
border-color: var(--primary-color);
&:hover {
background: none;
&.mantine-Tabs-tab {
padding: 0.5rem 1rem;
font-weight: 600;
font-size: 1rem;
background-color: var(--main-bg);
}
& .mantine-Tabs-panel {
padding: 1.5rem 0.5rem;
}
& .mantine-Tabs-tab {
padding: 1rem;
color: var(--btn-subtle-fg);
border-radius: 0;
&:hover {
color: var(--btn-subtle-fg-hover);
background: var(--btn-subtle-bg-hover);
}
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
}
button[data-active] {
color: var(--btn-subtle-fg);
background: none;
border-color: var(--primary-color);
&:hover {
background: none;
}
}
}
`;
export const Tabs = ({ children, ...props }: TabsProps) => {
return <StyledTabs {...props}>{children}</StyledTabs>;
return <StyledTabs {...props}>{children}</StyledTabs>;
};
const Panel = ({ children, ...props }: TabsPanelProps) => {
return (
<StyledTabs.Panel {...props}>
<Suspense fallback={<></>}>{children}</Suspense>
</StyledTabs.Panel>
);
return (
<StyledTabs.Panel {...props}>
<Suspense fallback={<></>}>{children}</Suspense>
</StyledTabs.Panel>
);
};
Tabs.List = StyledTabs.List;

View File

@ -7,50 +7,50 @@ import { textEllipsis } from '/@/renderer/styles';
type MantineTextTitleDivProps = MantineTitleProps & ComponentPropsWithoutRef<'div'>;
interface TextTitleProps extends MantineTextTitleDivProps {
$link?: boolean;
$noSelect?: boolean;
$secondary?: boolean;
children?: ReactNode;
overflow?: 'hidden' | 'visible';
to?: string;
weight?: number;
$link?: boolean;
$noSelect?: boolean;
$secondary?: boolean;
children?: ReactNode;
overflow?: 'hidden' | 'visible';
to?: string;
weight?: number;
}
const StyledTextTitle = styled(MantineHeader)<TextTitleProps>`
overflow: ${(props) => props.overflow};
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
cursor: ${(props) => props.$link && 'cursor'};
transition: color 0.2s ease-in-out;
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
overflow: ${(props) => props.overflow};
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
cursor: ${(props) => props.$link && 'cursor'};
transition: color 0.2s ease-in-out;
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
&:hover {
color: ${(props) => props.$link && 'var(--main-fg)'};
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
}
&:hover {
color: ${(props) => props.$link && 'var(--main-fg)'};
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
}
`;
const _TextTitle = ({ children, $secondary, overflow, $noSelect, ...rest }: TextTitleProps) => {
return (
<StyledTextTitle
$noSelect={$noSelect}
$secondary={$secondary}
overflow={overflow}
{...rest}
>
{children}
</StyledTextTitle>
);
return (
<StyledTextTitle
$noSelect={$noSelect}
$secondary={$secondary}
overflow={overflow}
{...rest}
>
{children}
</StyledTextTitle>
);
};
export const TextTitle = createPolymorphicComponent<'div', TextTitleProps>(_TextTitle);
_TextTitle.defaultProps = {
$link: false,
$noSelect: false,
$secondary: false,
font: undefined,
overflow: 'visible',
to: '',
weight: 400,
$link: false,
$noSelect: false,
$secondary: false,
font: undefined,
overflow: 'visible',
to: '',
weight: 400,
};

View File

@ -8,52 +8,52 @@ import { textEllipsis } from '/@/renderer/styles';
type MantineTextDivProps = MantineTextProps & ComponentPropsWithoutRef<'div'>;
interface TextProps extends MantineTextDivProps {
$link?: boolean;
$noSelect?: boolean;
$secondary?: boolean;
children?: ReactNode;
font?: Font;
overflow?: 'hidden' | 'visible';
to?: string;
weight?: number;
$link?: boolean;
$noSelect?: boolean;
$secondary?: boolean;
children?: ReactNode;
font?: Font;
overflow?: 'hidden' | 'visible';
to?: string;
weight?: number;
}
const StyledText = styled(MantineText)<TextProps>`
overflow: ${(props) => props.overflow};
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
font-family: ${(props) => props.font};
cursor: ${(props) => props.$link && 'cursor'};
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
overflow: ${(props) => props.overflow};
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
font-family: ${(props) => props.font};
cursor: ${(props) => props.$link && 'cursor'};
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
&:hover {
color: ${(props) => props.$link && 'var(--main-fg)'};
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
}
&:hover {
color: ${(props) => props.$link && 'var(--main-fg)'};
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
}
`;
export const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
return (
<StyledText
$noSelect={$noSelect}
$secondary={$secondary}
font={font}
overflow={overflow}
{...rest}
>
{children}
</StyledText>
);
return (
<StyledText
$noSelect={$noSelect}
$secondary={$secondary}
font={font}
overflow={overflow}
{...rest}
>
{children}
</StyledText>
);
};
export const Text = createPolymorphicComponent<'div', TextProps>(_Text);
_Text.defaultProps = {
$link: false,
$noSelect: false,
$secondary: false,
font: undefined,
overflow: 'visible',
to: '',
weight: 400,
$link: false,
$noSelect: false,
$secondary: false,
font: undefined,
overflow: 'visible',
to: '',
weight: 400,
};

View File

@ -1,76 +1,76 @@
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
import {
showNotification,
updateNotification,
hideNotification,
cleanNotifications,
cleanNotificationsQueue,
showNotification,
updateNotification,
hideNotification,
cleanNotifications,
cleanNotificationsQueue,
} from '@mantine/notifications';
interface NotificationProps extends MantineNotificationProps {
type?: 'success' | 'error' | 'warning' | 'info';
type?: 'success' | 'error' | 'warning' | 'info';
}
const showToast = ({ type, ...props }: NotificationProps) => {
const color =
type === 'success'
? 'var(--success-color)'
: type === 'warning'
? 'var(--warning-color)'
: type === 'error'
? 'var(--danger-color)'
: 'var(--primary-color)';
const color =
type === 'success'
? 'var(--success-color)'
: type === 'warning'
? 'var(--warning-color)'
: type === 'error'
? 'var(--danger-color)'
: 'var(--primary-color)';
const defaultTitle =
type === 'success'
? 'Success'
: type === 'warning'
? 'Warning'
: type === 'error'
? 'Error'
: 'Info';
const defaultTitle =
type === 'success'
? 'Success'
: type === 'warning'
? 'Warning'
: type === 'error'
? 'Error'
: 'Info';
const defaultDuration = type === 'error' ? 2000 : 1000;
const defaultDuration = type === 'error' ? 2000 : 1000;
return showNotification({
autoClose: defaultDuration,
styles: () => ({
closeButton: {
'&:hover': {
background: 'transparent',
},
},
description: {
color: 'var(--toast-description-fg)',
fontSize: '1rem',
},
loader: {
margin: '1rem',
},
root: {
'&::before': { backgroundColor: color },
background: 'var(--toast-bg)',
border: '2px solid var(--generic-border-color)',
bottom: '90px',
},
title: {
color: 'var(--toast-title-fg)',
fontSize: '1.3rem',
},
}),
title: defaultTitle,
...props,
});
return showNotification({
autoClose: defaultDuration,
styles: () => ({
closeButton: {
'&:hover': {
background: 'transparent',
},
},
description: {
color: 'var(--toast-description-fg)',
fontSize: '1rem',
},
loader: {
margin: '1rem',
},
root: {
'&::before': { backgroundColor: color },
background: 'var(--toast-bg)',
border: '2px solid var(--generic-border-color)',
bottom: '90px',
},
title: {
color: 'var(--toast-title-fg)',
fontSize: '1.3rem',
},
}),
title: defaultTitle,
...props,
});
};
export const toast = {
clean: cleanNotifications,
cleanQueue: cleanNotificationsQueue,
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
hide: hideNotification,
info: (props: NotificationProps) => showToast({ type: 'info', ...props }),
show: showToast,
success: (props: NotificationProps) => showToast({ type: 'success', ...props }),
update: updateNotification,
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
clean: cleanNotifications,
cleanQueue: cleanNotificationsQueue,
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
hide: hideNotification,
info: (props: NotificationProps) => showToast({ type: 'info', ...props }),
show: showToast,
success: (props: NotificationProps) => showToast({ type: 'success', ...props }),
update: updateNotification,
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
};

View File

@ -3,44 +3,44 @@ import { Tooltip as MantineTooltip } from '@mantine/core';
import styled from 'styled-components';
const StyledTooltip = styled(MantineTooltip)`
& .mantine-Tooltip-tooltip {
margin: 20px;
}
& .mantine-Tooltip-tooltip {
margin: 20px;
}
`;
export const Tooltip = ({ children, ...rest }: TooltipProps) => {
return (
<StyledTooltip
multiline
withinPortal
pl={10}
pr={10}
py={5}
radius="xs"
styles={{
tooltip: {
background: 'var(--tooltip-bg)',
boxShadow: '4px 4px 10px 0px rgba(0,0,0,0.2)',
color: 'var(--tooltip-fg)',
fontSize: '1.1rem',
fontWeight: 550,
maxWidth: '250px',
},
}}
transitionProps={{
duration: 250,
transition: 'fade',
}}
{...rest}
>
{children}
</StyledTooltip>
);
return (
<StyledTooltip
multiline
withinPortal
pl={10}
pr={10}
py={5}
radius="xs"
styles={{
tooltip: {
background: 'var(--tooltip-bg)',
boxShadow: '4px 4px 10px 0px rgba(0,0,0,0.2)',
color: 'var(--tooltip-fg)',
fontSize: '1.1rem',
fontWeight: 550,
maxWidth: '250px',
},
}}
transitionProps={{
duration: 250,
transition: 'fade',
}}
{...rest}
>
{children}
</StyledTooltip>
);
};
Tooltip.defaultProps = {
openDelay: 0,
position: 'top',
withArrow: true,
withinPortal: true,
openDelay: 0,
position: 'top',
withArrow: true,
withinPortal: true,
};

View File

@ -11,87 +11,91 @@ import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
interface BaseGridCardProps {
columnIndex: number;
controls: {
cardRows: CardRow<Album | AlbumArtist | Artist>[];
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
isHidden?: boolean;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
columnIndex: number;
controls: {
cardRows: CardRow<Album | AlbumArtist | Artist>[];
handleFavorite: (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
}) => void;
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
isHidden?: boolean;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
}
const DefaultCardContainer = styled.div<{ $isHidden?: boolean }>`
display: flex;
flex-direction: column;
width: 100%;
height: calc(100% - 2rem);
margin: 0.5rem;
overflow: hidden;
background: var(--card-default-bg);
border-radius: var(--card-default-radius);
cursor: pointer;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
display: flex;
flex-direction: column;
width: 100%;
height: calc(100% - 2rem);
margin: 0.5rem;
overflow: hidden;
background: var(--card-default-bg);
border-radius: var(--card-default-radius);
cursor: pointer;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
&:hover {
background: var(--card-default-bg-hover);
}
&:hover {
background: var(--card-default-bg-hover);
}
`;
const InnerCardContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 1rem;
overflow: hidden;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 1rem;
overflow: hidden;
.card-controls {
opacity: 0;
}
&:hover .card-controls {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
.card-controls {
opacity: 0;
}
&:hover .card-controls {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
}
}
}
`;
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
position: relative;
display: flex;
align-items: center;
height: 100%;
aspect-ratio: 1/1;
overflow: hidden;
background: var(--placeholder-bg);
border-radius: var(--card-default-radius);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
position: relative;
display: flex;
align-items: center;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
${(props) =>
props.$isFavorite &&
`
aspect-ratio: 1/1;
overflow: hidden;
background: var(--placeholder-bg);
border-radius: var(--card-default-radius);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
${(props) =>
props.$isFavorite &&
`
&::after {
position: absolute;
top: -50px;
@ -108,134 +112,134 @@ const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
`;
const Image = styled(SimpleImg)`
width: 100%;
max-width: 100%;
height: 100% !important;
max-height: 100%;
border: 0;
width: 100%;
max-width: 100%;
height: 100% !important;
max-height: 100%;
border: 0;
img {
height: 100%;
object-fit: cover;
}
img {
height: 100%;
object-fit: cover;
}
`;
const DetailContainer = styled.div`
margin-top: 0.5rem;
margin-top: 0.5rem;
`;
export const DefaultCard = ({
listChildProps,
data,
columnIndex,
controls,
isHidden,
listChildProps,
data,
columnIndex,
controls,
isHidden,
}: BaseGridCardProps) => {
const navigate = useNavigate();
const navigate = useNavigate();
if (data) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
if (data) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
let Placeholder = RiAlbumFill;
let Placeholder = RiAlbumFill;
switch (controls.itemType) {
case LibraryItem.ALBUM:
Placeholder = RiAlbumFill;
break;
case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill;
break;
default:
Placeholder = RiAlbumFill;
break;
switch (controls.itemType) {
case LibraryItem.ALBUM:
Placeholder = RiAlbumFill;
break;
case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill;
break;
default:
Placeholder = RiAlbumFill;
break;
}
return (
<DefaultCardContainer
key={`card-${columnIndex}-${listChildProps.index}`}
onClick={() => navigate(path)}
>
<InnerCardContainer>
<ImageContainer $isFavorite={data?.userFavorite}>
{data?.imageUrl ? (
<Image
importance="auto"
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
src={data?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<Placeholder
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ImageContainer>
<DetailContainer>
<CardRows
data={data}
rows={controls.cardRows}
/>
</DetailContainer>
</InnerCardContainer>
</DefaultCardContainer>
);
}
return (
<DefaultCardContainer
key={`card-${columnIndex}-${listChildProps.index}`}
onClick={() => navigate(path)}
>
<InnerCardContainer>
<ImageContainer $isFavorite={data?.userFavorite}>
{data?.imageUrl ? (
<Image
importance="auto"
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
src={data?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<Placeholder
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ImageContainer>
<DetailContainer>
<CardRows
data={data}
rows={controls.cardRows}
/>
</DetailContainer>
</InnerCardContainer>
</DefaultCardContainer>
<DefaultCardContainer
key={`card-${columnIndex}-${listChildProps.index}`}
$isHidden={isHidden}
>
<InnerCardContainer>
<ImageContainer>
<Skeleton
visible
radius="sm"
/>
</ImageContainer>
<DetailContainer>
<Stack spacing="sm">
{controls.cardRows.map((row, index) => (
<Skeleton
key={`${index}-${columnIndex}-${row.arrayProperty}`}
visible
height={14}
radius="sm"
/>
))}
</Stack>
</DetailContainer>
</InnerCardContainer>
</DefaultCardContainer>
);
}
return (
<DefaultCardContainer
key={`card-${columnIndex}-${listChildProps.index}`}
$isHidden={isHidden}
>
<InnerCardContainer>
<ImageContainer>
<Skeleton
visible
radius="sm"
/>
</ImageContainer>
<DetailContainer>
<Stack spacing="sm">
{controls.cardRows.map((row, index) => (
<Skeleton
key={`${index}-${columnIndex}-${row.arrayProperty}`}
visible
height={14}
radius="sm"
/>
))}
</Stack>
</DetailContainer>
</InnerCardContainer>
</DefaultCardContainer>
);
};

View File

@ -9,197 +9,197 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import {
ALBUM_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS,
ALBUM_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
const PlayButton = styled.button<PlayButtonType>`
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background-color: rgb(255, 255, 255);
border: none;
border-radius: 50%;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.1s ease-in-out;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background-color: rgb(255, 255, 255);
border: none;
border-radius: 50%;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.1s ease-in-out;
&:hover {
opacity: 1;
scale: 1.1;
}
&:hover {
opacity: 1;
scale: 1.1;
}
&:active {
opacity: 1;
scale: 1;
}
&:active {
opacity: 1;
scale: 1;
}
svg {
fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0);
}
svg {
fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0);
}
`;
const SecondaryButton = styled(_Button)`
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
&:hover {
opacity: 1;
scale: 1.1;
}
&:hover {
opacity: 1;
scale: 1.1;
}
&:active {
opacity: 1;
scale: 1;
}
&:active {
opacity: 1;
scale: 1;
}
`;
const GridCardControlsContainer = styled.div<{ $isFavorite?: boolean }>`
position: absolute;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: absolute;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
`;
const FavoriteBanner = styled.div`
position: absolute;
top: -50px;
left: -50px;
width: 80px;
height: 80px;
background-color: var(--primary-color);
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
transform: rotate(-45deg);
content: '';
pointer-events: none;
position: absolute;
top: -50px;
left: -50px;
width: 80px;
height: 80px;
background-color: var(--primary-color);
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
transform: rotate(-45deg);
content: '';
pointer-events: none;
`;
const ControlsRow = styled.div`
width: 100%;
height: calc(100% / 3);
width: 100%;
height: calc(100% / 3);
`;
const BottomControls = styled(ControlsRow)`
position: absolute;
bottom: 0;
display: flex;
gap: 0.5rem;
align-items: flex-end;
justify-content: flex-end;
padding: 1rem 0.5rem;
position: absolute;
bottom: 0;
display: flex;
gap: 0.5rem;
align-items: flex-end;
justify-content: flex-end;
padding: 1rem 0.5rem;
`;
const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
svg {
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
}
svg {
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
}
`;
export const GridCardControls = ({
itemData,
itemType,
handlePlayQueueAdd,
handleFavorite,
}: {
handleFavorite: (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
serverId: string;
}) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any;
itemType: LibraryItem;
}) => {
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
e.stopPropagation();
handlePlayQueueAdd?.({
byItemType: {
id: [itemData.id],
type: itemType,
},
playType: playType || playButtonBehavior,
});
};
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
e.preventDefault();
e.stopPropagation();
handleFavorite?.({
id: [itemData.id],
isFavorite: itemData.userFavorite,
itemType,
serverId,
});
setIsFavorite(!isFavorite);
};
const handleContextMenu = useHandleGeneralContextMenu(
itemData,
itemType,
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
);
handlePlayQueueAdd,
handleFavorite,
}: {
handleFavorite: (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
serverId: string;
}) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any;
itemType: LibraryItem;
}) => {
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
const playButtonBehavior = usePlayButtonBehavior();
return (
<>
{isFavorite ? <FavoriteBanner /> : null}
<GridCardControlsContainer
$isFavorite
className="card-controls"
>
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<BottomControls>
<SecondaryButton
p={5}
variant="subtle"
onClick={(e) => handleFavorites(e, itemData?.serverId)}
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{isFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
/>
)}
</FavoriteWrapper>
</SecondaryButton>
<SecondaryButton
p={5}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
>
<RiMoreFill
color="white"
size={20}
/>
</SecondaryButton>
</BottomControls>
</GridCardControlsContainer>
</>
);
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
e.stopPropagation();
handlePlayQueueAdd?.({
byItemType: {
id: [itemData.id],
type: itemType,
},
playType: playType || playButtonBehavior,
});
};
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
e.preventDefault();
e.stopPropagation();
handleFavorite?.({
id: [itemData.id],
isFavorite: itemData.userFavorite,
itemType,
serverId,
});
setIsFavorite(!isFavorite);
};
const handleContextMenu = useHandleGeneralContextMenu(
itemType,
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
);
return (
<>
{isFavorite ? <FavoriteBanner /> : null}
<GridCardControlsContainer
$isFavorite
className="card-controls"
>
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<BottomControls>
<SecondaryButton
p={5}
variant="subtle"
onClick={(e) => handleFavorites(e, itemData?.serverId)}
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{isFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
/>
)}
</FavoriteWrapper>
</SecondaryButton>
<SecondaryButton
p={5}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
>
<RiMoreFill
color="white"
size={20}
/>
</SecondaryButton>
</BottomControls>
</GridCardControlsContainer>
</>
);
};

View File

@ -6,58 +6,58 @@ import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster
import { GridCardData, ListDisplayType } from '/@/renderer/types';
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
const {
columnCount,
itemCount,
cardRows,
itemData,
itemType,
playButtonBehavior,
handlePlayQueueAdd,
handleFavorite,
route,
display,
} = data as GridCardData;
const {
columnCount,
itemCount,
cardRows,
itemData,
itemType,
playButtonBehavior,
handlePlayQueueAdd,
handleFavorite,
route,
display,
} = data as GridCardData;
const cards = [];
const startIndex = index * columnCount;
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
const cards = [];
const startIndex = index * columnCount;
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
const columnCountInRow = stopIndex - startIndex + 1;
let columnCountToAdd = 0;
if (columnCountInRow !== columnCount) {
columnCountToAdd = columnCount - columnCountInRow;
}
const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard;
const columnCountInRow = stopIndex - startIndex + 1;
let columnCountToAdd = 0;
if (columnCountInRow !== columnCount) {
columnCountToAdd = columnCount - columnCountInRow;
}
const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard;
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
cards.push(
<View
key={`card-${i}-${index}`}
columnIndex={i}
controls={{
cardRows,
handleFavorite,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={itemData[i]}
isHidden={i > stopIndex}
listChildProps={{ index }}
/>,
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
cards.push(
<View
key={`card-${i}-${index}`}
columnIndex={i}
controls={{
cardRows,
handleFavorite,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={itemData[i]}
isHidden={i > stopIndex}
listChildProps={{ index }}
/>,
);
}
return (
<div
style={{
...style,
display: 'flex',
}}
>
{cards}
</div>
);
}
return (
<div
style={{
...style,
display: 'flex',
}}
>
{cards}
</div>
);
}, areEqual);

Some files were not shown because too many files have changed in this diff Show More