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 = { module.exports = {
extends: ['erb', 'plugin:typescript-sort-keys/recommended'], extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
ignorePatterns: ['.erb/*', 'server'], ignorePatterns: ['.erb/*', 'server'],
parser: '@typescript-eslint/parser',
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 12,
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
project: './tsconfig.json', parserOptions: {
sourceType: 'module', createDefaultProgram: true,
tsconfigRootDir: './', ecmaVersion: 12,
}, parser: '@typescript-eslint/parser',
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', project: './tsconfig.json',
}, sourceType: 'module',
webpack: { tsconfigRootDir: './',
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'), },
}, 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 --> <!--- Include as many relevant details about the environment you experienced the bug in -->
- Application version (e.g. v0.1.0) : - Application version (e.g. v0.1.0) :
- Operating System and version (e.g. Windows 10) : - Operating System and version (e.g. Windows 10) :
- Server and version (e.g. Navidrome v0.48.0) : - Server and version (e.g. Navidrome v0.48.0) :
- Node version (if developing locally) : - Node version (if developing locally) :

10
.github/config.yml vendored
View File

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

10
.github/stale.yml vendored
View File

@ -4,14 +4,14 @@ daysUntilStale: 60
daysUntilClose: 7 daysUntilClose: 7
# Issues with these labels will never be considered stale # Issues with these labels will never be considered stale
exemptLabels: exemptLabels:
- discussion - discussion
- security - security
# Label to use when marking an issue as stale # Label to use when marking an issue as stale
staleLabel: wontfix staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable # Comment to post when marking an issue as stale. Set to `false` to disable
markComment: > markComment: >
This issue has been automatically marked as stale because it has not had 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.
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 # Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false closeComment: false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

54
.vscode/launch.json vendored
View File

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

44
.vscode/tasks.json vendored
View File

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

24
assets/assets.d.ts vendored
View File

@ -1,31 +1,31 @@
type Styles = Record<string, string>; type Styles = Record<string, string>;
declare module '*.svg' { declare module '*.svg' {
const content: string; const content: string;
export default content; export default content;
} }
declare module '*.png' { declare module '*.png' {
const content: string; const content: string;
export default content; export default content;
} }
declare module '*.jpg' { declare module '*.jpg' {
const content: string; const content: string;
export default content; export default content;
} }
declare module '*.scss' { declare module '*.scss' {
const content: Styles; const content: Styles;
export default content; export default content;
} }
declare module '*.sass' { declare module '*.sass' {
const content: Styles; const content: Styles;
export default content; export default content;
} }
declare module '*.css' { declare module '*.css' {
const content: Styles; const content: Styles;
export default content; 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", "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": {
"productName": "Feishin", "productName": "Feishin",
"appId": "org.jeffvli.feishin", "description": "Feishin music server",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "version": "0.2.0",
"asar": true, "scripts": {
"asarUnpack": "**\\*.{node,dll}", "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"files": [ "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"dist", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"node_modules", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"package.json" "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",
"afterSign": ".erb/scripts/notarize.js", "lint:styles": "npx stylelint **/*.tsx",
"electronVersion": "22.3.1", "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"mac": { "package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"target": { "package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"target": "default", "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",
"arch": [ "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"arm64", "start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
"x64" "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": { "build": {
"contents": [ "productName": "Feishin",
{ "appId": "org.jeffvli.feishin",
"x": 130, "artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"y": 220 "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
}, },
{ "dmg": {
"x": 410, "contents": [
"y": 220, {
"type": "link", "x": 130,
"path": "/Applications" "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": { "repository": {
"target": [ "type": "git",
"nsis", "url": "git+https://github.com/jeffvli/feishin.git"
"zip"
]
}, },
"linux": { "author": {
"target": [ "name": "jeffvli",
"AppImage", "url": "https://github.com/jeffvli/"
"tar.xz"
],
"icon": "assets/icons/placeholder.png",
"category": "Development"
}, },
"directories": { "contributors": [],
"app": "release/app", "license": "GPL-3.0",
"buildResources": "assets", "bugs": {
"output": "release/build" "url": "https://github.com/jeffvli/feishin/issues"
}, },
"extraResources": [ "keywords": [
"./assets/**" "subsonic",
"navidrome",
"airsonic",
"jellyfin",
"react",
"electron"
], ],
"publish": { "homepage": "https://github.com/jeffvli/feishin",
"provider": "github", "jest": {
"owner": "jeffvli", "testURL": "http://localhost/",
"repo": "feishin" "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'; import { App } from '../renderer/app';
describe('App', () => { describe('App', () => {
it('should render', () => { it('should render', () => {
expect(render(<App />)).toBeTruthy(); expect(render(<App />)).toBeTruthy();
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,164 +5,166 @@ import { getMainWindow } from '../../main';
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types'; import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
const mprisPlayer = Player({ const mprisPlayer = Player({
identity: 'Feishin', identity: 'Feishin',
maximumRate: 1.0, maximumRate: 1.0,
minimumRate: 1.0, minimumRate: 1.0,
name: 'Feishin', name: 'Feishin',
rate: 1.0, rate: 1.0,
supportedInterfaces: ['player'], supportedInterfaces: ['player'],
supportedMimeTypes: ['audio/mpeg', 'application/ogg'], supportedMimeTypes: ['audio/mpeg', 'application/ogg'],
supportedUriSchemes: ['file'], supportedUriSchemes: ['file'],
}); });
mprisPlayer.on('quit', () => { mprisPlayer.on('quit', () => {
process.exit(); process.exit();
}); });
mprisPlayer.on('stop', () => { mprisPlayer.on('stop', () => {
getMainWindow()?.webContents.send('renderer-player-stop'); getMainWindow()?.webContents.send('renderer-player-stop');
mprisPlayer.playbackStatus = 'Paused'; mprisPlayer.playbackStatus = 'Paused';
}); });
mprisPlayer.on('pause', () => { mprisPlayer.on('pause', () => {
getMainWindow()?.webContents.send('renderer-player-pause'); getMainWindow()?.webContents.send('renderer-player-pause');
mprisPlayer.playbackStatus = 'Paused'; mprisPlayer.playbackStatus = 'Paused';
}); });
mprisPlayer.on('play', () => { mprisPlayer.on('play', () => {
getMainWindow()?.webContents.send('renderer-player-play'); getMainWindow()?.webContents.send('renderer-player-play');
mprisPlayer.playbackStatus = 'Playing'; mprisPlayer.playbackStatus = 'Playing';
}); });
mprisPlayer.on('playpause', () => { mprisPlayer.on('playpause', () => {
getMainWindow()?.webContents.send('renderer-player-play-pause'); getMainWindow()?.webContents.send('renderer-player-play-pause');
if (mprisPlayer.playbackStatus !== 'Playing') { if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing'; mprisPlayer.playbackStatus = 'Playing';
} else { } else {
mprisPlayer.playbackStatus = 'Paused'; mprisPlayer.playbackStatus = 'Paused';
} }
}); });
mprisPlayer.on('next', () => { mprisPlayer.on('next', () => {
getMainWindow()?.webContents.send('renderer-player-next'); getMainWindow()?.webContents.send('renderer-player-next');
if (mprisPlayer.playbackStatus !== 'Playing') { if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing'; mprisPlayer.playbackStatus = 'Playing';
} }
}); });
mprisPlayer.on('previous', () => { mprisPlayer.on('previous', () => {
getMainWindow()?.webContents.send('renderer-player-previous'); getMainWindow()?.webContents.send('renderer-player-previous');
if (mprisPlayer.playbackStatus !== 'Playing') { if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING; mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;
} }
}); });
mprisPlayer.on('volume', (event: any) => { mprisPlayer.on('volume', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-volume', { getMainWindow()?.webContents.send('mpris-request-volume', {
volume: event, volume: event,
}); });
}); });
mprisPlayer.on('shuffle', (event: boolean) => { mprisPlayer.on('shuffle', (event: boolean) => {
getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event }); getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });
mprisPlayer.shuffle = event; mprisPlayer.shuffle = event;
}); });
mprisPlayer.on('loopStatus', (event: string) => { mprisPlayer.on('loopStatus', (event: string) => {
getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event }); getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });
mprisPlayer.loopStatus = event; mprisPlayer.loopStatus = event;
}); });
mprisPlayer.on('position', (event: any) => { mprisPlayer.on('position', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-position', { getMainWindow()?.webContents.send('mpris-request-position', {
position: event.position / 1e6, position: event.position / 1e6,
}); });
}); });
mprisPlayer.on('seek', (event: number) => { mprisPlayer.on('seek', (event: number) => {
getMainWindow()?.webContents.send('mpris-request-seek', { getMainWindow()?.webContents.send('mpris-request-seek', {
offset: event / 1e6, offset: event / 1e6,
}); });
}); });
ipcMain.on('mpris-update-position', (_event, arg) => { ipcMain.on('mpris-update-position', (_event, arg) => {
mprisPlayer.getPosition = () => arg * 1e6; mprisPlayer.getPosition = () => arg * 1e6;
}); });
ipcMain.on('mpris-update-seek', (_event, arg) => { ipcMain.on('mpris-update-seek', (_event, arg) => {
mprisPlayer.seeked(arg * 1e6); mprisPlayer.seeked(arg * 1e6);
}); });
ipcMain.on('mpris-update-volume', (_event, arg) => { ipcMain.on('mpris-update-volume', (_event, arg) => {
mprisPlayer.volume = Number(arg); mprisPlayer.volume = Number(arg);
}); });
ipcMain.on('mpris-update-repeat', (_event, arg) => { ipcMain.on('mpris-update-repeat', (_event, arg) => {
mprisPlayer.loopStatus = arg; mprisPlayer.loopStatus = arg;
}); });
ipcMain.on('mpris-update-shuffle', (_event, arg) => { ipcMain.on('mpris-update-shuffle', (_event, arg) => {
mprisPlayer.shuffle = arg; mprisPlayer.shuffle = arg;
}); });
ipcMain.on( ipcMain.on(
'mpris-update-song', 'mpris-update-song',
( (
_event, _event,
args: { args: {
currentTime: number; currentTime: number;
repeat: PlayerRepeat; repeat: PlayerRepeat;
shuffle: PlayerShuffle; shuffle: PlayerShuffle;
song: QueueSong; song: QueueSong;
status: PlayerStatus; 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'; import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions } from 'electron';
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string; selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu; submenu?: DarwinMenuItemConstructorOptions[] | Menu;
} }
export default class MenuBuilder { export default class MenuBuilder {
mainWindow: BrowserWindow; mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) { constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow; this.mainWindow = mainWindow;
}
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
} }
const template = buildMenu(): Menu {
process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate(); if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const menu = Menu.buildFromTemplate(template); const template =
Menu.setApplicationMenu(menu); process.platform === 'darwin'
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
return menu; const menu = Menu.buildFromTemplate(template);
} Menu.setApplicationMenu(menu);
setupDevelopmentEnvironment(): void { return menu;
this.mainWindow.webContents.on('context-menu', (_, props) => { }
const { x, y } = props;
Menu.buildFromTemplate([ setupDevelopmentEnvironment(): void {
{ this.mainWindow.webContents.on('context-menu', (_, props) => {
click: () => { const { x, y } = props;
this.mainWindow.webContents.inspectElement(x, y);
},
label: 'Inspect element',
},
]).popup({ window: this.mainWindow });
});
}
buildDarwinTemplate(): MenuItemConstructorOptions[] { Menu.buildFromTemplate([
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'
? [
{ {
accelerator: 'Ctrl+R', click: () => {
click: () => { this.mainWindow.webContents.inspectElement(x, y);
this.mainWindow.webContents.reload(); },
}, label: 'Inspect element',
label: '&Reload', },
]).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', accelerator: 'Command+Shift+H',
click: () => { label: 'Hide Others',
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); selector: 'hideOtherApplications:',
}, },
label: 'Toggle &Full Screen', { 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', accelerator: 'Ctrl+Command+F',
click: () => { click: () => {
this.mainWindow.webContents.toggleDevTools(); this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
}, },
label: 'Toggle &Developer Tools', label: 'Toggle Full Screen',
}, },
]
: [
{ {
accelerator: 'F11', accelerator: 'Alt+Command+I',
click: () => { click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); this.mainWindow.webContents.toggleDevTools();
}, },
label: 'Toggle &Full Screen', label: 'Toggle Developer Tools',
}, },
], ],
}, };
{ const subMenuViewProd: MenuItemConstructorOptions = {
label: 'Help', label: 'View',
submenu: [ submenu: [
{ {
click() { accelerator: 'Ctrl+Command+F',
shell.openExternal('https://electronjs.org'); click: () => {
}, this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
label: 'Learn More', },
}, label: 'Toggle Full Screen',
{ },
click() { ],
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme'); };
}, const subMenuWindow: DarwinMenuItemConstructorOptions = {
label: 'Documentation', label: 'Window',
}, submenu: [
{ {
click() { accelerator: 'Command+M',
shell.openExternal('https://www.electronjs.org/community'); label: 'Minimize',
}, selector: 'performMiniaturize:',
label: 'Community Discussions', },
}, { accelerator: 'Command+W', label: 'Close', selector: 'performClose:' },
{ { type: 'separator' },
click() { { label: 'Bring All to Front', selector: 'arrangeInFront:' },
shell.openExternal('https://github.com/electron/electron/issues'); ],
}, };
label: 'Search Issues', 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'; import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', { contextBridge.exposeInMainWorld('electron', {
browser, browser,
ipc, ipc,
localSettings, localSettings,
lyrics, lyrics,
mpris, mpris,
mpvPlayer, mpvPlayer,
mpvPlayerListener, mpvPlayerListener,
utils, utils,
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,47 +6,47 @@ import { URL } from 'url';
export let resolveHtmlPath: (htmlFileName: string) => string; export let resolveHtmlPath: (htmlFileName: string) => string;
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
const port = process.env.PORT || 4343; const port = process.env.PORT || 4343;
resolveHtmlPath = (htmlFileName: string) => { resolveHtmlPath = (htmlFileName: string) => {
const url = new URL(`http://localhost:${port}`); const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName; url.pathname = htmlFileName;
return url.href; return url.href;
}; };
} else { } else {
resolveHtmlPath = (htmlFileName: string) => { resolveHtmlPath = (htmlFileName: string) => {
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
}; };
} }
export const isMacOS = () => { export const isMacOS = () => {
return process.platform === 'darwin'; return process.platform === 'darwin';
}; };
export const isWindows = () => { export const isWindows = () => {
return process.platform === 'win32'; return process.platform === 'win32';
}; };
export const isLinux = () => { export const isLinux = () => {
return process.platform === 'linux'; return process.platform === 'linux';
}; };
export const hotkeyToElectronAccelerator = (hotkey: string) => { export const hotkeyToElectronAccelerator = (hotkey: string) => {
let accelerator = hotkey; let accelerator = hotkey;
const replacements = { const replacements = {
mod: 'CmdOrCtrl', mod: 'CmdOrCtrl',
numpad: 'num', numpad: 'num',
numpadadd: 'numadd', numpadadd: 'numadd',
numpaddecimal: 'numdec', numpaddecimal: 'numdec',
numpaddivide: 'numdiv', numpaddivide: 'numdiv',
numpadenter: 'numenter', numpadenter: 'numenter',
numpadmultiply: 'nummult', numpadmultiply: 'nummult',
numpadsubtract: 'numsub', numpadsubtract: 'numsub',
}; };
Object.keys(replacements).forEach((key) => { Object.keys(replacements).forEach((key) => {
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]); 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 { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast/index'; import { toast } from '/@/renderer/components/toast/index';
import type { import type {
AlbumDetailArgs, AlbumDetailArgs,
AlbumListArgs, AlbumListArgs,
SongListArgs, SongListArgs,
SongDetailArgs, SongDetailArgs,
AlbumArtistDetailArgs, AlbumArtistDetailArgs,
AlbumArtistListArgs, AlbumArtistListArgs,
SetRatingArgs, SetRatingArgs,
GenreListArgs, GenreListArgs,
CreatePlaylistArgs, CreatePlaylistArgs,
DeletePlaylistArgs, DeletePlaylistArgs,
PlaylistDetailArgs, PlaylistDetailArgs,
PlaylistListArgs, PlaylistListArgs,
MusicFolderListArgs, MusicFolderListArgs,
PlaylistSongListArgs, PlaylistSongListArgs,
ArtistListArgs, ArtistListArgs,
UpdatePlaylistArgs, UpdatePlaylistArgs,
UserListArgs, UserListArgs,
FavoriteArgs, FavoriteArgs,
TopSongListArgs, TopSongListArgs,
AddToPlaylistArgs, AddToPlaylistArgs,
AddToPlaylistResponse, AddToPlaylistResponse,
RemoveFromPlaylistArgs, RemoveFromPlaylistArgs,
RemoveFromPlaylistResponse, RemoveFromPlaylistResponse,
ScrobbleArgs, ScrobbleArgs,
ScrobbleResponse, ScrobbleResponse,
AlbumArtistDetailResponse, AlbumArtistDetailResponse,
FavoriteResponse, FavoriteResponse,
CreatePlaylistResponse, CreatePlaylistResponse,
AlbumArtistListResponse, AlbumArtistListResponse,
AlbumDetailResponse, AlbumDetailResponse,
AlbumListResponse, AlbumListResponse,
ArtistListResponse, ArtistListResponse,
GenreListResponse, GenreListResponse,
MusicFolderListResponse, MusicFolderListResponse,
PlaylistDetailResponse, PlaylistDetailResponse,
PlaylistListResponse, PlaylistListResponse,
RatingResponse, RatingResponse,
SongDetailResponse, SongDetailResponse,
SongListResponse, SongListResponse,
TopSongListResponse, TopSongListResponse,
UpdatePlaylistResponse, UpdatePlaylistResponse,
UserListResponse, UserListResponse,
AuthenticationResponse, AuthenticationResponse,
SearchArgs, SearchArgs,
SearchResponse, SearchResponse,
LyricsArgs, LyricsArgs,
LyricsResponse, LyricsResponse,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { ServerType } from '/@/renderer/types'; import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './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'; import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
export type ControllerEndpoint = Partial<{ export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>; addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: ( authenticate: (
url: string, url: string,
body: { password: string; username: string }, body: { password: string; username: string },
) => Promise<AuthenticationResponse>; ) => Promise<AuthenticationResponse>;
clearPlaylist: () => void; clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>; createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>; createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>; deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>; deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>; getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>; getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getArtistDetail: () => void; getArtistDetail: () => void;
getArtistInfo: (args: any) => void; getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>; getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getFavoritesList: () => void; getFavoritesList: () => void;
getFolderItemList: () => void; getFolderItemList: () => void;
getFolderList: () => void; getFolderList: () => void;
getFolderSongs: () => void; getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>; getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>; getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>; getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>; getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>; getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>; getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>; getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>; getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>; getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>; scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>; search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>; setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>; updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>; }>;
type ApiController = { type ApiController = {
jellyfin: ControllerEndpoint; jellyfin: ControllerEndpoint;
navidrome: ControllerEndpoint; navidrome: ControllerEndpoint;
subsonic: ControllerEndpoint; subsonic: ControllerEndpoint;
}; };
const endpoints: ApiController = { const endpoints: ApiController = {
jellyfin: { jellyfin: {
addToPlaylist: jfController.addToPlaylist, addToPlaylist: jfController.addToPlaylist,
authenticate: jfController.authenticate, authenticate: jfController.authenticate,
clearPlaylist: undefined, clearPlaylist: undefined,
createFavorite: jfController.createFavorite, createFavorite: jfController.createFavorite,
createPlaylist: jfController.createPlaylist, createPlaylist: jfController.createPlaylist,
deleteFavorite: jfController.deleteFavorite, deleteFavorite: jfController.deleteFavorite,
deletePlaylist: jfController.deletePlaylist, deletePlaylist: jfController.deletePlaylist,
getAlbumArtistDetail: jfController.getAlbumArtistDetail, getAlbumArtistDetail: jfController.getAlbumArtistDetail,
getAlbumArtistList: jfController.getAlbumArtistList, getAlbumArtistList: jfController.getAlbumArtistList,
getAlbumDetail: jfController.getAlbumDetail, getAlbumDetail: jfController.getAlbumDetail,
getAlbumList: jfController.getAlbumList, getAlbumList: jfController.getAlbumList,
getArtistDetail: undefined, getArtistDetail: undefined,
getArtistInfo: undefined, getArtistInfo: undefined,
getArtistList: undefined, getArtistList: undefined,
getFavoritesList: undefined, getFavoritesList: undefined,
getFolderItemList: undefined, getFolderItemList: undefined,
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: jfController.getGenreList, getGenreList: jfController.getGenreList,
getLyrics: jfController.getLyrics, getLyrics: jfController.getLyrics,
getMusicFolderList: jfController.getMusicFolderList, getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail, getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList, getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList, getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList, getRandomSongList: jfController.getRandomSongList,
getSongDetail: undefined, getSongDetail: undefined,
getSongList: jfController.getSongList, getSongList: jfController.getSongList,
getTopSongs: jfController.getTopSongList, getTopSongs: jfController.getTopSongList,
getUserList: undefined, getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist, removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble, scrobble: jfController.scrobble,
search: jfController.search, search: jfController.search,
setRating: undefined, setRating: undefined,
updatePlaylist: jfController.updatePlaylist, updatePlaylist: jfController.updatePlaylist,
}, },
navidrome: { navidrome: {
addToPlaylist: ndController.addToPlaylist, addToPlaylist: ndController.addToPlaylist,
authenticate: ndController.authenticate, authenticate: ndController.authenticate,
clearPlaylist: undefined, clearPlaylist: undefined,
createFavorite: ssController.createFavorite, createFavorite: ssController.createFavorite,
createPlaylist: ndController.createPlaylist, createPlaylist: ndController.createPlaylist,
deleteFavorite: ssController.removeFavorite, deleteFavorite: ssController.removeFavorite,
deletePlaylist: ndController.deletePlaylist, deletePlaylist: ndController.deletePlaylist,
getAlbumArtistDetail: ndController.getAlbumArtistDetail, getAlbumArtistDetail: ndController.getAlbumArtistDetail,
getAlbumArtistList: ndController.getAlbumArtistList, getAlbumArtistList: ndController.getAlbumArtistList,
getAlbumDetail: ndController.getAlbumDetail, getAlbumDetail: ndController.getAlbumDetail,
getAlbumList: ndController.getAlbumList, getAlbumList: ndController.getAlbumList,
getArtistDetail: undefined, getArtistDetail: undefined,
getArtistInfo: undefined, getArtistInfo: undefined,
getArtistList: undefined, getArtistList: undefined,
getFavoritesList: undefined, getFavoritesList: undefined,
getFolderItemList: undefined, getFolderItemList: undefined,
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: ndController.getGenreList, getGenreList: ndController.getGenreList,
getLyrics: undefined, getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList, getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail, getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList, getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList, getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList, getRandomSongList: ssController.getRandomSongList,
getSongDetail: ndController.getSongDetail, getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList, getSongList: ndController.getSongList,
getTopSongs: ssController.getTopSongList, getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList, getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist, removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble, scrobble: ssController.scrobble,
search: ssController.search3, search: ssController.search3,
setRating: ssController.setRating, setRating: ssController.setRating,
updatePlaylist: ndController.updatePlaylist, updatePlaylist: ndController.updatePlaylist,
}, },
subsonic: { subsonic: {
authenticate: ssController.authenticate, authenticate: ssController.authenticate,
clearPlaylist: undefined, clearPlaylist: undefined,
createFavorite: ssController.createFavorite, createFavorite: ssController.createFavorite,
createPlaylist: undefined, createPlaylist: undefined,
deleteFavorite: ssController.removeFavorite, deleteFavorite: ssController.removeFavorite,
deletePlaylist: undefined, deletePlaylist: undefined,
getAlbumArtistDetail: undefined, getAlbumArtistDetail: undefined,
getAlbumArtistList: undefined, getAlbumArtistList: undefined,
getAlbumDetail: undefined, getAlbumDetail: undefined,
getAlbumList: undefined, getAlbumList: undefined,
getArtistDetail: undefined, getArtistDetail: undefined,
getArtistInfo: undefined, getArtistInfo: undefined,
getArtistList: undefined, getArtistList: undefined,
getFavoritesList: undefined, getFavoritesList: undefined,
getFolderItemList: undefined, getFolderItemList: undefined,
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: undefined, getGenreList: undefined,
getLyrics: undefined, getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList, getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined, getPlaylistDetail: undefined,
getPlaylistList: undefined, getPlaylistList: undefined,
getSongDetail: undefined, getSongDetail: undefined,
getSongList: undefined, getSongList: undefined,
getTopSongs: ssController.getTopSongList, getTopSongs: ssController.getTopSongList,
getUserList: undefined, getUserList: undefined,
scrobble: ssController.scrobble, scrobble: ssController.scrobble,
search: ssController.search3, search: ssController.search3,
setRating: undefined, setRating: undefined,
updatePlaylist: undefined, updatePlaylist: undefined,
}, },
}; };
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => { const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
const serverType = type || useAuthStore.getState().currentServer?.type; const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) { if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' }); toast.error({ message: 'No server selected', title: 'Unable to route request' });
throw new Error(`No server selected`); throw new Error(`No server selected`);
} }
const controllerFn = endpoints?.[serverType]?.[endpoint]; const controllerFn = endpoints?.[serverType]?.[endpoint];
if (typeof controllerFn !== 'function') { if (typeof controllerFn !== 'function') {
toast.error({ toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`, message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request', 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 ( const authenticate = async (
url: string, url: string,
body: { legacy?: boolean; password: string; username: string }, body: { legacy?: boolean; password: string; username: string },
type: ServerType, 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) => { const getAlbumList = async (args: AlbumListArgs) => {
return ( return (
apiController( apiController(
'getAlbumList', 'getAlbumList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumList'] ) as ControllerEndpoint['getAlbumList']
)?.(args); )?.(args);
}; };
const getAlbumDetail = async (args: AlbumDetailArgs) => { const getAlbumDetail = async (args: AlbumDetailArgs) => {
return ( return (
apiController( apiController(
'getAlbumDetail', 'getAlbumDetail',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumDetail'] ) as ControllerEndpoint['getAlbumDetail']
)?.(args); )?.(args);
}; };
const getSongList = async (args: SongListArgs) => { const getSongList = async (args: SongListArgs) => {
return ( return (
apiController( apiController(
'getSongList', 'getSongList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongList'] ) as ControllerEndpoint['getSongList']
)?.(args); )?.(args);
}; };
const getSongDetail = async (args: SongDetailArgs) => { const getSongDetail = async (args: SongDetailArgs) => {
return ( return (
apiController( apiController(
'getSongDetail', 'getSongDetail',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongDetail'] ) as ControllerEndpoint['getSongDetail']
)?.(args); )?.(args);
}; };
const getMusicFolderList = async (args: MusicFolderListArgs) => { const getMusicFolderList = async (args: MusicFolderListArgs) => {
return ( return (
apiController( apiController(
'getMusicFolderList', 'getMusicFolderList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getMusicFolderList'] ) as ControllerEndpoint['getMusicFolderList']
)?.(args); )?.(args);
}; };
const getGenreList = async (args: GenreListArgs) => { const getGenreList = async (args: GenreListArgs) => {
return ( return (
apiController( apiController(
'getGenreList', 'getGenreList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getGenreList'] ) as ControllerEndpoint['getGenreList']
)?.(args); )?.(args);
}; };
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => { const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
return ( return (
apiController( apiController(
'getAlbumArtistDetail', 'getAlbumArtistDetail',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistDetail'] ) as ControllerEndpoint['getAlbumArtistDetail']
)?.(args); )?.(args);
}; };
const getAlbumArtistList = async (args: AlbumArtistListArgs) => { const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
return ( return (
apiController( apiController(
'getAlbumArtistList', 'getAlbumArtistList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistList'] ) as ControllerEndpoint['getAlbumArtistList']
)?.(args); )?.(args);
}; };
const getArtistList = async (args: ArtistListArgs) => { const getArtistList = async (args: ArtistListArgs) => {
return ( return (
apiController( apiController(
'getArtistList', 'getArtistList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getArtistList'] ) as ControllerEndpoint['getArtistList']
)?.(args); )?.(args);
}; };
const getPlaylistList = async (args: PlaylistListArgs) => { const getPlaylistList = async (args: PlaylistListArgs) => {
return ( return (
apiController( apiController(
'getPlaylistList', 'getPlaylistList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistList'] ) as ControllerEndpoint['getPlaylistList']
)?.(args); )?.(args);
}; };
const createPlaylist = async (args: CreatePlaylistArgs) => { const createPlaylist = async (args: CreatePlaylistArgs) => {
return ( return (
apiController( apiController(
'createPlaylist', 'createPlaylist',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['createPlaylist'] ) as ControllerEndpoint['createPlaylist']
)?.(args); )?.(args);
}; };
const updatePlaylist = async (args: UpdatePlaylistArgs) => { const updatePlaylist = async (args: UpdatePlaylistArgs) => {
return ( return (
apiController( apiController(
'updatePlaylist', 'updatePlaylist',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['updatePlaylist'] ) as ControllerEndpoint['updatePlaylist']
)?.(args); )?.(args);
}; };
const deletePlaylist = async (args: DeletePlaylistArgs) => { const deletePlaylist = async (args: DeletePlaylistArgs) => {
return ( return (
apiController( apiController(
'deletePlaylist', 'deletePlaylist',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['deletePlaylist'] ) as ControllerEndpoint['deletePlaylist']
)?.(args); )?.(args);
}; };
const addToPlaylist = async (args: AddToPlaylistArgs) => { const addToPlaylist = async (args: AddToPlaylistArgs) => {
return ( return (
apiController( apiController(
'addToPlaylist', 'addToPlaylist',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['addToPlaylist'] ) as ControllerEndpoint['addToPlaylist']
)?.(args); )?.(args);
}; };
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => { const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return ( return (
apiController( apiController(
'removeFromPlaylist', 'removeFromPlaylist',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['removeFromPlaylist'] ) as ControllerEndpoint['removeFromPlaylist']
)?.(args); )?.(args);
}; };
const getPlaylistDetail = async (args: PlaylistDetailArgs) => { const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
return ( return (
apiController( apiController(
'getPlaylistDetail', 'getPlaylistDetail',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistDetail'] ) as ControllerEndpoint['getPlaylistDetail']
)?.(args); )?.(args);
}; };
const getPlaylistSongList = async (args: PlaylistSongListArgs) => { const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
return ( return (
apiController( apiController(
'getPlaylistSongList', 'getPlaylistSongList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistSongList'] ) as ControllerEndpoint['getPlaylistSongList']
)?.(args); )?.(args);
}; };
const getUserList = async (args: UserListArgs) => { const getUserList = async (args: UserListArgs) => {
return ( return (
apiController( apiController(
'getUserList', 'getUserList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getUserList'] ) as ControllerEndpoint['getUserList']
)?.(args); )?.(args);
}; };
const createFavorite = async (args: FavoriteArgs) => { const createFavorite = async (args: FavoriteArgs) => {
return ( return (
apiController( apiController(
'createFavorite', 'createFavorite',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['createFavorite'] ) as ControllerEndpoint['createFavorite']
)?.(args); )?.(args);
}; };
const deleteFavorite = async (args: FavoriteArgs) => { const deleteFavorite = async (args: FavoriteArgs) => {
return ( return (
apiController( apiController(
'deleteFavorite', 'deleteFavorite',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['deleteFavorite'] ) as ControllerEndpoint['deleteFavorite']
)?.(args); )?.(args);
}; };
const updateRating = async (args: SetRatingArgs) => { const updateRating = async (args: SetRatingArgs) => {
return ( return (
apiController('setRating', args.apiClientProps.server?.type) as ControllerEndpoint['setRating'] apiController(
)?.(args); 'setRating',
args.apiClientProps.server?.type,
) as ControllerEndpoint['setRating']
)?.(args);
}; };
const getTopSongList = async (args: TopSongListArgs) => { const getTopSongList = async (args: TopSongListArgs) => {
return ( return (
apiController( apiController(
'getTopSongs', 'getTopSongs',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getTopSongs'] ) as ControllerEndpoint['getTopSongs']
)?.(args); )?.(args);
}; };
const scrobble = async (args: ScrobbleArgs) => { const scrobble = async (args: ScrobbleArgs) => {
return ( return (
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble'] apiController(
)?.(args); 'scrobble',
args.apiClientProps.server?.type,
) as ControllerEndpoint['scrobble']
)?.(args);
}; };
const search = async (args: SearchArgs) => { const search = async (args: SearchArgs) => {
return ( return (
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search'] apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
)?.(args); )?.(args);
}; };
const getRandomSongList = async (args: RandomSongListArgs) => { const getRandomSongList = async (args: RandomSongListArgs) => {
return ( return (
apiController( apiController(
'getRandomSongList', 'getRandomSongList',
args.apiClientProps.server?.type, args.apiClientProps.server?.type,
) as ControllerEndpoint['getRandomSongList'] ) as ControllerEndpoint['getRandomSongList']
)?.(args); )?.(args);
}; };
const getLyrics = async (args: LyricsArgs) => { const getLyrics = async (args: LyricsArgs) => {
return ( return (
apiController('getLyrics', args.apiClientProps.server?.type) as ControllerEndpoint['getLyrics'] apiController(
)?.(args); 'getLyrics',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getLyrics']
)?.(args);
}; };
export const controller = { export const controller = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
createFavorite, createFavorite,
createPlaylist, createPlaylist,
deleteFavorite, deleteFavorite,
deletePlaylist, deletePlaylist,
getAlbumArtistDetail, getAlbumArtistDetail,
getAlbumArtistList, getAlbumArtistList,
getAlbumDetail, getAlbumDetail,
getAlbumList, getAlbumList,
getArtistList, getArtistList,
getGenreList, getGenreList,
getLyrics, getLyrics,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,
getPlaylistSongList, getPlaylistSongList,
getRandomSongList, getRandomSongList,
getSongDetail, getSongDetail,
getSongList, getSongList,
getTopSongList, getTopSongList,
getUserList, getUserList,
removeFromPlaylist, removeFromPlaylist,
scrobble, scrobble,
search, search,
updatePlaylist, updatePlaylist,
updateRating, updateRating,
}; };

View File

@ -1,5 +1,5 @@
import { controller } from '/@/renderer/api/controller'; import { controller } from '/@/renderer/api/controller';
export const api = { 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(); const c = initContract();
export const contract = c.router({ export const contract = c.router({
addToPlaylist: { addToPlaylist: {
body: z.null(), body: z.null(),
method: 'POST', method: 'POST',
path: 'playlists/:id/items', path: 'playlists/:id/items',
query: jfType._parameters.addToPlaylist, query: jfType._parameters.addToPlaylist,
responses: { responses: {
204: jfType._response.addToPlaylist, 204: jfType._response.addToPlaylist,
400: jfType._response.error, 400: jfType._response.error,
},
}, },
}, authenticate: {
authenticate: { body: jfType._parameters.authenticate,
body: jfType._parameters.authenticate, headers: z.object({
headers: z.object({ 'X-Emby-Authorization': z.string(),
'X-Emby-Authorization': z.string(), }),
}), method: 'POST',
method: 'POST', path: 'users/authenticatebyname',
path: 'users/authenticatebyname', responses: {
responses: { 200: jfType._response.authenticate,
200: jfType._response.authenticate, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, createFavorite: {
createFavorite: { body: jfType._parameters.favorite,
body: jfType._parameters.favorite, method: 'POST',
method: 'POST', path: 'users/:userId/favoriteitems/:id',
path: 'users/:userId/favoriteitems/:id', responses: {
responses: { 200: jfType._response.favorite,
200: jfType._response.favorite, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, createPlaylist: {
createPlaylist: { body: jfType._parameters.createPlaylist,
body: jfType._parameters.createPlaylist, method: 'POST',
method: 'POST', path: 'playlists',
path: 'playlists', responses: {
responses: { 200: jfType._response.createPlaylist,
200: jfType._response.createPlaylist, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, deletePlaylist: {
deletePlaylist: { body: null,
body: null, method: 'DELETE',
method: 'DELETE', path: 'items/:id',
path: 'items/:id', responses: {
responses: { 204: jfType._response.deletePlaylist,
204: jfType._response.deletePlaylist, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getAlbumArtistDetail: {
getAlbumArtistDetail: { method: 'GET',
method: 'GET', path: 'users/:userId/items/:id',
path: 'users/:userId/items/:id', query: jfType._parameters.albumArtistDetail,
query: jfType._parameters.albumArtistDetail, responses: {
responses: { 200: jfType._response.albumArtist,
200: jfType._response.albumArtist, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getAlbumArtistList: {
getAlbumArtistList: { method: 'GET',
method: 'GET', path: 'artists/albumArtists',
path: 'artists/albumArtists', query: jfType._parameters.albumArtistList,
query: jfType._parameters.albumArtistList, responses: {
responses: { 200: jfType._response.albumArtistList,
200: jfType._response.albumArtistList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getAlbumDetail: {
getAlbumDetail: { method: 'GET',
method: 'GET', path: 'users/:userId/items/:id',
path: 'users/:userId/items/:id', query: jfType._parameters.albumDetail,
query: jfType._parameters.albumDetail, responses: {
responses: { 200: jfType._response.album,
200: jfType._response.album, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getAlbumList: {
getAlbumList: { method: 'GET',
method: 'GET', path: 'users/:userId/items',
path: 'users/:userId/items', query: jfType._parameters.albumList,
query: jfType._parameters.albumList, responses: {
responses: { 200: jfType._response.albumList,
200: jfType._response.albumList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getArtistList: {
getArtistList: { method: 'GET',
method: 'GET', path: 'artists',
path: 'artists', query: jfType._parameters.albumArtistList,
query: jfType._parameters.albumArtistList, responses: {
responses: { 200: jfType._response.albumArtistList,
200: jfType._response.albumArtistList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getGenreList: {
getGenreList: { method: 'GET',
method: 'GET', path: 'genres',
path: 'genres', responses: {
responses: { 200: jfType._response.genreList,
200: jfType._response.genreList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getMusicFolderList: {
getMusicFolderList: { method: 'GET',
method: 'GET', path: 'users/:userId/items',
path: 'users/:userId/items', responses: {
responses: { 200: jfType._response.musicFolderList,
200: jfType._response.musicFolderList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getPlaylistDetail: {
getPlaylistDetail: { method: 'GET',
method: 'GET', path: 'users/:userId/items/:id',
path: 'users/:userId/items/:id', query: jfType._parameters.playlistDetail,
query: jfType._parameters.playlistDetail, responses: {
responses: { 200: jfType._response.playlist,
200: jfType._response.playlist, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getPlaylistList: {
getPlaylistList: { method: 'GET',
method: 'GET', path: 'users/:userId/items',
path: 'users/:userId/items', query: jfType._parameters.playlistList,
query: jfType._parameters.playlistList, responses: {
responses: { 200: jfType._response.playlistList,
200: jfType._response.playlistList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getPlaylistSongList: {
getPlaylistSongList: { method: 'GET',
method: 'GET', path: 'playlists/:id/items',
path: 'playlists/:id/items', query: jfType._parameters.songList,
query: jfType._parameters.songList, responses: {
responses: { 200: jfType._response.playlistSongList,
200: jfType._response.playlistSongList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getSimilarArtistList: {
getSimilarArtistList: { method: 'GET',
method: 'GET', path: 'artists/:id/similar',
path: 'artists/:id/similar', query: jfType._parameters.similarArtistList,
query: jfType._parameters.similarArtistList, responses: {
responses: { 200: jfType._response.albumArtistList,
200: jfType._response.albumArtistList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getSongDetail: {
getSongDetail: { method: 'GET',
method: 'GET', path: 'song/:id',
path: 'song/:id', responses: {
responses: { 200: jfType._response.song,
200: jfType._response.song, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getSongList: {
getSongList: { method: 'GET',
method: 'GET', path: 'users/:userId/items',
path: 'users/:userId/items', query: jfType._parameters.songList,
query: jfType._parameters.songList, responses: {
responses: { 200: jfType._response.songList,
200: jfType._response.songList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, getSongLyrics: {
getSongLyrics: { method: 'GET',
method: 'GET', path: 'users/:userId/Items/:id/Lyrics',
path: 'users/:userId/Items/:id/Lyrics', responses: {
responses: { 200: jfType._response.lyrics,
200: jfType._response.lyrics, 404: jfType._response.error,
404: jfType._response.error, },
}, },
}, getTopSongsList: {
getTopSongsList: { method: 'GET',
method: 'GET', path: 'users/:userId/items',
path: 'users/:userId/items', query: jfType._parameters.songList,
query: jfType._parameters.songList, responses: {
responses: { 200: jfType._response.topSongsList,
200: jfType._response.topSongsList, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, removeFavorite: {
removeFavorite: { body: jfType._parameters.favorite,
body: jfType._parameters.favorite, method: 'DELETE',
method: 'DELETE', path: 'users/:userId/favoriteitems/:id',
path: 'users/:userId/favoriteitems/:id', responses: {
responses: { 200: jfType._response.favorite,
200: jfType._response.favorite, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, removeFromPlaylist: {
removeFromPlaylist: { body: null,
body: null, method: 'DELETE',
method: 'DELETE', path: 'playlists/:id/items',
path: 'playlists/:id/items', query: jfType._parameters.removeFromPlaylist,
query: jfType._parameters.removeFromPlaylist, responses: {
responses: { 200: jfType._response.removeFromPlaylist,
200: jfType._response.removeFromPlaylist, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, scrobblePlaying: {
scrobblePlaying: { body: jfType._parameters.scrobble,
body: jfType._parameters.scrobble, method: 'POST',
method: 'POST', path: 'sessions/playing',
path: 'sessions/playing', responses: {
responses: { 200: jfType._response.scrobble,
200: jfType._response.scrobble, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, scrobbleProgress: {
scrobbleProgress: { body: jfType._parameters.scrobble,
body: jfType._parameters.scrobble, method: 'POST',
method: 'POST', path: 'sessions/playing/progress',
path: 'sessions/playing/progress', responses: {
responses: { 200: jfType._response.scrobble,
200: jfType._response.scrobble, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, scrobbleStopped: {
scrobbleStopped: { body: jfType._parameters.scrobble,
body: jfType._parameters.scrobble, method: 'POST',
method: 'POST', path: 'sessions/playing/stopped',
path: 'sessions/playing/stopped', responses: {
responses: { 200: jfType._response.scrobble,
200: jfType._response.scrobble, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, search: {
search: { method: 'GET',
method: 'GET', path: 'users/:userId/items',
path: 'users/:userId/items', query: jfType._parameters.search,
query: jfType._parameters.search, responses: {
responses: { 200: jfType._response.search,
200: jfType._response.search, 400: jfType._response.error,
400: jfType._response.error, },
}, },
}, updatePlaylist: {
updatePlaylist: { body: jfType._parameters.updatePlaylist,
body: jfType._parameters.updatePlaylist, method: 'PUT',
method: 'PUT', path: 'items/:id',
path: 'items/:id', responses: {
responses: { 200: jfType._response.updatePlaylist,
200: jfType._response.updatePlaylist, 400: jfType._response.error,
400: jfType._response.error, },
}, },
},
}); });
const axiosClient = axios.create({}); const axiosClient = axios.create({});
axiosClient.defaults.paramsSerializer = (params) => { axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' }); return qs.stringify(params, { arrayFormat: 'repeat' });
}; };
axiosClient.interceptors.response.use( axiosClient.interceptors.response.use(
(response) => { (response) => {
return response; return response;
}, },
(error) => { (error) => {
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer; const currentServer = useAuthStore.getState().currentServer;
authenticationFailure(currentServer); authenticationFailure(currentServer);
} }
return Promise.reject(error); return Promise.reject(error);
}, },
); );
const parsePath = (fullPath: string) => { const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?'); const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params); const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null'); const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
return { return {
params: notNilParams, params: notNilParams,
path, path,
}; };
}; };
export const jfApiClient = (args: { export const jfApiClient = (args: {
server: ServerListItem | null; server: ServerListItem | null;
signal?: AbortSignal; signal?: AbortSignal;
url?: string; url?: string;
}) => { }) => {
const { server, url, signal } = args; const { server, url, signal } = args;
return initClient(contract, { return initClient(contract, {
api: async ({ path, method, headers, body }) => { api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined; let baseUrl: string | undefined;
let token: string | undefined; let token: string | undefined;
const { params, path: api } = parsePath(path); const { params, path: api } = parsePath(path);
if (server) { if (server) {
baseUrl = `${server?.url}`; baseUrl = `${server?.url}`;
token = server?.credential; token = server?.credential;
} else { } else {
baseUrl = url; baseUrl = url;
} }
try { try {
const result = await axiosClient.request({ const result = await axiosClient.request({
data: body, data: body,
headers: { headers: {
...headers, ...headers,
...(token && { 'X-MediaBrowser-Token': token }), ...(token && { 'X-MediaBrowser-Token': token }),
}, },
method: method as Method, method: method as Method,
params, params,
signal, signal,
url: `${baseUrl}/${api}`, url: `${baseUrl}/${api}`,
}); });
return { return {
body: result.data, body: result.data,
headers: result.headers as any, headers: result.headers as any,
status: result.status, status: result.status,
}; };
} catch (e: Error | AxiosError | any) { } catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) { if (isAxiosError(e)) {
const error = e as AxiosError; const error = e as AxiosError;
const response = error.response as AxiosResponse; const response = error.response as AxiosResponse;
return { return {
body: response?.data, body: response?.data,
headers: response?.headers as any, headers: response?.headers as any,
status: response.status, status: response.status,
}; };
} }
throw e; throw e;
} }
}, },
baseHeaders: { baseHeaders: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
baseUrl: '', baseUrl: '',
jsonQuery: false, 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 { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { import {
Song, Song,
LibraryItem, LibraryItem,
Album, Album,
AlbumArtist, AlbumArtist,
Playlist, Playlist,
MusicFolder, MusicFolder,
Genre, Genre,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types'; import { ServerListItem, ServerType } from '/@/renderer/types';
const getStreamUrl = (args: { const getStreamUrl = (args: {
container?: string; container?: string;
deviceId: string; deviceId: string;
eTag?: string; eTag?: string;
id: string; id: string;
mediaSourceId?: string; mediaSourceId?: string;
server: ServerListItem | null; server: ServerListItem | null;
}) => { }) => {
const { id, server, deviceId } = args; const { id, server, deviceId } = args;
return ( return (
`${server?.url}/audio` + `${server?.url}/audio` +
`/${id}/universal` + `/${id}/universal` +
`?userId=${server?.userId}` + `?userId=${server?.userId}` +
`&deviceId=${deviceId}` + `&deviceId=${deviceId}` +
'&audioCodec=aac' + '&audioCodec=aac' +
`&api_key=${server?.credential}` + `&api_key=${server?.credential}` +
`&playSessionId=${deviceId}` + `&playSessionId=${deviceId}` +
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' + '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
'&transcodingContainer=ts' + '&transcodingContainer=ts' +
'&transcodingProtocol=hls' '&transcodingProtocol=hls'
); );
}; };
const getAlbumArtistCoverArtUrl = (args: { const getAlbumArtistCoverArtUrl = (args: {
baseUrl: string; baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>; item: z.infer<typeof jfType._response.albumArtist>;
size: number; size: number;
}) => { }) => {
const size = args.size ? args.size : 300; const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) { if (!args.item.ImageTags?.Primary) {
return null; return null;
} }
return ( return (
`${args.baseUrl}/Items` + `${args.baseUrl}/Items` +
`/${args.item.Id}` + `/${args.item.Id}` +
'/Images/Primary' + '/Images/Primary' +
`?width=${size}&height=${size}` + `?width=${size}&height=${size}` +
'&quality=96' '&quality=96'
); );
}; };
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => { 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) { if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
return null; return null;
} }
return ( return (
`${args.baseUrl}/Items` + `${args.baseUrl}/Items` +
`/${args.item.Id}` + `/${args.item.Id}` +
'/Images/Primary' + '/Images/Primary' +
`?width=${size}&height=${size}` + `?width=${size}&height=${size}` +
'&quality=96' '&quality=96'
); );
}; };
const getSongCoverArtUrl = (args: { const getSongCoverArtUrl = (args: {
baseUrl: string; baseUrl: string;
item: z.infer<typeof jfType._response.song>; item: z.infer<typeof jfType._response.song>;
size: number; size: number;
}) => { }) => {
const size = args.size ? args.size : 100; const size = args.size ? args.size : 100;
if (args.item.ImageTags.Primary) { if (args.item.ImageTags.Primary) {
return ( return (
`${args.baseUrl}/Items` + `${args.baseUrl}/Items` +
`/${args.item.Id}` + `/${args.item.Id}` +
'/Images/Primary' + '/Images/Primary' +
`?width=${size}&height=${size}` + `?width=${size}&height=${size}` +
'&quality=96' '&quality=96'
); );
} }
if (args.item?.AlbumPrimaryImageTag) { if (args.item?.AlbumPrimaryImageTag) {
// Fall back to album art if no image embedded // Fall back to album art if no image embedded
return ( return (
`${args.baseUrl}/Items` + `${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` + `/${args.item?.AlbumId}` +
'/Images/Primary' + '/Images/Primary' +
`?width=${size}&height=${size}` + `?width=${size}&height=${size}` +
'&quality=96' '&quality=96'
); );
} }
return null; return null;
}; };
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => { 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) { if (!args.item.ImageTags?.Primary) {
return null; return null;
} }
return ( return (
`${args.baseUrl}/Items` + `${args.baseUrl}/Items` +
`/${args.item.Id}` + `/${args.item.Id}` +
'/Images/Primary' + '/Images/Primary' +
`?width=${size}&height=${size}` + `?width=${size}&height=${size}` +
'&quality=96' '&quality=96'
); );
}; };
const normalizeSong = ( const normalizeSong = (
item: z.infer<typeof jfType._response.song>, item: z.infer<typeof jfType._response.song>,
server: ServerListItem | null, server: ServerListItem | null,
deviceId: string, deviceId: string,
imageSize?: number, imageSize?: number,
): Song => { ): Song => {
return { return {
album: item.Album, album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({ albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id, id: entry.Id,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
})), })),
albumId: item.AlbumId, albumId: item.AlbumId,
artistName: item?.ArtistItems?.[0]?.Name, artistName: item?.ArtistItems?.[0]?.Name,
artists: item?.ArtistItems?.map((entry) => ({ artists: item?.ArtistItems?.map((entry) => ({
id: entry.Id, id: entry.Id,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
})), })),
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)), bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
bpm: null, bpm: null,
channels: null, channels: null,
comment: null, comment: null,
compilation: null, compilation: null,
container: (item.MediaSources && item.MediaSources[0]?.Container) || null, container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
createdAt: item.DateCreated, createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
duration: item.RunTimeTicks / 10000000, duration: item.RunTimeTicks / 10000000,
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })), genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
id: item.Id, id: item.Id,
imagePlaceholderUrl: null, imagePlaceholderUrl: null,
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }), imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
lastPlayedAt: null, lastPlayedAt: null,
lyrics: null, lyrics: null,
name: item.Name, name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null, path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0, playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId, playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null, // releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null, releaseDate: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null, releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
serverId: server?.id || '', serverId: server?.id || '',
serverType: ServerType.JELLYFIN, serverType: ServerType.JELLYFIN,
size: item.MediaSources && item.MediaSources[0]?.Size, size: item.MediaSources && item.MediaSources[0]?.Size,
streamUrl: getStreamUrl({ streamUrl: getStreamUrl({
container: item.MediaSources?.[0]?.Container, container: item.MediaSources?.[0]?.Container,
deviceId, deviceId,
eTag: item.MediaSources?.[0]?.ETag, eTag: item.MediaSources?.[0]?.ETag,
id: item.Id, id: item.Id,
mediaSourceId: item.MediaSources?.[0]?.Id, mediaSourceId: item.MediaSources?.[0]?.Id,
server, server,
}), }),
trackNumber: item.IndexNumber, trackNumber: item.IndexNumber,
uniqueId: nanoid(), uniqueId: nanoid(),
updatedAt: item.DateCreated, updatedAt: item.DateCreated,
userFavorite: (item.UserData && item.UserData.IsFavorite) || false, userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
userRating: null, userRating: null,
}; };
}; };
const normalizeAlbum = ( const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>, item: z.infer<typeof jfType._response.album>,
server: ServerListItem | null, server: ServerListItem | null,
imageSize?: number, imageSize?: number,
): Album => { ): Album => {
return { return {
albumArtists: albumArtists:
item.AlbumArtists.map((entry) => ({ item.AlbumArtists.map((entry) => ({
id: entry.Id, id: entry.Id,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
})) || [], })) || [],
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })), artists: item.ArtistItems?.map((entry) => ({
backdropImageUrl: null, id: entry.Id,
createdAt: item.DateCreated, imageUrl: null,
duration: item.RunTimeTicks / 10000, name: entry.Name,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), })),
id: item.Id, backdropImageUrl: null,
imagePlaceholderUrl: null, createdAt: item.DateCreated,
imageUrl: getAlbumCoverArtUrl({ duration: item.RunTimeTicks / 10000,
baseUrl: server?.url || '', genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
item, id: item.Id,
size: imageSize || 300, imagePlaceholderUrl: null,
}), imageUrl: getAlbumCoverArtUrl({
isCompilation: null, baseUrl: server?.url || '',
itemType: LibraryItem.ALBUM, item,
lastPlayedAt: null, size: imageSize || 300,
name: item.Name, }),
playCount: item.UserData?.PlayCount || 0, isCompilation: null,
releaseDate: item.PremiereDate?.split('T')[0] || null, itemType: LibraryItem.ALBUM,
releaseYear: item.ProductionYear || null, lastPlayedAt: null,
serverId: server?.id || '', name: item.Name,
serverType: ServerType.JELLYFIN, playCount: item.UserData?.PlayCount || 0,
size: null, releaseDate: item.PremiereDate?.split('T')[0] || null,
songCount: item?.ChildCount || null, releaseYear: item.ProductionYear || null,
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)), serverId: server?.id || '',
uniqueId: nanoid(), serverType: ServerType.JELLYFIN,
updatedAt: item?.DateLastMediaAdded || item.DateCreated, size: null,
userFavorite: item.UserData?.IsFavorite || false, songCount: item?.ChildCount || null,
userRating: 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 = ( const normalizeAlbumArtist = (
item: z.infer<typeof jfType._response.albumArtist> & { item: z.infer<typeof jfType._response.albumArtist> & {
similarArtists?: z.infer<typeof jfType._response.albumArtistList>; similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
}, },
server: ServerListItem | null, server: ServerListItem | null,
imageSize?: number, imageSize?: number,
): AlbumArtist => { ): AlbumArtist => {
const similarArtists = const similarArtists =
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map( item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
(entry) => ({ (entry) => ({
id: entry.Id, id: entry.Id,
imageUrl: getAlbumArtistCoverArtUrl({ imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '', baseUrl: server?.url || '',
item: entry, item: entry,
size: imageSize || 300, size: imageSize || 300,
}), }),
name: entry.Name, name: entry.Name,
}), }),
) || []; ) || [];
return { return {
albumCount: null, albumCount: null,
backgroundImageUrl: null, backgroundImageUrl: null,
biography: item.Overview || null, biography: item.Overview || null,
duration: item.RunTimeTicks / 10000, duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id, id: item.Id,
imageUrl: getAlbumArtistCoverArtUrl({ imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '', baseUrl: server?.url || '',
item, item,
size: imageSize || 300, size: imageSize || 300,
}), }),
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null, lastPlayedAt: null,
name: item.Name, name: item.Name,
playCount: item.UserData?.PlayCount || 0, playCount: item.UserData?.PlayCount || 0,
serverId: server?.id || '', serverId: server?.id || '',
serverType: ServerType.JELLYFIN, serverType: ServerType.JELLYFIN,
similarArtists, similarArtists,
songCount: null, songCount: null,
userFavorite: item.UserData?.IsFavorite || false, userFavorite: item.UserData?.IsFavorite || false,
userRating: null, userRating: null,
}; };
}; };
const normalizePlaylist = ( const normalizePlaylist = (
item: z.infer<typeof jfType._response.playlist>, item: z.infer<typeof jfType._response.playlist>,
server: ServerListItem | null, server: ServerListItem | null,
imageSize?: number, imageSize?: number,
): Playlist => { ): Playlist => {
const imageUrl = getPlaylistCoverArtUrl({ const imageUrl = getPlaylistCoverArtUrl({
baseUrl: server?.url || '', baseUrl: server?.url || '',
item, item,
size: imageSize || 300, size: imageSize || 300,
}); });
const imagePlaceholderUrl = null; const imagePlaceholderUrl = null;
return { return {
description: item.Overview || null, description: item.Overview || null,
duration: item.RunTimeTicks / 10000, duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id, id: item.Id,
imagePlaceholderUrl, imagePlaceholderUrl,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
itemType: LibraryItem.PLAYLIST, itemType: LibraryItem.PLAYLIST,
name: item.Name, name: item.Name,
owner: null, owner: null,
ownerId: null, ownerId: null,
public: null, public: null,
rules: null, rules: null,
serverId: server?.id || '', serverId: server?.id || '',
serverType: ServerType.JELLYFIN, serverType: ServerType.JELLYFIN,
size: null, size: null,
songCount: item?.ChildCount || null, songCount: item?.ChildCount || null,
sync: null, sync: null,
}; };
}; };
const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => { const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
return { return {
id: item.Id, id: item.Id,
name: item.Name, name: item.Name,
}; };
}; };
// const normalizeArtist = (item: any) => { // const normalizeArtist = (item: any) => {
@ -332,12 +336,12 @@ const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
// }; // };
const normalizeGenre = (item: JFGenre): Genre => { const normalizeGenre = (item: JFGenre): Genre => {
return { return {
albumCount: undefined, albumCount: undefined,
id: item.Id, id: item.Id,
name: item.Name, name: item.Name,
songCount: undefined, songCount: undefined,
}; };
}; };
// const normalizeFolder = (item: any) => { // const normalizeFolder = (item: any) => {
@ -360,10 +364,10 @@ const normalizeGenre = (item: JFGenre): Genre => {
// }; // };
export const jfNormalize = { export const jfNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
genre: normalizeGenre, genre: normalizeGenre,
musicFolder: normalizeMusicFolder, musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist, playlist: normalizePlaylist,
song: normalizeSong, 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'; import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
export type NDAuthenticate = { export type NDAuthenticate = {
id: string; id: string;
isAdmin: boolean; isAdmin: boolean;
name: string; name: string;
subsonicSalt: string; subsonicSalt: string;
subsonicToken: string; subsonicToken: string;
token: string; token: string;
username: string; username: string;
}; };
export type NDUser = { export type NDUser = {
createdAt: string; createdAt: string;
email: string; email: string;
id: string; id: string;
isAdmin: boolean; isAdmin: boolean;
lastAccessAt: string; lastAccessAt: string;
lastLoginAt: string; lastLoginAt: string;
name: string; name: string;
updatedAt: string; updatedAt: string;
userName: string; userName: string;
}; };
export type NDGenre = { export type NDGenre = {
id: string; id: string;
name: string; name: string;
}; };
export type NDAlbum = { export type NDAlbum = {
albumArtist: string; albumArtist: string;
albumArtistId: string; albumArtistId: string;
allArtistIds: string; allArtistIds: string;
artist: string; artist: string;
artistId: string; artistId: string;
compilation: boolean; compilation: boolean;
coverArtId?: string; // Removed after v0.48.0 coverArtId?: string; // Removed after v0.48.0
coverArtPath?: string; // Removed after v0.48.0 coverArtPath?: string; // Removed after v0.48.0
createdAt: string; createdAt: string;
duration: number; duration: number;
fullText: string; fullText: string;
genre: string; genre: string;
genres: NDGenre[]; genres: NDGenre[];
id: string; id: string;
maxYear: number; maxYear: number;
mbzAlbumArtistId: string; mbzAlbumArtistId: string;
mbzAlbumId: string; mbzAlbumId: string;
minYear: number; minYear: number;
name: string; name: string;
orderAlbumArtistName: string; orderAlbumArtistName: string;
orderAlbumName: string; orderAlbumName: string;
playCount: number; playCount: number;
playDate: string; playDate: string;
rating: number; rating: number;
size: number; size: number;
songCount: number; songCount: number;
sortAlbumArtistName: string; sortAlbumArtistName: string;
sortArtistName: string; sortArtistName: string;
starred: boolean; starred: boolean;
starredAt: string; starredAt: string;
updatedAt: string; updatedAt: string;
} & { songs?: NDSong[] }; } & { songs?: NDSong[] };
export type NDSong = { export type NDSong = {
album: string; album: string;
albumArtist: string; albumArtist: string;
albumArtistId: string; albumArtistId: string;
albumId: string; albumId: string;
artist: string; artist: string;
artistId: string; artistId: string;
bitRate: number; bitRate: number;
bookmarkPosition: number; bookmarkPosition: number;
bpm?: number; bpm?: number;
channels?: number; channels?: number;
comment?: string; comment?: string;
compilation: boolean; compilation: boolean;
createdAt: string; createdAt: string;
discNumber: number; discNumber: number;
duration: number; duration: number;
fullText: string; fullText: string;
genre: string; genre: string;
genres: NDGenre[]; genres: NDGenre[];
hasCoverArt: boolean; hasCoverArt: boolean;
id: string; id: string;
lyrics?: string; lyrics?: string;
mbzAlbumArtistId: string; mbzAlbumArtistId: string;
mbzAlbumId: string; mbzAlbumId: string;
mbzArtistId: string; mbzArtistId: string;
mbzTrackId: string; mbzTrackId: string;
orderAlbumArtistName: string; orderAlbumArtistName: string;
orderAlbumName: string; orderAlbumName: string;
orderArtistName: string; orderArtistName: string;
orderTitle: string; orderTitle: string;
path: string; path: string;
playCount: number; playCount: number;
playDate: string; playDate: string;
rating: number; rating: number;
size: number; size: number;
sortAlbumArtistName: string; sortAlbumArtistName: string;
sortArtistName: string; sortArtistName: string;
starred: boolean; starred: boolean;
starredAt: string; starredAt: string;
suffix: string; suffix: string;
title: string; title: string;
trackNumber: number; trackNumber: number;
updatedAt: string; updatedAt: string;
year: number; year: number;
}; };
export type NDAlbumArtist = { export type NDAlbumArtist = {
albumCount: number; albumCount: number;
biography: string; biography: string;
externalInfoUpdatedAt: string; externalInfoUpdatedAt: string;
externalUrl: string; externalUrl: string;
fullText: string; fullText: string;
genres: NDGenre[]; genres: NDGenre[];
id: string; id: string;
largeImageUrl?: string; largeImageUrl?: string;
mbzArtistId: string; mbzArtistId: string;
mediumImageUrl?: string; mediumImageUrl?: string;
name: string; name: string;
orderArtistName: string; orderArtistName: string;
playCount: number; playCount: number;
playDate: string; playDate: string;
rating: number; rating: number;
size: number; size: number;
smallImageUrl?: string; smallImageUrl?: string;
songCount: number; songCount: number;
starred: boolean; starred: boolean;
starredAt: string; starredAt: string;
} & { } & {
similarArtists?: SSArtistInfo['similarArtist']; similarArtists?: SSArtistInfo['similarArtist'];
}; };
export type NDAuthenticationResponse = NDAuthenticate; export type NDAuthenticationResponse = NDAuthenticate;
export type NDAlbumArtistList = { export type NDAlbumArtistList = {
items: NDAlbumArtist[]; items: NDAlbumArtist[];
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number;
}; };
export type NDAlbumArtistDetail = NDAlbumArtist; export type NDAlbumArtistDetail = NDAlbumArtist;
@ -155,9 +155,9 @@ export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
export type NDAlbumListResponse = NDAlbum[]; export type NDAlbumListResponse = NDAlbum[];
export type NDAlbumList = { export type NDAlbumList = {
items: NDAlbum[]; items: NDAlbum[];
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number;
}; };
export type NDSongDetail = NDSong; export type NDSongDetail = NDSong;
@ -167,142 +167,142 @@ export type NDSongDetailResponse = NDSong;
export type NDSongListResponse = NDSong[]; export type NDSongListResponse = NDSong[];
export type NDSongList = { export type NDSongList = {
items: NDSong[]; items: NDSong[];
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number;
}; };
export type NDArtistListResponse = NDAlbumArtist[]; export type NDArtistListResponse = NDAlbumArtist[];
export type NDPagination = { export type NDPagination = {
_end?: number; _end?: number;
_start?: number; _start?: number;
}; };
export enum NDSortOrder { export enum NDSortOrder {
ASC = 'ASC', ASC = 'ASC',
DESC = 'DESC', DESC = 'DESC',
} }
export type NDOrder = { export type NDOrder = {
_order?: NDSortOrder; _order?: NDSortOrder;
}; };
export enum NDGenreListSort { export enum NDGenreListSort {
NAME = 'name', NAME = 'name',
} }
export type NDGenreListParams = { export type NDGenreListParams = {
_sort?: NDGenreListSort; _sort?: NDGenreListSort;
id?: string; id?: string;
} & NDPagination & } & NDPagination &
NDOrder; NDOrder;
export enum NDAlbumListSort { export enum NDAlbumListSort {
ALBUM_ARTIST = 'albumArtist', ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist', ARTIST = 'artist',
DURATION = 'duration', DURATION = 'duration',
NAME = 'name', NAME = 'name',
PLAY_COUNT = 'playCount', PLAY_COUNT = 'playCount',
PLAY_DATE = 'play_date', PLAY_DATE = 'play_date',
RANDOM = 'random', RANDOM = 'random',
RATING = 'rating', RATING = 'rating',
RECENTLY_ADDED = 'recently_added', RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount', SONG_COUNT = 'songCount',
STARRED = 'starred', STARRED = 'starred',
YEAR = 'max_year', YEAR = 'max_year',
} }
export type NDAlbumListParams = { export type NDAlbumListParams = {
_sort?: NDAlbumListSort; _sort?: NDAlbumListSort;
album_id?: string; album_id?: string;
artist_id?: string; artist_id?: string;
compilation?: boolean; compilation?: boolean;
genre_id?: string; genre_id?: string;
has_rating?: boolean; has_rating?: boolean;
id?: string; id?: string;
name?: string; name?: string;
recently_played?: boolean; recently_played?: boolean;
starred?: boolean; starred?: boolean;
year?: number; year?: number;
} & NDPagination & } & NDPagination &
NDOrder; NDOrder;
export enum NDSongListSort { export enum NDSongListSort {
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title', ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title', ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS = 'album, discNumber, trackNumber', ALBUM_SONGS = 'album, discNumber, trackNumber',
ARTIST = 'artist', ARTIST = 'artist',
BPM = 'bpm', BPM = 'bpm',
CHANNELS = 'channels', CHANNELS = 'channels',
COMMENT = 'comment', COMMENT = 'comment',
DURATION = 'duration', DURATION = 'duration',
FAVORITED = 'starred ASC, starredAt ASC', FAVORITED = 'starred ASC, starredAt ASC',
GENRE = 'genre', GENRE = 'genre',
ID = 'id', ID = 'id',
PLAY_COUNT = 'playCount', PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate', PLAY_DATE = 'playDate',
RATING = 'rating', RATING = 'rating',
RECENTLY_ADDED = 'createdAt', RECENTLY_ADDED = 'createdAt',
TITLE = 'title', TITLE = 'title',
TRACK = 'track', TRACK = 'track',
YEAR = 'year, album, discNumber, trackNumber', YEAR = 'year, album, discNumber, trackNumber',
} }
export type NDSongListParams = { export type NDSongListParams = {
_sort?: NDSongListSort; _sort?: NDSongListSort;
album_id?: string[]; album_id?: string[];
artist_id?: string[]; artist_id?: string[];
genre_id?: string; genre_id?: string;
starred?: boolean; starred?: boolean;
} & NDPagination & } & NDPagination &
NDOrder; NDOrder;
export enum NDAlbumArtistListSort { export enum NDAlbumArtistListSort {
ALBUM_COUNT = 'albumCount', ALBUM_COUNT = 'albumCount',
FAVORITED = 'starred ASC, starredAt ASC', FAVORITED = 'starred ASC, starredAt ASC',
NAME = 'name', NAME = 'name',
PLAY_COUNT = 'playCount', PLAY_COUNT = 'playCount',
RATING = 'rating', RATING = 'rating',
SONG_COUNT = 'songCount', SONG_COUNT = 'songCount',
} }
export type NDAlbumArtistListParams = { export type NDAlbumArtistListParams = {
_sort?: NDAlbumArtistListSort; _sort?: NDAlbumArtistListSort;
genre_id?: string; genre_id?: string;
starred?: boolean; starred?: boolean;
} & NDPagination & } & NDPagination &
NDOrder; NDOrder;
export type NDAddToPlaylistResponse = { export type NDAddToPlaylistResponse = {
added: number; added: number;
}; };
export type NDAddToPlaylistBody = { export type NDAddToPlaylistBody = {
ids: string[]; ids: string[];
}; };
export type NDAddToPlaylist = null; export type NDAddToPlaylist = null;
export type NDRemoveFromPlaylistResponse = { export type NDRemoveFromPlaylistResponse = {
ids: string[]; ids: string[];
}; };
export type NDRemoveFromPlaylistParams = { export type NDRemoveFromPlaylistParams = {
id: string[]; id: string[];
}; };
export type NDRemoveFromPlaylist = null; export type NDRemoveFromPlaylist = null;
export type NDCreatePlaylistParams = { export type NDCreatePlaylistParams = {
comment?: string; comment?: string;
name: string; name: string;
public?: boolean; public?: boolean;
rules?: Record<string, any> | null; rules?: Record<string, any> | null;
}; };
export type NDCreatePlaylistResponse = { export type NDCreatePlaylistResponse = {
id: string; id: string;
}; };
export type NDCreatePlaylist = NDCreatePlaylistResponse; export type NDCreatePlaylist = NDCreatePlaylistResponse;
@ -312,7 +312,7 @@ export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
export type NDUpdatePlaylistResponse = NDPlaylist; export type NDUpdatePlaylistResponse = NDPlaylist;
export type NDDeletePlaylistParams = { export type NDDeletePlaylistParams = {
id: string; id: string;
}; };
export type NDDeletePlaylistResponse = null; export type NDDeletePlaylistResponse = null;
@ -320,21 +320,21 @@ export type NDDeletePlaylistResponse = null;
export type NDDeletePlaylist = NDDeletePlaylistResponse; export type NDDeletePlaylist = NDDeletePlaylistResponse;
export type NDPlaylist = { export type NDPlaylist = {
comment: string; comment: string;
createdAt: string; createdAt: string;
duration: number; duration: number;
evaluatedAt: string; evaluatedAt: string;
id: string; id: string;
name: string; name: string;
ownerId: string; ownerId: string;
ownerName: string; ownerName: string;
path: string; path: string;
public: boolean; public: boolean;
rules: Record<string, any> | null; rules: Record<string, any> | null;
size: number; size: number;
songCount: number; songCount: number;
sync: boolean; sync: boolean;
updatedAt: string; updatedAt: string;
}; };
export type NDPlaylistDetail = NDPlaylist; export type NDPlaylistDetail = NDPlaylist;
@ -342,125 +342,125 @@ export type NDPlaylistDetail = NDPlaylist;
export type NDPlaylistDetailResponse = NDPlaylist; export type NDPlaylistDetailResponse = NDPlaylist;
export type NDPlaylistList = { export type NDPlaylistList = {
items: NDPlaylist[]; items: NDPlaylist[];
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number;
}; };
export type NDPlaylistListResponse = NDPlaylist[]; export type NDPlaylistListResponse = NDPlaylist[];
export enum NDPlaylistListSort { export enum NDPlaylistListSort {
DURATION = 'duration', DURATION = 'duration',
NAME = 'name', NAME = 'name',
OWNER = 'ownerName', OWNER = 'ownerName',
PUBLIC = 'public', PUBLIC = 'public',
SONG_COUNT = 'songCount', SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt', UPDATED_AT = 'updatedAt',
} }
export type NDPlaylistListParams = { export type NDPlaylistListParams = {
_sort?: NDPlaylistListSort; _sort?: NDPlaylistListSort;
owner_id?: string; owner_id?: string;
} & NDPagination & } & NDPagination &
NDOrder; NDOrder;
export type NDPlaylistSong = NDSong & { export type NDPlaylistSong = NDSong & {
mediaFileId: string; mediaFileId: string;
playlistId: string; playlistId: string;
}; };
export type NDPlaylistSongListResponse = NDPlaylistSong[]; export type NDPlaylistSongListResponse = NDPlaylistSong[];
export type NDPlaylistSongList = { export type NDPlaylistSongList = {
items: NDPlaylistSong[]; items: NDPlaylistSong[];
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number;
}; };
export const NDSongQueryFields = [ export const NDSongQueryFields = [
{ label: 'Album', type: 'string', value: 'album' }, { label: 'Album', type: 'string', value: 'album' },
{ label: 'Album Artist', type: 'string', value: 'albumartist' }, { label: 'Album Artist', type: 'string', value: 'albumartist' },
{ label: 'Album Comment', type: 'string', value: 'albumcomment' }, { label: 'Album Comment', type: 'string', value: 'albumcomment' },
{ label: 'Album Type', type: 'string', value: 'albumtype' }, { label: 'Album Type', type: 'string', value: 'albumtype' },
{ label: 'Artist', type: 'string', value: 'artist' }, { label: 'Artist', type: 'string', value: 'artist' },
{ label: 'Bitrate', type: 'number', value: 'bitrate' }, { label: 'Bitrate', type: 'number', value: 'bitrate' },
{ label: 'BPM', type: 'number', value: 'bpm' }, { label: 'BPM', type: 'number', value: 'bpm' },
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' }, { label: 'Catalog Number', type: 'string', value: 'catalognumber' },
{ label: 'Channels', type: 'number', value: 'channels' }, { label: 'Channels', type: 'number', value: 'channels' },
{ label: 'Comment', type: 'string', value: 'comment' }, { label: 'Comment', type: 'string', value: 'comment' },
{ label: 'Date Added', type: 'date', value: 'dateadded' }, { label: 'Date Added', type: 'date', value: 'dateadded' },
{ label: 'Date Favorited', type: 'date', value: 'dateloved' }, { label: 'Date Favorited', type: 'date', value: 'dateloved' },
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' }, { label: 'Date Last Played', type: 'date', value: 'lastplayed' },
{ label: 'Date Modified', type: 'date', value: 'datemodified' }, { label: 'Date Modified', type: 'date', value: 'datemodified' },
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' }, { label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
{ label: 'Disc Number', type: 'number', value: 'discnumber' }, { label: 'Disc Number', type: 'number', value: 'discnumber' },
{ label: 'Duration', type: 'number', value: 'duration' }, { label: 'Duration', type: 'number', value: 'duration' },
{ label: 'File Path', type: 'string', value: 'filepath' }, { label: 'File Path', type: 'string', value: 'filepath' },
{ label: 'File Type', type: 'string', value: 'filetype' }, { label: 'File Type', type: 'string', value: 'filetype' },
{ label: 'Genre', type: 'string', value: 'genre' }, { label: 'Genre', type: 'string', value: 'genre' },
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' }, { label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' }, { label: 'Is Compilation', type: 'boolean', value: 'compilation' },
{ label: 'Is Favorite', type: 'boolean', value: 'loved' }, { label: 'Is Favorite', type: 'boolean', value: 'loved' },
{ label: 'Lyrics', type: 'string', value: 'lyrics' }, { label: 'Lyrics', type: 'string', value: 'lyrics' },
{ label: 'Name', type: 'string', value: 'title' }, { label: 'Name', type: 'string', value: 'title' },
{ label: 'Play Count', type: 'number', value: 'playcount' }, { label: 'Play Count', type: 'number', value: 'playcount' },
{ label: 'Rating', type: 'number', value: 'rating' }, { label: 'Rating', type: 'number', value: 'rating' },
{ label: 'Size', type: 'number', value: 'size' }, { label: 'Size', type: 'number', value: 'size' },
{ label: 'Sort Album', type: 'string', value: 'sortalbum' }, { label: 'Sort Album', type: 'string', value: 'sortalbum' },
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' }, { label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
{ label: 'Sort Artist', type: 'string', value: 'sortartist' }, { label: 'Sort Artist', type: 'string', value: 'sortartist' },
{ label: 'Sort Name', type: 'string', value: 'sorttitle' }, { label: 'Sort Name', type: 'string', value: 'sorttitle' },
{ label: 'Track Number', type: 'number', value: 'tracknumber' }, { label: 'Track Number', type: 'number', value: 'tracknumber' },
{ label: 'Year', type: 'number', value: 'year' }, { label: 'Year', type: 'number', value: 'year' },
]; ];
export const NDSongQueryDateOperators = [ export const NDSongQueryDateOperators = [
{ label: 'is', value: 'is' }, { label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' }, { label: 'is not', value: 'isNot' },
{ label: 'is before', value: 'before' }, { label: 'is before', value: 'before' },
{ label: 'is after', value: 'after' }, { label: 'is after', value: 'after' },
{ label: 'is in the last', value: 'inTheLast' }, { label: 'is in the last', value: 'inTheLast' },
{ label: 'is not in the last', value: 'notInTheLast' }, { label: 'is not in the last', value: 'notInTheLast' },
{ label: 'is in the range', value: 'inTheRange' }, { label: 'is in the range', value: 'inTheRange' },
]; ];
export const NDSongQueryStringOperators = [ export const NDSongQueryStringOperators = [
{ label: 'is', value: 'is' }, { label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' }, { label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' }, { label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' }, { label: 'does not contain', value: 'notContains' },
{ label: 'starts with', value: 'startsWith' }, { label: 'starts with', value: 'startsWith' },
{ label: 'ends with', value: 'endsWith' }, { label: 'ends with', value: 'endsWith' },
]; ];
export const NDSongQueryBooleanOperators = [ export const NDSongQueryBooleanOperators = [
{ label: 'is', value: 'is' }, { label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' }, { label: 'is not', value: 'isNot' },
]; ];
export const NDSongQueryNumberOperators = [ export const NDSongQueryNumberOperators = [
{ label: 'is', value: 'is' }, { label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' }, { label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' }, { label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' }, { label: 'does not contain', value: 'notContains' },
{ label: 'is greater than', value: 'gt' }, { label: 'is greater than', value: 'gt' },
{ label: 'is less than', value: 'lt' }, { label: 'is less than', value: 'lt' },
{ label: 'is in the range', value: 'inTheRange' }, { label: 'is in the range', value: 'inTheRange' },
]; ];
export type NDUserListParams = { export type NDUserListParams = {
_sort?: NDUserListSort; _sort?: NDUserListSort;
} & NDPagination & } & NDPagination &
NDOrder; NDOrder;
export type NDUserListResponse = NDUser[]; export type NDUserListResponse = NDUser[];
export type NDUserList = { export type NDUserList = {
items: NDUser[]; items: NDUser[];
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number;
}; };
export enum NDUserListSort { export enum NDUserListSort {
NAME = 'name', NAME = 'name',
} }

View File

@ -15,187 +15,188 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
const c = initContract(); const c = initContract();
export const contract = c.router({ export const contract = c.router({
addToPlaylist: { addToPlaylist: {
body: ndType._parameters.addToPlaylist, body: ndType._parameters.addToPlaylist,
method: 'POST', method: 'POST',
path: 'playlist/:id/tracks', path: 'playlist/:id/tracks',
responses: { responses: {
200: resultWithHeaders(ndType._response.addToPlaylist), 200: resultWithHeaders(ndType._response.addToPlaylist),
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
},
}, },
}, authenticate: {
authenticate: { body: ndType._parameters.authenticate,
body: ndType._parameters.authenticate, method: 'POST',
method: 'POST', path: 'auth/login',
path: 'auth/login', responses: {
responses: { 200: resultWithHeaders(ndType._response.authenticate),
200: resultWithHeaders(ndType._response.authenticate), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, createPlaylist: {
createPlaylist: { body: ndType._parameters.createPlaylist,
body: ndType._parameters.createPlaylist, method: 'POST',
method: 'POST', path: 'playlist',
path: 'playlist', responses: {
responses: { 200: resultWithHeaders(ndType._response.createPlaylist),
200: resultWithHeaders(ndType._response.createPlaylist), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, deletePlaylist: {
deletePlaylist: { body: null,
body: null, method: 'DELETE',
method: 'DELETE', path: 'playlist/:id',
path: 'playlist/:id', responses: {
responses: { 200: resultWithHeaders(ndType._response.deletePlaylist),
200: resultWithHeaders(ndType._response.deletePlaylist), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getAlbumArtistDetail: {
getAlbumArtistDetail: { method: 'GET',
method: 'GET', path: 'artist/:id',
path: 'artist/:id', responses: {
responses: { 200: resultWithHeaders(ndType._response.albumArtist),
200: resultWithHeaders(ndType._response.albumArtist), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getAlbumArtistList: {
getAlbumArtistList: { method: 'GET',
method: 'GET', path: 'artist',
path: 'artist', query: ndType._parameters.albumArtistList,
query: ndType._parameters.albumArtistList, responses: {
responses: { 200: resultWithHeaders(ndType._response.albumArtistList),
200: resultWithHeaders(ndType._response.albumArtistList), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getAlbumDetail: {
getAlbumDetail: { method: 'GET',
method: 'GET', path: 'album/:id',
path: 'album/:id', responses: {
responses: { 200: resultWithHeaders(ndType._response.album),
200: resultWithHeaders(ndType._response.album), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getAlbumList: {
getAlbumList: { method: 'GET',
method: 'GET', path: 'album',
path: 'album', query: ndType._parameters.albumList,
query: ndType._parameters.albumList, responses: {
responses: { 200: resultWithHeaders(ndType._response.albumList),
200: resultWithHeaders(ndType._response.albumList), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getGenreList: {
getGenreList: { method: 'GET',
method: 'GET', path: 'genre',
path: 'genre', responses: {
responses: { 200: resultWithHeaders(ndType._response.genreList),
200: resultWithHeaders(ndType._response.genreList), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getPlaylistDetail: {
getPlaylistDetail: { method: 'GET',
method: 'GET', path: 'playlist/:id',
path: 'playlist/:id', responses: {
responses: { 200: resultWithHeaders(ndType._response.playlist),
200: resultWithHeaders(ndType._response.playlist), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getPlaylistList: {
getPlaylistList: { method: 'GET',
method: 'GET', path: 'playlist',
path: 'playlist', query: ndType._parameters.playlistList,
query: ndType._parameters.playlistList, responses: {
responses: { 200: resultWithHeaders(ndType._response.playlistList),
200: resultWithHeaders(ndType._response.playlistList), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getPlaylistSongList: {
getPlaylistSongList: { method: 'GET',
method: 'GET', path: 'playlist/:id/tracks',
path: 'playlist/:id/tracks', query: ndType._parameters.songList,
query: ndType._parameters.songList, responses: {
responses: { 200: resultWithHeaders(ndType._response.playlistSongList),
200: resultWithHeaders(ndType._response.playlistSongList), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getSongDetail: {
getSongDetail: { method: 'GET',
method: 'GET', path: 'song/:id',
path: 'song/:id', responses: {
responses: { 200: resultWithHeaders(ndType._response.song),
200: resultWithHeaders(ndType._response.song), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getSongList: {
getSongList: { method: 'GET',
method: 'GET', path: 'song',
path: 'song', query: ndType._parameters.songList,
query: ndType._parameters.songList, responses: {
responses: { 200: resultWithHeaders(ndType._response.songList),
200: resultWithHeaders(ndType._response.songList), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, getUserList: {
getUserList: { method: 'GET',
method: 'GET', path: 'user',
path: 'user', query: ndType._parameters.userList,
query: ndType._parameters.userList, responses: {
responses: { 200: resultWithHeaders(ndType._response.userList),
200: resultWithHeaders(ndType._response.userList), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, removeFromPlaylist: {
removeFromPlaylist: { body: null,
body: null, method: 'DELETE',
method: 'DELETE', path: 'playlist/:id/tracks',
path: 'playlist/:id/tracks', query: ndType._parameters.removeFromPlaylist,
query: ndType._parameters.removeFromPlaylist, responses: {
responses: { 200: resultWithHeaders(ndType._response.removeFromPlaylist),
200: resultWithHeaders(ndType._response.removeFromPlaylist), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
}, updatePlaylist: {
updatePlaylist: { body: ndType._parameters.updatePlaylist,
body: ndType._parameters.updatePlaylist, method: 'PUT',
method: 'PUT', path: 'playlist/:id',
path: 'playlist/:id', responses: {
responses: { 200: resultWithHeaders(ndType._response.updatePlaylist),
200: resultWithHeaders(ndType._response.updatePlaylist), 500: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error), },
}, },
},
}); });
const axiosClient = axios.create({}); const axiosClient = axios.create({});
axiosClient.defaults.paramsSerializer = (params) => { axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' }); return qs.stringify(params, { arrayFormat: 'repeat' });
}; };
const parsePath = (fullPath: string) => { 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 // Convert indexed object to array
const newParams: Record<string, any> = {}; const newParams: Record<string, any> = {};
Object.keys(parsedParams).forEach((key) => { Object.keys(parsedParams).forEach((key) => {
const isIndexedArrayObject = const isIndexedArrayObject =
typeof parsedParams[key] === 'object' && Object.keys(parsedParams[key] || {}).includes('0'); typeof parsedParams[key] === 'object' &&
Object.keys(parsedParams[key] || {}).includes('0');
if (!isIndexedArrayObject) { if (!isIndexedArrayObject) {
newParams[key] = parsedParams[key]; newParams[key] = parsedParams[key];
} else { } else {
newParams[key] = Object.values(parsedParams[key] || {}); 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 { return {
params: notNilParams, params: notNilParams,
path, path,
}; };
}; };
let authSuccess = true; let authSuccess = true;
@ -205,184 +206,186 @@ const RETRY_DELAY_MS = 1000;
const MAX_RETRIES = 5; const MAX_RETRIES = 5;
const waitForResult = async (count = 0): Promise<void> => { const waitForResult = async (count = 0): Promise<void> => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (count === MAX_RETRIES || !shouldDelay) resolve(); if (count === MAX_RETRIES || !shouldDelay) resolve();
setTimeout(() => { setTimeout(() => {
waitForResult(count + 1) waitForResult(count + 1)
.then(resolve) .then(resolve)
.catch(resolve); .catch(resolve);
}, RETRY_DELAY_MS); }, RETRY_DELAY_MS);
}); });
}; };
const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS); const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);
const TIMEOUT_ERROR = Error(); const TIMEOUT_ERROR = Error();
axiosClient.interceptors.response.use( axiosClient.interceptors.response.use(
(response) => { (response) => {
const serverId = useAuthStore.getState().currentServer?.id; const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) { if (serverId) {
const headerCredential = response.headers['x-nd-authorization'] as string | undefined; const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
if (headerCredential) { if (headerCredential) {
useAuthStore.getState().actions.updateServer(serverId, { useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: headerCredential, ndCredential: headerCredential,
}); });
} }
} }
authSuccess = true; authSuccess = true;
return response; return response;
}, },
(error) => { (error) => {
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer; const currentServer = useAuthStore.getState().currentServer;
if (localSettings && currentServer?.savePassword) { if (localSettings && currentServer?.savePassword) {
// eslint-disable-next-line promise/no-promise-in-callback // eslint-disable-next-line promise/no-promise-in-callback
return localSettings return localSettings
.passwordGet(currentServer.id) .passwordGet(currentServer.id)
.then(async (password: string | null) => { .then(async (password: string | null) => {
authSuccess = false; authSuccess = false;
if (password === null) { if (password === null) {
throw error; 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) { limitedFail(currentServer);
await waitForResult(); }
// Hopefully the delay was sufficient for authentication. return Promise.reject(error);
// 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);
},
); );
export const ndApiClient = (args: { export const ndApiClient = (args: {
server: ServerListItem | null; server: ServerListItem | null;
signal?: AbortSignal; signal?: AbortSignal;
url?: string; url?: string;
}) => { }) => {
const { server, url, signal } = args; const { server, url, signal } = args;
return initClient(contract, { return initClient(contract, {
api: async ({ path, method, headers, body }) => { api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined; let baseUrl: string | undefined;
let token: string | undefined; let token: string | undefined;
const { params, path: api } = parsePath(path); const { params, path: api } = parsePath(path);
if (server) { if (server) {
baseUrl = `${server?.url}/api`; baseUrl = `${server?.url}/api`;
token = server?.ndCredential; token = server?.ndCredential;
} else { } else {
baseUrl = url; baseUrl = url;
} }
try { try {
if (shouldDelay) await waitForResult(); if (shouldDelay) await waitForResult();
const result = await axiosClient.request({ const result = await axiosClient.request({
data: body, data: body,
headers: { headers: {
...headers, ...headers,
...(token && { 'x-nd-authorization': `Bearer ${token}` }), ...(token && { 'x-nd-authorization': `Bearer ${token}` }),
}, },
method: method as Method, method: method as Method,
params, params,
signal, signal,
url: `${baseUrl}/${api}`, url: `${baseUrl}/${api}`,
}); });
return { return {
body: { data: result.data, headers: result.headers }, body: { data: result.data, headers: result.headers },
headers: result.headers as any, headers: result.headers as any,
status: result.status, status: result.status,
}; };
} catch (e: Error | AxiosError | any) { } catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) { if (isAxiosError(e)) {
const error = e as AxiosError; const error = e as AxiosError;
const response = error.response as AxiosResponse; const response = error.response as AxiosResponse;
return { return {
body: { data: response.data, headers: response.headers }, body: { data: response.data, headers: response.headers },
headers: response.headers as any, headers: response.headers as any,
status: response.status, status: response.status,
}; };
} }
throw e; throw e;
} }
}, },
baseHeaders: { baseHeaders: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
baseUrl: '', baseUrl: '',
jsonQuery: false, jsonQuery: false,
}); });
}; };

View File

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

View File

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

View File

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

View File

@ -1,132 +1,132 @@
import { QueryFunctionContext } from '@tanstack/react-query'; import { QueryFunctionContext } from '@tanstack/react-query';
import { LyricSource } from './types'; import { LyricSource } from './types';
import type { import type {
AlbumListQuery, AlbumListQuery,
SongListQuery, SongListQuery,
AlbumDetailQuery, AlbumDetailQuery,
AlbumArtistListQuery, AlbumArtistListQuery,
ArtistListQuery, ArtistListQuery,
PlaylistListQuery, PlaylistListQuery,
PlaylistDetailQuery, PlaylistDetailQuery,
PlaylistSongListQuery, PlaylistSongListQuery,
UserListQuery, UserListQuery,
AlbumArtistDetailQuery, AlbumArtistDetailQuery,
TopSongListQuery, TopSongListQuery,
SearchQuery, SearchQuery,
SongDetailQuery, SongDetailQuery,
RandomSongListQuery, RandomSongListQuery,
LyricsQuery, LyricsQuery,
LyricSearchQuery, LyricSearchQuery,
} from './types'; } from './types';
export const queryKeys: Record< export const queryKeys: Record<
string, string,
Record<string, (...props: any) => QueryFunctionContext['queryKey']> Record<string, (...props: any) => QueryFunctionContext['queryKey']>
> = { > = {
albumArtists: { albumArtists: {
detail: (serverId: string, query?: AlbumArtistDetailQuery) => { detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const; if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] 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) => { albums: {
if (query) return [serverId, 'albumArtists', 'list', query] as const; detail: (serverId: string, query?: AlbumDetailQuery) =>
return [serverId, 'albumArtists', 'list'] as const; [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, artists: {
topSongs: (serverId: string, query?: TopSongListQuery) => { list: (serverId: string, query?: ArtistListQuery) => {
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const; if (query) return [serverId, 'artists', 'list', query] as const;
return [serverId, 'albumArtists', 'topSongs'] as const; return [serverId, 'artists', 'list'] as const;
},
root: (serverId: string) => [serverId, 'artists'] as const,
}, },
}, genres: {
albums: { list: (serverId: string) => [serverId, 'genres', 'list'] as const,
detail: (serverId: string, query?: AlbumDetailQuery) => root: (serverId: string) => [serverId, 'genres'] as const,
[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'], musicFolders: {
serverRoot: (serverId: string) => [serverId, 'albums'], list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
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;
}, },
root: (serverId: string) => [serverId, 'artists'] as const, playlists: {
}, detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
genres: { if (query) return [serverId, 'playlists', id, 'detail', query] as const;
list: (serverId: string) => [serverId, 'genres', 'list'] as const, if (id) return [serverId, 'playlists', id, 'detail'] as const;
root: (serverId: string) => [serverId, 'genres'] as const, return [serverId, 'playlists', 'detail'] as const;
}, },
musicFolders: { detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const, if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const;
}, if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
playlists: { return [serverId, 'playlists', 'detailSongList'] as const;
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => { },
if (query) return [serverId, 'playlists', id, 'detail', query] as const; list: (serverId: string, query?: PlaylistListQuery) => {
if (id) return [serverId, 'playlists', id, 'detail'] as const; if (query) return [serverId, 'playlists', 'list', query] as const;
return [serverId, 'playlists', 'detail'] 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) => { search: {
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const; list: (serverId: string, query?: SearchQuery) => {
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const; if (query) return [serverId, 'search', 'list', query] as const;
return [serverId, 'playlists', 'detailSongList'] as const; return [serverId, 'search', 'list'] as const;
},
root: (serverId: string) => [serverId, 'search'] as const,
}, },
list: (serverId: string, query?: PlaylistListQuery) => { server: {
if (query) return [serverId, 'playlists', 'list', query] as const; root: (serverId: string) => [serverId] as const,
return [serverId, 'playlists', 'list'] as const;
}, },
root: (serverId: string) => [serverId, 'playlists'] as const, songs: {
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => { detail: (serverId: string, query?: SongDetailQuery) => {
if (query && id) return [serverId, 'playlists', id, 'songList', query] as const; if (query) return [serverId, 'songs', 'detail', query] as const;
if (id) return [serverId, 'playlists', id, 'songList'] as const; return [serverId, 'songs', 'detail'] as const;
return [serverId, 'playlists', 'songList'] 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: {
search: { list: (serverId: string, query?: UserListQuery) => {
list: (serverId: string, query?: SearchQuery) => { if (query) return [serverId, 'users', 'list', query] as const;
if (query) return [serverId, 'search', 'list', query] as const; return [serverId, 'users', 'list'] as const;
return [serverId, 'search', '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 = { export type SSBaseResponse = {
serverVersion?: 'string'; serverVersion?: 'string';
status: 'string'; status: 'string';
type?: 'string'; type?: 'string';
version: 'string'; version: 'string';
}; };
export type SSMusicFolderList = SSMusicFolder[]; export type SSMusicFolderList = SSMusicFolder[];
export type SSMusicFolderListResponse = { export type SSMusicFolderListResponse = {
musicFolders: { musicFolders: {
musicFolder: SSMusicFolder[]; musicFolder: SSMusicFolder[];
}; };
}; };
export type SSGenreList = SSGenre[]; export type SSGenreList = SSGenre[];
export type SSGenreListResponse = { export type SSGenreListResponse = {
genres: { genres: {
genre: SSGenre[]; genre: SSGenre[];
}; };
}; };
export type SSAlbumArtistDetailParams = { export type SSAlbumArtistDetailParams = {
id: string; id: string;
}; };
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] }; export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
export type SSAlbumArtistDetailResponse = { export type SSAlbumArtistDetailResponse = {
artist: SSAlbumArtistListEntry & { artist: SSAlbumArtistListEntry & {
album: SSAlbumListEntry[]; album: SSAlbumListEntry[];
}; };
}; };
export type SSAlbumArtistList = { export type SSAlbumArtistList = {
items: SSAlbumArtistListEntry[]; items: SSAlbumArtistListEntry[];
startIndex: number; startIndex: number;
totalRecordCount: number | null; totalRecordCount: number | null;
}; };
export type SSAlbumArtistListResponse = { export type SSAlbumArtistListResponse = {
artists: { artists: {
ignoredArticles: string; ignoredArticles: string;
index: SSArtistIndex[]; index: SSArtistIndex[];
lastModified: number; lastModified: number;
}; };
}; };
export type SSAlbumList = { export type SSAlbumList = {
items: SSAlbumListEntry[]; items: SSAlbumListEntry[];
startIndex: number; startIndex: number;
totalRecordCount: number | null; totalRecordCount: number | null;
}; };
export type SSAlbumListResponse = { export type SSAlbumListResponse = {
albumList2: { albumList2: {
album: SSAlbumListEntry[]; album: SSAlbumListEntry[];
}; };
}; };
export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] }; export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] };
export type SSAlbumDetailResponse = { export type SSAlbumDetailResponse = {
album: SSAlbum; album: SSAlbum;
}; };
export type SSArtistInfoParams = { export type SSArtistInfoParams = {
count?: number; count?: number;
id: string; id: string;
includeNotPresent?: boolean; includeNotPresent?: boolean;
}; };
export type SSArtistInfoResponse = { export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo; artistInfo2: SSArtistInfo;
}; };
export type SSArtistInfo = { export type SSArtistInfo = {
biography: string; biography: string;
largeImageUrl?: string; largeImageUrl?: string;
lastFmUrl?: string; lastFmUrl?: string;
mediumImageUrl?: string; mediumImageUrl?: string;
musicBrainzId?: string; musicBrainzId?: string;
similarArtist?: { 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; albumCount: string;
artistImageUrl?: string; artistImageUrl?: string;
coverArt?: string; coverArt?: string;
id: string; id: string;
name: 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 = { export type SSAlbumListEntry = {
album: string; album: string;
artist: string; artist: string;
artistId: string; artistId: string;
coverArt: string; coverArt: string;
created: string; created: string;
duration: number; duration: number;
genre?: string; genre?: string;
id: string; id: string;
isDir: boolean; isDir: boolean;
isVideo: boolean; isVideo: boolean;
name: string; name: string;
parent: string; parent: string;
songCount: number; songCount: number;
starred?: boolean; starred?: boolean;
title: string; title: string;
userRating?: number; userRating?: number;
year: number; year: number;
}; };
export type SSAlbum = { export type SSAlbum = {
song: SSSong[]; song: SSSong[];
} & SSAlbumListEntry; } & SSAlbumListEntry;
export type SSSong = { export type SSSong = {
album: string; album: string;
albumId: string; albumId: string;
artist: string; artist: string;
artistId?: string; artistId?: string;
bitRate: number; bitRate: number;
contentType: string; contentType: string;
coverArt: string; coverArt: string;
created: string; created: string;
discNumber?: number; discNumber?: number;
duration: number; duration: number;
genre: string; genre: string;
id: string; id: string;
isDir: boolean; isDir: boolean;
isVideo: boolean; isVideo: boolean;
parent: string; parent: string;
path: string; path: string;
playCount: number; playCount: number;
size: number; size: number;
starred?: boolean; starred?: boolean;
suffix: string; suffix: string;
title: string; title: string;
track: number; track: number;
type: string; type: string;
userRating?: number; userRating?: number;
year: number; year: number;
}; };
export type SSAlbumListParams = { export type SSAlbumListParams = {
fromYear?: number; fromYear?: number;
genre?: string; genre?: string;
musicFolderId?: string; musicFolderId?: string;
offset?: number; offset?: number;
size?: number; size?: number;
toYear?: number; toYear?: number;
type: string; type: string;
}; };
export type SSAlbumArtistListParams = { export type SSAlbumArtistListParams = {
musicFolderId?: string; musicFolderId?: string;
}; };
export type SSFavoriteParams = { export type SSFavoriteParams = {
albumId?: string; albumId?: string;
artistId?: string; artistId?: string;
id?: string; id?: string;
}; };
export type SSFavorite = null; export type SSFavorite = null;
@ -192,8 +192,8 @@ export type SSFavorite = null;
export type SSFavoriteResponse = null; export type SSFavoriteResponse = null;
export type SSRatingParams = { export type SSRatingParams = {
id: string; id: string;
rating: number; rating: number;
}; };
export type SSRating = null; export type SSRating = null;
@ -201,24 +201,24 @@ export type SSRating = null;
export type SSRatingResponse = null; export type SSRatingResponse = null;
export type SSTopSongListParams = { export type SSTopSongListParams = {
artist: string; artist: string;
count?: number; count?: number;
}; };
export type SSTopSongListResponse = { export type SSTopSongListResponse = {
topSongs: { topSongs: {
song: SSSong[]; song: SSSong[];
}; };
}; };
export type SSTopSongList = { export type SSTopSongList = {
items: SSSong[]; items: SSSong[];
startIndex: number; startIndex: number;
totalRecordCount: number | null; totalRecordCount: number | null;
}; };
export type SSScrobbleParams = { export type SSScrobbleParams = {
id: string; id: string;
submission?: boolean; submission?: boolean;
time?: number; time?: number;
}; };

View File

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

View File

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

View File

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

View File

@ -1,240 +1,240 @@
import { z } from 'zod'; import { z } from 'zod';
const baseResponse = z.object({ const baseResponse = z.object({
'subsonic-response': z.object({ 'subsonic-response': z.object({
status: z.string(), status: z.string(),
version: z.string(), version: z.string(),
}), }),
}); });
const authenticate = z.null(); const authenticate = z.null();
const authenticateParameters = z.object({ const authenticateParameters = z.object({
c: z.string(), c: z.string(),
f: z.string(), f: z.string(),
p: z.string().optional(), p: z.string().optional(),
s: z.string().optional(), s: z.string().optional(),
t: z.string().optional(), t: z.string().optional(),
u: z.string(), u: z.string(),
v: z.string(), v: z.string(),
}); });
const createFavoriteParameters = z.object({ const createFavoriteParameters = z.object({
albumId: z.array(z.string()).optional(), albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(), artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(), id: z.array(z.string()).optional(),
}); });
const createFavorite = z.null(); const createFavorite = z.null();
const removeFavoriteParameters = z.object({ const removeFavoriteParameters = z.object({
albumId: z.array(z.string()).optional(), albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(), artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(), id: z.array(z.string()).optional(),
}); });
const removeFavorite = z.null(); const removeFavorite = z.null();
const setRatingParameters = z.object({ const setRatingParameters = z.object({
id: z.string(), id: z.string(),
rating: z.number(), rating: z.number(),
}); });
const setRating = z.null(); const setRating = z.null();
const musicFolder = z.object({ const musicFolder = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
}); });
const musicFolderList = z.object({ const musicFolderList = z.object({
musicFolders: z.object({ musicFolders: z.object({
musicFolder: z.array(musicFolder), musicFolder: z.array(musicFolder),
}), }),
}); });
const song = z.object({ const song = z.object({
album: z.string().optional(), album: z.string().optional(),
albumId: z.string().optional(), albumId: z.string().optional(),
artist: z.string().optional(), artist: z.string().optional(),
artistId: z.string().optional(), artistId: z.string().optional(),
averageRating: z.number().optional(), averageRating: z.number().optional(),
bitRate: z.number().optional(), bitRate: z.number().optional(),
contentType: z.string(), contentType: z.string(),
coverArt: z.string().optional(), coverArt: z.string().optional(),
created: z.string(), created: z.string(),
discNumber: z.number(), discNumber: z.number(),
duration: z.number().optional(), duration: z.number().optional(),
genre: z.string().optional(), genre: z.string().optional(),
id: z.string(), id: z.string(),
isDir: z.boolean(), isDir: z.boolean(),
isVideo: z.boolean(), isVideo: z.boolean(),
parent: z.string(), parent: z.string(),
path: z.string(), path: z.string(),
playCount: z.number().optional(), playCount: z.number().optional(),
size: z.number(), size: z.number(),
starred: z.boolean().optional(), starred: z.boolean().optional(),
suffix: z.string(), suffix: z.string(),
title: z.string(), title: z.string(),
track: z.number().optional(), track: z.number().optional(),
type: z.string(), type: z.string(),
userRating: z.number().optional(), userRating: z.number().optional(),
year: z.number().optional(), year: z.number().optional(),
}); });
const album = z.object({ const album = z.object({
album: z.string(), album: z.string(),
artist: z.string(), artist: z.string(),
artistId: z.string(), artistId: z.string(),
coverArt: z.string(), coverArt: z.string(),
created: z.string(), created: z.string(),
duration: z.number(), duration: z.number(),
genre: z.string().optional(), genre: z.string().optional(),
id: z.string(), id: z.string(),
isDir: z.boolean(), isDir: z.boolean(),
isVideo: z.boolean(), isVideo: z.boolean(),
name: z.string(), name: z.string(),
parent: z.string(), parent: z.string(),
song: z.array(song), song: z.array(song),
songCount: z.number(), songCount: z.number(),
starred: z.boolean().optional(), starred: z.boolean().optional(),
title: z.string(), title: z.string(),
userRating: z.number().optional(), userRating: z.number().optional(),
year: z.number().optional(), year: z.number().optional(),
}); });
const albumListParameters = z.object({ const albumListParameters = z.object({
fromYear: z.number().optional(), fromYear: z.number().optional(),
genre: z.string().optional(), genre: z.string().optional(),
musicFolderId: z.string().optional(), musicFolderId: z.string().optional(),
offset: z.number().optional(), offset: z.number().optional(),
size: z.number().optional(), size: z.number().optional(),
toYear: z.number().optional(), toYear: z.number().optional(),
type: z.string().optional(), type: z.string().optional(),
}); });
const albumList = z.array(album.omit({ song: true })); const albumList = z.array(album.omit({ song: true }));
const albumArtist = z.object({ const albumArtist = z.object({
albumCount: z.string(), albumCount: z.string(),
artistImageUrl: z.string().optional(), artistImageUrl: z.string().optional(),
coverArt: z.string().optional(), coverArt: z.string().optional(),
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
}); });
const albumArtistList = z.object({ const albumArtistList = z.object({
artist: z.array(albumArtist), artist: z.array(albumArtist),
name: z.string(), name: z.string(),
}); });
const artistInfoParameters = z.object({ const artistInfoParameters = z.object({
count: z.number().optional(), count: z.number().optional(),
id: z.string(), id: z.string(),
includeNotPresent: z.boolean().optional(), includeNotPresent: z.boolean().optional(),
}); });
const artistInfo = z.object({ const artistInfo = z.object({
artistInfo: z.object({ artistInfo: z.object({
biography: z.string().optional(), biography: z.string().optional(),
largeImageUrl: z.string().optional(), largeImageUrl: z.string().optional(),
lastFmUrl: z.string().optional(), lastFmUrl: z.string().optional(),
mediumImageUrl: z.string().optional(), mediumImageUrl: z.string().optional(),
musicBrainzId: z.string().optional(), musicBrainzId: z.string().optional(),
similarArtist: z.array( similarArtist: z.array(
z.object({ z.object({
albumCount: z.string(), albumCount: z.string(),
artistImageUrl: z.string().optional(), artistImageUrl: z.string().optional(),
coverArt: z.string().optional(), coverArt: z.string().optional(),
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
}), }),
), ),
smallImageUrl: z.string().optional(), smallImageUrl: z.string().optional(),
}), }),
}); });
const topSongsListParameters = z.object({ const topSongsListParameters = z.object({
artist: z.string(), // The name of the artist, not the artist ID artist: z.string(), // The name of the artist, not the artist ID
count: z.number().optional(), count: z.number().optional(),
}); });
const topSongsList = z.object({ const topSongsList = z.object({
topSongs: z.object({ topSongs: z.object({
song: z.array(song), song: z.array(song),
}), }),
}); });
const scrobbleParameters = z.object({ const scrobbleParameters = z.object({
id: z.string(), id: z.string(),
submission: z.boolean().optional(), submission: z.boolean().optional(),
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to. time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
}); });
const scrobble = z.null(); const scrobble = z.null();
const search3 = z.object({ const search3 = z.object({
searchResult3: z.object({ searchResult3: z.object({
album: z.array(album), album: z.array(album),
artist: z.array(albumArtist), artist: z.array(albumArtist),
song: z.array(song), song: z.array(song),
}), }),
}); });
const search3Parameters = z.object({ const search3Parameters = z.object({
albumCount: z.number().optional(), albumCount: z.number().optional(),
albumOffset: z.number().optional(), albumOffset: z.number().optional(),
artistCount: z.number().optional(), artistCount: z.number().optional(),
artistOffset: z.number().optional(), artistOffset: z.number().optional(),
musicFolderId: z.string().optional(), musicFolderId: z.string().optional(),
query: z.string().optional(), query: z.string().optional(),
songCount: z.number().optional(), songCount: z.number().optional(),
songOffset: z.number().optional(), songOffset: z.number().optional(),
}); });
const randomSongListParameters = z.object({ const randomSongListParameters = z.object({
fromYear: z.number().optional(), fromYear: z.number().optional(),
genre: z.string().optional(), genre: z.string().optional(),
musicFolderId: z.string().optional(), musicFolderId: z.string().optional(),
size: z.number().optional(), size: z.number().optional(),
toYear: z.number().optional(), toYear: z.number().optional(),
}); });
const randomSongList = z.object({ const randomSongList = z.object({
randomSongs: z.object({ randomSongs: z.object({
song: z.array(song), song: z.array(song),
}), }),
}); });
export const ssType = { export const ssType = {
_parameters: { _parameters: {
albumList: albumListParameters, albumList: albumListParameters,
artistInfo: artistInfoParameters, artistInfo: artistInfoParameters,
authenticate: authenticateParameters, authenticate: authenticateParameters,
createFavorite: createFavoriteParameters, createFavorite: createFavoriteParameters,
randomSongList: randomSongListParameters, randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters, removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters, scrobble: scrobbleParameters,
search3: search3Parameters, search3: search3Parameters,
setRating: setRatingParameters, setRating: setRatingParameters,
topSongsList: topSongsListParameters, topSongsList: topSongsListParameters,
}, },
_response: { _response: {
album, album,
albumArtist, albumArtist,
albumArtistList, albumArtistList,
albumList, albumList,
artistInfo, artistInfo,
authenticate, authenticate,
baseResponse, baseResponse,
createFavorite, createFavorite,
musicFolderList, musicFolderList,
randomSongList, randomSongList,
removeFavorite, removeFavorite,
scrobble, scrobble,
search3, search3,
setRating, setRating,
song, song,
topSongsList, 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 // 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) => { export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
return z.object({ return z.object({
data: itemSchema, data: itemSchema,
headers: z.instanceof(AxiosHeaders), headers: z.instanceof(AxiosHeaders),
}); });
}; };
export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>( export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
itemSchema: ItemType, itemSchema: ItemType,
) => { ) => {
return z.object({ return z.object({
'subsonic-response': z 'subsonic-response': z
.object({ .object({
status: z.string(), status: z.string(),
version: z.string(), version: z.string(),
}) })
.extend(itemSchema), .extend(itemSchema),
}); });
}; };
export const authenticationFailure = (currentServer: ServerListItem | null) => { export const authenticationFailure = (currentServer: ServerListItem | null) => {
toast.error({ toast.error({
message: 'Your session has expired.', message: 'Your session has expired.',
}); });
if (currentServer) { if (currentServer) {
const serverId = currentServer.id; const serverId = currentServer.id;
const token = currentServer.ndCredential; const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`); console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined }); useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null); 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; const ipc = isElectron() ? window.electron.ipc : null;
export const App = () => { export const App = () => {
const theme = useTheme(); const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent); const contentFont = useSettingsStore((state) => state.general.fontContent);
const { type: playbackType } = usePlaybackSettings(); const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings(); const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd(); const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls(); const { clearQueue, restoreQueue } = useQueueControls();
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--content-font-family', contentFont); root.style.setProperty('--content-font-family', contentFont);
}, [contentFont]); }, [contentFont]);
// Start the mpv instance on startup // Start the mpv instance on startup
useEffect(() => { useEffect(() => {
const initializeMpv = async () => { const initializeMpv = async () => {
const isRunning: boolean | undefined = await mpvPlayer?.isRunning(); const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
if (!isRunning) { if (!isRunning) {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters; const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties = { const properties = {
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties), ...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
};
mpvPlayer?.initialize({
extraParameters,
properties,
});
mpvPlayer?.volume(properties.volume);
}
}; };
mpvPlayer?.initialize({ if (isElectron() && playbackType === PlaybackType.LOCAL) {
extraParameters, initializeMpv();
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);
} }
});
}
return () => { return () => {
ipc?.removeAllListeners('renderer-player-restore-queue'); clearQueue();
ipc?.removeAllListeners('renderer-player-save-queue'); mpvPlayer?.stop();
}; mpvPlayer?.cleanup();
}, [playbackType, restoreQueue]); };
}, [clearQueue, playbackType]);
return ( useEffect(() => {
<MantineProvider if (isElectron()) {
withGlobalStyles ipc?.send('set-global-shortcuts', bindings);
withNormalizeCSS }
theme={{ }, [bindings]);
colorScheme: theme as 'light' | 'dark',
components: { useEffect(() => {
Modal: { if (isElectron()) {
styles: { mpvPlayer.restoreQueue();
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
close: { marginRight: '0.5rem' }, mpvPlayerListener.rendererSaveQueue(() => {
content: { borderRadius: '5px' }, const { current, queue } = usePlayerStore.getState();
header: { const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
background: 'var(--modal-header-bg)', current: {
paddingBottom: '1rem', ...current,
}, status: PlayerStatus.PAUSED,
title: { fontSize: 'medium', fontWeight: 500 }, },
}, queue,
}, };
}, mpvPlayer.saveQueue(stateToSave);
defaultRadius: 'xs', });
dir: 'ltr',
focusRing: 'auto', mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
focusRingStyles: { const playerData = restoreQueue(data);
inputStyles: () => ({ if (playbackType === PlaybackType.LOCAL) {
border: '1px solid var(--primary-color)', mpvPlayer.setQueue(playerData, true);
}), }
resetStyles: () => ({ outline: 'none' }), });
styles: () => ({ }
outline: '1px solid var(--primary-color)',
outlineOffset: '-1px', return () => {
}), ipc?.removeAllListeners('renderer-player-restore-queue');
}, ipc?.removeAllListeners('renderer-player-save-queue');
fontFamily: 'var(--content-font-family)', };
fontSizes: { }, [playbackType, restoreQueue]);
lg: '1.1rem',
md: '1rem', return (
sm: '0.9rem', <MantineProvider
xl: '1.5rem', withGlobalStyles
xs: '0.8rem', withNormalizeCSS
}, theme={{
headings: { colorScheme: theme as 'light' | 'dark',
fontFamily: 'var(--content-font-family)', components: {
fontWeight: 700, Modal: {
}, styles: {
other: {}, body: { background: 'var(--modal-bg)', padding: '1rem !important' },
spacing: { close: { marginRight: '0.5rem' },
lg: '2rem', content: { borderRadius: '5px' },
md: '1rem', header: {
sm: '0.5rem', background: 'var(--modal-header-bg)',
xl: '4rem', paddingBottom: '1rem',
xs: '0rem', },
}, title: { fontSize: 'medium', fontWeight: 500 },
}} },
> },
<ModalsProvider },
modalProps={{ defaultRadius: 'xs',
centered: true, dir: 'ltr',
styles: { focusRing: 'auto',
body: { position: 'relative' }, focusRingStyles: {
content: { overflow: 'auto' }, inputStyles: () => ({
}, border: '1px solid var(--primary-color)',
transitionProps: { }),
duration: 300, resetStyles: () => ({ outline: 'none' }),
exitDuration: 300, styles: () => ({
transition: 'fade', outline: '1px solid var(--primary-color)',
}, outlineOffset: '-1px',
}} }),
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }} },
> fontFamily: 'var(--content-font-family)',
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}> fontSizes: {
<ContextMenuProvider> lg: '1.1rem',
<AppRouter /> md: '1rem',
</ContextMenuProvider> sm: '0.9rem',
</PlayQueueHandlerContext.Provider> xl: '1.5rem',
</ModalsProvider> xs: '0.8rem',
</MantineProvider> },
); 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; type AccordionProps = MantineAccordionProps;
const StyledAccordion = styled(MantineAccordion)` const StyledAccordion = styled(MantineAccordion)`
& .mantine-Accordion-panel { & .mantine-Accordion-panel {
background: var(--paper-bg); background: var(--paper-bg);
} }
.mantine-Accordion-control { .mantine-Accordion-control {
background: var(--paper-bg); background: var(--paper-bg);
} }
`; `;
export const Accordion = ({ children, ...props }: AccordionProps) => { export const Accordion = ({ children, ...props }: AccordionProps) => {
return <StyledAccordion {...props}>{children}</StyledAccordion>; return <StyledAccordion {...props}>{children}</StyledAccordion>;
}; };
Accordion.Control = StyledAccordion.Control; Accordion.Control = StyledAccordion.Control;

View File

@ -4,188 +4,192 @@ import type { ReactPlayerProps } from 'react-player';
import ReactPlayer from 'react-player'; import ReactPlayer from 'react-player';
import type { Song } from '/@/renderer/api/types'; import type { Song } from '/@/renderer/api/types';
import { import {
crossfadeHandler, crossfadeHandler,
gaplessHandler, gaplessHandler,
} from '/@/renderer/components/audio-player/utils/list-handlers'; } from '/@/renderer/components/audio-player/utils/list-handlers';
import { useSettingsStore } from '/@/renderer/store/settings.store'; import { useSettingsStore } from '/@/renderer/store/settings.store';
import type { CrossfadeStyle } from '/@/renderer/types'; import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types'; import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
interface AudioPlayerProps extends ReactPlayerProps { interface AudioPlayerProps extends ReactPlayerProps {
crossfadeDuration: number; crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle; crossfadeStyle: CrossfadeStyle;
currentPlayer: 1 | 2; currentPlayer: 1 | 2;
playbackStyle: PlaybackStyle; playbackStyle: PlaybackStyle;
player1: Song; player1: Song;
player2: Song; player2: Song;
status: PlayerStatus; status: PlayerStatus;
volume: number; volume: number;
} }
export type AudioPlayerProgress = { export type AudioPlayerProgress = {
loaded: number; loaded: number;
loadedSeconds: number; loadedSeconds: number;
played: number; played: number;
playedSeconds: number; playedSeconds: number;
}; };
const getDuration = (ref: any) => { const getDuration = (ref: any) => {
return ref.current?.player?.player?.player?.duration; return ref.current?.player?.player?.player?.duration;
}; };
export const AudioPlayer = forwardRef( export const AudioPlayer = forwardRef(
( (
{ {
status, status,
playbackStyle, playbackStyle,
crossfadeStyle, crossfadeStyle,
crossfadeDuration, crossfadeDuration,
currentPlayer, currentPlayer,
autoNext, autoNext,
player1, player1,
player2, player2,
muted, muted,
volume, volume,
}: AudioPlayerProps, }: AudioPlayerProps,
ref: any, ref: any,
) => { ) => {
const player1Ref = useRef<any>(null); const player1Ref = useRef<any>(null);
const player2Ref = useRef<any>(null); const player2Ref = useRef<any>(null);
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId); const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
get player1() { get player1() {
return player1Ref?.current; return player1Ref?.current;
}, },
get player2() { get player2() {
return player2Ref?.current; return player2Ref?.current;
}, },
})); }));
const handleOnEnded = () => { const handleOnEnded = () => {
autoNext(); autoNext();
setIsTransitioning(false); setIsTransitioning(false);
}; };
useEffect(() => { useEffect(() => {
if (status === PlayerStatus.PLAYING) { if (status === PlayerStatus.PLAYING) {
if (currentPlayer === 1) { if (currentPlayer === 1) {
player1Ref.current?.getInternalPlayer()?.play(); player1Ref.current?.getInternalPlayer()?.play();
} else { } else {
player2Ref.current?.getInternalPlayer()?.play(); player2Ref.current?.getInternalPlayer()?.play();
} }
} else { } else {
player1Ref.current?.getInternalPlayer()?.pause(); player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause(); player2Ref.current?.getInternalPlayer()?.pause();
} }
}, [currentPlayer, status]); }, [currentPlayer, status]);
const handleCrossfade1 = useCallback( const handleCrossfade1 = useCallback(
(e: AudioPlayerProgress) => { (e: AudioPlayerProgress) => {
return crossfadeHandler({ return crossfadeHandler({
currentPlayer, currentPlayer,
currentPlayerRef: player1Ref, currentPlayerRef: player1Ref,
currentTime: e.playedSeconds, currentTime: e.playedSeconds,
duration: getDuration(player1Ref), duration: getDuration(player1Ref),
fadeDuration: crossfadeDuration, fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle, fadeType: crossfadeStyle,
isTransitioning, isTransitioning,
nextPlayerRef: player2Ref, nextPlayerRef: player2Ref,
player: 1, player: 1,
setIsTransitioning, setIsTransitioning,
volume, volume,
}); });
}, },
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume], [crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
); );
const handleCrossfade2 = useCallback( const handleCrossfade2 = useCallback(
(e: AudioPlayerProgress) => { (e: AudioPlayerProgress) => {
return crossfadeHandler({ return crossfadeHandler({
currentPlayer, currentPlayer,
currentPlayerRef: player2Ref, currentPlayerRef: player2Ref,
currentTime: e.playedSeconds, currentTime: e.playedSeconds,
duration: getDuration(player2Ref), duration: getDuration(player2Ref),
fadeDuration: crossfadeDuration, fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle, fadeType: crossfadeStyle,
isTransitioning, isTransitioning,
nextPlayerRef: player1Ref, nextPlayerRef: player1Ref,
player: 2, player: 2,
setIsTransitioning, setIsTransitioning,
volume, volume,
}); });
}, },
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume], [crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
); );
const handleGapless1 = useCallback( const handleGapless1 = useCallback(
(e: AudioPlayerProgress) => { (e: AudioPlayerProgress) => {
return gaplessHandler({ return gaplessHandler({
currentTime: e.playedSeconds, currentTime: e.playedSeconds,
duration: getDuration(player1Ref), duration: getDuration(player1Ref),
isFlac: player1?.container === 'flac', isFlac: player1?.container === 'flac',
isTransitioning, isTransitioning,
nextPlayerRef: player2Ref, nextPlayerRef: player2Ref,
setIsTransitioning, setIsTransitioning,
}); });
}, },
[isTransitioning, player1?.container], [isTransitioning, player1?.container],
); );
const handleGapless2 = useCallback( const handleGapless2 = useCallback(
(e: AudioPlayerProgress) => { (e: AudioPlayerProgress) => {
return gaplessHandler({ return gaplessHandler({
currentTime: e.playedSeconds, currentTime: e.playedSeconds,
duration: getDuration(player2Ref), duration: getDuration(player2Ref),
isFlac: player2?.container === 'flac', isFlac: player2?.container === 'flac',
isTransitioning, isTransitioning,
nextPlayerRef: player1Ref, nextPlayerRef: player1Ref,
setIsTransitioning, setIsTransitioning,
}); });
}, },
[isTransitioning, player2?.container], [isTransitioning, player2?.container],
); );
useEffect(() => { useEffect(() => {
if (isElectron()) { if (isElectron()) {
if (audioDeviceId) { if (audioDeviceId) {
player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId); player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId); player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
} else { } else {
player1Ref.current?.getInternalPlayer()?.setSinkId(''); player1Ref.current?.getInternalPlayer()?.setSinkId('');
player2Ref.current?.getInternalPlayer()?.setSinkId(''); player2Ref.current?.getInternalPlayer()?.setSinkId('');
} }
} }
}, [audioDeviceId]); }, [audioDeviceId]);
return ( return (
<> <>
<ReactPlayer <ReactPlayer
ref={player1Ref} ref={player1Ref}
height={0} height={0}
muted={muted} muted={muted}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING} playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250} progressInterval={isTransitioning ? 10 : 250}
url={player1?.streamUrl} url={player1?.streamUrl}
volume={volume} volume={volume}
width={0} width={0}
onEnded={handleOnEnded} onEnded={handleOnEnded}
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1} onProgress={
/> playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
<ReactPlayer }
ref={player2Ref} />
height={0} <ReactPlayer
muted={muted} ref={player2Ref}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING} height={0}
progressInterval={isTransitioning ? 10 : 250} muted={muted}
url={player2?.streamUrl} playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
volume={volume} progressInterval={isTransitioning ? 10 : 250}
width={0} url={player2?.streamUrl}
onEnded={handleOnEnded} volume={volume}
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2} 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'; import { CrossfadeStyle } from '/@/renderer/types';
export const gaplessHandler = (args: { export const gaplessHandler = (args: {
currentTime: number; currentTime: number;
duration: number; duration: number;
isFlac: boolean; isFlac: boolean;
isTransitioning: boolean; isTransitioning: boolean;
nextPlayerRef: any; nextPlayerRef: any;
setIsTransitioning: Dispatch<boolean>; setIsTransitioning: Dispatch<boolean>;
}) => { }) => {
const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } = const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } =
args; args;
if (!isTransitioning) { if (!isTransitioning) {
if (currentTime > duration - 2) { if (currentTime > duration - 2) {
return setIsTransitioning(true); return setIsTransitioning(true);
}
return null;
}
const durationPadding = isFlac ? 0.065 : 0.116;
if (currentTime + durationPadding >= duration) {
return nextPlayerRef.current.getInternalPlayer().play();
} }
return null; return null;
}
const durationPadding = isFlac ? 0.065 : 0.116;
if (currentTime + durationPadding >= duration) {
return nextPlayerRef.current.getInternalPlayer().play();
}
return null;
}; };
export const crossfadeHandler = (args: { export const crossfadeHandler = (args: {
currentPlayer: 1 | 2; currentPlayer: 1 | 2;
currentPlayerRef: any; currentPlayerRef: any;
currentTime: number; currentTime: number;
duration: number; duration: number;
fadeDuration: number; fadeDuration: number;
fadeType: CrossfadeStyle; fadeType: CrossfadeStyle;
isTransitioning: boolean; isTransitioning: boolean;
nextPlayerRef: any; nextPlayerRef: any;
player: 1 | 2; player: 1 | 2;
setIsTransitioning: Dispatch<boolean>; setIsTransitioning: Dispatch<boolean>;
volume: number; volume: number;
}) => { }) => {
const { const {
currentTime, currentTime,
player, player,
currentPlayer, currentPlayer,
currentPlayerRef, currentPlayerRef,
nextPlayerRef, nextPlayerRef,
fadeDuration, fadeDuration,
fadeType, fadeType,
duration, duration,
volume, volume,
isTransitioning, isTransitioning,
setIsTransitioning, setIsTransitioning,
} = args; } = args;
if (!isTransitioning || currentPlayer !== player) { if (!isTransitioning || currentPlayer !== player) {
const shouldBeginTransition = currentTime >= duration - fadeDuration; const shouldBeginTransition = currentTime >= duration - fadeDuration;
if (shouldBeginTransition) { if (shouldBeginTransition) {
setIsTransitioning(true); setIsTransitioning(true);
return nextPlayerRef.current.getInternalPlayer().play(); 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; 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; export type BadgeProps = MantineBadgeProps;
const StyledBadge = styled(MantineBadge)<BadgeProps>` const StyledBadge = styled(MantineBadge)<BadgeProps>`
border-radius: var(--badge-radius); border-radius: var(--badge-radius);
.mantine-Badge-root { .mantine-Badge-root {
color: var(--badge-fg); color: var(--badge-fg);
} }
.mantine-Badge-inner { .mantine-Badge-inner {
color: var(--badge-fg); color: var(--badge-fg);
} }
`; `;
const _Badge = ({ children, ...props }: BadgeProps) => { const _Badge = ({ children, ...props }: BadgeProps) => {
return ( return (
<StyledBadge <StyledBadge
radius="md" radius="md"
size="sm" size="sm"
styles={{ styles={{
root: { background: 'var(--badge-bg)' }, root: { background: 'var(--badge-bg)' },
}} }}
{...props} {...props}
> >
{children} {children}
</StyledBadge> </StyledBadge>
); );
}; };
export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge); 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'; import { Tooltip } from '/@/renderer/components/tooltip';
export interface ButtonProps extends MantineButtonProps { export interface ButtonProps extends MantineButtonProps {
children: React.ReactNode; children: React.ReactNode;
loading?: boolean; loading?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
tooltip?: Omit<TooltipProps, 'children'>; tooltip?: Omit<TooltipProps, 'children'>;
} }
interface StyledButtonProps extends ButtonProps { interface StyledButtonProps extends ButtonProps {
ref: Ref<HTMLButtonElement>; ref: Ref<HTMLButtonElement>;
} }
const StyledButton = styled(MantineButton)<StyledButtonProps>` 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)`}; color: ${(props) => `var(--btn-${props.variant}-fg)`};
background: ${(props) => `var(--btn-${props.variant}-bg)`}; background: ${(props) => `var(--btn-${props.variant}-bg)`};
border: ${(props) => `var(--btn-${props.variant}-border)`};
opacity: 0.6; 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;
&: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)`};
svg { 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 { &:disabled {
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`}; color: ${(props) => `var(--btn-${props.variant}-fg)`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`}; background: ${(props) => `var(--btn-${props.variant}-bg)`};
}
&:active { opacity: 0.6;
transform: none; }
}
& .mantine-Button-centerLoader { &:not([data-disabled])&:hover {
display: none; 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 { svg {
display: flex; fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
height: 100%; }
margin-right: 0.5rem; }
transform: translateY(-0.1rem);
}
.mantine-Button-rightIcon { &:not([data-disabled])&:focus-visible {
display: flex; color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
margin-left: 0.5rem; 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 }>` const ButtonChildWrapper = styled.span<{ $loading?: boolean }>`
color: ${(props) => props.$loading && 'transparent !important'}; color: ${(props) => props.$loading && 'transparent !important'};
`; `;
const SpinnerWrapper = styled.div` const SpinnerWrapper = styled.div`
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate3d(-50%, -50%, 0); transform: translate3d(-50%, -50%, 0);
`; `;
export const _Button = forwardRef<HTMLButtonElement, ButtonProps>( export const _Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, tooltip, ...props }: ButtonProps, ref) => { ({ children, tooltip, ...props }: ButtonProps, ref) => {
if (tooltip) { if (tooltip) {
return ( return (
<Tooltip <Tooltip
withinPortal withinPortal
{...tooltip} {...tooltip}
> >
<StyledButton <StyledButton
ref={ref} ref={ref}
loaderPosition="center" loaderPosition="center"
{...props} {...props}
> >
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper> <ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
{props.loading && ( {props.loading && (
<SpinnerWrapper> <SpinnerWrapper>
<Spinner /> <Spinner />
</SpinnerWrapper> </SpinnerWrapper>
)} )}
</StyledButton> </StyledButton>
</Tooltip> </Tooltip>
); );
} }
return ( return (
<StyledButton <StyledButton
ref={ref} ref={ref}
loaderPosition="center" loaderPosition="center"
{...props} {...props}
> >
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper> <ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
{props.loading && ( {props.loading && (
<SpinnerWrapper> <SpinnerWrapper>
<Spinner /> <Spinner />
</SpinnerWrapper> </SpinnerWrapper>
)} )}
</StyledButton> </StyledButton>
); );
}, },
); );
export const Button = createPolymorphicComponent<'button', ButtonProps>(_Button); export const Button = createPolymorphicComponent<'button', ButtonProps>(_Button);
_Button.defaultProps = { _Button.defaultProps = {
loading: undefined, loading: undefined,
onClick: undefined, onClick: undefined,
tooltip: undefined, tooltip: undefined,
}; };
interface HoldButtonProps extends ButtonProps { interface HoldButtonProps extends ButtonProps {
timeoutProps: { timeoutProps: {
callback: () => void; callback: () => void;
duration: number; duration: number;
}; };
} }
export const TimeoutButton = ({ timeoutProps, ...props }: HoldButtonProps) => { export const TimeoutButton = ({ timeoutProps, ...props }: HoldButtonProps) => {
const [, setTimeoutRemaining] = useState(timeoutProps.duration); const [, setTimeoutRemaining] = useState(timeoutProps.duration);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(0); const intervalRef = useRef(0);
const callback = () => { const callback = () => {
timeoutProps.callback(); timeoutProps.callback();
setTimeoutRemaining(timeoutProps.duration); setTimeoutRemaining(timeoutProps.duration);
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
setIsRunning(false); setIsRunning(false);
}; };
const { start, clear } = useTimeout(callback, timeoutProps.duration); const { start, clear } = useTimeout(callback, timeoutProps.duration);
const startTimeout = useCallback(() => { const startTimeout = useCallback(() => {
if (isRunning) { if (isRunning) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
setIsRunning(false); setIsRunning(false);
clear(); clear();
} else { } else {
setIsRunning(true); setIsRunning(true);
start(); start();
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
setTimeoutRemaining((prev) => prev - 100); setTimeoutRemaining((prev) => prev - 100);
}, 100); }, 100);
intervalRef.current = intervalId; intervalRef.current = intervalId;
} }
}, [clear, isRunning, start]); }, [clear, isRunning, start]);
return ( return (
<Button <Button
sx={{ color: 'var(--danger-color)' }} sx={{ color: 'var(--danger-color)' }}
onClick={startTimeout} onClick={startTimeout}
{...props} {...props}
> >
{isRunning ? 'Cancel' : props.children} {isRunning ? 'Cancel' : props.children}
</Button> </Button>
); );
}; };

View File

@ -11,208 +11,208 @@ import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
import { CardRows } from '/@/renderer/components/card/card-rows'; import { CardRows } from '/@/renderer/components/card/card-rows';
const CardWrapper = styled.div<{ const CardWrapper = styled.div<{
link?: boolean; link?: boolean;
}>` }>`
padding: 1rem; padding: 1rem;
background: var(--card-default-bg); background: var(--card-default-bg);
border-radius: var(--card-default-radius); border-radius: var(--card-default-radius);
cursor: ${({ link }) => link && 'pointer'}; cursor: ${({ link }) => link && 'pointer'};
transition: border 0.2s ease-in-out, background 0.2s ease-in-out; transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
&:hover { &:hover {
background: var(--card-default-bg-hover); background: var(--card-default-bg-hover);
}
&:hover div {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
} }
}
&:focus-visible { &:hover div {
outline: 1px solid #fff; opacity: 1;
} }
&:hover * {
&::before {
opacity: 0.5;
}
}
&:focus-visible {
outline: 1px solid #fff;
}
`; `;
const StyledCard = styled.div` const StyledCard = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0; padding: 0;
border-radius: var(--card-default-radius); border-radius: var(--card-default-radius);
`; `;
const ImageSection = styled.div` const ImageSection = styled.div`
position: relative; position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
border-radius: var(--card-default-radius); border-radius: var(--card-default-radius);
&::before { &::before {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
z-index: 1; z-index: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%); background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0; opacity: 0;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
content: ''; content: '';
user-select: none; user-select: none;
} }
`; `;
const Image = styled(SimpleImg)` const Image = styled(SimpleImg)`
border-radius: var(--card-default-radius); border-radius: var(--card-default-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%); box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
`; `;
const ControlsContainer = styled.div` const ControlsContainer = styled.div`
position: absolute; position: absolute;
bottom: 0; bottom: 0;
z-index: 50; z-index: 50;
width: 100%; width: 100%;
opacity: 0; opacity: 0;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
`; `;
const DetailSection = styled.div` const DetailSection = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`; `;
const Row = styled.div<{ $secondary?: boolean }>` const Row = styled.div<{ $secondary?: boolean }>`
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
height: 22px; height: 22px;
padding: 0 0.2rem; padding: 0 0.2rem;
overflow: hidden; overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')}; color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
user-select: none; user-select: none;
`; `;
interface BaseGridCardProps { interface BaseGridCardProps {
controls: { controls: {
cardRows: CardRow<Album | Artist | AlbumArtist>[]; cardRows: CardRow<Album | Artist | AlbumArtist>[];
itemType: LibraryItem; itemType: LibraryItem;
playButtonBehavior: Play; playButtonBehavior: Play;
route: CardRoute; route: CardRoute;
}; };
data: any; data: any;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void; handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
loading?: boolean; loading?: boolean;
size: number; size: number;
} }
export const AlbumCard = ({ export const AlbumCard = ({
loading, loading,
size, size,
handlePlayQueueAdd, handlePlayQueueAdd,
data, data,
controls, controls,
}: BaseGridCardProps) => { }: BaseGridCardProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { itemType, cardRows, route } = controls; const { itemType, cardRows, route } = controls;
const handleNavigate = useCallback(() => { const handleNavigate = useCallback(() => {
navigate( navigate(
generatePath( generatePath(
route.route, route.route,
route.slugs?.reduce((acc, slug) => { route.slugs?.reduce((acc, slug) => {
return { return {
...acc, ...acc,
[slug.slugProperty]: data[slug.idProperty], [slug.slugProperty]: data[slug.idProperty],
}; };
}, {}), }, {}),
), ),
); );
}, [data, navigate, route.route, route.slugs]); }, [data, navigate, route.route, route.slugs]);
if (!loading) { if (!loading) {
return ( return (
<CardWrapper <CardWrapper
link link
onClick={handleNavigate} 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%'}
> >
<Row /> <StyledCard>
</Skeleton> <ImageSection>
))} {data?.imageUrl ? (
</DetailSection> <Image
</StyledCard> animationDuration={0.3}
</CardWrapper> 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 { LibraryItem } from '/@/renderer/api/types';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { import {
ALBUM_CONTEXT_MENU_ITEMS, ALBUM_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS, ARTIST_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items'; } from '/@/renderer/features/context-menu/context-menu-items';
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>; type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
const PlayButton = styled.button<PlayButtonType>` const PlayButton = styled.button<PlayButtonType>`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 50px; width: 50px;
height: 50px; height: 50px;
background-color: rgb(255, 255, 255); background-color: rgb(255, 255, 255);
border: none; border: none;
border-radius: 50%; border-radius: 50%;
opacity: 0.8; opacity: 0.8;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear; transition: scale 0.2s linear;
&:hover { &:hover {
opacity: 1; opacity: 1;
scale: 1.1; scale: 1.1;
} }
&:active { &:active {
opacity: 1; opacity: 1;
scale: 1; scale: 1;
} }
svg { svg {
fill: rgb(0, 0, 0); fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0); stroke: rgb(0, 0, 0);
} }
`; `;
const SecondaryButton = styled(_Button)` const SecondaryButton = styled(_Button)`
opacity: 0.8; opacity: 0.8;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear; transition: scale 0.2s linear;
&:hover { &:hover {
opacity: 1; opacity: 1;
scale: 1.1; scale: 1.1;
} }
&:active { &:active {
opacity: 1; opacity: 1;
scale: 1; scale: 1;
} }
`; `;
const GridCardControlsContainer = styled.div` const GridCardControlsContainer = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
`; `;
const ControlsRow = styled.div` const ControlsRow = styled.div`
width: 100%; width: 100%;
height: calc(100% / 3); height: calc(100% / 3);
`; `;
// const TopControls = styled(ControlsRow)` // const TopControls = styled(ControlsRow)`
@ -91,87 +91,87 @@ const ControlsRow = styled.div`
// `; // `;
const BottomControls = styled(ControlsRow)` const BottomControls = styled(ControlsRow)`
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
`; `;
const FavoriteWrapper = styled.span<{ isFavorite: boolean }>` const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
svg { svg {
fill: ${(props) => props.isFavorite && 'var(--primary-color)'}; fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
} }
`; `;
export const CardControls = ({ export const CardControls = ({
itemData, 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(
itemType, 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 ( const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
<GridCardControlsContainer> e.preventDefault();
<BottomControls> e.stopPropagation();
<PlayButton onClick={handlePlay}> handlePlayQueueAdd?.({
<RiPlayFill size={25} /> byItemType: {
</PlayButton> id: [itemData.id],
<Group spacing="xs"> type: itemType,
<SecondaryButton },
disabled playType: playType || playButtonBehavior,
p={5} });
sx={{ svg: { fill: 'white !important' } }} };
variant="subtle"
> const handleContextMenu = useHandleGeneralContextMenu(
<FavoriteWrapper isFavorite={itemData?.isFavorite}> itemType,
{itemData?.isFavorite ? ( itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
<RiHeartFill size={20} /> );
) : (
<RiHeartLine return (
color="white" <GridCardControlsContainer>
size={20} <BottomControls>
/> <PlayButton onClick={handlePlay}>
)} <RiPlayFill size={25} />
</FavoriteWrapper> </PlayButton>
</SecondaryButton> <Group spacing="xs">
<SecondaryButton <SecondaryButton
p={5} disabled
sx={{ svg: { fill: 'white !important' } }} p={5}
variant="subtle" sx={{ svg: { fill: 'white !important' } }}
onClick={(e) => { variant="subtle"
e.preventDefault(); >
e.stopPropagation(); <FavoriteWrapper isFavorite={itemData?.isFavorite}>
handleContextMenu(e, [itemData]); {itemData?.isFavorite ? (
}} <RiHeartFill size={20} />
> ) : (
<RiMore2Fill <RiHeartLine
color="white" color="white"
size={20} size={20}
/> />
</SecondaryButton> )}
</Group> </FavoriteWrapper>
</BottomControls> </SecondaryButton>
</GridCardControlsContainer> <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'; import { CardRow } from '/@/renderer/types';
const Row = styled.div<{ $secondary?: boolean }>` const Row = styled.div<{ $secondary?: boolean }>`
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
height: 22px; height: 22px;
padding: 0 0.2rem; padding: 0 0.2rem;
overflow: hidden; overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')}; color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
user-select: none; user-select: none;
`; `;
interface CardRowsProps { interface CardRowsProps {
data: any; data: any;
rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[]; rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
} }
export const CardRows = ({ data, rows }: CardRowsProps) => { export const CardRows = ({ data, rows }: CardRowsProps) => {
return ( return (
<> <>
{rows.map((row, index: number) => { {rows.map((row, index: number) => {
if (row.arrayProperty && row.route) { if (row.arrayProperty && row.route) {
return ( return (
<Row <Row
key={`row-${row.property}-${index}`} key={`row-${row.property}-${index}`}
$secondary={index > 0} $secondary={index > 0}
> >
{data[row.property].map((item: any, itemIndex: number) => ( {data[row.property].map((item: any, itemIndex: number) => (
<React.Fragment key={`${data.id}-${item.id}`}> <React.Fragment key={`${data.id}-${item.id}`}>
{itemIndex > 0 && ( {itemIndex > 0 && (
<Text <Text
$noSelect $noSelect
$secondary $secondary
sx={{ sx={{
display: 'inline-block', display: 'inline-block',
padding: '0 2px 0 1px', padding: '0 2px 0 1px',
}} }}
> >
, ,
</Text> </Text>
)}{' '} )}{' '}
<Text <Text
$link $link
$noSelect $noSelect
$secondary={index > 0} $secondary={index > 0}
component={Link} component={Link}
overflow="hidden" overflow="hidden"
size={index > 0 ? 'sm' : 'md'} size={index > 0 ? 'sm' : 'md'}
to={generatePath( to={generatePath(
row.route!.route, row.route!.route,
row.route!.slugs?.reduce((acc, slug) => { row.route!.slugs?.reduce((acc, slug) => {
return { return {
...acc, ...acc,
[slug.slugProperty]: data[row.property][itemIndex][slug.idProperty], [slug.slugProperty]:
}; data[row.property][itemIndex][
}, {}), slug.idProperty
)} ],
onClick={(e) => e.stopPropagation()} };
> }, {}),
{row.arrayProperty && item[row.arrayProperty]} )}
</Text> onClick={(e) => e.stopPropagation()}
</React.Fragment> >
))} {row.arrayProperty && item[row.arrayProperty]}
</Row> </Text>
); </React.Fragment>
} ))}
</Row>
);
}
if (row.arrayProperty) { if (row.arrayProperty) {
return ( return (
<Row key={`row-${row.property}`}> <Row key={`row-${row.property}`}>
{data[row.property].map((item: any) => ( {data[row.property].map((item: any) => (
<Text <Text
key={`${data.id}-${item.id}`} key={`${data.id}-${item.id}`}
$noSelect $noSelect
$secondary={index > 0} $secondary={index > 0}
overflow="hidden" overflow="hidden"
size={index > 0 ? 'sm' : 'md'} size={index > 0 ? 'sm' : 'md'}
> >
{row.arrayProperty && item[row.arrayProperty]} {row.arrayProperty && item[row.arrayProperty]}
</Text> </Text>
))} ))}
</Row> </Row>
); );
} }
return ( return (
<Row key={`row-${row.property}`}> <Row key={`row-${row.property}`}>
{row.route ? ( {row.route ? (
<Text <Text
$link $link
$noSelect $noSelect
component={Link} component={Link}
overflow="hidden" overflow="hidden"
to={generatePath( to={generatePath(
row.route.route, row.route.route,
row.route.slugs?.reduce((acc, slug) => { row.route.slugs?.reduce((acc, slug) => {
return { return {
...acc, ...acc,
[slug.slugProperty]: data[slug.idProperty], [slug.slugProperty]: data[slug.idProperty],
}; };
}, {}), }, {}),
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{data && data[row.property]} {data && data[row.property]}
</Text> </Text>
) : ( ) : (
<Text <Text
$noSelect $noSelect
$secondary={index > 0} $secondary={index > 0}
overflow="hidden" overflow="hidden"
size={index > 0 ? 'sm' : 'md'} size={index > 0 ? 'sm' : 'md'}
> >
{data && data[row.property]} {data && data[row.property]}
</Text> </Text>
)} )}
</Row> </Row>
); );
})} })}
</> </>
); );
}; };
export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = { export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
albumArtists: { albumArtists: {
arrayProperty: 'name', arrayProperty: 'name',
property: 'albumArtists', property: 'albumArtists',
route: { route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
}, },
}, artists: {
artists: { arrayProperty: 'name',
arrayProperty: 'name', property: 'artists',
property: 'artists', route: {
route: { route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], },
}, },
}, createdAt: {
createdAt: { property: 'createdAt',
property: 'createdAt', },
}, duration: {
duration: { property: 'duration',
property: 'duration', },
}, lastPlayedAt: {
lastPlayedAt: { property: 'lastPlayedAt',
property: 'lastPlayedAt', },
}, name: {
name: { property: 'name',
property: 'name', route: {
route: { route: AppRoute.LIBRARY_ALBUMS_DETAIL,
route: AppRoute.LIBRARY_ALBUMS_DETAIL, slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
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> } = { export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
albumCount: { albumCount: {
property: 'albumCount', property: 'albumCount',
}, },
duration: { duration: {
property: 'duration', property: 'duration',
}, },
genres: { genres: {
property: 'genres', property: 'genres',
}, },
lastPlayedAt: { lastPlayedAt: {
property: 'lastPlayedAt', property: 'lastPlayedAt',
}, },
name: { name: {
property: 'name', property: 'name',
route: { route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], 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'; import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
interface BaseGridCardProps { interface BaseGridCardProps {
controls: { controls: {
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[]; cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
handleFavorite: (options: { handleFavorite: (options: {
id: string[]; id: string[];
isFavorite: boolean; isFavorite: boolean;
itemType: LibraryItem; itemType: LibraryItem;
serverId: string; serverId: string;
}) => void; }) => void;
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined; handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
itemType: LibraryItem; itemType: LibraryItem;
playButtonBehavior: Play; playButtonBehavior: Play;
route: CardRoute; route: CardRoute;
}; };
data: any; data: any;
isLoading?: boolean; isLoading?: boolean;
} }
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>` const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)}; opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto; pointer-events: auto;
.card-controls { .card-controls {
opacity: 0; opacity: 0;
} }
`; `;
const ImageContainerStyles = css` const ImageContainerStyles = css`
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
aspect-ratio: 1/1; aspect-ratio: 1/1;
overflow: hidden; overflow: hidden;
background: var(--card-default-bg); background: var(--card-default-bg);
border-radius: var(--card-poster-radius); 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 { &::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 { &:hover {
opacity: 1; &::before {
} opacity: 0.5;
}
}
&:hover .card-controls {
opacity: 1;
}
`; `;
const ImageContainer = styled(Link)<{ $isFavorite?: boolean }>` const ImageContainer = styled(Link)<{ $isFavorite?: boolean }>`
${ImageContainerStyles} ${ImageContainerStyles}
`; `;
const ImageContainerSkeleton = styled.div` const ImageContainerSkeleton = styled.div`
${ImageContainerStyles} ${ImageContainerStyles}
`; `;
const Image = styled(SimpleImg)` const Image = styled(SimpleImg)`
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
height: 100% !important; height: 100% !important;
max-height: 100%; max-height: 100%;
border: 0; border: 0;
img { img {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
`; `;
const DetailContainer = styled.div` const DetailContainer = styled.div`
margin-top: 0.5rem; margin-top: 0.5rem;
`; `;
export const PosterCard = ({ export const PosterCard = ({
data, data,
controls, controls,
isLoading, isLoading,
uniqueId, uniqueId,
}: BaseGridCardProps & { uniqueId: string }) => { }: BaseGridCardProps & { uniqueId: string }) => {
if (!isLoading) { if (!isLoading) {
const path = generatePath( const path = generatePath(
controls.route.route, controls.route.route,
controls.route.slugs?.reduce((acc, slug) => { controls.route.slugs?.reduce((acc, slug) => {
return { return {
...acc, ...acc,
[slug.slugProperty]: data[slug.idProperty], [slug.slugProperty]: data[slug.idProperty],
}; };
}, {}), }, {}),
); );
let Placeholder = RiAlbumFill; let Placeholder = RiAlbumFill;
switch (controls.itemType) { switch (controls.itemType) {
case LibraryItem.ALBUM: case LibraryItem.ALBUM:
Placeholder = RiAlbumFill; Placeholder = RiAlbumFill;
break; break;
case LibraryItem.ARTIST: case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill; Placeholder = RiUserVoiceFill;
break; break;
case LibraryItem.ALBUM_ARTIST: case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill; Placeholder = RiUserVoiceFill;
break; break;
case LibraryItem.PLAYLIST: case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill; Placeholder = RiPlayListFill;
break; break;
default: default:
Placeholder = RiAlbumFill; Placeholder = RiAlbumFill;
break; 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 ( return (
<PosterCardContainer key={`${uniqueId}-${data.id}`}> <PosterCardContainer key={`placeholder-${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) => (
<Skeleton <Skeleton
key={`${index}-${row.arrayProperty}`} visible
visible radius="sm"
height={14} >
radius="sm" <ImageContainerSkeleton />
/> </Skeleton>
))} <DetailContainer>
</Stack> <Stack spacing="sm">
</DetailContainer> {controls.cardRows.map((row, index) => (
</PosterCardContainer> <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'; import styled from 'styled-components';
const StyledCheckbox = styled(MantineCheckbox)` const StyledCheckbox = styled(MantineCheckbox)`
& .mantine-Checkbox-input { & .mantine-Checkbox-input {
background-color: var(--input-bg); background-color: var(--input-bg);
&:checked { &:checked {
background-color: var(--primary-color); background-color: var(--primary-color);
border-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>( export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ ...props }: CheckboxProps, ref) => { ({ ...props }: CheckboxProps, ref) => {
return ( return (
<StyledCheckbox <StyledCheckbox
ref={ref} ref={ref}
{...props} {...props}
/> />
); );
}, },
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import type { import type {
PopoverProps as MantinePopoverProps, PopoverProps as MantinePopoverProps,
PopoverDropdownProps as MantinePopoverDropdownProps, PopoverDropdownProps as MantinePopoverDropdownProps,
} from '@mantine/core'; } from '@mantine/core';
import { Popover as MantinePopover } from '@mantine/core'; import { Popover as MantinePopover } from '@mantine/core';
import styled from 'styled-components'; import styled from 'styled-components';
@ -11,28 +11,28 @@ type PopoverDropdownProps = MantinePopoverDropdownProps;
const StyledPopover = styled(MantinePopover)``; const StyledPopover = styled(MantinePopover)``;
const StyledDropdown = styled(MantinePopover.Dropdown)<PopoverDropdownProps>` const StyledDropdown = styled(MantinePopover.Dropdown)<PopoverDropdownProps>`
padding: 0.5rem; padding: 0.5rem;
font-size: 0.9em; font-size: 0.9em;
font-family: var(--content-font-family); font-family: var(--content-font-family);
background-color: var(--dropdown-menu-bg); background-color: var(--dropdown-menu-bg);
border: var(--dropdown-menu-border); border: var(--dropdown-menu-border);
`; `;
export const Popover = ({ children, ...props }: PopoverProps) => { export const Popover = ({ children, ...props }: PopoverProps) => {
return ( return (
<StyledPopover <StyledPopover
withinPortal withinPortal
styles={{ styles={{
dropdown: { dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))', filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
}, },
}} }}
transitionProps={{ transition: 'fade' }} transitionProps={{ transition: 'fade' }}
{...props} {...props}
> >
{children} {children}
</StyledPopover> </StyledPopover>
); );
}; };
Popover.Target = MantinePopover.Target; 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'; import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
const FILTER_GROUP_OPTIONS_DATA = [ const FILTER_GROUP_OPTIONS_DATA = [
{ {
label: 'Match all', label: 'Match all',
value: 'all', value: 'all',
}, },
{ {
label: 'Match any', label: 'Match any',
value: 'any', value: 'any',
}, },
]; ];
type AddArgs = { type AddArgs = {
groupIndex: number[]; groupIndex: number[];
level: number; level: number;
}; };
type DeleteArgs = { type DeleteArgs = {
groupIndex: number[]; groupIndex: number[];
level: number; level: number;
uniqueId: string; uniqueId: string;
}; };
interface QueryBuilderProps { interface QueryBuilderProps {
data: Record<string, any>; data: Record<string, any>;
filters: { label: string; type: string; value: string }[]; filters: { label: string; type: string; value: string }[];
groupIndex: number[]; groupIndex: number[];
level: number; level: number;
onAddRule: (args: AddArgs) => void; onAddRule: (args: AddArgs) => void;
onAddRuleGroup: (args: AddArgs) => void; onAddRuleGroup: (args: AddArgs) => void;
onChangeField: (args: any) => void; onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void; onChangeOperator: (args: any) => void;
onChangeType: (args: any) => void; onChangeType: (args: any) => void;
onChangeValue: (args: any) => void; onChangeValue: (args: any) => void;
onClearFilters: () => void; onClearFilters: () => void;
onDeleteRule: (args: DeleteArgs) => void; onDeleteRule: (args: DeleteArgs) => void;
onDeleteRuleGroup: (args: DeleteArgs) => void; onDeleteRuleGroup: (args: DeleteArgs) => void;
onResetFilters: () => void; onResetFilters: () => void;
operators: { operators: {
boolean: { label: string; value: string }[]; boolean: { label: string; value: string }[];
date: { label: string; value: string }[]; date: { label: string; value: string }[];
number: { label: string; value: string }[]; number: { label: string; value: string }[];
string: { label: string; value: string }[]; string: { label: string; value: string }[];
}; };
uniqueId: string; uniqueId: string;
} }
export const QueryBuilder = ({ export const QueryBuilder = ({
data, data,
level, level,
onAddRule, onAddRule,
onDeleteRuleGroup, onDeleteRuleGroup,
onDeleteRule, onDeleteRule,
onAddRuleGroup, onAddRuleGroup,
onChangeType, onChangeType,
onChangeField, onChangeField,
operators, operators,
onChangeOperator, onChangeOperator,
onChangeValue, onChangeValue,
onClearFilters, onClearFilters,
onResetFilters, onResetFilters,
groupIndex, groupIndex,
uniqueId, uniqueId,
filters, filters,
}: QueryBuilderProps) => { }: QueryBuilderProps) => {
const handleAddRule = () => { const handleAddRule = () => {
onAddRule({ groupIndex, level }); onAddRule({ groupIndex, level });
}; };
const handleAddRuleGroup = () => { const handleAddRuleGroup = () => {
onAddRuleGroup({ groupIndex, level }); onAddRuleGroup({ groupIndex, level });
}; };
const handleDeleteRuleGroup = () => { const handleDeleteRuleGroup = () => {
onDeleteRuleGroup({ groupIndex, level, uniqueId }); onDeleteRuleGroup({ groupIndex, level, uniqueId });
}; };
const handleChangeType = (value: string | null) => { const handleChangeType = (value: string | null) => {
onChangeType({ groupIndex, level, value }); onChangeType({ groupIndex, level, value });
}; };
return ( return (
<Stack <Stack
ml={`${level * 10}px`} ml={`${level * 10}px`}
spacing="sm" 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}
> >
<RiAddLine size={20} /> <Group spacing="sm">
</Button> <Select
<DropdownMenu position="bottom-start"> data={FILTER_GROUP_OPTIONS_DATA}
<DropdownMenu.Target> maxWidth={175}
<Button size="sm"
p={0} value={data.type}
size="sm" width="20%"
variant="subtle" onChange={handleChangeType}
> />
<RiMore2Line size={20} /> <Button
</Button> px={5}
</DropdownMenu.Target> size="sm"
<DropdownMenu.Dropdown> tooltip={{ label: 'Add rule' }}
<DropdownMenu.Item variant="default"
icon={<RiAddFill />} onClick={handleAddRule}
onClick={handleAddRuleGroup} >
> <RiAddLine size={20} />
Add rule group </Button>
</DropdownMenu.Item> <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 && ( {level > 0 && (
<DropdownMenu.Item <DropdownMenu.Item
icon={<RiDeleteBinFill />} icon={<RiDeleteBinFill />}
onClick={handleDeleteRuleGroup} onClick={handleDeleteRuleGroup}
> >
Remove rule group Remove rule group
</DropdownMenu.Item> </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 && ( </Stack>
<> );
<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>
);
}; };

View File

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

View File

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

View File

@ -9,169 +9,169 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types'; import { Platform } from '/@/renderer/types';
interface ScrollAreaProps extends MantineScrollAreaProps { interface ScrollAreaProps extends MantineScrollAreaProps {
children: React.ReactNode; children: React.ReactNode;
} }
const StyledScrollArea = styled(MantineScrollArea)` const StyledScrollArea = styled(MantineScrollArea)`
& .mantine-ScrollArea-thumb { & .mantine-ScrollArea-thumb {
background: var(--scrollbar-thumb-bg); background: var(--scrollbar-thumb-bg);
border-radius: 0; border-radius: 0;
} }
& .mantine-ScrollArea-scrollbar { & .mantine-ScrollArea-scrollbar {
padding: 0; padding: 0;
background: var(--scrollbar-track-bg); background: var(--scrollbar-track-bg);
} }
& .mantine-ScrollArea-viewport > div { & .mantine-ScrollArea-viewport > div {
display: block !important; display: block !important;
} }
`; `;
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>` const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>`
height: 100%; height: 100%;
overflow-y: overlay !important; overflow-y: overlay !important;
&::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
margin-top: ${(props) => margin-top: ${(props) =>
props.windowBarStyle === Platform.WINDOWS || props.windowBarStyle === Platform.WINDOWS ||
props.windowBarStyle === Platform.MACOS || props.windowBarStyle === Platform.MACOS ||
props.windowBarStyle === Platform.LINUX props.windowBarStyle === Platform.LINUX
? '0px' ? '0px'
: props.scrollBarOffset || '65px'}; : props.scrollBarOffset || '65px'};
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
margin-top: ${(props) => margin-top: ${(props) =>
props.windowBarStyle === Platform.WINDOWS || props.windowBarStyle === Platform.WINDOWS ||
props.windowBarStyle === Platform.MACOS || props.windowBarStyle === Platform.MACOS ||
props.windowBarStyle === Platform.LINUX props.windowBarStyle === Platform.LINUX
? '0px' ? '0px'
: props.scrollBarOffset || '65px'}; : props.scrollBarOffset || '65px'};
} }
`; `;
export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, ref: Ref<any>) => { export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, ref: Ref<any>) => {
return ( return (
<StyledScrollArea <StyledScrollArea
ref={ref} ref={ref}
scrollbarSize={12} scrollbarSize={12}
{...props} {...props}
> >
{children} {children}
</StyledScrollArea> </StyledScrollArea>
); );
}); });
interface NativeScrollAreaProps { interface NativeScrollAreaProps {
children: React.ReactNode; children: React.ReactNode;
debugScrollPosition?: boolean; debugScrollPosition?: boolean;
noHeader?: boolean; noHeader?: boolean;
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any }; pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
scrollBarOffset?: string; scrollBarOffset?: string;
scrollHideDelay?: number; scrollHideDelay?: number;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
export const NativeScrollArea = forwardRef( export const NativeScrollArea = forwardRef(
( (
{ {
children, children,
pageHeaderProps, pageHeaderProps,
debugScrollPosition, debugScrollPosition,
scrollBarOffset, scrollBarOffset,
scrollHideDelay, scrollHideDelay,
noHeader, noHeader,
...props ...props
}: NativeScrollAreaProps, }: NativeScrollAreaProps,
ref: Ref<HTMLDivElement>, ref: Ref<HTMLDivElement>,
) => { ) => {
const { windowBarStyle } = useWindowSettings(); const { windowBarStyle } = useWindowSettings();
const [hideScrollbar, setHideScrollbar] = useState(false); const [hideScrollbar, setHideScrollbar] = useState(false);
const [hideHeader, setHideHeader] = useState(true); const [hideHeader, setHideHeader] = useState(true);
const { start, clear } = useTimeout( const { start, clear } = useTimeout(
() => setHideScrollbar(true), () => setHideScrollbar(true),
scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0, scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0,
); );
const containerRef = useRef(null); const containerRef = useRef(null);
const mergedRef = useMergedRef(ref, containerRef); const mergedRef = useMergedRef(ref, containerRef);
const { scrollYProgress } = useScroll({ const { scrollYProgress } = useScroll({
container: containerRef, container: containerRef,
offset: pageHeaderProps?.offset || ['center start', 'end start'], offset: pageHeaderProps?.offset || ['center start', 'end start'],
target: pageHeaderProps?.target, target: pageHeaderProps?.target,
}); });
// Automatically hide the scrollbar after the timeout duration // Automatically hide the scrollbar after the timeout duration
useEffect(() => { 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={() => {
start(); start();
}} // eslint-disable-next-line react-hooks/exhaustive-deps
{...props} }, []);
>
{children} useEffect(() => {
</StyledNativeScrollArea> const setHeaderVisibility = (v: number) => {
{debugScrollPosition && ( if (v === 1) {
<motion.div return setHideHeader(false);
style={{ }
background: 'red',
height: '10px', if (hideHeader === false) {
left: 0, return setHideHeader(true);
position: 'fixed', }
right: 0,
scaleX: scrollYProgress, return undefined;
top: 0, };
transformOrigin: '0%',
width: '100%', const unsubscribe = scrollYProgress.on('change', setHeaderVisibility);
zIndex: 5000,
}} 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'; import { shallow } from 'zustand/shallow';
interface SearchInputProps extends TextInputProps { interface SearchInputProps extends TextInputProps {
initialWidth?: number; initialWidth?: number;
openedWidth?: number; openedWidth?: number;
value?: string; value?: string;
} }
export const SearchInput = ({ export const SearchInput = ({
initialWidth, initialWidth,
onChange, onChange,
openedWidth, openedWidth,
...props ...props
}: SearchInputProps) => { }: SearchInputProps) => {
const { ref, focused } = useFocusWithin(); const { ref, focused } = useFocusWithin();
const mergedRef = useMergedRef<HTMLInputElement>(ref); const mergedRef = useMergedRef<HTMLInputElement>(ref);
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow); const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
const isOpened = focused || ref.current?.value; const isOpened = focused || ref.current?.value;
const showIcon = !isOpened || (openedWidth || 100) > 100; const showIcon = !isOpened || (openedWidth || 100) > 100;
useHotkeys([[binding.hotkey, () => ref.current.select()]]); useHotkeys([[binding.hotkey, () => ref.current.select()]]);
const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => { const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.code === 'Escape') { if (e.code === 'Escape') {
onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>); onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);
ref.current.value = ''; ref.current.value = '';
ref.current.blur(); ref.current.blur();
} }
}; };
return ( return (
<TextInput <TextInput
ref={mergedRef} ref={mergedRef}
{...props} {...props}
icon={showIcon && <RiSearchLine />} icon={showIcon && <RiSearchLine />}
rightSection={ rightSection={
isOpened ? ( isOpened ? (
<ActionIcon <ActionIcon
onClick={() => { onClick={() => {
ref.current.value = ''; ref.current.value = '';
ref.current.focus(); 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,
},
}} }}
> width={isOpened ? openedWidth || 150 : initialWidth || 35}
<RiCloseFill /> onChange={onChange}
</ActionIcon> onKeyDown={handleEscape}
) : 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}
/>
);
}; };

View File

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

View File

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

View File

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

View File

@ -5,30 +5,30 @@ import styled from 'styled-components';
type SliderProps = MantineSliderProps; type SliderProps = MantineSliderProps;
const StyledSlider = styled(MantineSlider)` const StyledSlider = styled(MantineSlider)`
& .mantine-Slider-track { & .mantine-Slider-track {
height: 0.5rem; height: 0.5rem;
background-color: var(--slider-track-bg); background-color: var(--slider-track-bg);
} }
& .mantine-Slider-bar { & .mantine-Slider-bar {
background-color: var(--primary-color); background-color: var(--primary-color);
} }
& .mantine-Slider-thumb { & .mantine-Slider-thumb {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
background: var(--slider-thumb-bg); background: var(--slider-thumb-bg);
border: none; border: none;
} }
& .mantine-Slider-label { & .mantine-Slider-label {
padding: 0 1rem; padding: 0 1rem;
color: var(--tooltip-fg); color: var(--tooltip-fg);
font-size: 1em; font-size: 1em;
background: var(--tooltip-bg); background: var(--tooltip-bg);
} }
`; `;
export const Slider = ({ ...props }: SliderProps) => { 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'; import { rotating } from '/@/renderer/styles';
interface SpinnerProps extends IconType { interface SpinnerProps extends IconType {
color?: string; color?: string;
container?: boolean; container?: boolean;
size?: number; size?: number;
} }
export const SpinnerIcon = styled(RiLoader5Fill)` export const SpinnerIcon = styled(RiLoader5Fill)`
${rotating} ${rotating}
animation: rotating 1s ease-in-out infinite; animation: rotating 1s ease-in-out infinite;
`; `;
export const Spinner = ({ ...props }: SpinnerProps) => { export const Spinner = ({ ...props }: SpinnerProps) => {
if (props.container) { if (props.container) {
return ( return (
<Center <Center
h="100%" h="100%"
w="100%" w="100%"
> >
<SpinnerIcon <SpinnerIcon
color={props.color} color={props.color}
size={props.size} size={props.size}
/> />
</Center> </Center>
); );
} }
return <SpinnerIcon {...props} />; return <SpinnerIcon {...props} />;
}; };
Spinner.defaultProps = { Spinner.defaultProps = {
color: undefined, color: undefined,
size: 15, size: 15,
}; };

View File

@ -5,24 +5,24 @@ import styled from 'styled-components';
type SwitchProps = MantineSwitchProps; type SwitchProps = MantineSwitchProps;
const StyledSwitch = styled(MantineSwitch)` const StyledSwitch = styled(MantineSwitch)`
display: flex; display: flex;
& .mantine-Switch-track { & .mantine-Switch-track {
background-color: var(--switch-track-bg); background-color: var(--switch-track-bg);
border: none; border: none;
}
& .mantine-Switch-input {
&:checked + .mantine-Switch-track {
background-color: var(--switch-track-enabled-bg);
} }
}
& .mantine-Switch-thumb { & .mantine-Switch-input {
background-color: var(--switch-thumb-bg); &: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) => { 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; type TabsProps = MantineTabsProps;
const StyledTabs = styled(MantineTabs)` const StyledTabs = styled(MantineTabs)`
height: 100%; height: 100%;
& .mantine-Tabs-tabsList { & .mantine-Tabs-tabsList {
padding-right: 1rem; 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);
} }
transition: background 0.2s ease-in-out, color 0.2s ease-in-out; &.mantine-Tabs-tab {
} padding: 0.5rem 1rem;
font-weight: 600;
button[data-active] { font-size: 1rem;
color: var(--btn-subtle-fg); background-color: var(--main-bg);
background: none; }
border-color: var(--primary-color);
& .mantine-Tabs-panel {
&:hover { padding: 1.5rem 0.5rem;
background: none; }
& .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) => { export const Tabs = ({ children, ...props }: TabsProps) => {
return <StyledTabs {...props}>{children}</StyledTabs>; return <StyledTabs {...props}>{children}</StyledTabs>;
}; };
const Panel = ({ children, ...props }: TabsPanelProps) => { const Panel = ({ children, ...props }: TabsPanelProps) => {
return ( return (
<StyledTabs.Panel {...props}> <StyledTabs.Panel {...props}>
<Suspense fallback={<></>}>{children}</Suspense> <Suspense fallback={<></>}>{children}</Suspense>
</StyledTabs.Panel> </StyledTabs.Panel>
); );
}; };
Tabs.List = StyledTabs.List; Tabs.List = StyledTabs.List;

View File

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

View File

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

View File

@ -1,76 +1,76 @@
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications'; import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
import { import {
showNotification, showNotification,
updateNotification, updateNotification,
hideNotification, hideNotification,
cleanNotifications, cleanNotifications,
cleanNotificationsQueue, cleanNotificationsQueue,
} from '@mantine/notifications'; } from '@mantine/notifications';
interface NotificationProps extends MantineNotificationProps { interface NotificationProps extends MantineNotificationProps {
type?: 'success' | 'error' | 'warning' | 'info'; type?: 'success' | 'error' | 'warning' | 'info';
} }
const showToast = ({ type, ...props }: NotificationProps) => { const showToast = ({ type, ...props }: NotificationProps) => {
const color = const color =
type === 'success' type === 'success'
? 'var(--success-color)' ? 'var(--success-color)'
: type === 'warning' : type === 'warning'
? 'var(--warning-color)' ? 'var(--warning-color)'
: type === 'error' : type === 'error'
? 'var(--danger-color)' ? 'var(--danger-color)'
: 'var(--primary-color)'; : 'var(--primary-color)';
const defaultTitle = const defaultTitle =
type === 'success' type === 'success'
? 'Success' ? 'Success'
: type === 'warning' : type === 'warning'
? 'Warning' ? 'Warning'
: type === 'error' : type === 'error'
? 'Error' ? 'Error'
: 'Info'; : 'Info';
const defaultDuration = type === 'error' ? 2000 : 1000; const defaultDuration = type === 'error' ? 2000 : 1000;
return showNotification({ return showNotification({
autoClose: defaultDuration, autoClose: defaultDuration,
styles: () => ({ styles: () => ({
closeButton: { closeButton: {
'&:hover': { '&:hover': {
background: 'transparent', background: 'transparent',
}, },
}, },
description: { description: {
color: 'var(--toast-description-fg)', color: 'var(--toast-description-fg)',
fontSize: '1rem', fontSize: '1rem',
}, },
loader: { loader: {
margin: '1rem', margin: '1rem',
}, },
root: { root: {
'&::before': { backgroundColor: color }, '&::before': { backgroundColor: color },
background: 'var(--toast-bg)', background: 'var(--toast-bg)',
border: '2px solid var(--generic-border-color)', border: '2px solid var(--generic-border-color)',
bottom: '90px', bottom: '90px',
}, },
title: { title: {
color: 'var(--toast-title-fg)', color: 'var(--toast-title-fg)',
fontSize: '1.3rem', fontSize: '1.3rem',
}, },
}), }),
title: defaultTitle, title: defaultTitle,
...props, ...props,
}); });
}; };
export const toast = { export const toast = {
clean: cleanNotifications, clean: cleanNotifications,
cleanQueue: cleanNotificationsQueue, cleanQueue: cleanNotificationsQueue,
error: (props: NotificationProps) => showToast({ type: 'error', ...props }), error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
hide: hideNotification, hide: hideNotification,
info: (props: NotificationProps) => showToast({ type: 'info', ...props }), info: (props: NotificationProps) => showToast({ type: 'info', ...props }),
show: showToast, show: showToast,
success: (props: NotificationProps) => showToast({ type: 'success', ...props }), success: (props: NotificationProps) => showToast({ type: 'success', ...props }),
update: updateNotification, update: updateNotification,
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }), 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'; import styled from 'styled-components';
const StyledTooltip = styled(MantineTooltip)` const StyledTooltip = styled(MantineTooltip)`
& .mantine-Tooltip-tooltip { & .mantine-Tooltip-tooltip {
margin: 20px; margin: 20px;
} }
`; `;
export const Tooltip = ({ children, ...rest }: TooltipProps) => { export const Tooltip = ({ children, ...rest }: TooltipProps) => {
return ( return (
<StyledTooltip <StyledTooltip
multiline multiline
withinPortal withinPortal
pl={10} pl={10}
pr={10} pr={10}
py={5} py={5}
radius="xs" radius="xs"
styles={{ styles={{
tooltip: { tooltip: {
background: 'var(--tooltip-bg)', background: 'var(--tooltip-bg)',
boxShadow: '4px 4px 10px 0px rgba(0,0,0,0.2)', boxShadow: '4px 4px 10px 0px rgba(0,0,0,0.2)',
color: 'var(--tooltip-fg)', color: 'var(--tooltip-fg)',
fontSize: '1.1rem', fontSize: '1.1rem',
fontWeight: 550, fontWeight: 550,
maxWidth: '250px', maxWidth: '250px',
}, },
}} }}
transitionProps={{ transitionProps={{
duration: 250, duration: 250,
transition: 'fade', transition: 'fade',
}} }}
{...rest} {...rest}
> >
{children} {children}
</StyledTooltip> </StyledTooltip>
); );
}; };
Tooltip.defaultProps = { Tooltip.defaultProps = {
openDelay: 0, openDelay: 0,
position: 'top', position: 'top',
withArrow: true, withArrow: true,
withinPortal: 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'; import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
interface BaseGridCardProps { interface BaseGridCardProps {
columnIndex: number; columnIndex: number;
controls: { controls: {
cardRows: CardRow<Album | AlbumArtist | Artist>[]; cardRows: CardRow<Album | AlbumArtist | Artist>[];
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void; handleFavorite: (options: {
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void; id: string[];
itemType: LibraryItem; isFavorite: boolean;
playButtonBehavior: Play; itemType: LibraryItem;
route: CardRoute; }) => void;
}; handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
data: any; itemType: LibraryItem;
isHidden?: boolean; playButtonBehavior: Play;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>; route: CardRoute;
};
data: any;
isHidden?: boolean;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
} }
const DefaultCardContainer = styled.div<{ $isHidden?: boolean }>` const DefaultCardContainer = styled.div<{ $isHidden?: boolean }>`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: calc(100% - 2rem); height: calc(100% - 2rem);
margin: 0.5rem; margin: 0.5rem;
overflow: hidden; overflow: hidden;
background: var(--card-default-bg); background: var(--card-default-bg);
border-radius: var(--card-default-radius); border-radius: var(--card-default-radius);
cursor: pointer; cursor: pointer;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)}; opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto; pointer-events: auto;
&:hover { &:hover {
background: var(--card-default-bg-hover); background: var(--card-default-bg-hover);
} }
`; `;
const InnerCardContainer = styled.div` const InnerCardContainer = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 1rem; padding: 1rem;
overflow: hidden; overflow: hidden;
.card-controls { .card-controls {
opacity: 0; opacity: 0;
} }
&:hover .card-controls { &:hover .card-controls {
opacity: 1; opacity: 1;
} }
&:hover * { &:hover * {
&::before { &::before {
opacity: 0.5; opacity: 0.5;
}
} }
}
`; `;
const ImageContainer = styled.div<{ $isFavorite?: boolean }>` const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
position: relative; position: relative;
display: flex; display: flex;
align-items: center; 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%;
height: 100%; height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%); aspect-ratio: 1/1;
opacity: 0; overflow: hidden;
transition: all 0.2s ease-in-out; background: var(--placeholder-bg);
content: ''; border-radius: var(--card-default-radius);
user-select: none;
} &::before {
${(props) => position: absolute;
props.$isFavorite && 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 { &::after {
position: absolute; position: absolute;
top: -50px; top: -50px;
@ -108,134 +112,134 @@ const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
`; `;
const Image = styled(SimpleImg)` const Image = styled(SimpleImg)`
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
height: 100% !important; height: 100% !important;
max-height: 100%; max-height: 100%;
border: 0; border: 0;
img { img {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
`; `;
const DetailContainer = styled.div` const DetailContainer = styled.div`
margin-top: 0.5rem; margin-top: 0.5rem;
`; `;
export const DefaultCard = ({ export const DefaultCard = ({
listChildProps, listChildProps,
data, data,
columnIndex, columnIndex,
controls, controls,
isHidden, isHidden,
}: BaseGridCardProps) => { }: BaseGridCardProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
if (data) { if (data) {
const path = generatePath( const path = generatePath(
controls.route.route, controls.route.route,
controls.route.slugs?.reduce((acc, slug) => { controls.route.slugs?.reduce((acc, slug) => {
return { return {
...acc, ...acc,
[slug.slugProperty]: data[slug.idProperty], [slug.slugProperty]: data[slug.idProperty],
}; };
}, {}), }, {}),
); );
let Placeholder = RiAlbumFill; let Placeholder = RiAlbumFill;
switch (controls.itemType) { switch (controls.itemType) {
case LibraryItem.ALBUM: case LibraryItem.ALBUM:
Placeholder = RiAlbumFill; Placeholder = RiAlbumFill;
break; break;
case LibraryItem.ARTIST: case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill; Placeholder = RiUserVoiceFill;
break; break;
case LibraryItem.ALBUM_ARTIST: case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill; Placeholder = RiUserVoiceFill;
break; break;
case LibraryItem.PLAYLIST: case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill; Placeholder = RiPlayListFill;
break; break;
default: default:
Placeholder = RiAlbumFill; Placeholder = RiAlbumFill;
break; 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 ( return (
<DefaultCardContainer <DefaultCardContainer
key={`card-${columnIndex}-${listChildProps.index}`} key={`card-${columnIndex}-${listChildProps.index}`}
onClick={() => navigate(path)} $isHidden={isHidden}
> >
<InnerCardContainer> <InnerCardContainer>
<ImageContainer $isFavorite={data?.userFavorite}> <ImageContainer>
{data?.imageUrl ? ( <Skeleton
<Image visible
importance="auto" radius="sm"
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'} />
src={data?.imageUrl} </ImageContainer>
/> <DetailContainer>
) : ( <Stack spacing="sm">
<Center {controls.cardRows.map((row, index) => (
sx={{ <Skeleton
background: 'var(--placeholder-bg)', key={`${index}-${columnIndex}-${row.arrayProperty}`}
borderRadius: 'var(--card-default-radius)', visible
height: '100%', height={14}
width: '100%', radius="sm"
}} />
> ))}
<Placeholder </Stack>
color="var(--placeholder-fg)" </DetailContainer>
size={35} </InnerCardContainer>
/> </DefaultCardContainer>
</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}`}
$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 { LibraryItem } from '/@/renderer/api/types';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { import {
ALBUM_CONTEXT_MENU_ITEMS, ALBUM_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS, ARTIST_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items'; } from '/@/renderer/features/context-menu/context-menu-items';
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>; type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
const PlayButton = styled.button<PlayButtonType>` const PlayButton = styled.button<PlayButtonType>`
position: absolute; position: absolute;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 50px; width: 50px;
height: 50px; height: 50px;
background-color: rgb(255, 255, 255); background-color: rgb(255, 255, 255);
border: none; border: none;
border-radius: 50%; border-radius: 50%;
opacity: 0.8; opacity: 0.8;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
transition: scale 0.1s ease-in-out; transition: scale 0.1s ease-in-out;
&:hover { &:hover {
opacity: 1; opacity: 1;
scale: 1.1; scale: 1.1;
} }
&:active { &:active {
opacity: 1; opacity: 1;
scale: 1; scale: 1;
} }
svg { svg {
fill: rgb(0, 0, 0); fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0); stroke: rgb(0, 0, 0);
} }
`; `;
const SecondaryButton = styled(_Button)` const SecondaryButton = styled(_Button)`
opacity: 0.8; opacity: 0.8;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear; transition: scale 0.2s linear;
&:hover { &:hover {
opacity: 1; opacity: 1;
scale: 1.1; scale: 1.1;
} }
&:active { &:active {
opacity: 1; opacity: 1;
scale: 1; scale: 1;
} }
`; `;
const GridCardControlsContainer = styled.div<{ $isFavorite?: boolean }>` const GridCardControlsContainer = styled.div<{ $isFavorite?: boolean }>`
position: absolute; position: absolute;
z-index: 100; z-index: 100;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
`; `;
const FavoriteBanner = styled.div` const FavoriteBanner = styled.div`
position: absolute; position: absolute;
top: -50px; top: -50px;
left: -50px; left: -50px;
width: 80px; width: 80px;
height: 80px; height: 80px;
background-color: var(--primary-color); background-color: var(--primary-color);
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%); box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
transform: rotate(-45deg); transform: rotate(-45deg);
content: ''; content: '';
pointer-events: none; pointer-events: none;
`; `;
const ControlsRow = styled.div` const ControlsRow = styled.div`
width: 100%; width: 100%;
height: calc(100% / 3); height: calc(100% / 3);
`; `;
const BottomControls = styled(ControlsRow)` const BottomControls = styled(ControlsRow)`
position: absolute; position: absolute;
bottom: 0; bottom: 0;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: flex-end; align-items: flex-end;
justify-content: flex-end; justify-content: flex-end;
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
`; `;
const FavoriteWrapper = styled.span<{ isFavorite: boolean }>` const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
svg { svg {
fill: ${(props) => props.isFavorite && 'var(--primary-color)'}; fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
} }
`; `;
export const GridCardControls = ({ export const GridCardControls = ({
itemData, 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(
itemType, 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 ( const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
<> e.preventDefault();
{isFavorite ? <FavoriteBanner /> : null} e.stopPropagation();
<GridCardControlsContainer
$isFavorite handlePlayQueueAdd?.({
className="card-controls" byItemType: {
> id: [itemData.id],
<PlayButton onClick={handlePlay}> type: itemType,
<RiPlayFill size={25} /> },
</PlayButton> playType: playType || playButtonBehavior,
<BottomControls> });
<SecondaryButton };
p={5}
variant="subtle" const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
onClick={(e) => handleFavorites(e, itemData?.serverId)} e.preventDefault();
> e.stopPropagation();
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{isFavorite ? ( handleFavorite?.({
<RiHeartFill size={20} /> id: [itemData.id],
) : ( isFavorite: itemData.userFavorite,
<RiHeartLine itemType,
color="white" serverId,
size={20} });
/>
)} setIsFavorite(!isFavorite);
</FavoriteWrapper> };
</SecondaryButton>
<SecondaryButton const handleContextMenu = useHandleGeneralContextMenu(
p={5} itemType,
variant="subtle" itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
onClick={(e) => { );
e.preventDefault();
e.stopPropagation(); return (
handleContextMenu(e, [itemData]); <>
}} {isFavorite ? <FavoriteBanner /> : null}
> <GridCardControlsContainer
<RiMoreFill $isFavorite
color="white" className="card-controls"
size={20} >
/> <PlayButton onClick={handlePlay}>
</SecondaryButton> <RiPlayFill size={25} />
</BottomControls> </PlayButton>
</GridCardControlsContainer> <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'; import { GridCardData, ListDisplayType } from '/@/renderer/types';
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => { export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
const { const {
columnCount, columnCount,
itemCount, itemCount,
cardRows, cardRows,
itemData, itemData,
itemType, itemType,
playButtonBehavior, playButtonBehavior,
handlePlayQueueAdd, handlePlayQueueAdd,
handleFavorite, handleFavorite,
route, route,
display, display,
} = data as GridCardData; } = data as GridCardData;
const cards = []; const cards = [];
const startIndex = index * columnCount; const startIndex = index * columnCount;
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1); const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
const columnCountInRow = stopIndex - startIndex + 1; const columnCountInRow = stopIndex - startIndex + 1;
let columnCountToAdd = 0; let columnCountToAdd = 0;
if (columnCountInRow !== columnCount) { if (columnCountInRow !== columnCount) {
columnCountToAdd = columnCount - columnCountInRow; columnCountToAdd = columnCount - columnCountInRow;
} }
const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard; const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard;
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) { for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
cards.push( cards.push(
<View <View
key={`card-${i}-${index}`} key={`card-${i}-${index}`}
columnIndex={i} columnIndex={i}
controls={{ controls={{
cardRows, cardRows,
handleFavorite, handleFavorite,
handlePlayQueueAdd, handlePlayQueueAdd,
itemType, itemType,
playButtonBehavior, playButtonBehavior,
route, route,
}} }}
data={itemData[i]} data={itemData[i]}
isHidden={i > stopIndex} isHidden={i > stopIndex}
listChildProps={{ index }} listChildProps={{ index }}
/>, />,
);
}
return (
<div
style={{
...style,
display: 'flex',
}}
>
{cards}
</div>
); );
}
return (
<div
style={{
...style,
display: 'flex',
}}
>
{cards}
</div>
);
}, areEqual); }, areEqual);

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