Compare commits

..

4 Commits

Author SHA1 Message Date
jeffvli 5900d41e0a handle sticky elements on new layout 2026-04-04 13:42:50 -07:00
jeffvli efe94b3a3b inset the windowbar 2026-04-04 13:25:35 -07:00
jeffvli 231b6f3865 inset the playerbar 2026-04-04 13:21:22 -07:00
jeffvli 2fbd3ab02d inset the main content / sidebars 2026-04-04 13:21:01 -07:00
383 changed files with 22473 additions and 24141 deletions
+2 -2
View File
@@ -121,7 +121,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, macos-26, ubuntu-latest]
os: [windows-latest, macos-latest, ubuntu-latest]
steps:
- name: Checkout git repo
@@ -156,7 +156,7 @@ jobs:
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (macOS)
if: matrix.os == 'macos-26'
if: matrix.os == 'macos-latest'
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
+2 -2
View File
@@ -115,7 +115,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, macos-26, ubuntu-latest]
os: [windows-latest, macos-latest, ubuntu-latest]
steps:
- name: Checkout git repo
@@ -156,7 +156,7 @@ jobs:
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-26'
if: matrix.os == 'macos-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2
@@ -51,4 +51,5 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+1 -2
View File
@@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
os: [macos-26]
os: [macos-latest]
steps:
- name: Checkout git repo
@@ -24,7 +24,6 @@ jobs:
- name: Build and Publish releases
env:
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2
with:
+4 -4
View File
@@ -30,7 +30,7 @@ jobs:
strategy:
matrix:
os: [macos-26, ubuntu-latest, windows-latest]
os: [macos-latest, ubuntu-latest, windows-latest]
steps:
- name: Checkout git repo
@@ -65,7 +65,7 @@ jobs:
pnpm run package:linux:pr
- name: Build for MacOS
if: ${{ matrix.os == 'macos-26' }}
if: ${{ matrix.os == 'macos-latest' }}
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
@@ -86,7 +86,7 @@ jobs:
zip -r dist/linux-binaries.zip dist/*.{AppImage,deb,rpm}
- name: Zip MacOS Binaries
if: ${{ matrix.os == 'macos-26' }}
if: ${{ matrix.os == 'macos-latest' }}
run: |
zip -r dist/macos-binaries.zip dist/*.dmg
@@ -105,7 +105,7 @@ jobs:
path: dist/linux-binaries.zip
- name: Upload MacOS Binaries
if: ${{ matrix.os == 'macos-26' }}
if: ${{ matrix.os == 'macos-latest' }}
uses: actions/upload-artifact@v7
with:
name: macos-binaries
+2 -2
View File
@@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, macos-26, ubuntu-latest]
os: [windows-latest, macos-latest, ubuntu-latest]
steps:
- name: Checkout git repo
@@ -36,7 +36,7 @@ jobs:
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-26'
if: matrix.os == 'macos-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2
-4
View File
@@ -169,10 +169,6 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
- [Qm-Music](https://github.com/chenqimiao/qm-music)
- More (?)
- [Plex](https://www.plex.tv/media-server-downloads)
- [Feishin fork by lux032](https://github.com/lux032/feishin) - Plex is not natively supported. Use the fork by lux032 to use Plex with Feishin.
### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux
This happens when you have user (unprivileged) namespaces disabled (`sysctl kernel.unprivileged_userns_clone` returns 0). You can fix this by either enabling unprivileged namespaces, or by making the `chrome-sandbox` Setuid.
Binary file not shown.
+4 -6
View File
@@ -40,15 +40,13 @@ mac:
arch:
- arm64
- x64
icon: media/feishin.icon
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: '-'
identity: "-"
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -63,7 +61,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: "1.0.2"
npmRebuild: false
+3 -6
View File
@@ -40,15 +40,12 @@ mac:
arch:
- arm64
- x64
icon: media/feishin.icon
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: '-'
identity: "-"
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -63,7 +60,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: "1.0.2"
npmRebuild: false
publish:
+3 -6
View File
@@ -40,15 +40,12 @@ mac:
arch:
- arm64
- x64
icon: media/feishin.icon
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: '-'
identity: "-"
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -63,7 +60,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: "1.0.2"
npmRebuild: false
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
+2 -3
View File
@@ -1,11 +1,10 @@
import react from '@vitejs/plugin-react';
import { externalizeDepsPlugin, UserConfig } from 'electron-vite';
import { resolve } from 'path';
import conditionalImportPlugin from 'vite-plugin-conditional-import';
import dynamicImportPlugin from 'vite-plugin-dynamic-import';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { createReactPlugin } from './vite.react-plugin';
const currentOSEnv = process.platform;
const electronRendererTarget = 'chrome87';
@@ -65,7 +64,7 @@ const config: UserConfig = {
localsConvention: 'camelCase',
},
},
plugins: [createReactPlugin(), ViteEjsPlugin({ web: false })],
plugins: [react(), ViteEjsPlugin({ web: false })],
resolve: {
alias: {
'/@/i18n': resolve('src/i18n'),
+1 -1
View File
@@ -25,7 +25,7 @@ export default tseslint.config(
'react-refresh': eslintPluginReactRefresh,
},
rules: {
...eslintPluginReactHooks.configs['recommended-latest'].rules,
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules,
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-duplicate-enum-values': 'off',
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><g style="display:inline" transform="translate(-53.452 -43.352)scale(1.11813)"><circle cx="256" cy="240.312" r="21.5" style="opacity:1;fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.19597;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke;filter:url(#filter249)"/><path d="M220.85 277.951 183.5 315.6l36 36.1 20-19.7s5.856-6.2 16.5-6.2 16.5 6.2 16.5 6.2l20 19.7 36-36.1-37.35-37.649A51.5 51.5 0 0 1 256 291.812a51.5 51.5 0 0 1-35.15-13.86" style="opacity:1;fill:#000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter249)"/><path d="M256 145.4a25.7 25.7 0 0 0-18.229 7.551L66.97 323.47A25.42 25.42 0 0 0 59.5 341.5c0 14.083 11.417 25.5 25.5 25.5a25.42 25.42 0 0 0 18.031-7.469l103.895-103.597a51.5 51.5 0 0 1-2.426-15.621 51.5 51.5 0 0 1 51.5-51.5 51.5 51.5 0 0 1 51.5 51.5 51.5 51.5 0 0 1-2.426 15.62L408.97 359.532A25.42 25.42 0 0 0 427 367c14.083 0 25.5-11.417 25.5-25.5a25.42 25.42 0 0 0-7.469-18.031L274.23 152.95a25.7 25.7 0 0 0-18.229-7.55" style="display:inline;opacity:1;fill:#000;fill-opacity:1;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;paint-order:markers fill stroke;filter:url(#filter249)"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-202
View File
@@ -1,202 +0,0 @@
{
"fill-specializations" : [
{
"value" : {
"linear-gradient" : [
"display-p3:0.87416,0.87416,0.87416,1.00000",
"display-p3:0.99575,0.99575,0.99575,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0.3
}
}
}
},
{
"appearance" : "dark",
"value" : "system-dark"
}
],
"groups" : [
{
"blend-mode-specializations" : [
{
"appearance" : "tinted",
"value" : "normal"
}
],
"blur-material-specializations" : [
{
"value" : 0.7
},
{
"appearance" : "dark",
"value" : 0.7
},
{
"appearance" : "tinted",
"value" : null
}
],
"hidden" : false,
"layers" : [
{
"blend-mode-specializations" : [
{
"appearance" : "tinted",
"value" : "normal"
}
],
"fill-specializations" : [
{
"value" : {
"solid" : "extended-gray:0.00000,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"linear-gradient" : [
"display-p3:0.78674,0.78674,0.78674,1.00000",
"display-p3:0.87416,0.87416,0.87416,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0
}
}
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "gray:1.00000,1.00000"
}
}
],
"glass-specializations" : [
{
"value" : true
},
{
"appearance" : "dark",
"value" : true
},
{
"appearance" : "tinted",
"value" : true
}
],
"hidden" : false,
"image-name" : "feishin.svg",
"name" : "feishin",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.79,
"translation-in-points" : [
18,
-2
]
}
}
],
"lighting-specializations" : [
{
"value" : "individual"
},
{
"appearance" : "tinted",
"value" : "combined"
}
],
"position" : {
"scale" : 2.2,
"translation-in-points" : [
0,
0
]
},
"shadow-specializations" : [
{
"value" : {
"kind" : "neutral",
"opacity" : 1
}
},
{
"appearance" : "dark",
"value" : {
"kind" : "layer-color",
"opacity" : 0.5
}
},
{
"appearance" : "tinted",
"value" : {
"kind" : "neutral",
"opacity" : 1
}
}
],
"specular-specializations" : [
{
"value" : false
},
{
"appearance" : "dark",
"value" : false
},
{
"appearance" : "tinted",
"value" : true
}
],
"translucency-specializations" : [
{
"value" : {
"enabled" : true,
"value" : 0.29
}
},
{
"appearance" : "dark",
"value" : {
"enabled" : false,
"value" : 0.29
}
},
{
"appearance" : "tinted",
"value" : {
"enabled" : true,
"value" : 0.5
}
}
]
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}
+74 -78
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.11.0",
"version": "1.9.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -31,36 +31,36 @@
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"postinstall": "electron-builder install-app-deps",
"lint": "pnpm run typecheck && pnpm run lint-code && pnpm run lint-styles",
"lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix",
"lint-code": "eslint --max-warnings=0 --cache .",
"lint-code:fix": "eslint --cache --fix .",
"lint-styles": "stylelint --max-warnings=0 'src/**/*.{css,scss}'",
"lint-styles:fix": "stylelint 'src/**/*.{css,scss}' --fix",
"lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix",
"package": "pnpm run build && electron-builder",
"package:dev": "pnpm run build && electron-builder --dir",
"package:linux": "pnpm run build && electron-builder --linux",
"package:linux:pr": "pnpm run build && electron-builder --linux --publish never",
"package:linux-arm64:pr": "pnpm run build && electron-builder --linux --arm64 --publish never",
"package:linux:pr": "pnpm run build && electron-builder --linux --publish never",
"package:mac": "pnpm run build && electron-builder --mac",
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
"package:win": "pnpm run build && electron-builder --win",
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
"publish:linux": "pnpm run build && electron-builder --publish always --linux",
"publish:linux:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux",
"publish:linux:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux",
"publish:linux-arm64": "pnpm run build && electron-builder --publish always --linux --arm64",
"publish:linux-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux --arm64",
"publish:linux-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux --arm64",
"publish:linux:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux",
"publish:linux:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux",
"publish:mac": "pnpm run build && electron-builder --publish always --mac",
"publish:mac:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --mac",
"publish:mac:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --mac",
"publish:win": "pnpm run build && electron-builder --publish always --win",
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win",
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
"publish:win-arm64": "pnpm run build && electron-builder --publish always --win --arm64",
"publish:win-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win --arm64",
"publish:win-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win --arm64",
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win",
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
"start": "electron-vite preview",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -68,123 +68,119 @@
"version": "pnpm version --no-git-tag-version",
"postversion": "node ./scripts/update-app-stream.mjs"
},
"resolutions": {
"react-router": "7.14.0",
"xml2js": "0.5.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"@mantine/colors-generator": "^9.1.1",
"@mantine/core": "^9.1.1",
"@mantine/dates": "^9.1.1",
"@mantine/form": "^9.1.1",
"@mantine/hooks": "^9.1.1",
"@mantine/modals": "^9.1.1",
"@mantine/notifications": "^9.1.1",
"@mantine/colors-generator": "^8.3.8",
"@mantine/core": "^8.3.8",
"@mantine/dates": "^8.3.8",
"@mantine/form": "^8.3.8",
"@mantine/hooks": "^8.3.8",
"@mantine/modals": "^8.3.8",
"@mantine/notifications": "^8.3.8",
"@radix-ui/react-context-menu": "^2.2.16",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tanstack/react-query-persist-client": "^5.96.2",
"@tanstack/react-query": "^5.90.9",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-query-persist-client": "^5.90.11",
"@ts-rest/core": "^3.52.1",
"@wavesurfer/react": "^1.0.12",
"@xhayper/discord-rpc": "^1.3.3",
"audiomotion-analyzer": "^4.5.4",
"axios": "^1.14.0",
"butterchurn": "3.0.0-beta.5",
"butterchurn-presets": "3.0.0-beta.4",
"cheerio": "^1.2.0",
"@wavesurfer/react": "^1.0.11",
"@xhayper/discord-rpc": "^1.3.0",
"audiomotion-analyzer": "^4.5.1",
"axios": "^1.13.5",
"butterchurn": "^3.0.0-beta.5",
"butterchurn-presets": "^3.0.0-beta.4",
"cheerio": "^1.1.2",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"dayjs": "^1.11.19",
"dompurify": "^3.3.0",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^5.4.3",
"electron-store": "^8.2.0",
"electron-updater": "^6.8.3",
"fast-average-color": "9.5.0",
"fast-xml-parser": "^5.5.10",
"electron-updater": "^6.6.2",
"fast-average-color": "^9.5.0",
"fast-xml-parser": "^5.3.8",
"format-duration": "^3.0.2",
"fuse.js": "^7.2.0",
"i18next": "^25.10.10",
"fuse.js": "^7.1.0",
"i18next": "^25.6.2",
"icecast-metadata-stats": "^0.1.12",
"idb-keyval": "^6.2.2",
"immer": "^10.2.0",
"is-electron": "^2.2.2",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"md5": "^2.3.0",
"motion": "^12.38.0",
"motion": "^12.23.24",
"mpris-service": "^2.1.2",
"nanoid": "^3.3.11",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"overlayscrollbars": "^2.14.0",
"nuqs": "^2.7.1",
"overlayscrollbars": "^2.11.1",
"overlayscrollbars-react": "^0.5.6",
"qs": "^6.15.0",
"react": "^19.2.4",
"react-call": "^1.8.2",
"react-dom": "^19.2.4",
"qs": "^6.14.2",
"react": "^19.1.0",
"react-call": "^1.8.1",
"react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0",
"react-i18next": "^16.6.6",
"react-icons": "^5.6.0",
"react-player": "^2.16.1",
"react-router": "^7.14.0",
"react-split-pane": "^3.2.0",
"react-i18next": "^16.3.3",
"react-icons": "^5.5.0",
"react-player": "^2.16.0",
"react-router": "^7.13.1",
"react-split-pane": "^3.0.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11",
"react-window-v2": "npm:react-window@^2.2.7",
"semver": "^7.7.4",
"react-window-v2": "npm:react-window@^2.2.3",
"semver": "^7.5.4",
"string-to-color": "^2.2.2",
"wavesurfer.js": "^7.12.5",
"ws": "^8.20.0",
"zod": "^3.25.76",
"zustand": "^5.0.12"
"wavesurfer.js": "^7.11.1",
"ws": "^8.18.2",
"zod": "^3.22.3",
"zustand": "^5.0.5"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/electron-localshortcut": "^3.1.3",
"@types/lodash": "^4.17.24",
"@types/md5": "^2.3.6",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/electron-localshortcut": "^3.1.0",
"@types/lodash": "^4.17.18",
"@types/md5": "^2.3.5",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"@vitejs/plugin-react": "^5.1.1",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.8.6",
"electron": "^39.4.0",
"electron-builder": "^26.8.2",
"electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-prettier": "^5.5.5",
"eslint": "^9.24.0",
"eslint-plugin-perfectionist": "^4.13.0",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"i18next-parser": "^9.4.0",
"eslint-plugin-react-refresh": "^0.4.24",
"i18next-parser": "^9.3.0",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"prettier-plugin-packagejson": "^2.5.22",
"stylelint": "^16.26.1",
"stylelint-config-css-modules": "^4.6.0",
"stylelint-config-recess-order": "^7.7.0",
"prettier": "^3.6.2",
"prettier-plugin-packagejson": "^2.5.19",
"stylelint": "^16.25.0",
"stylelint-config-css-modules": "^4.5.1",
"stylelint-config-recess-order": "^7.4.0",
"stylelint-config-standard": "^39.0.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"typescript": "^5.8.3",
"vite": "^7.2.2",
"vite-plugin-conditional-import": "^0.1.7",
"vite-plugin-dynamic-import": "^1.6.0",
"vite-plugin-ejs": "^1.7.0",
"vite-plugin-pwa": "^1.2.0"
"vite-plugin-pwa": "^1.1.0"
},
"pnpm": {
"onlyBuiltDependencies": [
+2460 -2457
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,9 +1,9 @@
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig, normalizePath } from 'vite';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { version } from './package.json';
import { createReactPlugin } from './vite.react-plugin';
export default defineConfig({
build: {
@@ -35,7 +35,7 @@ export default defineConfig({
},
},
plugins: [
createReactPlugin(),
react(),
ViteEjsPlugin({
prod: process.env.NODE_ENV === 'production',
root: normalizePath(path.resolve(__dirname, './src/remote')),
+12 -4
View File
@@ -1,4 +1,4 @@
import { PostProcessorModule } from 'i18next';
import { PostProcessorModule, TOptions } from 'i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
@@ -203,17 +203,25 @@ const titleCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
};
// const ignoreSentenceCaseLanguages = ['de'];
const ignoreSentenceCaseLanguages = ['de'];
const sentenceCasePostProcessor: PostProcessorModule = {
name: 'sentenceCase',
process: (value: string) => {
process: (
value: string,
_key: string,
_options: TOptions<Record<string, string>>,
translator: any,
) => {
const sentences = value.split('. ');
return sentences
.map((sentence) => {
return (
sentence.charAt(0).toLocaleUpperCase() + sentence.slice(1).toLocaleLowerCase()
sentence.charAt(0).toLocaleUpperCase() +
(!ignoreSentenceCaseLanguages.includes(translator.language)
? sentence.slice(1).toLocaleLowerCase()
: sentence.slice(1))
);
})
.join('. ');
+925 -957
View File
File diff suppressed because it is too large Load Diff
+926 -937
View File
File diff suppressed because it is too large Load Diff
+871 -871
View File
File diff suppressed because it is too large Load Diff
+421 -434
View File
File diff suppressed because it is too large Load Diff
+918 -930
View File
File diff suppressed because it is too large Load Diff
+500 -514
View File
File diff suppressed because it is too large Load Diff
+742 -742
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -12,7 +12,7 @@
"unfavorite": "حذف از موردعلاقه‌ها",
"shuffle_off": "پخش تصادفی غیر فعال",
"skip_forward": "برو جلو",
"queue_moveToTop": "جابجا کردن انتخاب شده به بالا",
"queue_moveToTop": "جابجا کردن انتخاب شده به پایین",
"queue_clear": "خالی کردن صف",
"queue_remove": "حذف انتخاب شده",
"addLast": "افزودن به پایان",
@@ -24,7 +24,7 @@
"mute": "بی‌صدا کردن",
"playbackFetchCancel": "دارد طول می‌کشد... برای لفو کردن اعلان را ببندید",
"playbackFetchInProgress": "بارگذاری قطعه‌ها…",
"queue_moveToBottom": "جابجا کردن انتخاب شده به پایین",
"queue_moveToBottom": "جابجا کردن انتخاب شده به بالا",
"addNext": "افزودن به پسین",
"favorite": "مورد علاقه",
"playSimilarSongs": "پخش آهنگ‌های همگون",
@@ -70,7 +70,7 @@
"hotkey_rate1": "امتیاز ۱ ستاره",
"hotkey_skipForward": "برو جلو",
"disableLibraryUpdateOnStartup": "غیرفعال کردن بررسی آخرین نسخه در آغاز به کار برنامه",
"discordApplicationId_description": "the application ID for {{discord}} Rich Presence (defaults to {{defaultId}})",
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"hotkey_playbackPlay": "پخش",
"hotkey_volumeDown": "کم کردن صدا",
@@ -109,7 +109,7 @@
"customFontPath": "مسیر قلم سفارشی",
"audioPlayer": "پخش‌کنندهٔ صدا",
"hotkey_rate0": "حذف امتیاز",
"discordApplicationId": "{{discord}} application ID",
"discordApplicationId": "{{discord}} application id",
"hotkey_volumeMute": "بستن صدا",
"showSkipButton": "نمایش دکمهٔ رد کردن",
"customFontPath_description": "مسیر قلم سفارشی را برای استفاده در اپلیکیشن مشخص کنید",
@@ -132,7 +132,7 @@
"buttonSize": "اندازه‌ی دکمه‌ی پخش نوار",
"contextMenu": "پیکربندی فهرست زمینه (کلیک راست)",
"buttonSize_description": "اندازه‌ی دکمه‌های پخش نوار",
"audioExclusiveMode_description": "حالت اختصاصی خروجی را فعال می‌کند. در این حالت، سامانه معمولاً قفل است و فقط MPV می‌تواند خروجی صدا دهد",
"audioExclusiveMode_description": "حالت اختصاصی خروجی را فعال می‌کند. در این حالت، سامانه معمولاً قفل است و فقط mpv می‌تواند خروجی صدا دهد",
"clearQueryCache_description": "یک 'پاک‌سازی نرم' از فیشین. این فهرست‌های پخش و فراداده‌ی قطعه‌ها را تازه می‌کند و متن شعرهای ذخیره شده را بازنشانی می‌کند. پیکربندی‌ها، اعتبارنامه‌های سرویس‌دهنده و نگاره‌های کَش شده حفظ می‌شوند",
"clearCache_description": "یک 'پاک‌سازی سخت' فیشین. افزون بر پاک‌سازی کَش فیشین، کَش مرورگر هم تهی می‌شود (نگاره‌های ذخیره شده و باقی دارایی‌ها). اعتبارنامه‌ها و پیکربندی‌ها حفظ می‌شوند",
"contextMenu_description": "به شما اجازه می‌دهد که آیتم‌های نمایش داده شده در فهرستی که وقتی روی یک آیتم کلیک راست می‌کنید پدیدار می‌شود، را پنهان کنید. آیتم‌هایی که منتخب نیستند پنهان می‌شوند",
@@ -176,7 +176,7 @@
"backward": "به عقب",
"increase": "افزایش",
"rating": "امتیاز",
"bpm": "BPM",
"bpm": "bpm",
"refresh": "تازه‌سازی",
"unknown": "ناشناخته",
"areYouSure": "مطمئنید؟",
@@ -313,9 +313,9 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"isRecentlyPlayed": "به تازگی پخش شده است",
"isFavorited": "موردعلاقه است",
"bpm": "BPM",
"bpm": "bpm",
"releaseYear": "سال انتشار",
"id": "ID",
"id": "id",
"disc": "دیسک",
"biography": "زندگی‌نامه",
"songCount": "تعداد ترانه",
+753 -753
View File
File diff suppressed because it is too large Load Diff
+975 -987
View File
File diff suppressed because it is too large Load Diff
+728 -728
View File
File diff suppressed because it is too large Load Diff
+821 -821
View File
File diff suppressed because it is too large Load Diff
+734 -734
View File
File diff suppressed because it is too large Load Diff
+7 -16
View File
@@ -22,8 +22,8 @@
"queue_clear": "キューをクリア",
"muted": "ミュート中",
"unfavorite": "お気に入り解除",
"queue_moveToTop": "選択項目を先頭に移動",
"queue_moveToBottom": "選択項目を一番下に移動",
"queue_moveToTop": "選択項目を一番下に移動",
"queue_moveToBottom": "選択項目を先頭に移動",
"shuffle_off": "シャッフル無効",
"addLast": "最後",
"mute": "ミュート",
@@ -271,7 +271,7 @@
"customCss": "カスタム CSS",
"customCssEnable_description": "カスタム CSS の記述を許可します",
"customCssEnable": "カスタム CSS を有効にする",
"customCssNotice": "警告: ある程度のサニタイズ (URL() と content: の禁止) はありますが、カスタム CSS を使用するとインターフェースの変更によりリスクが生じる可能性があります",
"customCssNotice": "警告: ある程度のサニタイズ (url() と content: の禁止) はありますが、カスタム CSS を使用するとインターフェースの変更によりリスクが生じる可能性があります",
"releaseChannel_optionBeta": "ベータ",
"releaseChannel_optionLatest": "最新",
"releaseChannel": "リリースチャンネル",
@@ -501,7 +501,7 @@
"name": "名前",
"maximize": "最大化",
"decrease": "減少",
"ok": "Ok",
"ok": "OK",
"description": "説明",
"configure": "設定",
"path": "パス",
@@ -639,7 +639,7 @@
"titleCombined": "$t(common.title) (結合)",
"dateAdded": "追加日",
"size": "$t(common.size)",
"bpm": "$t(common.BPM)",
"bpm": "$t(common.bpm)",
"lastPlayed": "最後に再生",
"trackNumber": "トラック番号",
"rowIndex": "行インデックス",
@@ -1037,7 +1037,7 @@
},
"updateServer": {
"title": "サーバーをアップデート",
"success": "サーバーの更新に成功しました"
"success": "サーバーがアップデートされました"
},
"queryEditor": {
"input_optionMatchAll": "すべて一致",
@@ -1102,9 +1102,6 @@
},
"saveQueue": {
"success": "プレイキューをサーバーに保存しました"
},
"editRadioStation": {
"success": "ラジオ局の更新に成功しました"
}
},
"entity": {
@@ -1335,12 +1332,6 @@
"d": "D",
"z": "Z"
}
},
"systemAudioConsentAllow": "許可",
"systemAudioConsentBody": "ビジュアライザーを機能させるためには、システムオーディオへのアクセスが必要です",
"systemAudioConsentDecline": "拒否",
"systemAudioConsentTitle": "システムオーディオへのアクセスを許可しますか?",
"systemAudioCaptureFailed": "キャプチャを開始できませんでした: {{message}}",
"systemAudioNoAudioTrack": "音声トラックが返されませんでした。プロンプトが表示されたら、音声キャプチャが有効になっていることを確認してください。"
}
}
}
+6 -6
View File
@@ -106,7 +106,7 @@
"ascending": "오름차순",
"areYouSure": "확실한가요?",
"bitrate": "비트 전송률",
"bpm": "BPM",
"bpm": "bpm",
"biography": "바이오그래피",
"center": "중앙",
"channel_other": "채널",
@@ -127,7 +127,7 @@
"filters": "필터",
"noResultsFromQuery": "쿼리 결과가 없습니다",
"note": "노트",
"ok": "Ok",
"ok": "OK",
"owner": "소유자",
"sampleRate": "샘플레이트",
"tags": "태그",
@@ -224,7 +224,7 @@
"biography": "바이오그래피",
"channels": "$t(common.channel_other)",
"duration": "길이",
"bpm": "BPM",
"bpm": "bpm",
"albumCount": "$t(entity.album, {\"count\": 2}) 앨범수",
"comment": "코멘트",
"favorited": "즐겨찾기",
@@ -251,7 +251,7 @@
"input_name": "서버 이름",
"input_password": "비밀번호",
"input_savePassword": "비밀번호 저장하기",
"input_url": "URL",
"input_url": "url",
"error_savePassword": "비밀번호를 저장하는 도중 오류가 발생했습니다",
"ignoreCors": "CORS 무시 ($t(common.restartRequired))",
"ignoreSsl": "SSL 무시 ($t(common.restartRequired))",
@@ -458,8 +458,8 @@
"playSimilarSongs": "비슷한 곡 재생",
"previous": "이전",
"queue_clear": "재생 대기열 지우기",
"queue_moveToBottom": "선택한 곡을 가장 아래로 이동",
"queue_moveToTop": "선택한 곡을 가장 로 이동",
"queue_moveToBottom": "선택한 곡을 가장 로 이동",
"queue_moveToTop": "선택한 곡을 가장 아래로 이동",
"queue_remove": "선택한 항목 삭제",
"repeat": "반복",
"repeat_all": "모두 반복하기",
File diff suppressed because it is too large Load Diff
+903 -913
View File
File diff suppressed because it is too large Load Diff
+910 -919
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+352 -352
View File
@@ -1,166 +1,166 @@
{
"action": {
"addToFavorites": "Adicionar a $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "Adicionar a $t(entity.playlist, {\"count\": 1})",
"clearQueue": "Limpar fila",
"createPlaylist": "Criar $t(entity.playlist, {\"count\": 1})",
"deletePlaylist": "Apagar $t(entity.playlist, {\"count\": 1})",
"deselectAll": "Desmarcar todos",
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
"goToPage": "Vá para página",
"moveToNext": "Mover para o próximo",
"moveToBottom": "Mover para baixo",
"moveToTop": "Mover para o topo",
"addToFavorites": "adicionar a $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "adicionar a $t(entity.playlist, {\"count\": 1})",
"clearQueue": "limpar fila",
"createPlaylist": "criar $t(entity.playlist, {\"count\": 1})",
"deletePlaylist": "apagar $t(entity.playlist, {\"count\": 1})",
"deselectAll": "desmarcar todos",
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
"goToPage": "vá para página",
"moveToNext": "mover para o próximo",
"moveToBottom": "mover para baixo",
"moveToTop": "mover para o topo",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "Remover de $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "Remover da $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "Remover da fila",
"setRating": "Definir classificação",
"toggleSmartPlaylistEditor": "Alternar editor $t(entity.smartPlaylist)",
"viewPlaylists": "Ver $t(entity.playlist, {\"count\": 2})",
"removeFromFavorites": "remover de $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "remover da $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "remover da fila",
"setRating": "definir classificação",
"toggleSmartPlaylistEditor": "alternar editor $t(entity.smartPlaylist)",
"viewPlaylists": "ver $t(entity.playlist, {\"count\": 2})",
"openIn": {
"lastfm": "Abrir em Last.fm",
"musicbrainz": "Abrir em MusicBrainz"
}
},
"common": {
"action_one": "Ação",
"action_one": "ação",
"action_many": "ações",
"action_other": "Ações",
"add": "Adicionar",
"additionalParticipants": "Participantes adicionais",
"newVersion": "Uma nova versão foi instalada ({{version}})",
"viewReleaseNotes": "Ver notas de lançamento",
"albumGain": "Ganho do álbum",
"albumPeak": "Pico do álbum",
"areYouSure": "Tem certeza?",
"ascending": "Ascendente",
"backward": "Para trás",
"biography": "Biografia",
"bitrate": "Taxa de bits",
"bpm": "Bpm",
"cancel": "Cancelar",
"center": "Centro",
"channel_one": "Canal",
"action_other": "ações",
"add": "adicionar",
"additionalParticipants": "participantes adicionais",
"newVersion": "uma nova versão foi instalada ({{version}})",
"viewReleaseNotes": "ver notas de lançamento",
"albumGain": "ganho do álbum",
"albumPeak": "pico do álbum",
"areYouSure": "tem certeza?",
"ascending": "ascendente",
"backward": "para trás",
"biography": "biografia",
"bitrate": "taxa de bits",
"bpm": "bpm",
"cancel": "cancelar",
"center": "centro",
"channel_one": "canal",
"channel_many": "canais",
"channel_other": "Canais",
"clear": "Limpar",
"close": "Fechar",
"codec": "Codec",
"collapse": "Minimizar",
"comingSoon": "Em breve…",
"configure": "Configurar",
"confirm": "Confirmar",
"create": "Criar",
"channel_other": "canais",
"clear": "limpar",
"close": "fechar",
"codec": "codec",
"collapse": "minimizar",
"comingSoon": "em breve…",
"configure": "configurar",
"confirm": "confirmar",
"create": "criar",
"currentSong": "$t(entity.track, {\"count\": 1}) atual",
"decrease": "Diminuir",
"delete": "Apagar",
"descending": "Abaixar",
"description": "Descrição",
"disable": "Desativar",
"disc": "Disco",
"dismiss": "Liberar",
"duration": "Duração",
"edit": "Editar",
"enable": "Ativar",
"expand": "Expandir",
"favorite": "Favorito",
"filter_one": "Filtro",
"decrease": "diminuir",
"delete": "apagar",
"descending": "abaixar",
"description": "descrição",
"disable": "desativar",
"disc": "disco",
"dismiss": "liberar",
"duration": "duração",
"edit": "editar",
"enable": "ativar",
"expand": "expandir",
"favorite": "favorito",
"filter_one": "filtro",
"filter_many": "filtros",
"filter_other": "Filtros",
"filters": "Filtros",
"forceRestartRequired": "Reinicie para aplicar as alterações… feche a notificação para reiniciar",
"forward": "Para frente",
"gap": "Intervalo",
"grouping": "Agrupamento",
"home": "Início",
"increase": "Incrementar",
"left": "Esquerda",
"limit": "Limite",
"manage": "Gerir",
"maximize": "Maximizar",
"menu": "Menu",
"minimize": "Minimizar",
"modified": "Modificado",
"filter_other": "filtros",
"filters": "filtros",
"forceRestartRequired": "reinicie para aplicar as alterações… feche a notificação para reiniciar",
"forward": "para frente",
"gap": "intervalo",
"grouping": "agrupamento",
"home": "início",
"increase": "incrementar",
"left": "esquerda",
"limit": "limite",
"manage": "gerir",
"maximize": "maximizar",
"menu": "menu",
"minimize": "minimizar",
"modified": "modificado",
"mbid": "ID no MusicBrainz",
"name": "Nome",
"no": "Não",
"none": "Nenhum",
"noResultsFromQuery": "A consulta não retornou resultados",
"note": "Observação",
"ok": "Ok",
"owner": "Dono",
"path": "Caminho",
"playerMustBePaused": "O player deve estar pausado",
"preview": "Pré-visualizar",
"previousSong": "Anterior $t(entity.track, {\"count\": 1})",
"quit": "Sair",
"random": "Aleatório",
"rating": "Classificação",
"refresh": "Atualizar",
"reload": "Recarregar",
"reset": "Reiniciar",
"resetToDefault": "Restaurar ao padrão",
"restartRequired": "É necessário reiniciar",
"right": "Direita",
"save": "Gravar",
"saveAndReplace": "Gravar e substituir",
"saveAs": "Gravar como",
"search": "Procurar",
"setting_one": "Configuração",
"name": "nome",
"no": "não",
"none": "nenhum",
"noResultsFromQuery": "a consulta não retornou resultados",
"note": "observação",
"ok": "ok",
"owner": "dono",
"path": "caminho",
"playerMustBePaused": "o player deve estar pausado",
"preview": "pré-visualizar",
"previousSong": "anterior $t(entity.track, {\"count\": 1})",
"quit": "sair",
"random": "aleatório",
"rating": "classificação",
"refresh": "atualizar",
"reload": "recarregar",
"reset": "reiniciar",
"resetToDefault": "restaurar ao padrão",
"restartRequired": "é necessário reiniciar",
"right": "direita",
"save": "gravar",
"saveAndReplace": "gravar e substituir",
"saveAs": "gravar como",
"search": "procurar",
"setting_one": "configuração",
"setting_many": "",
"setting_other": "",
"share": "Partilhar",
"size": "Tamanho",
"sortOrder": "Ordem",
"tags": "Tags",
"title": "Titulo",
"trackNumber": "Faixa",
"trackGain": "Ganho da faixa",
"trackPeak": "Pico da faixa",
"translation": "Tradução",
"unknown": "Desconhecido",
"version": "Versão",
"year": "Ano",
"yes": "Sim"
"share": "partilhar",
"size": "tamanho",
"sortOrder": "ordem",
"tags": "tags",
"title": "titulo",
"trackNumber": "faixa",
"trackGain": "ganho da faixa",
"trackPeak": "pico da faixa",
"translation": "tradução",
"unknown": "desconhecido",
"version": "versão",
"year": "ano",
"yes": "sim"
},
"entity": {
"album_one": "Álbum",
"album_one": "álbum",
"album_many": "álbuns",
"album_other": "Álbuns",
"albumArtist_one": "Artista do álbum",
"album_other": "álbuns",
"albumArtist_one": "artista do álbum",
"albumArtist_many": "artistas do álbum",
"albumArtist_other": "Artistas do álbum",
"albumArtist_other": "artistas do álbum",
"albumArtistCount_one": "{{count}} artista do álbum",
"albumArtistCount_many": "{{count}} artistas do álbum",
"albumArtistCount_other": "{{count}} artistas do álbum",
"albumWithCount_one": "{{count}} álbum",
"albumWithCount_many": "{{count}} álbuns",
"albumWithCount_other": "{{count}} álbuns",
"artist_one": "Artista",
"artist_one": "artista",
"artist_many": "artistas",
"artist_other": "Artistas",
"artist_other": "artistas",
"artistWithCount_one": "{{count}} artista",
"artistWithCount_many": "{{count}} artistas",
"artistWithCount_other": "{{count}} artistas",
"favorite_one": "Favorito",
"favorite_one": "favorito",
"favorite_many": "favoritos",
"favorite_other": "Favoritos",
"folder_one": "Pasta",
"favorite_other": "favoritos",
"folder_one": "pasta",
"folder_many": "pastas",
"folder_other": "Pastas",
"folder_other": "pastas",
"folderWithCount_one": "{{count}} pasta",
"folderWithCount_many": "{{count}} pastas",
"folderWithCount_other": "{{count}} pastas",
"genre_one": "Gênero",
"genre_one": "gênero",
"genre_many": "gêneros",
"genre_other": "Gêneros",
"genre_other": "gêneros",
"genreWithCount_one": "{{count}} gênero",
"genreWithCount_many": "{{count}} gêneros",
"genreWithCount_other": "{{count}} gêneros",
"playlist_one": "Playlist",
"playlist_one": "playlist",
"playlist_many": "playlists",
"playlist_other": "Playlists",
"playlist_other": "playlists",
"play_one": "{{count}} reprodução",
"play_many": "{{count}} reproduções",
"play_other": "{{count}} reproduções",
@@ -168,189 +168,189 @@
"playlistWithCount_many": "{{count}} playlists",
"playlistWithCount_other": "{{count}} playlists",
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
"track_one": "Faixa",
"track_one": "faixa",
"track_many": "faixas",
"track_other": "Faixas",
"song_one": "Música",
"track_other": "faixas",
"song_one": "música",
"song_many": "músicas",
"song_other": "Músicas",
"song_other": "músicas",
"trackWithCount_one": "{{count}} faixa",
"trackWithCount_many": "{{count}} faixas",
"trackWithCount_other": "{{count}} faixas"
},
"error": {
"apiRouteError": "Não é possível encaminhar a solicitação",
"audioDeviceFetchError": "Ocorreu um erro ao tentar obter dispositivos de áudio",
"authenticationFailed": "Falha na autenticação",
"badAlbum": "Está a ver este erro por que está música não é parte de algum album. um motivo comum para si estar a ver este erro é se a sua música estiver na raiz da sua pasta de músicas. o Jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta",
"badValue": "Opção inválida \"{{value}}\". este valor não existe no momento",
"credentialsRequired": "Credenciais necessárias",
"endpointNotImplementedError": "Endpoint {{endpoint}} não está implementado para {{serverType}}",
"genericError": "Um erro ocorreu",
"invalidServer": "Servidor inválido",
"localFontAccessDenied": "Acesso a fontes locais rejeitado",
"loginRateError": "Muitas tentativas de login, tente novamente em alguns segundos",
"apiRouteError": "não é possível encaminhar a solicitação",
"audioDeviceFetchError": "ocorreu um erro ao tentar obter dispositivos de áudio",
"authenticationFailed": "falha na autenticação",
"badAlbum": "está a ver este erro por que está música não é parte de algum album. um motivo comum para si estar a ver este erro é se a sua música estiver na raiz da sua pasta de músicas. o Jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta",
"badValue": "opção inválida \"{{value}}\". este valor não existe no momento",
"credentialsRequired": "credenciais necessárias",
"endpointNotImplementedError": "endpoint {{endpoint}} não está implementado para {{serverType}}",
"genericError": "um erro ocorreu",
"invalidServer": "servidor inválido",
"localFontAccessDenied": "acesso a fontes locais rejeitado",
"loginRateError": "muitas tentativas de login, tente novamente em alguns segundos",
"mpvRequired": "MPV necessário",
"networkError": "Ocorreu um erro na internet",
"openError": "Não foi possível abrir o ficheiro",
"playbackError": "Ocorreu um erro ao tentar reproduzir a média",
"remoteDisableError": "Ocorreu um erro ao tentar $t(common.disable) o servidor remoto",
"remoteEnableError": "Ocorreu um erro ao tentar $t(common.enable) o servidor remoto",
"remotePortError": "Ocorreu um erro ao tentar definir a porta do servidor remoto",
"remotePortWarning": "Reinicie o servidor para aplicar a nova porta",
"serverNotSelectedError": "Nenhum servidor selecionado",
"serverRequired": "Servidor necessário",
"sessionExpiredError": "A sua sessão expirou",
"systemFontError": "Ocorreu um erro ao tentar obter fontes do sistema"
"networkError": "ocorreu um erro na internet",
"openError": "não foi possível abrir o ficheiro",
"playbackError": "ocorreu um erro ao tentar reproduzir a média",
"remoteDisableError": "ocorreu um erro ao tentar $t(common.disable) o servidor remoto",
"remoteEnableError": "ocorreu um erro ao tentar $t(common.enable) o servidor remoto",
"remotePortError": "ocorreu um erro ao tentar definir a porta do servidor remoto",
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
"serverNotSelectedError": "nenhum servidor selecionado",
"serverRequired": "servidor necessário",
"sessionExpiredError": "a sua sessão expirou",
"systemFontError": "ocorreu um erro ao tentar obter fontes do sistema"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"albumCount": "Número de $t(entity.album, {\"count\": 2})",
"albumCount": "número de $t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "Bibliografia",
"bitrate": "Bitrate",
"bpm": "Bpm",
"biography": "bibliografia",
"bitrate": "bitrate",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"comment": "Comentário",
"comment": "comentário",
"communityRating": "Nota da comunidade",
"criticRating": "Avaliação da crítica",
"dateAdded": "Data de adição",
"disc": "Disco",
"duration": "Duração",
"favorited": "Favoritado",
"fromYear": "A partir do ano",
"criticRating": "avaliação da crítica",
"dateAdded": "data de adição",
"disc": "disco",
"duration": "duração",
"favorited": "favoritado",
"fromYear": "a partir do ano",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "Id",
"isCompilation": "É compilação",
"isFavorited": "É favoritado",
"isPublic": "É público",
"isRated": "Possui avaliação",
"isRecentlyPlayed": "Foi tocado recentemente",
"lastPlayed": "Última tocada",
"mostPlayed": "Mais tocado",
"name": "Nome",
"note": "Nota",
"id": "id",
"isCompilation": "é compilação",
"isFavorited": "é favoritado",
"isPublic": "é público",
"isRated": "possui avaliação",
"isRecentlyPlayed": "foi tocado recentemente",
"lastPlayed": "última tocada",
"mostPlayed": "mais tocado",
"name": "nome",
"note": "nota",
"owner": "$t(common.owner)",
"path": "Caminho",
"playCount": "Contador de reproduções",
"random": "Aleatório",
"rating": "Avaliação",
"recentlyAdded": "Adicionado recentemente",
"recentlyPlayed": "Tocado recentemente",
"recentlyUpdated": "Atualizado recentemente",
"releaseDate": "Data de lançamento",
"releaseYear": "Ano de lançamento",
"search": "Buscar",
"songCount": "Contador de músicas",
"title": "Titulo",
"toYear": "Até o ano",
"trackNumber": "Faixa"
"path": "caminho",
"playCount": "contador de reproduções",
"random": "aleatório",
"rating": "avaliação",
"recentlyAdded": "adicionado recentemente",
"recentlyPlayed": "tocado recentemente",
"recentlyUpdated": "atualizado recentemente",
"releaseDate": "data de lançamento",
"releaseYear": "ano de lançamento",
"search": "buscar",
"songCount": "contador de músicas",
"title": "titulo",
"toYear": "até o ano",
"trackNumber": "faixa"
},
"form": {
"addServer": {
"error_savePassword": "Um erro ocorreu ao tentar gravar a palavra-passe",
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
"ignoreSsl": "Ignorar ssl ($t(common.restartRequired))",
"input_legacyAuthentication": "Ativar autenticação legada",
"input_name": "Nome do servidor",
"input_password": "Palavra-passe",
"input_savePassword": "Gravar palavra-passe",
"input_url": "Url",
"input_username": "Nome de utilizador",
"success": "Servidor adicionado com sucesso",
"title": "Adicionar servidor"
"error_savePassword": "um erro ocorreu ao tentar gravar a palavra-passe",
"ignoreCors": "ignorar CORS ($t(common.restartRequired))",
"ignoreSsl": "ignorar ssl ($t(common.restartRequired))",
"input_legacyAuthentication": "ativar autenticação legada",
"input_name": "nome do servidor",
"input_password": "palavra-passe",
"input_savePassword": "gravar palavra-passe",
"input_url": "url",
"input_username": "nome de utilizador",
"success": "servidor adicionado com sucesso",
"title": "adicionar servidor"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"input_skipDuplicates": "Pular duplicadas",
"success": "Adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "Adicionar à $t(entity.playlist, {\"count\": 1})"
"input_skipDuplicates": "pular duplicadas",
"success": "adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "adicionar à $t(entity.playlist, {\"count\": 1})"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "Público",
"input_public": "público",
"success": "$t(entity.playlist, {\"count\": 1}) criada com sucesso",
"title": "Criar $t(entity.playlist, {\"count\": 1})"
"title": "criar $t(entity.playlist, {\"count\": 1})"
},
"deletePlaylist": {
"input_confirm": "Escreva o nome da $t(entity.playlist, {\"count\": 1}) para confirmar",
"input_confirm": "escreva o nome da $t(entity.playlist, {\"count\": 1}) para confirmar",
"success": "$t(entity.playlist, {\"count\": 1}) apagada com sucesso",
"title": "Apagar $t(entity.playlist, {\"count\": 1})"
"title": "apagar $t(entity.playlist, {\"count\": 1})"
},
"editPlaylist": {
"publicJellyfinNote": "O Jellyfin por algum motivo não expõe se uma playlist é pública ou não. Se deseja que ela permaneça pública, por favor selecione a seguinte entrada",
"success": "$t(entity.playlist, {\"count\": 1}) atualizada com sucesso",
"title": "Editar $t(entity.playlist, {\"count\": 1})"
"title": "editar $t(entity.playlist, {\"count\": 1})"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
"input_name": "$t(common.name)",
"title": "Pesquisa de letras"
"title": "pesquisa de letras"
},
"queryEditor": {
"input_optionMatchAll": "Corresponder todos",
"input_optionMatchAny": "Corresponder qualquer um"
"input_optionMatchAll": "corresponder todos",
"input_optionMatchAny": "corresponder qualquer um"
},
"shareItem": {
"allowDownloading": "Permitir descargas",
"description": "Descrição",
"setExpiration": "Definir expiração",
"success": "Ligação de compartilhamento copiado para a área de transferência (ou clique aqui para abrir)",
"expireInvalid": "A expiração deve ser uma data futura",
"createFailed": "Falha ao criar compartilhamento (o compartilhamento está ativado?)"
"allowDownloading": "permitir descargas",
"description": "descrição",
"setExpiration": "definir expiração",
"success": "ligação de compartilhamento copiado para a área de transferência (ou clique aqui para abrir)",
"expireInvalid": "a expiração deve ser uma data futura",
"createFailed": "falha ao criar compartilhamento (o compartilhamento está ativado?)"
},
"updateServer": {
"success": "Servidor atualizado com sucesso",
"title": "Atualizar servidor"
"success": "servidor atualizado com sucesso",
"title": "atualizar servidor"
}
},
"page": {
"albumArtistDetail": {
"about": "Sobre {{artist}}",
"appearsOn": "Aparece em",
"recentReleases": "Lançamentos recentes",
"viewDiscography": "Ver discografia",
"appearsOn": "aparece em",
"recentReleases": "lançamentos recentes",
"viewDiscography": "ver discografia",
"relatedArtists": "$t(entity.artist, {\"count\": 2}) relacionados",
"topSongs": "Músicas mais tocadas",
"topSongsFrom": "Músicas mais tocadas de {{title}}",
"viewAll": "Ver tudo",
"viewAllTracks": "Ver todas as $t(entity.track, {\"count\": 2})"
"topSongs": "músicas mais tocadas",
"topSongsFrom": "músicas mais tocadas de {{title}}",
"viewAll": "ver tudo",
"viewAllTracks": "ver todas as $t(entity.track, {\"count\": 2})"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
},
"albumDetail": {
"moreFromArtist": "Mais deste $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "Mais que {{item}}",
"released": "Lançado"
"moreFromArtist": "mais deste $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "mais que {{item}}",
"released": "lançado"
},
"albumList": {
"artistAlbums": "Álbuns de {{artist}}",
"artistAlbums": "álbuns de {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
"title": "$t(entity.album, {\"count\": 2})"
},
"appMenu": {
"collapseSidebar": "Recolher barra lateral",
"expandSidebar": "Expandir barra lateral",
"goBack": "Voltar",
"goForward": "Avançar",
"manageServers": "Gerir servidores",
"openBrowserDevtools": "Abrir ferramentas do programador",
"collapseSidebar": "recolher barra lateral",
"expandSidebar": "expandir barra lateral",
"goBack": "voltar",
"goForward": "avançar",
"manageServers": "gerir servidores",
"openBrowserDevtools": "abrir ferramentas do programador",
"quit": "$t(common.quit)",
"selectServer": "Selecionar servidor",
"selectServer": "selecionar servidor",
"settings": "$t(common.setting, {\"count\": 2})",
"version": "Versão {{version}}"
"version": "versão {{version}}"
},
"manageServers": {
"title": "Gerir servidores",
"serverDetails": "Pormenores do servidor",
"title": "gerir servidores",
"serverDetails": "pormenores do servidor",
"url": "URL",
"username": "Nome de utilizador",
"editServerDetailsTooltip": "Editar pormenores do servidor",
"removeServer": "Remover servidor"
"username": "nome de utilizador",
"editServerDetailsTooltip": "editar pormenores do servidor",
"removeServer": "remover servidor"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
@@ -361,7 +361,7 @@
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"download": "Descarregar",
"download": "descarregar",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
@@ -373,69 +373,69 @@
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "Partilhar elemento",
"showDetails": "Obter informações"
"shareItem": "partilhar elemento",
"showDetails": "obter informações"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "Fundo dinâmico",
"dynamicImageBlur": "Tamanho do desfoque da imagem",
"dynamicIsImage": "Ativar imagem de fundo",
"followCurrentLyric": "Acompanhar letra",
"lyricAlignment": "Alinhamento da letra",
"lyricOffset": "Deslocamento da letra (ms)",
"lyricGap": "Espaçamento da letra",
"lyricSize": "Tamanho da letra",
"opacity": "Opacidade",
"showLyricMatch": "Exibir correspondência da letra",
"showLyricProvider": "Exibir origem da letra",
"synchronized": "Sincronizado",
"unsynchronized": "Não sincronizado",
"useImageAspectRatio": "Usar proporção da imagem"
"dynamicBackground": "fundo dinâmico",
"dynamicImageBlur": "tamanho do desfoque da imagem",
"dynamicIsImage": "ativar imagem de fundo",
"followCurrentLyric": "acompanhar letra",
"lyricAlignment": "alinhamento da letra",
"lyricOffset": "deslocamento da letra (ms)",
"lyricGap": "espaçamento da letra",
"lyricSize": "tamanho da letra",
"opacity": "opacidade",
"showLyricMatch": "exibir correspondência da letra",
"showLyricProvider": "exibir origem da letra",
"synchronized": "sincronizado",
"unsynchronized": "não sincronizado",
"useImageAspectRatio": "usar proporção da imagem"
},
"lyrics": "Letra",
"related": "Relacionado",
"upNext": "A seguir",
"visualizer": "Visualizador",
"noLyrics": "Nenhuma letra encontrada"
"lyrics": "letra",
"related": "relacionado",
"upNext": "a seguir",
"visualizer": "visualizador",
"noLyrics": "nenhuma letra encontrada"
},
"genreList": {
"showAlbums": "Mostrar $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "Mostrar $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
"showAlbums": "mostrar $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "mostrar $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
"title": "$t(entity.genre, {\"count\": 2})"
},
"globalSearch": {
"commands": {
"goToPage": "Ir à página",
"searchFor": "Procurar {{query}}",
"serverCommands": "Comandos do servidor"
"goToPage": "ir à página",
"searchFor": "procurar {{query}}",
"serverCommands": "comandos do servidor"
},
"title": "Comandos"
"title": "comandos"
},
"home": {
"explore": "Explore a sua biblioteca",
"mostPlayed": "Mais tocado",
"newlyAdded": "Lançamentos recém-adicionados",
"recentlyPlayed": "Tocado recentemente",
"explore": "explore a sua biblioteca",
"mostPlayed": "mais tocado",
"newlyAdded": "lançamentos recém-adicionados",
"recentlyPlayed": "tocado recentemente",
"title": "$t(common.home)"
},
"itemDetail": {
"copyPath": "Copiar caminho para a área de transferência",
"copiedPath": "Caminho copiado com sucesso",
"openFile": "Mostrar faixa no gestor de ficheiros"
"copyPath": "copiar caminho para a área de transferência",
"copiedPath": "caminho copiado com sucesso",
"openFile": "mostrar faixa no gestor de ficheiros"
},
"playlist": {
"reorder": "Reordenar apenas disponível quando ordenado pelo ID"
"reorder": "reordenar apenas disponível quando ordenado pelo id"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
},
"setting": {
"advanced": "Avançado",
"generalTab": "Geral",
"hotkeysTab": "Teclas de atalho",
"playbackTab": "Reprodução",
"windowTab": "Janela"
"advanced": "avançado",
"generalTab": "geral",
"hotkeysTab": "teclas de atalho",
"playbackTab": "reprodução",
"windowTab": "janela"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
@@ -444,8 +444,8 @@
"folders": "$t(entity.folder, {\"count\": 2})",
"genres": "$t(entity.genre, {\"count\": 2})",
"home": "$t(common.home)",
"myLibrary": "A minha biblioteca",
"nowPlaying": "Agora a tocar",
"myLibrary": "a minha biblioteca",
"nowPlaying": "agora a tocar",
"playlists": "$t(entity.playlist, {\"count\": 2})",
"search": "$t(common.search)",
"settings": "$t(common.setting, {\"count\": 2})",
@@ -453,92 +453,92 @@
"tracks": "$t(entity.track, {\"count\": 2})"
},
"trackList": {
"artistTracks": "Faixas de {{artist}}",
"artistTracks": "faixas de {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})"
}
},
"player": {
"addLast": "Adicionar no final",
"addNext": "Adicionar a seguir",
"favorite": "Favorito",
"mute": "Mudo",
"muted": "Mudo",
"next": "Próximo",
"play": "Tocar",
"playbackFetchCancel": "Isto demora um pouco... feche a notificação para cancelar",
"playbackFetchInProgress": "A carregar músicas…",
"playbackFetchNoResults": "Nenhuma música encontrada",
"playbackSpeed": "Velocidade de reprodução",
"playRandom": "Tocar aleatório",
"playSimilarSongs": "Tocar músicas similares",
"previous": "Anterior",
"queue_clear": "Limpar fila",
"queue_moveToBottom": "Mover selecionados para o fim",
"queue_moveToTop": "Mover selecionados para o topo",
"queue_remove": "Remover selecionados",
"repeat": "Repetir",
"repeat_all": "Repetir tudo",
"repeat_off": "Repetição desativada",
"shuffle": "Tocar aleatório",
"shuffle_off": "Aleatório desativado",
"skip": "Pular",
"skip_back": "Retroceder",
"skip_forward": "Avançar",
"stop": "Parar",
"toggleFullscreenPlayer": "Alternar player de ecrã cheio",
"unfavorite": "Remover favorito",
"pause": "Pausar",
"viewQueue": "Ver fila"
"addLast": "adicionar no final",
"addNext": "adicionar a seguir",
"favorite": "favorito",
"mute": "mudo",
"muted": "mudo",
"next": "próximo",
"play": "tocar",
"playbackFetchCancel": "isto demora um pouco... feche a notificação para cancelar",
"playbackFetchInProgress": "a carregar músicas…",
"playbackFetchNoResults": "nenhuma música encontrada",
"playbackSpeed": "velocidade de reprodução",
"playRandom": "tocar aleatório",
"playSimilarSongs": "tocar músicas similares",
"previous": "anterior",
"queue_clear": "limpar fila",
"queue_moveToBottom": "mover selecionados para o topo",
"queue_moveToTop": "mover selecionados para o fim",
"queue_remove": "remover selecionados",
"repeat": "repetir",
"repeat_all": "repetir tudo",
"repeat_off": "repetição desativada",
"shuffle": "tocar aleatório",
"shuffle_off": "aleatório desativado",
"skip": "pular",
"skip_back": "retroceder",
"skip_forward": "avançar",
"stop": "parar",
"toggleFullscreenPlayer": "alternar player de ecrã cheio",
"unfavorite": "remover favorito",
"pause": "pausar",
"viewQueue": "ver fila"
},
"setting": {
"accentColor": "Cor de realce",
"accentColor_description": "Define a cor de realce para a aplicação",
"albumBackground": "Imagem de fundo do álbum",
"albumBackground_description": "Adiciona uma imagem de fundo contendo a arte do álbum para a página de álbum",
"albumBackgroundBlur": "Tamanho de desfoque da imagem de fundo do álbum",
"albumBackgroundBlur_description": "Ajusta a quantidade de desfoque aplicada para a imagem de fundo do álbum",
"applicationHotkeys": "Teclas de atalho da aplicação",
"applicationHotkeys_description": "Configure as teclas de atalho da aplicação. clique na caixa de seleção para definir como tecla de atalho global (somente desktop)",
"artistConfiguration": "Configuração da página de artista de álbum",
"artistConfiguration_description": "Configure quais elementos serão mostrados, e em qual ordem, na página de artista de álbum",
"audioDevice": "Dispositivo de áudio",
"audioDevice_description": "Selecione o dispositivo de áudio usado para reprodução (somente player web)",
"audioExclusiveMode": "Modo de áudio exclusivo",
"audioExclusiveMode_description": "Ativar modo de saída exclusiva. Neste modo, o sistema é geralmente bloqueado, e apenas mpv terá saída de áudio",
"audioPlayer": "Player de áudio",
"audioPlayer_description": "Selecione o player de áudio usado para reprodução",
"buttonSize": "Tamanho do botão da barra de reprodução",
"buttonSize_description": "O tamanho dos botões da barra de reprodução",
"clearCache": "Limpar cache do navegador",
"clearCache_description": "Uma 'limpeza geral' do Feishin. Em adição a limpar o cache do Feishin, limpa o cache do navegador (imagens gravadas e outros recursos). As credenciais de servidor e as configurações serão mantidas",
"clearQueryCache": "Limpar cache do Feishin",
"clearQueryCache_description": "Uma 'limpeza leve' do Feishin. Isto irá renovar playlists, metadados de faixas, e resetar letras gravadas. As configurações, as credenciais de servidor e o cache de imagens serão mantidos",
"clearCacheSuccess": "Cache limpo com sucesso",
"contextMenu": "Configuração do menu de contexto (clique do botão direito do rato)",
"contextMenu_description": "Permite esconder elementos exibidos no menu quando clica num elemento com o botão direito. elementos não selecionados serão escondidos",
"crossfadeDuration": "Duraçao de crossfade",
"crossfadeDuration_description": "Define a duração do efeito crossfade",
"crossfadeStyle_description": "Seleciona qual estilo de crossfade usado no player de áudio",
"customCssEnable": "Ativar CSS customizado",
"customCssEnable_description": "Permite escrever CSS customizado",
"customCssNotice": "Aviso: apesar de existir alguma higienização (URL() e content: não são permitidas), o uso de CSS personalizado ainda pode representar riscos ao alterar a interface",
"customCss": "Css customizado",
"disableLibraryUpdateOnStartup": "Desativar a verificação de novas versões na inicialização",
"accentColor": "cor de realce",
"accentColor_description": "define a cor de realce para a aplicação",
"albumBackground": "imagem de fundo do álbum",
"albumBackground_description": "adiciona uma imagem de fundo contendo a arte do álbum para a página de álbum",
"albumBackgroundBlur": "tamanho de desfoque da imagem de fundo do álbum",
"albumBackgroundBlur_description": "ajusta a quantidade de desfoque aplicada para a imagem de fundo do álbum",
"applicationHotkeys": "teclas de atalho da aplicação",
"applicationHotkeys_description": "configure as teclas de atalho da aplicação. clique na caixa de seleção para definir como tecla de atalho global (somente desktop)",
"artistConfiguration": "configuração da página de artista de álbum",
"artistConfiguration_description": "configure quais elementos serão mostrados, e em qual ordem, na página de artista de álbum",
"audioDevice": "dispositivo de áudio",
"audioDevice_description": "selecione o dispositivo de áudio usado para reprodução (somente player web)",
"audioExclusiveMode": "modo de áudio exclusivo",
"audioExclusiveMode_description": "ativar modo de saída exclusiva. Neste modo, o sistema é geralmente bloqueado, e apenas mpv terá saída de áudio",
"audioPlayer": "player de áudio",
"audioPlayer_description": "selecione o player de áudio usado para reprodução",
"buttonSize": "tamanho do botão da barra de reprodução",
"buttonSize_description": "o tamanho dos botões da barra de reprodução",
"clearCache": "limpar cache do navegador",
"clearCache_description": "uma 'limpeza geral' do feishin. em adição a limpar o cache do feishin, limpa o cache do navegador (imagens gravadas e outros recursos). as credenciais de servidor e as configurações serão mantidas",
"clearQueryCache": "limpar cache do feishin",
"clearQueryCache_description": "uma 'limpeza leve' do feishin. isto irá renovar playlists, metadados de faixas, e resetar letras gravadas. as configurações, as credenciais de servidor e o cache de imagens serão mantidos",
"clearCacheSuccess": "cache limpo com sucesso",
"contextMenu": "configuração do menu de contexto (clique do botão direito do rato)",
"contextMenu_description": "permite esconder elementos exibidos no menu quando clica num elemento com o botão direito. elementos não selecionados serão escondidos",
"crossfadeDuration": "duraçao de crossfade",
"crossfadeDuration_description": "define a duração do efeito crossfade",
"crossfadeStyle_description": "seleciona qual estilo de crossfade usado no player de áudio",
"customCssEnable": "ativar css customizado",
"customCssEnable_description": "permite escrever css customizado",
"customCssNotice": "Aviso: apesar de existir alguma higienização (url() e content: não são permitidas), o uso de css personalizado ainda pode representar riscos ao alterar a interface",
"customCss": "css customizado",
"disableLibraryUpdateOnStartup": "desativar a verificação de novas versões na inicialização",
"discordApplicationId": "{{discord}} ID da aplicação",
"discordIdleStatus_description": "Quando ativado, atualiza o estado enquanto o player está ocioso",
"discordUpdateInterval_description": "O tempo em segundos entre cada atualização (mínimo 15 segundos)",
"playButtonBehavior_description": "Define o comportamento padrão do botão play ao adicionar músicas à fila"
"discordIdleStatus_description": "quando ativado, atualiza o estado enquanto o player está ocioso",
"discordUpdateInterval_description": "o tempo em segundos entre cada atualização (mínimo 15 segundos)",
"playButtonBehavior_description": "define o comportamento padrão do botão play ao adicionar músicas à fila"
},
"table": {
"column": {
"discNumber": "Disco",
"discNumber": "disco",
"size": "$t(common.size)",
"title": "Titulo"
"title": "titulo"
},
"config": {
"label": {
"discNumber": "Numero do disco",
"discNumber": "numero do disco",
"titleCombined": "$t(common.title) (combinado)"
}
}
+13 -13
View File
@@ -1,19 +1,19 @@
{
"common": {
"confirm": "Confirmă",
"create": "Creează",
"biography": "Biografie",
"areYouSure": "Ești sigur?",
"no": "Nu",
"name": "Nume",
"ok": "Ok",
"note": "Notă",
"yes": "Da",
"explicit": "Explicit",
"year": "An",
"menu": "Meniu"
"confirm": "confirmă",
"create": "creează",
"biography": "biografie",
"areYouSure": "ești sigur?",
"no": "nu",
"name": "nume",
"ok": "ok",
"note": "notă",
"yes": "da",
"explicit": "explicit",
"year": "an",
"menu": "meniu"
},
"filter": {
"biography": "Biografie"
"biography": "biografie"
}
}
+774 -781
View File
File diff suppressed because it is too large Load Diff
+579 -579
View File
File diff suppressed because it is too large Load Diff
+438 -438
View File
File diff suppressed because it is too large Load Diff
+421 -421
View File
File diff suppressed because it is too large Load Diff
+341 -341
View File
@@ -1,323 +1,323 @@
{
"action": {
"editPlaylist": "Redigera $t(entity.playlist, {\"count\": 1})",
"goToPage": "Gå till sida",
"moveToTop": "Flytta till toppen",
"clearQueue": "Rensa kö",
"addToFavorites": "Lägg till $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "Lägg till $t(entity.playlist, {\"count\": 1})",
"createPlaylist": "Skapa $t(entity.playlist, {\"count\": 1})",
"removeFromPlaylist": "Ta bort från $t(entity.playlist, {\"count\": 1})",
"viewPlaylists": "Visa $t(entity.playlist, {\"count\": 2})",
"editPlaylist": "redigera $t(entity.playlist, {\"count\": 1})",
"goToPage": "gå till sida",
"moveToTop": "flytta till toppen",
"clearQueue": "rensa kö",
"addToFavorites": "lägg till $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "lägg till $t(entity.playlist, {\"count\": 1})",
"createPlaylist": "skapa $t(entity.playlist, {\"count\": 1})",
"removeFromPlaylist": "ta bort från $t(entity.playlist, {\"count\": 1})",
"viewPlaylists": "visa $t(entity.playlist, {\"count\": 2})",
"refresh": "$t(common.refresh)",
"deletePlaylist": "Ta bort $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "Ta bort från kö",
"deselectAll": "Avmarkera alla",
"moveToBottom": "Flytta till botten",
"setRating": "Sätt betyg",
"toggleSmartPlaylistEditor": "Växla $t(entity.smartPlaylist) redigerare",
"removeFromFavorites": "Ta bort från $t(entity.favorite, {\"count\": 2})",
"downloadStarted": "Startade nedladdning av {{count}} objekt",
"moveToNext": "Flytta till nästa",
"moveUp": "Flytta upp",
"moveDown": "Flytta ner",
"holdToMoveToTop": "Håll för att flytta till toppen",
"holdToMoveToBottom": "Håll för att flytta till botten",
"moveItems": "Flytta objekt",
"shuffle": "Slumpa",
"shuffleAll": "Slumpa alla",
"shuffleSelected": "Slumpa valda",
"viewMore": "Visa mer",
"deletePlaylist": "ta bort $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "ta bort från kö",
"deselectAll": "avmarkera alla",
"moveToBottom": "flytta till botten",
"setRating": "sätt betyg",
"toggleSmartPlaylistEditor": "växla $t(entity.smartPlaylist) redigerare",
"removeFromFavorites": "ta bort från $t(entity.favorite, {\"count\": 2})",
"downloadStarted": "startade nedladdning av {{count}} objekt",
"moveToNext": "flytta till nästa",
"moveUp": "flytta upp",
"moveDown": "flytta ner",
"holdToMoveToTop": "håll för att flytta till toppen",
"holdToMoveToBottom": "håll för att flytta till botten",
"moveItems": "flytta objekt",
"shuffle": "slumpa",
"shuffleAll": "slumpa alla",
"shuffleSelected": "slumpa valda",
"viewMore": "visa mer",
"openIn": {
"lastfm": "Öppna i Last.fm",
"musicbrainz": "Öppna i MusicBrainz"
},
"createRadioStation": "Skapa $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "Ta bort $t(entity.radioStation, {\"count\": 1})",
"addOrRemoveFromSelection": "Lägg till eller ta bort från markerade",
"selectRangeOfItems": "Välj en mängd objekt",
"selectAll": "Markera alla",
"openApplicationDirectory": "Öppna applikationskatalog"
"createRadioStation": "skapa $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "ta bort $t(entity.radioStation, {\"count\": 1})",
"addOrRemoveFromSelection": "lägg till eller ta bort från markerade",
"selectRangeOfItems": "välj en mängd objekt",
"selectAll": "markera alla",
"openApplicationDirectory": "öppna applikationskatalog"
},
"common": {
"backward": "Bakåt",
"increase": "Öka",
"rating": "Betyg",
"bpm": "Bpm",
"refresh": "Laddaom",
"unknown": "Okänd",
"areYouSure": "Är du säker?",
"edit": "Redigera",
"favorite": "Favorit",
"left": "Vänster",
"save": "Spara",
"right": "Höger",
"currentSong": "Aktuell $t(entity.track, {\"count\": 1})",
"collapse": "Kollaps",
"trackNumber": "Spår",
"descending": "Fallande",
"add": "Lägg till",
"gap": "Avstånd",
"ascending": "Stigande",
"dismiss": "Avskeda",
"year": "År",
"manage": "Hantera",
"limit": "Gräns",
"minimize": "Minimera",
"modified": "Modifierad",
"duration": "Längd",
"name": "Namn",
"maximize": "Maximera",
"decrease": "Minska",
"ok": "Ok",
"description": "Beskrivning",
"configure": "Konfigurera",
"path": "Sökväg",
"no": "Nej",
"owner": "Ägare",
"enable": "Aktivera",
"clear": "Töm",
"forward": "Framåt",
"delete": "Ta bort",
"cancel": "Avbryt",
"forceRestartRequired": "Starta om för att tillämpa ändringar... Stäng meddelandet för att starta om",
"setting_one": "Inställning",
"backward": "bakåt",
"increase": "öka",
"rating": "betyg",
"bpm": "bpm",
"refresh": "laddaom",
"unknown": "okänd",
"areYouSure": "är du säker?",
"edit": "redigera",
"favorite": "favorit",
"left": "vänster",
"save": "spara",
"right": "höger",
"currentSong": "aktuell $t(entity.track, {\"count\": 1})",
"collapse": "kollaps",
"trackNumber": "spår",
"descending": "fallande",
"add": "lägg till",
"gap": "avstånd",
"ascending": "stigande",
"dismiss": "avskeda",
"year": "år",
"manage": "hantera",
"limit": "gräns",
"minimize": "minimera",
"modified": "modifierad",
"duration": "längd",
"name": "namn",
"maximize": "maximera",
"decrease": "minska",
"ok": "ok",
"description": "beskrivning",
"configure": "konfigurera",
"path": "sökväg",
"no": "nej",
"owner": "ägare",
"enable": "aktivera",
"clear": "töm",
"forward": "framåt",
"delete": "ta bort",
"cancel": "avbryt",
"forceRestartRequired": "starta om för att tillämpa ändringar... Stäng meddelandet för att starta om",
"setting_one": "inställning",
"setting_other": "",
"version": "Version",
"title": "Titel",
"filter_one": "Filter",
"filter_other": "Filter",
"filters": "Filter",
"create": "Skapa",
"bitrate": "Bithastighet",
"saveAndReplace": "Spara och skrivöver",
"action_one": "Handling",
"action_other": "Handlingar",
"playerMustBePaused": "Spelaren måste pausas",
"confirm": "Bekräfta",
"resetToDefault": "Återställ till standard",
"home": "Hem",
"comingSoon": "Kommer snart…",
"reset": "Nollställ",
"channel_one": "Kanal",
"channel_other": "Kanaler",
"disable": "Inaktivera",
"sortOrder": "Ordning",
"none": "Ingen",
"menu": "Meny",
"restartRequired": "Omstart krävs",
"previousSong": "Föregående $t(entity.track, {\"count\": 1})",
"noResultsFromQuery": "Frågan returnerade inga resultat",
"quit": "Avsluta",
"expand": "Expandera",
"search": "Sök",
"saveAs": "Spara som",
"disc": "Skiva",
"yes": "Ja",
"random": "Slumpmässig",
"size": "Storlek",
"biography": "Biografi",
"note": "Anteckning",
"center": "Center",
"explicitStatus": "Olämplig status",
"additionalParticipants": "Ytterligare medverkare",
"newVersion": "En ny version har installerats {{version}}",
"viewReleaseNotes": "Se utgåveinformation",
"bitDepth": "Bitdjup",
"close": "Stäng",
"codec": "Kodek",
"doNotShowAgain": "Visa inte detta igen",
"view": "Visa",
"externalLinks": "Externa länkar",
"faster": "Snabbare",
"version": "version",
"title": "titel",
"filter_one": "filter",
"filter_other": "filter",
"filters": "filter",
"create": "skapa",
"bitrate": "bithastighet",
"saveAndReplace": "spara och skrivöver",
"action_one": "handling",
"action_other": "handlingar",
"playerMustBePaused": "spelaren måste pausas",
"confirm": "bekräfta",
"resetToDefault": "återställ till standard",
"home": "hem",
"comingSoon": "kommer snart…",
"reset": "nollställ",
"channel_one": "kanal",
"channel_other": "kanaler",
"disable": "inaktivera",
"sortOrder": "ordning",
"none": "ingen",
"menu": "meny",
"restartRequired": "omstart krävs",
"previousSong": "föregående $t(entity.track, {\"count\": 1})",
"noResultsFromQuery": "frågan returnerade inga resultat",
"quit": "avsluta",
"expand": "expandera",
"search": "sök",
"saveAs": "spara som",
"disc": "skiva",
"yes": "ja",
"random": "slumpmässig",
"size": "storlek",
"biography": "biografi",
"note": "anteckning",
"center": "center",
"explicitStatus": "olämplig status",
"additionalParticipants": "ytterligare medverkare",
"newVersion": "en ny version har installerats {{version}}",
"viewReleaseNotes": "se utgåveinformation",
"bitDepth": "bitdjup",
"close": "stäng",
"codec": "kodek",
"doNotShowAgain": "visa inte detta igen",
"view": "visa",
"externalLinks": "externa länkar",
"faster": "snabbare",
"mbid": "MusicBrainz ID",
"noFilters": "Inga filter konfigurerade",
"preview": "Förhandsvisa",
"private": "Privat",
"public": "Allmän",
"recordLabel": "Skivbolag",
"releaseType": "Utgåvetyp",
"reload": "Ladda om",
"sampleRate": "Samplingstakt",
"slower": "Långsammare",
"share": "Dela",
"sort": "Sortera",
"tags": "Taggar",
"translation": "Översättning",
"explicit": "Olämplig",
"clean": "Städad",
"gridRows": "Rutnätsrader",
"tableColumns": "Tabellkolumner",
"noFilters": "inga filter konfigurerade",
"preview": "förhandsvisa",
"private": "privat",
"public": "allmän",
"recordLabel": "skivbolag",
"releaseType": "utgåvetyp",
"reload": "ladda om",
"sampleRate": "samplingstakt",
"slower": "långsammare",
"share": "dela",
"sort": "sortera",
"tags": "taggar",
"translation": "översättning",
"explicit": "olämplig",
"clean": "städad",
"gridRows": "rutnätsrader",
"tableColumns": "tabellkolumner",
"itemsMore": "{{count}} fler",
"countSelected": "{{count}} markerade"
},
"error": {
"remotePortWarning": "Starta om servern för att tillämpa den nya porten",
"systemFontError": "Ett fel uppstod vid försök att hämta systemteckensnitt",
"playbackError": "Ett fel uppstod vid försök att spela upp media",
"endpointNotImplementedError": "Endpoint {{endpoint}} är inte implementerad för {{serverType}}",
"remotePortError": "Ett fel uppstod vid försök att ange serverporten",
"serverRequired": "Server krävs",
"authenticationFailed": "Autentiseringen misslyckades",
"apiRouteError": "Det går inte att dirigera begäran",
"genericError": "Ett fel uppstod",
"credentialsRequired": "Autentiseringsuppgifter som krävs",
"sessionExpiredError": "Din session har löpt ut",
"remotePortWarning": "starta om servern för att tillämpa den nya porten",
"systemFontError": "ett fel uppstod vid försök att hämta systemteckensnitt",
"playbackError": "ett fel uppstod vid försök att spela upp media",
"endpointNotImplementedError": "endpoint {{endpoint}} är inte implementerad för {{serverType}}",
"remotePortError": "ett fel uppstod vid försök att ange serverporten",
"serverRequired": "server krävs",
"authenticationFailed": "autentiseringen misslyckades",
"apiRouteError": "det går inte att dirigera begäran",
"genericError": "ett fel uppstod",
"credentialsRequired": "autentiseringsuppgifter som krävs",
"sessionExpiredError": "din session har löpt ut",
"remoteEnableError": "Ett fel uppstod vid försök att $t(common.enable) servern",
"localFontAccessDenied": "Åtkomst nekad till lokala teckensnitt",
"serverNotSelectedError": "Ingen server vald",
"remoteDisableError": "Ett fel uppstod vid försök av $t(common.disable) servern",
"localFontAccessDenied": "åtkomst nekad till lokala teckensnitt",
"serverNotSelectedError": "ingen server vald",
"remoteDisableError": "ett fel uppstod vid försök av $t(common.disable) servern",
"mpvRequired": "MPV krävs",
"audioDeviceFetchError": "Ett fel uppstod vid hämtning av ljudenheter",
"invalidServer": "Ogiltig server",
"loginRateError": "För många inloggningsförsök, försök igen om några sekunder",
"badAlbum": "Du ser denna sidan eftersom denna låten inte är en del av ett album. du ser troligtvis detta problemet för att du har en låt på toppnivån i din musikmapp. Jellyfin grupperar bara låtar om de finns i en mapp",
"badValue": "Felaktigt alternativ \"{{value}}\". detta värde existerar inte längre",
"multipleServerSaveQueueError": "Spelningskön har en eller flera låtar som inte är från den nuvarande valda servern. detta är inte stöttat",
"networkError": "En nätverksfel uppstod",
"notificationDenied": "Åtkomst till notifieringarna var nekad. inställningen har ingen verkan",
"openError": "Kunde inte öppna filen",
"settingsSyncError": "Diskrepans hittades mellan inställningarna för renderingsprocessen och huvudprocessen. starta om applikationen för att ändringarna ska tillämpas"
"audioDeviceFetchError": "ett fel uppstod vid hämtning av ljudenheter",
"invalidServer": "ogiltig server",
"loginRateError": "för många inloggningsförsök, försök igen om några sekunder",
"badAlbum": "du ser denna sidan eftersom denna låten inte är en del av ett album. du ser troligtvis detta problemet för att du har en låt på toppnivån i din musikmapp. Jellyfin grupperar bara låtar om de finns i en mapp",
"badValue": "felaktigt alternativ \"{{value}}\". detta värde existerar inte längre",
"multipleServerSaveQueueError": "spelningskön har en eller flera låtar som inte är från den nuvarande valda servern. detta är inte stöttat",
"networkError": "en nätverksfel uppstod",
"notificationDenied": "åtkomst till notifieringarna var nekad. inställningen har ingen verkan",
"openError": "kunde inte öppna filen",
"settingsSyncError": "diskrepans hittades mellan inställningarna för renderingsprocessen och huvudprocessen. starta om applikationen för att ändringarna ska tillämpas"
},
"filter": {
"mostPlayed": "Mest spelade",
"comment": "Kommentar",
"playCount": "Antal spelningar",
"recentlyUpdated": "Nyligen uppdaterad",
"mostPlayed": "mest spelade",
"comment": "kommentar",
"playCount": "antal spelningar",
"recentlyUpdated": "nyligen uppdaterad",
"channels": "$t(common.channel_other)",
"isCompilation": "Är kompilering",
"recentlyPlayed": "Nyligen spelad",
"isRated": "Är betygsatt",
"isCompilation": "är kompilering",
"recentlyPlayed": "nyligen spelad",
"isRated": "är betygsatt",
"owner": "$t(common.owner)",
"title": "Titel",
"rating": "Betyg",
"search": "Sök",
"bitrate": "Bithastighet",
"title": "titel",
"rating": "betyg",
"search": "sök",
"bitrate": "bithastighet",
"genre": "$t(entity.genre, {\"count\": 1})",
"recentlyAdded": "Nyligen tillagda",
"note": "Anteckning",
"name": "Namn",
"dateAdded": "Datum tillagt",
"releaseDate": "Utgivningsdag",
"communityRating": "Betyg från communityn",
"path": "Sökväg",
"favorited": "Favoritmärkt",
"recentlyAdded": "nyligen tillagda",
"note": "anteckning",
"name": "namn",
"dateAdded": "datum tillagt",
"releaseDate": "utgivningsdag",
"communityRating": "betyg från communityn",
"path": "sökväg",
"favorited": "favoritmärkt",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"isRecentlyPlayed": "Spelas nyligen",
"isFavorited": "Är favoritmärkt",
"bpm": "Bpm",
"releaseYear": "Utgivningsår",
"id": "Id",
"disc": "Skiva",
"biography": "Biografi",
"isRecentlyPlayed": "spelas nyligen",
"isFavorited": "är favoritmärkt",
"bpm": "bpm",
"releaseYear": "utgivningsår",
"id": "id",
"disc": "skiva",
"biography": "biografi",
"artist": "$t(entity.artist, {\"count\": 1})",
"duration": "Längd",
"isPublic": "Är offentlig",
"random": "Slumpmässig",
"lastPlayed": "Senast spelad",
"toYear": "Till år",
"fromYear": "Från år",
"duration": "längd",
"isPublic": "är offentlig",
"random": "slumpmässig",
"lastPlayed": "senast spelad",
"toYear": "till år",
"fromYear": "från år",
"album": "$t(entity.album, {\"count\": 1})",
"trackNumber": "Spår",
"songCount": "Sångräkning",
"criticRating": "Kritikerbetyg",
"trackNumber": "spår",
"songCount": "sångräkning",
"criticRating": "kritikerbetyg",
"albumCount": "$t(entity.album, {\"count\": 2}) antal",
"explicitStatus": "$t(common.explicitStatus)"
},
"form": {
"deletePlaylist": {
"title": "Ta bort $t(entity.playlist, {\"count\": 1})",
"title": "ta bort $t(entity.playlist, {\"count\": 1})",
"success": "$t(entity.playlist, {\"count\": 1}) har tagits bort",
"input_confirm": "Skriv namnet på $t(entity.playlist, {\"count\": 1}) för att bekräfta"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"title": "Skapa $t(entity.playlist, {\"count\": 1})",
"input_public": "Offentlig",
"title": "skapa $t(entity.playlist, {\"count\": 1})",
"input_public": "offentlig",
"input_name": "$t(common.name)",
"success": "$t(entity.playlist, {\"count\": 1}) skapad",
"input_owner": "$t(common.owner)"
},
"addServer": {
"title": "Lägg till server",
"input_username": "Användarnamn",
"input_url": "Länk",
"input_password": "Lösenord",
"input_legacyAuthentication": "Aktivera äldre autentisering",
"input_name": "Server namn",
"success": "Servern har lagts till",
"input_savePassword": "Spara lösenord",
"ignoreSsl": "Ignorera ssl ($t(common.restartRequired))",
"ignoreCors": "Ignorera cors ($t(common.restartRequired))",
"error_savePassword": "Ett fel uppstod när lösenordet skulle sparas",
"input_preferInstantMix": "Föredra instant mixning",
"input_preferInstantMixDescription": "Använd bara instant mixning för att få liknande låtar. användbar om du har plugin för att förändra detta beteendet"
"title": "lägg till server",
"input_username": "användarnamn",
"input_url": "länk",
"input_password": "lösenord",
"input_legacyAuthentication": "aktivera äldre autentisering",
"input_name": "server namn",
"success": "servern har lagts till",
"input_savePassword": "spara lösenord",
"ignoreSsl": "ignorera ssl ($t(common.restartRequired))",
"ignoreCors": "ignorera cors ($t(common.restartRequired))",
"error_savePassword": "ett fel uppstod när lösenordet skulle sparas",
"input_preferInstantMix": "föredra instant mixning",
"input_preferInstantMixDescription": "använd bara instant mixning för att få liknande låtar. användbar om du har plugin för att förändra detta beteendet"
},
"addToPlaylist": {
"success": "Lade till $t(entity.trackWithCount, {\"count\": {{message}} }) till $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "Lägg till i $t(entity.playlist, {\"count\": 1})",
"input_skipDuplicates": "Hoppa över dubbletter",
"success": "lade till $t(entity.trackWithCount, {\"count\": {{message}} }) till $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "lägg till i $t(entity.playlist, {\"count\": 1})",
"input_skipDuplicates": "hoppa över dubbletter",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "Skapa $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "Sök $t(entity.playlist, {\"count\": 2}) eller skriv för att skapa en ny"
"create": "skapa $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "sök $t(entity.playlist, {\"count\": 2}) eller skriv för att skapa en ny"
},
"updateServer": {
"title": "Uppdatera server",
"success": "Servern har uppdaterats"
"title": "uppdatera server",
"success": "servern har uppdaterats"
},
"queryEditor": {
"input_optionMatchAll": "Matcha alla",
"input_optionMatchAny": "Matcha något"
"input_optionMatchAll": "matcha alla",
"input_optionMatchAny": "matcha något"
},
"lyricSearch": {
"input_name": "$t(common.name)",
"input_artist": "$t(entity.artist, {\"count\": 1})",
"title": "Sångtext sök"
"title": "sångtext sök"
},
"editPlaylist": {
"title": "Redigera $t(entity.playlist, {\"count\": 1})",
"title": "redigera $t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "Jellyfin visar av någon anledning inte om en spellista är publik eller inte. Om du önskar att denna ska förbli publik, så får du ha följande indata markerade"
},
"largeFetchConfirmation": {
"title": "Lägg till objekt till kön",
"title": "lägg till objekt till kön",
"description": "Åtgärden kommer att lägga till alla objekt till den nuvarande filtrerade vyn"
},
"createRadioStation": {
"success": "Radiostation skapades",
"title": "Skapa radiostation",
"input_homepageUrl": "Hemside-URL",
"input_name": "Namn",
"input_streamUrl": "Stream url"
"success": "radiostation skapades",
"title": "skapa radiostation",
"input_homepageUrl": "hemside-URL",
"input_name": "namn",
"input_streamUrl": "stream url"
}
},
"page": {
"fullscreenPlayer": {
"config": {
"showLyricMatch": "Visa låttext matchning",
"dynamicBackground": "Dynamisk bakgrund",
"followCurrentLyric": "Följ aktuell låttext",
"opacity": "Ogenomskinlighet",
"lyricSize": "Låttext storlek",
"lyricAlignment": "Låttext justering",
"lyricGap": "Låttext mellanrum",
"synchronized": "Synkroniserad",
"showLyricProvider": "Visa sångtextleverantör",
"unsynchronized": "Osynkroniserad"
"dynamicBackground": "dynamisk bakgrund",
"followCurrentLyric": "följ aktuell låttext",
"opacity": "ogenomskinlighet",
"lyricSize": "låttext storlek",
"lyricAlignment": "låttext justering",
"lyricGap": "låttext mellanrum",
"synchronized": "synkroniserad",
"showLyricProvider": "visa sångtextleverantör",
"unsynchronized": "osynkroniserad"
},
"lyrics": "Sångtext",
"related": "Relaterad"
"lyrics": "sångtext",
"related": "relaterad"
},
"appMenu": {
"selectServer": "Välj server",
"version": "Version {{version}}",
"selectServer": "välj server",
"version": "version {{version}}",
"settings": "$t(common.setting, {\"count\": 2})",
"manageServers": "Hantera servrar",
"expandSidebar": "Expandera sidofältet",
"openBrowserDevtools": "Öppna webbläsarens utvecklingsverktyg",
"manageServers": "hantera servrar",
"expandSidebar": "expandera sidofältet",
"openBrowserDevtools": "öppna webbläsarens utvecklingsverktyg",
"quit": "$t(common.quit)",
"goBack": "Gå tillbaka",
"goForward": "Gå framåt",
"collapseSidebar": "Växla sidofältet"
"goBack": "gå tillbaka",
"goForward": "gå framåt",
"collapseSidebar": "växla sidofältet"
},
"contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)",
@@ -336,20 +336,20 @@
"play": "$t(player.play)",
"numberSelected": "{{count}} vald",
"removeFromQueue": "$t(action.removeFromQueue)",
"download": "Ladda ner",
"download": "ladda ner",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "Dela objekt",
"goTo": "Gå till",
"goToAlbum": "Gå till $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "Gå till $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "Hämta information"
"shareItem": "dela objekt",
"goTo": "gå till",
"goToAlbum": "gå till $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "gå till $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "hämta information"
},
"albumDetail": {
"moreFromArtist": "Mer från $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "Mer från {{item}}"
"moreFromArtist": "mer från $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "mer från {{item}}"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
@@ -358,124 +358,124 @@
"title": "$t(entity.album, {\"count\": 2})"
},
"sidebar": {
"nowPlaying": "Nu spelas"
"nowPlaying": "nu spelas"
},
"home": {
"mostPlayed": "Mest spelade",
"newlyAdded": "Nytillkomna utgåvor",
"explore": "Utforska från ditt bibliotek",
"recentlyPlayed": "Nyligen spelat"
"mostPlayed": "mest spelade",
"newlyAdded": "nytillkomna utgåvor",
"explore": "utforska från ditt bibliotek",
"recentlyPlayed": "nyligen spelat"
},
"setting": {
"playbackTab": "Uppspelning",
"generalTab": "Allmänt",
"hotkeysTab": "Snabbtangenter",
"windowTab": "Fönster"
"playbackTab": "uppspelning",
"generalTab": "allmänt",
"hotkeysTab": "snabbtangenter",
"windowTab": "fönster"
},
"globalSearch": {
"commands": {
"serverCommands": "Serverkommandon",
"goToPage": "Gå till sidan",
"searchFor": "Sök efter {{query}}"
"serverCommands": "serverkommandon",
"goToPage": "gå till sidan",
"searchFor": "sök efter {{query}}"
},
"title": "Kommandon"
"title": "kommandon"
},
"manageServers": {
"url": "URL",
"username": "Användarnamn",
"editServerDetailsTooltip": "Redigera serverinställningar",
"removeServer": "Ta bort server"
"username": "användarnamn",
"editServerDetailsTooltip": "redigera serverinställningar",
"removeServer": "ta bort server"
}
},
"entity": {
"playlist_one": "Spellista",
"playlist_other": "Spellistor",
"artist_one": "Artist",
"artist_other": "Artister",
"albumArtist_one": "Albumartist",
"albumArtist_other": "Albumartister",
"albumArtistCount_one": "{{count}} albumartist",
"albumArtistCount_other": "{{count}} albumartister",
"playlist_one": "spellista",
"playlist_other": "spellistor",
"artist_one": "artist",
"artist_other": "artister",
"albumArtist_one": "albumartist",
"albumArtist_other": "albumartister",
"albumArtistCount_one": "{{count}} Albumartist",
"albumArtistCount_other": "{{count}} Albumartister",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} album",
"favorite_one": "Favorit",
"favorite_other": "Favoriter",
"folder_one": "Mapp",
"folder_other": "Mappar",
"album_one": "Album",
"album_other": "Album",
"favorite_one": "favorit",
"favorite_other": "favoriter",
"folder_one": "mapp",
"folder_other": "mappar",
"album_one": "album",
"album_other": "album",
"playlistWithCount_one": "{{count}} spellista",
"playlistWithCount_other": "{{count}} spellistor",
"folderWithCount_one": "{{count}} mapp",
"folderWithCount_other": "{{count}} mappar",
"track_one": "Spår",
"track_other": "Spår",
"track_one": "spår",
"track_other": "spår",
"trackWithCount_one": "{{count}} spår",
"trackWithCount_other": "{{count}} spår",
"artistWithCount_one": "{{count}} artist",
"artistWithCount_other": "{{count}} artister",
"genre_one": "Genre",
"genre_other": "Genrer",
"genre_one": "genre",
"genre_other": "genrer",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genrer",
"play_one": "{{count}} spelning",
"play_other": "{{count}} spelningar",
"smartPlaylist": "Smart $t(entity.playlist, {\"count\": 1})",
"song_one": "Låt",
"song_other": "Låtar",
"radioStation_one": "Radiostation",
"radioStation_other": "Radiostationer",
"smartPlaylist": "smart $t(entity.playlist, {\"count\": 1})",
"song_one": "låt",
"song_other": "låtar",
"radioStation_one": "radiostation",
"radioStation_other": "radiostationer",
"radioStationWithCount_one": "{{count}} radiostation",
"radioStationWithCount_other": "{{count}} radiostationer"
},
"player": {
"repeat_all": "Repetera alla",
"repeat": "Repetera",
"queue_remove": "Ta bort markerad",
"playRandom": "Spela slumpmässigt",
"previous": "Föregående",
"favorite": "Favorit",
"next": "Nästa",
"shuffle": "Blanda",
"playbackFetchNoResults": "Inga låtar hittades",
"playbackFetchInProgress": "Laddar låtar…",
"addNext": "Lägg till nästa",
"playbackSpeed": "Uppspelningshastighet",
"playbackFetchCancel": "Det här tar ett tag... stäng aviseringen för att avbryta",
"play": "Spela",
"repeat_off": "Repetera inaktiverad",
"queue_clear": "Rensa kö",
"muted": "Mutad",
"queue_moveToTop": "Flytta markerad till toppen",
"queue_moveToBottom": "Flytta markerad till botten",
"addLast": "Lägg till sist",
"mute": "Muta"
"repeat_all": "repetera alla",
"repeat": "repetera",
"queue_remove": "ta bort markerad",
"playRandom": "spela slumpmässigt",
"previous": "föregående",
"favorite": "favorit",
"next": "nästa",
"shuffle": "blanda",
"playbackFetchNoResults": "inga låtar hittades",
"playbackFetchInProgress": "laddar låtar…",
"addNext": "lägg till nästa",
"playbackSpeed": "uppspelningshastighet",
"playbackFetchCancel": "det här tar ett tag... stäng aviseringen för att avbryta",
"play": "spela",
"repeat_off": "repetera inaktiverad",
"queue_clear": "rensa kö",
"muted": "mutad",
"queue_moveToTop": "flytta markerad till botten",
"queue_moveToBottom": "flytta markerad till toppen",
"addLast": "lägg till sist",
"mute": "muta"
},
"datetime": {
"minuteShort": "Min",
"secondShort": "Sek",
"hourShort": "H",
"dayShort": "Dag"
"minuteShort": "min",
"secondShort": "sek",
"hourShort": "h",
"dayShort": "dag"
},
"filterOperator": {
"after": "Är efter",
"afterDate": "Är efter (datum)",
"before": "Är före",
"beforeDate": "Är före (datum)",
"contains": "Innehåller",
"endsWith": "Slutar med",
"inPlaylist": "Är inom",
"inTheLast": "Är i den sista",
"inTheRange": "Är i spannet",
"inTheRangeDate": "Är i spannet (datum)",
"is": "Är",
"isNot": "Är inte",
"isGreaterThan": "Är större än",
"isLessThan": "Är mindre än",
"matchesRegex": "Matchar regex",
"notContains": "Innehåller inte",
"notInPlaylist": "Är inte inom",
"notInTheLast": "Är inte inom den sista",
"startsWith": "Startar med"
"after": "är efter",
"afterDate": "är efter (datum)",
"before": "är före",
"beforeDate": "är före (datum)",
"contains": "innehåller",
"endsWith": "slutar med",
"inPlaylist": "är inom",
"inTheLast": "är i den sista",
"inTheRange": "är i spannet",
"inTheRangeDate": "är i spannet (datum)",
"is": "är",
"isNot": "är inte",
"isGreaterThan": "är större än",
"isLessThan": "är mindre än",
"matchesRegex": "matchar regex",
"notContains": "innehåller inte",
"notInPlaylist": "är inte inom",
"notInTheLast": "är inte inom den sista",
"startsWith": "startar med"
}
}
+8 -8
View File
@@ -627,8 +627,8 @@
"playbackFetchNoResults": "பாடல்கள் எதுவும் கிடைக்கவில்லை",
"playbackSpeed": "பிளேபேக் விரைவு",
"playRandom": "சீரற்ற முறையில் விளையாடுங்கள்",
"queue_moveToBottom": "தேர்ந்தெடுக்கப்பட்டதை கீழே நகர்த்தவும்",
"queue_moveToTop": "மேலே தேர்ந்தெடுக்கப்பட்ட நகர்த்த",
"queue_moveToBottom": "மேலே தேர்ந்தெடுக்கப்பட்ட நகர்த்த",
"queue_moveToTop": "தேர்ந்தெடுக்கப்பட்டதை கீழே நகர்த்தவும்",
"skip_back": "பின்னோக்கி தவிர்க்கவும்",
"skip_forward": "முன்னோக்கி தவிர்க்கவும்",
"stop": "நிறுத்து",
@@ -800,7 +800,7 @@
"enableRemote": "ரிமோட் கண்ட்ரோல் சேவையகத்தை இயக்கவும்",
"enableRemote_description": "பயன்பாட்டைக் கட்டுப்படுத்த மற்ற சாதனங்களை அனுமதிக்க ரிமோட் கண்ட்ரோல் சேவையகத்தை இயக்குகிறது",
"externalLinks": "வெளிப்புற இணைப்புகளைக் காட்டு",
"externalLinks_description": "கலைஞர்/ஆல்பம் பக்கங்களில் வெளிப்புற இணைப்புகளை (Last.fm, மியூசிக் ப்ரெய்ன்ச்) காண்பிக்க உதவுகிறது",
"externalLinks_description": "கலைஞர்/ஆல்பம் பக்கங்களில் வெளிப்புற இணைப்புகளை (last.fm, மியூசிக் ப்ரெய்ன்ச்) காண்பிக்க உதவுகிறது",
"exitToTray": "தட்டில் வெளியேறவும்",
"globalMediaHotkeys": "உலகளாவிய மீடியா ஆட்கீச்",
"discordUpdateInterval": "{{discord}} பணக்கார இருப்பு புதுப்பிப்பு இடைவெளி",
@@ -872,7 +872,7 @@
"discordServeImage_description": "சேவையகத்திலிருந்தே {{discord}} சிறந்த இருப்புக்கான கவர் ஆர்ட்டைப் பகிரவும், செல்லிஃபின் மற்றும் நவிட்ரோமுக்கு மட்டுமே கிடைக்கும். படங்களைப் பெற {{discord}} ஒரு போட்டைப் பயன்படுத்துகிறது, எனவே உங்கள் சர்வர் பொது இணையத்திலிருந்து அணுகக்கூடியதாக இருக்க வேண்டும்",
"preferLocalLyrics": "உள்ளக பாடல்களை விரும்புங்கள்",
"preferLocalLyrics_description": "கிடைக்கும்போது தொலைநிலை பாடல்களை விட உள்ளக பாடல்களை விரும்புங்கள்",
"lastfm": "Last.fm இணைப்புகளைக் காட்டு",
"lastfm": "last.fm இணைப்புகளைக் காட்டு",
"lastfm_description": "கலைஞர்/ஆல்பம் பக்கங்களில் Last.fm க்கான இணைப்புகளைக் காட்டு",
"musicbrainz": "மியூசிக் பிரேன்ச் இணைப்புகளைக் காட்டு",
"musicbrainz_description": "கலைஞர்/ஆல்பம் பக்கங்களில் மியூசிக் பிரைன்ச் இணைப்புகளைக் காட்டு, அங்கு மியூசிக் பிரைன்ச் ID உள்ளது",
@@ -925,7 +925,7 @@
"exportImportSettings_control_title": "இறக்குமதி / ஏற்றுமதி அமைப்புகள்",
"exportImportSettings_destructiveWarning": "அமைப்புகளை இறக்குமதி செய்வது அழிவுகரமானது, கீழே உள்ள \"இறக்குமதி\" என்பதைக் சொடுக்கு செய்வதற்கு முன் மேலே உள்ளவற்றை மதிப்பாய்வு செய்யவும்!",
"exportImportSettings_importBtn": "இறக்குமதி அமைப்புகள்",
"exportImportSettings_importModalTitle": "Feishin அமைப்புகளை இறக்குமதி செய்யவும்",
"exportImportSettings_importModalTitle": "feishin அமைப்புகளை இறக்குமதி செய்யவும்",
"exportImportSettings_importSuccess": "அமைப்புகள் வெற்றிகரமாக இறக்குமதி செய்யப்பட்டன!",
"exportImportSettings_notValidJSON": "அனுப்பப்பட்ட கோப்பு சாதொபொகு செல்லுபடியாகாது",
"exportImportSettings_offendingKeyError": "\"{{offendingKey}}\" தவறானது - {{reason}}",
@@ -948,8 +948,8 @@
"logLevel_optionError": "பிழை",
"logLevel_optionInfo": "தகவல்",
"logLevel_optionWarn": "முன்னறிவிப்பு",
"mpvExtraParameters": "MPV கூடுதல் அளவுருக்கள்",
"mpvExtraParameters_description": "MPV க்கு அனுப்ப கூடுதல் வாதங்கள்",
"mpvExtraParameters": "mpv கூடுதல் அளவுருக்கள்",
"mpvExtraParameters_description": "mpv க்கு அனுப்ப கூடுதல் வாதங்கள்",
"notify": "பாடல் அறிவிப்புகளை இயக்கவும்",
"notify_description": "தற்போதைய பாடலை மாற்றும்போது அறிவிப்புகளைக் காட்டு",
"pathReplace": "கோப்பு பாதை மாற்று",
@@ -1015,7 +1015,7 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.BPM)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"dateAdded": "தேதி சேர்க்கப்பட்டது",
+554 -554
View File
File diff suppressed because it is too large Load Diff
+355 -415
View File
@@ -1,181 +1,176 @@
{
"action": {
"addToFavorites": "Додати до $t(entity.favorite, {\"count\": 2})",
"addOrRemoveFromSelection": "Додати або видалити з вибору",
"selectRangeOfItems": "Вибрати діапазон елементів",
"addToPlaylist": "Додати до $t(entity.playlist, {\"count\": 1})",
"clearQueue": "Очистити чергу",
"createPlaylist": "Створити $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "Створити $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "Видалити $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "Видалити $t(entity.radioStation, {\"count\": 1})",
"selectAll": "Вибрати все",
"deselectAll": "Скасувати вибір усього",
"downloadStarted": "Почато завантаження {{count}} елементів",
"editPlaylist": "Редагувати $t(entity.playlist, {\"count\": 1})",
"goToPage": "Перейти на сторінку",
"moveToNext": "Перейти до наступного",
"moveToBottom": "Перемістити вниз",
"moveToTop": "Перемістити вгору",
"moveUp": "Перемістити вище",
"moveDown": "Перемістити нижче",
"holdToMoveToTop": "Утримуйте, щоб перемістити вгору",
"holdToMoveToBottom": "Утримувати, щоб перемістити вниз",
"moveItems": "Перемістити елементи",
"shuffle": "Перемішати",
"shuffleAll": "Все випадково",
"shuffleSelected": "Вибране випадково",
"addToFavorites": "додати до $t(entity.favorite, {\"count\": 2})",
"addOrRemoveFromSelection": "додати або видалити з вибору",
"selectRangeOfItems": "вибрати діапазон елементів",
"addToPlaylist": "додати до $t(entity.playlist, {\"count\": 1})",
"clearQueue": "очистити чергу",
"createPlaylist": "створити $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "створити $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "видалити $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "видалити $t(entity.radioStation, {\"count\": 1})",
"selectAll": "вибрати все",
"deselectAll": "скасувати вибір усього",
"downloadStarted": "почато завантаження {{count}} елементів",
"editPlaylist": "редагувати $t(entity.playlist, {\"count\": 1})",
"goToPage": "перейти на сторінку",
"moveToNext": "перейти до наступного",
"moveToBottom": "перемістити вниз",
"moveToTop": "перемістити вгору",
"moveUp": "перемістити вище",
"moveDown": "перемістити нижче",
"holdToMoveToTop": "утримуйте, щоб перемістити вгору",
"holdToMoveToBottom": "утримувати, щоб перемістити вниз",
"moveItems": "перемістити елементи",
"shuffle": "перемішати",
"shuffleAll": "все випадково",
"shuffleSelected": "вибране випадково",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "Видалити з $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "Видалити з $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "Видалити з черги",
"setRating": "Встановити рейтинг",
"toggleSmartPlaylistEditor": "Перемикати редактор $t(entity.smartPlaylist)",
"viewPlaylists": "Показати $t(entity.playlist, {\"count\": 2})",
"viewMore": "Переглянути більше",
"openApplicationDirectory": "Відкрити каталог додатків",
"removeFromFavorites": "видалити з $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "видалити з $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "видалити з черги",
"setRating": "встановити рейтинг",
"toggleSmartPlaylistEditor": "перемикати редактор $t(entity.smartPlaylist)",
"viewPlaylists": "показати $t(entity.playlist, {\"count\": 2})",
"viewMore": "переглянути більше",
"openApplicationDirectory": "відкрити каталог додатків",
"openIn": {
"lastfm": "Відкрити в Last.fm",
"musicbrainz": "Відкрити в MusicBrainz",
"listenbrainz": "Відкрити у ListenBrainz",
"qobuz": "Відкрити у Qobuz",
"spotify": "Відкрити у Spotify"
"musicbrainz": "Відкрити в MusicBrainz"
}
},
"common": {
"countSelected": "Вибрано {{count}}",
"explicitStatus": "Явний статус",
"action_one": "Дія",
"countSelected": "вибрано {{count}}",
"explicitStatus": "явний статус",
"action_one": "дія",
"action_few": "дії",
"action_many": "дій",
"add": "Додати",
"additionalParticipants": "Додаткові учасники",
"newVersion": "Встановлено нову версію ({{version}})",
"viewReleaseNotes": "Переглянути список змін",
"albumGain": "Підсилення альбому",
"albumPeak": "Піковий рівень альбому",
"areYouSure": "Ви впевнені?",
"ascending": "Зростаючи",
"backward": "Назад",
"biography": "Біографія",
"bitDepth": "Розрядність",
"bitrate": "Бітрейт",
"bpm": "Уд/хв",
"cancel": "Скасувати",
"center": "Посередині",
"channel_one": "Канал",
"add": "додати",
"additionalParticipants": "додаткові учасники",
"newVersion": "встановлено нову версію ({{version}})",
"viewReleaseNotes": "переглянути список змін",
"albumGain": "підсилення альбому",
"albumPeak": "піковий рівень альбому",
"areYouSure": "ви впевнені?",
"ascending": "зростаючи",
"backward": "назад",
"biography": "біографія",
"bitDepth": "розрядність",
"bitrate": "бітрейт",
"bpm": "уд/хв",
"cancel": "скасувати",
"center": "посередині",
"channel_one": "канал",
"channel_few": "канали",
"channel_many": "каналів",
"clear": "Очистити",
"close": "Закрити",
"codec": "Кодек",
"collapse": "Згорнути",
"comingSoon": "Скоро…",
"configure": "Налаштувати",
"confirm": "Підтвердити",
"create": "Створити",
"currentSong": "Поточний $t(entity.track, {\"count\": 1})",
"decrease": "Знизити",
"delete": "Видалити",
"descending": "За спаданням",
"description": "Опис",
"disable": "Вимкнути",
"disc": "Диск",
"dismiss": "Відхилити",
"doNotShowAgain": "Не показувати це знову",
"duration": "Тривалість",
"view": "Показати",
"edit": "Змінити",
"enable": "Увімкнути",
"expand": "Розширити",
"example": "Приклад",
"externalLinks": "Зовнішні посилання",
"faster": "Швидше",
"favorite": "Улюблений",
"filter_one": "Фільтр",
"clear": "очистити",
"close": "закрити",
"codec": "кодек",
"collapse": "згорнути",
"comingSoon": "скоро…",
"configure": "налаштувати",
"confirm": "підтвердити",
"create": "створити",
"currentSong": "поточний $t(entity.track, {\"count\": 1})",
"decrease": "знизити",
"delete": "видалити",
"descending": "за спаданням",
"description": "опис",
"disable": "вимкнути",
"disc": "диск",
"dismiss": "відхилити",
"doNotShowAgain": "не показувати це знову",
"duration": "тривалість",
"view": "показати",
"edit": "змінити",
"enable": "увімкнути",
"expand": "розширити",
"example": "приклад",
"externalLinks": "зовнішні посилання",
"faster": "швидше",
"favorite": "улюблений",
"filter_one": "фільтр",
"filter_few": "фільтри",
"filter_many": "фільтрів",
"filters": "Фільтри",
"filter_single": "Одиночний",
"filter_multiple": "Кілька",
"forceRestartRequired": "Перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
"forward": "Уперед",
"gap": "Прогалина",
"grouping": "Групування",
"home": "Додому",
"increase": "Збільшити",
"left": "Ліво",
"limit": "Ліміт",
"manage": "Управління",
"maximize": "Максимізувати",
"menu": "Меню",
"minimize": "Мінімізувати",
"modified": "Відредаговано",
"filters": "фільтри",
"filter_single": "одиночний",
"filter_multiple": "кілька",
"forceRestartRequired": "перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
"forward": "уперед",
"gap": "прогалина",
"grouping": "групування",
"home": "додому",
"increase": "збільшити",
"left": "ліво",
"limit": "ліміт",
"manage": "управління",
"maximize": "максимізувати",
"menu": "меню",
"minimize": "мінімізувати",
"modified": "відредаговано",
"mbid": "MusicBrainz ID",
"mood": "Настрій",
"name": "Назва",
"no": "Ні",
"none": "Жоден",
"noResultsFromQuery": "Запит не дав результатів",
"noFilters": "Фільтри не налаштовані",
"note": "Примітка",
"ok": "Ок",
"owner": "Власник",
"path": "Шлях",
"playerMustBePaused": "Плеєр повинен бути призупинений",
"preview": "Перегляд",
"previousSong": "Минулий $t(entity.track, {\"count\": 1})",
"private": "Приватний",
"public": "Публічний",
"quit": "Вийти",
"random": "Випадково",
"rating": "Рейтинг",
"retry": "Повторити спробу",
"recordLabel": "Лейбл звукозапису",
"releaseType": "Тип випуску",
"refresh": "Оновити",
"reload": "Перезавантажити",
"rename": "Перейменувати",
"reset": "Скинути",
"resetToDefault": "Скинути до заводських налаштувань",
"restartRequired": "Необхідний перезапуск",
"right": "Право",
"clean": "Чистo",
"sampleRate": "Частота дискретизації",
"save": "Зберегти",
"saveAndReplace": "Зберегти та замінити",
"saveAs": "Зберегти як",
"search": "Пошук",
"setting_one": "Налаштування",
"mood": "настрій",
"name": "назва",
"no": "ні",
"none": "жоден",
"noResultsFromQuery": "запит не дав результатів",
"noFilters": "фільтри не налаштовані",
"note": "примітка",
"ok": "ок",
"owner": "власник",
"path": "шлях",
"playerMustBePaused": "плеєр повинен бути призупинений",
"preview": "перегляд",
"previousSong": "минулий $t(entity.track, {\"count\": 1})",
"private": "приватний",
"public": "публічний",
"quit": "вийти",
"random": "випадково",
"rating": "рейтинг",
"retry": "повторити спробу",
"recordLabel": "лейбл звукозапису",
"releaseType": "тип випуску",
"refresh": "оновити",
"reload": "перезавантажити",
"rename": "перейменувати",
"reset": "скинути",
"resetToDefault": "скинути до заводських налаштувань",
"restartRequired": "необхідний перезапуск",
"right": "право",
"clean": "чистo",
"sampleRate": "частота дискретизації",
"save": "зберегти",
"saveAndReplace": "зберегти та замінити",
"saveAs": "зберегти як",
"search": "пошук",
"setting_one": "налаштування",
"setting_few": "налаштування",
"setting_many": "налаштувань",
"slower": "Повільніше",
"share": "Поділитися",
"size": "Розмір",
"sort": "Впорядкувати",
"sortOrder": "Порядок",
"tags": "Теги",
"title": "Назва",
"trackNumber": "Трек",
"trackGain": "Підсилення треку",
"trackPeak": "Піковий рівень треку",
"translation": "Переклад",
"unknown": "Невідомий",
"version": "Версія",
"year": "Рік",
"yes": "Так",
"slower": "повільніше",
"share": "поділитися",
"size": "розмір",
"sort": "впорядкувати",
"sortOrder": "порядок",
"tags": "теги",
"title": "назва",
"trackNumber": "трек",
"trackGain": "підсилення треку",
"trackPeak": "піковий рівень треку",
"translation": "переклад",
"unknown": "невідомий",
"version": "версія",
"year": "рік",
"yes": "так",
"explicit": "Експліцитний зміст",
"gridRows": "Рядки сітки",
"tableColumns": "Стовпці таблиці",
"itemsMore": "{{count}} більше",
"numberOfResults": "{{numberOfResults}} результатів",
"newVersionAvailable": "Доступна нова версія"
"gridRows": "рядки сітки",
"tableColumns": "стовпці таблиці",
"itemsMore": "{{count}} більше"
},
"entity": {
"album_one": "Альбом",
"album_one": "альбом",
"album_few": "альбоми",
"album_many": "альбомів",
"albumArtist_one": "Виконавець альбому",
"albumArtist_one": "виконавець альбому",
"albumArtist_few": "виконавці альбому",
"albumArtist_many": "виконавців альбому",
"albumArtistCount_one": "{{count}} виконавець альбому",
@@ -184,34 +179,34 @@
"albumWithCount_one": "{{count}} альбом",
"albumWithCount_few": "{{count}} альбоми",
"albumWithCount_many": "{{count}} альбомів",
"radioStation_one": "Радіостанція",
"radioStation_one": "радіостанція",
"radioStation_few": "радіостанції",
"radioStation_many": "радіостанцій",
"radioStationWithCount_one": "{{count}} радіостанція",
"radioStationWithCount_few": "{{count}} радіостанції",
"radioStationWithCount_many": "{{count}} радіостанцій",
"artist_one": "Виконавець",
"artist_one": "виконавець",
"artist_few": "виконавці",
"artist_many": "виконавців",
"artistWithCount_one": "{{count}} виконавець",
"artistWithCount_few": "{{count}} виконавці",
"artistWithCount_many": "{{count}} виконавців",
"favorite_one": "Улюблений",
"favorite_one": "улюблений",
"favorite_few": "улюблені",
"favorite_many": "улюблених",
"folder_one": "Папка",
"folder_one": "папка",
"folder_few": "папки",
"folder_many": "папок",
"folderWithCount_one": "{{count}} папка",
"folderWithCount_few": "{{count}} папки",
"folderWithCount_many": "{{count}} папок",
"genre_one": "Жанр",
"genre_one": "жанр",
"genre_few": "жанри",
"genre_many": "жанрів",
"genreWithCount_one": "{{count}} жанр",
"genreWithCount_few": "{{count}} жанри",
"genreWithCount_many": "{{count}} жанрів",
"playlist_one": "Плейлист",
"playlist_one": "плейлист",
"playlist_few": "плейлисти",
"playlist_many": "плейлистів",
"play_one": "{{count}} відтворення",
@@ -220,11 +215,11 @@
"playlistWithCount_one": "{{count}} плейлист",
"playlistWithCount_few": "{{count}} плейлисти",
"playlistWithCount_many": "{{count}} плейлистів",
"smartPlaylist": "Розумний $t(entity.playlist, {\"count\": 1})",
"track_one": "Трек",
"smartPlaylist": "розумний $t(entity.playlist, {\"count\": 1})",
"track_one": "трек",
"track_few": "треки",
"track_many": "треків",
"song_one": "Пісня",
"song_one": "пісня",
"song_few": "пісні",
"song_many": "пісень",
"trackWithCount_one": "{{count}} трек",
@@ -232,266 +227,257 @@
"trackWithCount_many": "{{count}} треків"
},
"error": {
"apiRouteError": "Неможливо виконати запит",
"audioDeviceFetchError": "Сталася помилка під час спроби отримати аудіопристрої",
"authenticationFailed": "Аутентифікація не вдалася",
"badAlbum": "Ви бачите цю сторінку, тому що ця пісня не входить до альбому. найімовірніше, ця проблема виникає, якщо у верхньому рівні вашої музичної папки знаходиться пісня. Jellyfin групує треки тільки в тому випадку, якщо вони знаходяться в папці",
"badValue": "Недійсний параметр \"{{value}}\". це значення більше не існує",
"credentialsRequired": "Необхідні дані для входу",
"endpointNotImplementedError": "Кінцева точка {{endpoint}} не реалізована для {{serverType}}",
"genericError": "Сталася помилка",
"invalidServer": "Недійсний сервер",
"localFontAccessDenied": "Відмова в доступі до локальних шрифтів",
"loginRateError": "Занадто багато спроб входу, спробуйте ще раз через кілька секунд",
"mpvRequired": "Необхідний MPV",
"multipleServerSaveQueueError": "У черзі відтворення є одна або кілька пісень, які не належать до поточного сервера. це не підтримується",
"networkError": "Сталася мережева помилка",
"noNetwork": "Сервер недоступний",
"noNetworkDescription": "Не вдалося підключитися до цього сервера",
"notificationDenied": "Дозвіл на сповіщення було відхилено. це налаштування не має впливу",
"openError": "Не вдалося відкрити файл",
"playbackError": "Сталася помилка під час спроби відтворити медіафайл",
"remoteDisableError": "Сталася помилка під час спроби $t(common.disable) віддаленого сервера",
"remoteEnableError": "Сталася помилка під час спроби $t(common.enable) віддаленого сервера",
"remotePortError": "Сталася помилка під час спроби налаштувати порт віддаленого сервера",
"remotePortWarning": "Перезапустіть сервер щоб застосувати новий порт",
"saveQueueFailed": "Не вдалося зберегти чергу",
"serverNotSelectedError": "Не вибрано жодного сервера",
"serverRequired": "Потрібен сервер",
"sessionExpiredError": "Ваша сесія закінчилася",
"systemFontError": "Сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "Виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни",
"invalidJson": "Недійсний JSON",
"playbackPausedDueToError": "Відтворення було призупинено через помилку"
"apiRouteError": "неможливо виконати запит",
"audioDeviceFetchError": "сталася помилка під час спроби отримати аудіопристрої",
"authenticationFailed": "аутентифікація не вдалася",
"badAlbum": "ви бачите цю сторінку, тому що ця пісня не входить до альбому. найімовірніше, ця проблема виникає, якщо у верхньому рівні вашої музичної папки знаходиться пісня. Jellyfin групує треки тільки в тому випадку, якщо вони знаходяться в папці",
"badValue": "недійсний параметр \"{{value}}\". це значення більше не існує",
"credentialsRequired": "необхідні дані для входу",
"endpointNotImplementedError": "кінцева точка {{endpoint}} не реалізована для {{serverType}}",
"genericError": "сталася помилка",
"invalidServer": "недійсний сервер",
"localFontAccessDenied": "відмова в доступі до локальних шрифтів",
"loginRateError": "занадто багато спроб входу, спробуйте ще раз через кілька секунд",
"mpvRequired": "необхідний MPV",
"multipleServerSaveQueueError": "у черзі відтворення є одна або кілька пісень, які не належать до поточного сервера. це не підтримується",
"networkError": "сталася мережева помилка",
"noNetwork": "сервер недоступний",
"noNetworkDescription": "не вдалося підключитися до цього сервера",
"notificationDenied": "дозвіл на сповіщення було відхилено. це налаштування не має впливу",
"openError": "не вдалося відкрити файл",
"playbackError": "сталася помилка під час спроби відтворити медіафайл",
"remoteDisableError": "сталася помилка під час спроби $t(common.disable) віддаленого сервера",
"remoteEnableError": "сталася помилка під час спроби $t(common.enable) віддаленого сервера",
"remotePortError": "сталася помилка під час спроби налаштувати порт віддаленого сервера",
"remotePortWarning": "перезапустіть сервер щоб застосувати новий порт",
"saveQueueFailed": "не вдалося зберегти чергу",
"serverNotSelectedError": "не вибрано жодного сервера",
"serverRequired": "потрібен сервер",
"sessionExpiredError": "ваша сесія закінчилася",
"systemFontError": "сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"albumCount": "Кількість $t(entity.album, {\"count\": 2})",
"albumCount": "кількість $t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "Біографія",
"bitrate": "Бітрейт",
"bpm": "Уд/хв",
"biography": "біографія",
"bitrate": "бітрейт",
"bpm": "уд/хв",
"channels": "$t(common.channel, {\"count\": 2})",
"comment": "Коментар",
"communityRating": "Рейтинг спільноти",
"criticRating": "Рейтинг критиків",
"dateAdded": "Дата додавання",
"disc": "Диск",
"duration": "Тривалість",
"favorited": "Улюблене",
"fromYear": "З року",
"comment": "коментар",
"communityRating": "рейтинг спільноти",
"criticRating": "рейтинг критиків",
"dateAdded": "дата додавання",
"disc": "диск",
"duration": "тривалість",
"favorited": "улюблене",
"fromYear": "з року",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "Id",
"isCompilation": "Є компіляцією",
"isFavorited": "Є улюбленим",
"isPublic": "Є публічним",
"isRated": "Є оціненим",
"isRecentlyPlayed": "Нещодавно відтворено",
"lastPlayed": "Останнє відтворене",
"mostPlayed": "Найбільш відтворювані",
"name": "Назва",
"note": "Примітка",
"id": "id",
"isCompilation": "є компіляцією",
"isFavorited": "є улюбленим",
"isPublic": "є публічним",
"isRated": "є оціненим",
"isRecentlyPlayed": "нещодавно відтворено",
"lastPlayed": "нещодавно відтворені",
"mostPlayed": "найбільш відтворювані",
"name": "назва",
"note": "примітка",
"owner": "$t(common.owner)",
"path": "Шлях",
"playCount": "Кількість відтворень",
"random": "Випадково",
"rating": "Рейтинг",
"recentlyAdded": "Нещодавно додано",
"recentlyPlayed": "Нещодавно відтворено",
"recentlyUpdated": "Нещодавно оновлено",
"releaseDate": "Дата випуску",
"releaseYear": "Рік випуску",
"search": "Шукати",
"songCount": "Кількість пісень",
"sortName": "Сортування за назвою",
"title": "Назва",
"toYear": "До року",
"trackNumber": "Трек",
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "І",
"matchOr": "Або"
"path": "шлях",
"playCount": "кількість відтворень",
"random": "випадково",
"rating": "рейтинг",
"recentlyAdded": "нещодавно додано",
"recentlyPlayed": "нещодавно відтворено",
"recentlyUpdated": "нещодавно оновлено",
"releaseDate": "дата випуску",
"releaseYear": "рік випуску",
"search": "шукати",
"songCount": "кількість пісень",
"sortName": "сортування за назвою",
"title": "назва",
"toYear": "до року",
"trackNumber": "трек",
"explicitStatus": "$t(common.explicitStatus)"
},
"datetime": {
"minuteShort": "Хв.",
"secondShort": "Сек.",
"hourShort": "Год",
"dayShort": "Дн."
"minuteShort": "хв.",
"secondShort": "сек.",
"hourShort": "год",
"dayShort": "дн."
},
"filterOperator": {
"after": "Є після",
"afterDate": "Після (дата)",
"before": "Є перед",
"beforeDate": "Є перед (дата)",
"contains": "Містить",
"endsWith": "Закінчується на",
"inPlaylist": "Є в",
"inTheLast": "Є в останньому",
"inTheRange": "Є в межах",
"inTheRangeDate": "Є в межах (дата)",
"is": "Є",
"isNot": "Не є",
"isGreaterThan": "Більше ніж",
"isLessThan": "Менше ніж",
"matchesRegex": "Відповідає регулярному виразу",
"notContains": "Не містить",
"notInPlaylist": "Немає в",
"notInTheLast": "Не є в останньому",
"startsWith": "Починається з"
"after": "є після",
"afterDate": "після (дата)",
"before": "є перед",
"beforeDate": "є перед (дата)",
"contains": "містить",
"endsWith": "закінчується на",
"inPlaylist": "є в",
"inTheLast": "є в останньому",
"inTheRange": "є в межах",
"inTheRangeDate": "є в межах (дата)",
"is": "є",
"isNot": "не є",
"isGreaterThan": "більше ніж",
"isLessThan": "менше ніж",
"matchesRegex": "відповідає регулярному виразу",
"notContains": "не містить",
"notInPlaylist": "немає в",
"notInTheLast": "не є в останньому",
"startsWith": "починається з"
},
"form": {
"addServer": {
"error_savePassword": "Сталася помилка під час спроби зберегти пароль",
"ignoreCors": "Ігнорувати cors ($t(common.restartRequired))",
"ignoreSsl": "Ігнорувати ssl ($t(common.restartRequired)}",
"input_legacyAuthentication": "Увімкнути застарілу автентифікацію",
"input_name": "Назва сервера",
"input_password": "Пароль",
"input_preferInstantMix": "Віддавати перевагу миттєвому міксу",
"input_preferInstantMixDescription": "Використовувати тільки миттєвий мікс щоб отримати подібні пісні. корисно, коли у вас є плагіни, які змінюють цю поведінку",
"input_preferRemoteUrl": "Віддавати перевагу публічній URL-адресі",
"input_remoteUrl": "Публічна URL-адреса",
"input_remoteUrlPlaceholder": "Опціонально: публічна URL-адреса для зовнішніх функцій",
"input_savePassword": "Зберегти пароль",
"error_savePassword": "сталася помилка під час спроби зберегти пароль",
"ignoreCors": "ігнорувати cors ($t(common.restartRequired))",
"ignoreSsl": "ігнорувати ssl ($t(common.restartRequired)}",
"input_legacyAuthentication": "увімкнути застарілу автентифікацію",
"input_name": "назва сервера",
"input_password": "пароль",
"input_preferInstantMix": "віддавати перевагу миттєвому міксу",
"input_preferInstantMixDescription": "використовувати тільки миттєвий мікс щоб отримати подібні пісні. корисно, коли у вас є плагіни, які змінюють цю поведінку",
"input_preferRemoteUrl": "віддавати перевагу публічній URL-адресі",
"input_remoteUrl": "публічна URL-адреса",
"input_remoteUrlPlaceholder": "опціонально: публічна URL-адреса для зовнішніх функцій",
"input_savePassword": "зберегти пароль",
"input_url": "URL-адреса",
"input_username": "Ім'я користувача",
"success": "Сервер додано успішно",
"title": "Додати сервер"
"success": "сервер додано успішно",
"title": "додати сервер"
},
"largeFetchConfirmation": {
"title": "Додати елементи до черги",
"title": "додати елементи до черги",
"description": "Ця дія додасть усі елементи в поточний відфільтрований перегляд"
},
"addToPlaylist": {
"create": "Створити $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"create": "створити $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"input_skipDuplicates": "Пропустити дублікати",
"searchOrCreate": "Шукайте $t(entity.playlist, {\"count\": 2}) або пишіть, щоб створити новий",
"success": "Додано $t(entity.trackWithCount, {\"count\": {{message}} }) до $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "Додати до $t(entity.playlist, {\"count\": 1})"
"input_skipDuplicates": "пропустити дублікати",
"searchOrCreate": "шукайте $t(entity.playlist, {\"count\": 2}) або пишіть, щоб створити новий",
"success": "додано $t(entity.trackWithCount, {\"count\": {{message}} }) до $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "додати до $t(entity.playlist, {\"count\": 1})"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "Публічний",
"input_public": "публічний",
"success": "$t(entity.playlist, {\"count\": 1}) стрворено успішно",
"title": "Створити $t(entity.playlist, {\"count\": 1})"
"title": "створити $t(entity.playlist, {\"count\": 1})"
},
"createRadioStation": {
"success": "Радіостанція створена успішно",
"title": "Створити радіостанцію",
"input_homepageUrl": "Адреса домашньої сторінки",
"input_name": "Назва",
"success": "радіостанція створена успішно",
"title": "створити радіостанцію",
"input_homepageUrl": "адреса домашньої сторінки",
"input_name": "назва",
"input_streamUrl": "URL-адреса потоку"
},
"deletePlaylist": {
"input_confirm": "Введіть ім'я $t(entity.playlist, {\"count\": 1}) для підтвердження",
"input_confirm": "введіть ім'я $t(entity.playlist, {\"count\": 1}) для підтвердження",
"success": "$t(entity.playlist, {\"count\": 1}) успішно видалено",
"title": "Видалити $t(entity.playlist, {\"count\": 1})"
"title": "видалити $t(entity.playlist, {\"count\": 1})"
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче",
"success": "$t(entity.playlist, {\"count\": 1}) успішно оновлено",
"title": "Змінити $t(entity.playlist, {\"count\": 1})"
"title": "змінити $t(entity.playlist, {\"count\": 1})"
},
"lyricsExport": {
"export": "Експортувати тексти пісень",
"input_synced": "Експортувати синхронізовані тексти пісень",
"export": "експортувати тексти пісень",
"input_synced": "експортувати синхронізовані тексти пісень",
"input_offset": "$t(setting.lyricOffset)"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
"input_name": "$t(common.name)",
"title": "Шукати тексти пісень"
"title": "шукати тексти пісень"
},
"queryEditor": {
"title": "Редактор запитів",
"input_optionMatchAll": "Збіг за всіма",
"input_optionMatchAny": "Збіг за будь-яким",
"addRuleGroup": "Додати групу правил",
"removeRuleGroup": "Видалити групу правил",
"resetToDefault": "Скинути до заводських налаштувань",
"clearFilters": "Очистити фільтри"
"title": "редактор запитів",
"input_optionMatchAll": "збіг за всіма",
"input_optionMatchAny": "збіг за будь-яким",
"addRuleGroup": "додати групу правил",
"removeRuleGroup": "видалити групу правил",
"resetToDefault": "скинути до заводських налаштувань",
"clearFilters": "очистити фільтри"
},
"saveQueue": {
"success": "Черга відтворення збережена на сервері"
"success": "черга відтворення збережена на сервері"
},
"shareItem": {
"allowDownloading": "Дозволити завантаження",
"description": "Опис",
"setExpiration": "Встановити термін дії",
"success": "Посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)",
"expireInvalid": "Термін дії повинен бути в майбутньому",
"createFailed": "Не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)",
"copyToClipboard": "Скопіювати до буфера обміну: Ctrl+C, enter",
"successMustClick": "Посилання успішно створено, натисніть сюди, щоб відкрити"
"allowDownloading": "дозволити завантаження",
"description": "опис",
"setExpiration": "встановити термін дії",
"success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)",
"expireInvalid": "термін дії повинен бути в майбутньому",
"createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)"
},
"shuffleAll": {
"title": "Відтворити випадково",
"title": "відтворити випадково",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "Скільки пісень?",
"input_minYear": "Від року",
"input_maxYear": "До року",
"input_played": "Відтворити фільтр",
"input_played_optionAll": "Всі треки",
"input_played_optionUnplayed": "Тільки не відтворені треки",
"input_played_optionPlayed": "Тільки відтворені треки"
"input_limit": "скільки пісень?",
"input_minYear": "від року",
"input_maxYear": "до року",
"input_played": "відтворити фільтр",
"input_played_optionAll": "всі треки",
"input_played_optionUnplayed": "тільки не відтворені треки",
"input_played_optionPlayed": "тільки відтворені треки"
},
"updateServer": {
"success": "Сервер успішно оновлено",
"title": "Оновити сервер"
"success": "сервер успішно оновлено",
"title": "оновити сервер"
},
"privateMode": {
"enabled": "Приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій",
"disabled": "Приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій",
"title": "Приватний режим"
},
"editRadioStation": {
"success": "Радіо станція успішно оновлена"
"enabled": "приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій",
"disabled": "приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій",
"title": "приватний режим"
}
},
"player": {
"skip": "Пропустити"
"skip": "пропустити"
},
"page": {
"albumArtistDetail": {
"about": "Про {{artist}}",
"appearsOn": "З'являється на",
"favoriteSongs": "Улюблені пісні",
"groupingTypeAll": "Всі типи випуску",
"groupingTypePrimary": "Основні типи випуску",
"recentReleases": "Останні випуски",
"viewDiscography": "Переглянути дискографію",
"relatedArtists": "Подібні $t(entity.artist, {\"count\": 2})",
"topSongs": "Найкращі пісні",
"topSongsCommunity": "Спільнота",
"topSongsFrom": "Найкращі пісні від {{title}}",
"topSongsPersonal": "Особисте",
"favoriteSongsFrom": "Улюблені пісні від {{title}}",
"viewAll": "Показати все",
"viewAllTracks": "Показати усі $t(entity.track, {\"count\": 2})"
"appearsOn": "з'являється на",
"favoriteSongs": "улюблені пісні",
"groupingTypeAll": "всі типи випуску",
"groupingTypePrimary": "основні типи випуску",
"recentReleases": "останні випуски",
"viewDiscography": "переглянути дискографію",
"relatedArtists": "подібні $t(entity.artist, {\"count\": 2})",
"topSongs": "найкращі пісні",
"topSongsCommunity": "спільнота",
"topSongsFrom": "найкращі пісні від {{title}}",
"topSongsPersonal": "особисте",
"favoriteSongsFrom": "улюблені пісні від {{title}}",
"viewAll": "показати все",
"viewAllTracks": "показати усі $t(entity.track, {\"count\": 2})"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
},
"albumDetail": {
"moreFromArtist": "Більше від цього $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "Більше від {{item}}",
"released": "Видано"
"moreFromArtist": "більше від цього $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "більше від {{item}}",
"released": "видано"
},
"albumList": {
"artistAlbums": "Альбоми виконавця {{artist}}",
"artistAlbums": "альбоми виконавця {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
"title": "$t(entity.album, {\"count\": 2})"
},
"radioList": {
"title": "Радіостанції"
"title": "радіостанції"
},
"releasenotes": {
"commitsSinceStable": "Комміти від {{stable}}",
"noNewCommits": "Немає нових коммітів у цьому періоді",
"noStableReleaseToCompare": "Немає доступної стабільної версії для порівняння"
"commitsSinceStable": "комміти від {{stable}}",
"noNewCommits": "немає нових коммітів у цьому періоді",
"noStableReleaseToCompare": "немає доступної стабільної версії для порівняння"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
@@ -501,30 +487,30 @@
"privateMode": "(Приватний режим)"
},
"appMenu": {
"collapseSidebar": "Згорнути бічну панель",
"commandPalette": "Відкрити палітру команд",
"expandSidebar": "Розгорнути бічну панель",
"goBack": "Повернутися назад",
"goForward": "Перейти вперед",
"manageServers": "Управління серверами",
"privateModeOff": "Вимкнути приватний режим",
"privateModeOn": "Увімкнути приватний режим",
"openBrowserDevtools": "Відкрити інструменти розробника",
"collapseSidebar": "згорнути бічну панель",
"commandPalette": "відкрити палітру команд",
"expandSidebar": "розгорнути бічну панель",
"goBack": "повернутися назад",
"goForward": "перейти вперед",
"manageServers": "управління серверами",
"privateModeOff": "вимкнути приватний режим",
"privateModeOn": "увімкнути приватний режим",
"openBrowserDevtools": "відкрити інструменти розробника",
"quit": "$t(common.quit)",
"selectServer": "Вибрати сервер",
"selectMusicFolder": "Вибрати папку з музикою",
"noMusicFolder": "Не вибрано папку з музикою",
"selectServer": "вибрати сервер",
"selectMusicFolder": "вибрати папку з музикою",
"noMusicFolder": "не вибрано папку з музикою",
"multipleMusicFolders": "Вибрано {{count}} папок з музикою",
"settings": "$t(common.setting, {\"count\": 2})",
"version": "Версія {{version}}"
"version": "версія {{version}}"
},
"manageServers": {
"title": "Управління серверами",
"serverDetails": "Інформація про сервер",
"title": "управління серверами",
"serverDetails": "інформація про сервер",
"url": "URL-адреса",
"username": "Ім'я користувача",
"editServerDetailsTooltip": "Редагувати дані сервера",
"removeServer": "Видалити сервер"
"editServerDetailsTooltip": "редагувати дані сервера",
"removeServer": "видалити сервер"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
@@ -535,7 +521,7 @@
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"download": "Завантажити",
"download": "завантажити",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
@@ -548,57 +534,11 @@
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "Поділитися елементом",
"goTo": "Перейти до",
"goToAlbum": "Перейти до $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "Перейти до $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "Отримати інформацію"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "Динамічний фон",
"dynamicImageBlur": "Розмір розмиття зображення",
"dynamicIsImage": "Включити фонове зображення",
"followCurrentLyric": "Слідкувати за поточним рядком",
"lyricAlignment": "Вирівнювання тексту",
"lyricOffset": "Затримка тексту (мс)",
"lyricGap": "Розмір між рядками",
"lyricSize": "Розмір тексту",
"opacity": "Непрозорість",
"showLyricMatch": "Показувати збіг тексту пісень",
"showLyricProvider": "Показувати джерело тексту пісень",
"synchronized": "Синхронізовано",
"unsynchronized": "Несинхронізовано",
"useImageAspectRatio": "Використовувати співвідношення сторін зображення"
},
"lyrics": "Текст пісні",
"related": "Пов'язані",
"upNext": "Далі",
"visualizer": "Візуалізатор",
"noLyrics": "Текст пісні не знайдено"
},
"genreList": {
"showAlbums": "Показати $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "Показати $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
"title": "$t(entity.genre, {\"count\": 2})"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"globalSearch": {
"commands": {
"goToPage": "Перейти до сторінки",
"searchFor": "Шукати на {{query}}",
"serverCommands": "Команди сервера"
},
"title": "Команди"
},
"home": {
"explore": "Дослідити з вашої бібліотеки",
"genres": "$t(entity.genre, {\"count\": 2})",
"mostPlayed": "Найбільш відтворені",
"newlyAdded": "Нещодавно додані релізи",
"recentlyPlayed": "Нещодавно відтворені"
"shareItem": "поділитися елементом",
"goTo": "перейти до",
"goToAlbum": "перейти до $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "перейти до $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "отримати інформацію"
}
}
}
+34 -37
View File
@@ -46,7 +46,7 @@
"common": {
"increase": "增高",
"rating": "评分",
"bpm": "BPM",
"bpm": "bpm",
"refresh": "刷新",
"unknown": "未知",
"edit": "编辑",
@@ -208,8 +208,8 @@
"queue_clear": "清空播放队列",
"muted": "已静音",
"unfavorite": "取消收藏",
"queue_moveToTop": "将所选项移至部",
"queue_moveToBottom": "将所选项移至部",
"queue_moveToTop": "将所选项移至部",
"queue_moveToBottom": "将所选项移至部",
"shuffle_off": "禁用随机播放",
"addLast": "最后",
"mute": "静音",
@@ -240,12 +240,12 @@
"setting": {
"crossfadeStyle_description": "选择用于音频播放器的淡入淡出风格",
"hotkey_favoriteCurrentSong": "收藏$t(common.currentSong)",
"audioExclusiveMode_description": "启用独占输出模式。在此模式下,系统通常被锁定为只有 MPV 能够输出音频",
"audioExclusiveMode_description": "启用独占输出模式。在此模式下,系统通常被锁定为只有 mpv 能够输出音频",
"disableLibraryUpdateOnStartup": "禁用启动时查询新版本",
"gaplessAudio": "无缝音频",
"audioPlayer_description": "选择用于播放的音频播放器",
"globalMediaHotkeys": "全局媒体快捷键",
"gaplessAudio_description": "调整 MPV 无缝音频设置",
"gaplessAudio_description": "调整 mpv 无缝音频设置",
"followLyric_description": "滚动歌词到当前播放位置",
"audioExclusiveMode": "音频独占模式",
"font": "字体",
@@ -261,7 +261,7 @@
"followLyric": "跟随当前歌词",
"crossfadeDuration": "淡入淡出持续时间",
"audioPlayer": "音频播放器",
"discordApplicationId": "{{discord}} 应用 ID",
"discordApplicationId": "{{discord}} 应用 id",
"applicationHotkeys_description": "配置应用快捷键。勾选设为全局快捷键(仅桌面端)",
"customFontPath_description": "设置应用使用的自定义字体路径",
"gaplessAudio_optionWeak": "弱(推荐)",
@@ -285,7 +285,7 @@
"scrobble": "记录播放信息",
"skipDuration_description": "设置每次按下跳过按钮将会跳过的时长",
"fontType_optionSystem": "系统字体",
"mpvExecutablePath_description": "设置 MPV 可执行文件的路径。如果留空,则使用默认路径",
"mpvExecutablePath_description": "设置 mpv 可执行文件的路径。如果留空,则使用默认路径",
"sampleRate": "采样率",
"sidePlayQueueStyle_optionAttached": "吸附",
"sidebarConfiguration": "侧边栏设定",
@@ -334,7 +334,7 @@
"hotkey_toggleShuffle": "切换随机",
"theme": "主题",
"playbackStyle_description": "选择音频播放器的播放风格",
"mpvExecutablePath": "MPV 可执行文件路径",
"mpvExecutablePath": "mpv 可执行文件路径",
"hotkey_rate2": "评为 2 星",
"playButtonBehavior_description": "设置将歌曲添加到播放队列时播放按钮的默认行为",
"minimumScrobblePercentage_description": "歌曲被记录为已播放所需的最小播放百分比",
@@ -344,7 +344,7 @@
"savePlayQueue": "保存播放队列",
"minimumScrobbleSeconds_description": "歌曲被记录为已播放所需的最小播放时间",
"skipPlaylistPage_description": "打开歌单时,直接查看歌曲列表而非查看默认页面",
"fontType_description": "内置字体可以选择 Feishin 提供的字体之一。系统字体允许您选择操作系统提供的任何字体。自定义选项允许您使用自己的字体",
"fontType_description": "内置字体可以选择 feishin 提供的字体之一。系统字体允许您选择操作系统提供的任何字体。自定义选项允许您使用自己的字体",
"playButtonBehavior": "播放按钮行为",
"volumeWheelStep": "音量滚轮分度",
"sidebarPlaylistList_description": "显示或隐藏侧边栏歌单列表",
@@ -385,20 +385,20 @@
"replayGainClipping_description": "自动降低增益以防止{{ReplayGain}}造成削波",
"replayGainPreamp": "{{ReplayGain}}前置放大(分贝)",
"replayGainClipping": "{{ReplayGain}}削波",
"discordUpdateInterval": "{{discord}} Rich Presence 更新间隔",
"discordApplicationId_description": "{{discord}} Rich Presence 应用 ID(默认为 {{defaultId}}",
"discordUpdateInterval": "{{discord}} rich presence 更新间隔",
"discordApplicationId_description": "{{discord}} rich presence 应用 id(默认为 {{defaultId}}",
"discordUpdateInterval_description": "更新间隔秒数(至少 15 秒)",
"discordRichPresence_description": "在 {{discord}} Rich Presence 中显示播放状态。图片键为:{{icon}}、{{playing}} 和 {{paused}}",
"discordRichPresence_description": "在 {{discord}} rich presence 中显示播放状态。图片键为:{{icon}}、{{playing}} 和 {{paused}}",
"accentColor": "强调色",
"accentColor_description": "设置应用的强调色",
"replayGainPreamp_description": "调整应用在{{ReplayGain}}值上的前置放大增益",
"discordIdleStatus": "显示 Rich Presence 闲置状态",
"discordIdleStatus": "显示 rich presence 闲置状态",
"clearCache": "清除浏览器缓存",
"buttonSize": "播放器栏按钮大小",
"buttonSize_description": "播放器栏按钮大小",
"clearCache_description": "Feishin的“硬清除”。除了清除Feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。服务器凭据和设置会被保留",
"clearQueryCache_description": "Feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。设置、服务器凭据和缓存图像会被保留",
"clearQueryCache": "清除Feishin缓存",
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。服务器凭据和设置会被保留",
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。设置、服务器凭据和缓存图像会被保留",
"clearQueryCache": "清除feishin缓存",
"externalLinks": "显示外部链接",
"externalLinks_description": "允许在艺术家/专辑页面上显示外部链接(Last.fm、MusicBrainz",
"mpvExtraParameters_help": "每行一个",
@@ -420,10 +420,10 @@
"contextMenu_description": "允许您隐藏右键单击项目时显示在菜单中的项目。未选中的项目将被隐藏",
"customCssEnable_description": "允许编写自定义 css",
"customCss": "自定义css",
"customCss_description": "自定义css内容。注意:内容和远程URL是不允许的属性。内容预览展示如下。出于安全考虑,您未设置的其它字段也会显示",
"customCss_description": "自定义css内容。注意:内容和远程url是不允许的属性。内容预览展示如下。出于安全考虑,您未设置的其它字段也会显示",
"contextMenu": "上下文菜单(右键单击)配置",
"customCssEnable": "启用自定义 css",
"customCssNotice": "警告:虽然预设了一些安全限制(不允许 URL() 和 content:),但使用自定义 css 仍然会因更改界面而带来风险",
"customCssNotice": "警告:虽然预设了一些安全限制(不允许 url() 和 content:),但使用自定义 css 仍然会因更改界面而带来风险",
"transcode_description": "可以转码为不同的格式",
"transcodeBitrate": "转码比特率",
"albumBackground": "专辑背景图片",
@@ -451,14 +451,14 @@
"lastfmApiKey": "{{lastfm}} API 密钥",
"lastfmApiKey_description": "{{lastfm}} 的 API 密钥。封面艺术图所需",
"discordServeImage": "从服务器提供 {{discord}} 图像",
"discordServeImage_description": "从服务器本身分享 {{discord}} Rich Presence 的封面艺术,仅适用于 Jellyfin 和 Navidrome。 {{discord}} 使用机器人来获取图像,因此您的服务器必须可通过公共互联网访问",
"discordServeImage_description": "从服务器本身分享 {{discord}} rich presence 的封面艺术,仅适用于 Jellyfin 和 Navidrome。 {{discord}} 使用机器人来获取图像,因此您的服务器必须可通过公共互联网访问",
"musicbrainz": "显示 MusicBrainz 链接",
"musicbrainz_description": "在艺术家/专辑页面上显示 MusicBrainz 链接(如果存在 MusicBrainz ID",
"lastfm": "显示 Last.fm 链接",
"lastfm": "显示 last.fm 链接",
"lastfm_description": "在艺术家/专辑页面上显示 Last.fm 的链接",
"preferLocalLyrics_description": "优先选择本地歌词(如有),而不是远程歌词",
"preferLocalLyrics": "首选本地歌词",
"discordPausedStatus": "暂停时显示Rich Presence",
"discordPausedStatus": "暂停时显示rich presence",
"discordPausedStatus_description": "启用后将在播放器暂停时显示状态",
"preservePitch": "保持音高",
"preservePitch_description": "在调整播放速度时保持音高",
@@ -489,7 +489,7 @@
"exportImportSettings_control_title": "导入/导出设置",
"exportImportSettings_destructiveWarning": "导入设置会破坏现有设置,请在点击下方“导入”按钮前仔细阅读以上内容!",
"exportImportSettings_importBtn": "导入设置",
"exportImportSettings_importModalTitle": "导入 Feishin 设置",
"exportImportSettings_importModalTitle": "导入 feishin 设置",
"exportImportSettings_importSuccess": "设置已成功导入!",
"exportImportSettings_notValidJSON": "传递的文件不是有效的 JSON 文件",
"exportImportSettings_offendingKeyError": "\"{{offendingKey}}\" 不正确 - {{reason}}",
@@ -510,14 +510,14 @@
"combinedLyricsAndVisualizer": "在播放器侧边栏合并歌词和可视化界面",
"autoDJ_description": "自动添加相似歌曲到队列中",
"notify_description": "歌曲变更时显示通知",
"mpvExtraParameters_description": "向MPV传递额外参数",
"mpvExtraParameters_description": "向mpv传递额外参数",
"audioFadeOnStatusChange": "音频改变时淡入淡出",
"showVisualizerInSidebar": "在播放器侧边栏显示可视化效果",
"showLyricsInSidebar": "在播放器侧边栏显示歌词",
"analyticsDisable": "退出使用情况的分析",
"artistReleaseTypeConfiguration": "艺术家发行类型设置",
"useThemeAccentColor": "使用主题强调色",
"mpvExtraParameters": "MPV额外参数",
"mpvExtraParameters": "mpv额外参数",
"showRatings": "显示星级评分",
"followCurrentSong": "跟随当前歌曲",
"logLevel": "日志等级",
@@ -552,7 +552,7 @@
"autoDJ_timing": "定时",
"autoDJ_timing_description": "自动 DJ 触发前队列中剩余的歌曲数量",
"crossfadeStyle": "交叉渐变风格",
"discordRichPresence": "{{discord}} Rich Presence",
"discordRichPresence": "{{discord}} rich presence",
"homeFeatureStyle_description": "控制首页特色轮播图的样式",
"homeFeatureStyle": "首页特色旋转样式",
"homeFeatureStyle_optionMultiple": "多样",
@@ -586,7 +586,7 @@
"automaticUpdates_description": "自动检查并安装更新",
"releaseChannel_optionAlpha": "alpha(每日构建版)",
"discordStateIcon": "显示播放图标",
"discordStateIcon_description": "在 Rich Presence 状态中显示一个小的播放图标。启用“暂停时显示 Rich Presence 在线状态”后,暂停图标始终显示",
"discordStateIcon_description": "在 rich presence 状态中显示一个小的播放图标。启用“暂停时显示 rich presence 在线状态”后,暂停图标始终显示",
"blurExplicitImages": "模糊显式图片",
"blurExplicitImages_description": "专辑和歌曲封面若被标记为不雅内容,将会进行模糊处理",
"autosave": "自动保存播放队列",
@@ -673,7 +673,7 @@
"fromYear": "起始年份",
"criticRating": "评论家评分",
"trackNumber": "曲目",
"bpm": "BPM",
"bpm": "bpm",
"artist": "$t(entity.artist, {\"count\": 1})",
"comment": "评论",
"isCompilation": "为合辑",
@@ -687,7 +687,7 @@
"genre": "$t(entity.genre, {\"count\": 1})",
"note": "注释",
"albumCount": "$t(entity.album, {\"count\": 2})数",
"id": "ID",
"id": "id",
"disc": "碟片",
"duration": "时长",
"album": "$t(entity.album, {\"count\": 1})",
@@ -925,12 +925,12 @@
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
"ignoreCors": "忽略 cors $t(common.restartRequired)",
"error_savePassword": "保存密码时出现错误",
"input_url": "URL",
"input_url": "url",
"input_preferInstantMixDescription": "仅使用即时混音来获取类似的歌曲。如果您有修改此行为的插件,则很有用",
"input_preferInstantMix": "首选即时混音",
"input_preferRemoteUrl": "首选公共 URL",
"input_remoteUrl": "公共 URL",
"input_remoteUrlPlaceholder": "可选:对外功能的公共 URL"
"input_preferRemoteUrl": "首选公共 url",
"input_remoteUrl": "公共 url",
"input_remoteUrlPlaceholder": "可选:对外功能的公共 url"
},
"addToPlaylist": {
"success": "添加$t(entity.trackWithCount, {\"count\": {{message}} })到$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
@@ -1015,9 +1015,6 @@
"input_played_optionPlayed": "仅已播放的曲目",
"input_limit": "有多少首歌?",
"input_played": "播放筛选器"
},
"editRadioStation": {
"success": "电台更新成功"
}
},
"table": {
@@ -1066,7 +1063,7 @@
"duration": "$t(common.duration)",
"dateAdded": "添加日期",
"size": "$t(common.size)",
"bpm": "$t(common.BPM)",
"bpm": "$t(common.bpm)",
"lastPlayed": "最后播放",
"trackNumber": "音轨编号",
"rowIndex": "行索引",
@@ -1112,7 +1109,7 @@
"releaseDate": "发布日期",
"bitrate": "比特率",
"title": "标题",
"bpm": "BPM",
"bpm": "bpm",
"dateAdded": "添加日期",
"artist": "$t(entity.artist, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})",
+27 -38
View File
@@ -3,7 +3,7 @@
"backward": "返回",
"biography": "簡介",
"bitrate": "位元率",
"bpm": "BPM",
"bpm": "bpm",
"clear": "清空",
"collapse": "折疊",
"comingSoon": "即將推出…",
@@ -233,9 +233,7 @@
"showLyricMatch": "顯示匹配的歌詞",
"dynamicImageBlur": "圖片模糊大小",
"dynamicIsImage": "啟用背景圖片",
"lyricOffset": "歌詞偏移時間 (ms)",
"lyricOpacityNonActive": "非活躍歌詞的不透明度",
"lyricScaleNonActive": "非活躍歌詞的比例"
"lyricOffset": "歌詞偏移時間 (ms)"
},
"lyrics": "歌詞",
"related": "相關",
@@ -396,8 +394,8 @@
"next": "下一首",
"play": "播放",
"playbackFetchCancel": "請稍等…關閉通知以取消",
"queue_moveToBottom": "使所選置",
"queue_moveToTop": "使所選置",
"queue_moveToBottom": "使所選置",
"queue_moveToTop": "使所選置",
"playSimilarSongs": "播放相似歌曲",
"viewQueue": "檢視佇列",
"addLastShuffled": "新增至尾端 (隨機)",
@@ -426,7 +424,7 @@
"hotkey_volumeDown": "音量降低",
"hotkey_volumeMute": "靜音",
"minimumScrobblePercentage": "最小紀錄時長(百分比)",
"minimumScrobblePercentage_description": "歌曲被記錄為已播放(Scrobble)所需的最小播放百分比",
"minimumScrobblePercentage_description": "歌曲被記錄為已播放(scrobble)所需的最小播放百分比",
"theme_description": "設定應用程式的主題",
"accentColor": "強調色",
"accentColor_description": "設定應用程式的強調色",
@@ -435,7 +433,7 @@
"audioDevice": "音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備",
"audioExclusiveMode": "音訊獨佔模式",
"audioExclusiveMode_description": "啟用獨佔輸出模式。在此模式下,系統通常被鎖定,只有 MPV 能夠輸出音訊。視覺化音訊截取在此選項啟用時不會作用",
"audioExclusiveMode_description": "啟用獨佔輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊",
"audioPlayer": "音訊播放器",
"crossfadeDuration": "淡入淡出持續時間",
"crossfadeDuration_description": "設定淡入淡出持續時間",
@@ -443,12 +441,12 @@
"customFontPath": "自定字體路徑",
"customFontPath_description": "設定應用程式使用的自定字體路徑",
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
"discordApplicationId": "{{discord}} 應用程式 ID",
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}}",
"discordIdleStatus": "顯示 Rich Presence 閒置狀態",
"discordApplicationId": "{{discord}} 應用程式 id",
"discordApplicationId_description": "{{discord}} rich presence 應用程式 id(預設為 {{defaultId}}",
"discordIdleStatus": "顯示 rich presence 閒置狀態",
"discordIdleStatus_description": "啟用後將會在播放器閒置時更新狀態",
"discordRichPresence_description": "在 {{discord}} Rich Presence 中顯示播放狀態。圖片鍵為:{{icon}}、{{playing}} 和 {{paused}}",
"discordUpdateInterval": "{{discord}} Rich Presence 更新間隔",
"discordRichPresence_description": "在 {{discord}} rich presence 中顯示播放狀態。圖片鍵為:{{icon}}、{{playing}} 和 {{paused}}",
"discordUpdateInterval": "{{discord}} rich presence 更新間隔",
"discordUpdateInterval_description": "更新間隔秒數(至少 15 秒)",
"enableRemote": "啟用遠端控制伺服器",
"enableRemote_description": "啟用遠端控制伺服器,以允許其他設備控制此應用程式",
@@ -456,12 +454,12 @@
"followLyric": "跟隨目前歌詞",
"font_description": "設定應用程式使用的字體",
"fontType": "字體類型",
"fontType_description": "內建字體可以選擇 Feishin 提供的字體之一。系統字體允許您選擇作業系統提供的任何字體。自定選項允許您使用自己的字體",
"fontType_description": "內建字體可以選擇 feishin 提供的字體之一。系統字體允許您選擇作業系統提供的任何字體。自定選項允許您使用自己的字體",
"fontType_optionBuiltIn": "內建字體",
"fontType_optionCustom": "自定字體",
"fontType_optionSystem": "系統字體",
"gaplessAudio": "無間隔音訊",
"gaplessAudio_description": "調整 MPV 無間隔音訊設定",
"gaplessAudio_description": "調整 mpv 無間隔音訊設定",
"gaplessAudio_optionWeak": "弱(建議)",
"globalMediaHotkeys": "全域媒體快捷鍵",
"hotkey_browserForward": "瀏覽器往前",
@@ -500,8 +498,8 @@
"minimizeToTray": "最小化到系統匣",
"minimizeToTray_description": "將應用程式最小化到系統匣",
"minimumScrobbleSeconds": "最小紀錄時間(秒)",
"minimumScrobbleSeconds_description": "歌曲被記錄為已播放(Scrobble)所需的最小播放時間",
"mpvExecutablePath": "MPV 執行檔路徑",
"minimumScrobbleSeconds_description": "歌曲被記錄為已播放(scrobble)所需的最小播放時間",
"mpvExecutablePath": "mpv 執行檔路徑",
"playbackStyle_optionCrossFade": "淡入淡出",
"playbackStyle_optionNormal": "一般",
"playButtonBehavior": "播放按鈕動作",
@@ -563,7 +561,7 @@
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
"hotkey_playbackStop": "停止",
"hotkey_rate0": "清除評分",
"mpvExecutablePath_description": "設定 MPV 執行檔的路徑。如果留空,則使用預設路徑",
"mpvExecutablePath_description": "設定 mpv 執行檔的路徑。如果留空,則使用預設路徑",
"playbackStyle_description": "選擇播放器的播放風格",
"playButtonBehavior_optionPlay": "$t(player.play)",
"remotePassword": "遠端控制伺服器密碼",
@@ -590,10 +588,10 @@
"contextMenu_description": "允許您隱藏在右鍵選單項目時顯示的項目。未選取的項目將被隱藏",
"customCssEnable": "啟用自訂CSS",
"customCssEnable_description": "允許撰寫自訂CSS",
"customCssNotice": "警告:即使已限制某些用法(不允許 URL() 和 content:),但使用自訂 CSS 仍然會透過更改介面帶來風險",
"customCssNotice": "警告:即使已限制某些用法(不允許 url() 和 content:),但使用自訂 CSS 仍然會透過更改介面帶來風險",
"customCss": "自訂CSS",
"customCss_description": "自訂 CSS 內容。注意:內容和遠端 URL 是不允許使用的屬性。您的內容預覽如下所示。由於需要進行清理,因此存在一些您未設定的其他欄位",
"discordPausedStatus": "暫停時顯示 Rich Presence",
"discordPausedStatus": "暫停時顯示 rich presence",
"discordPausedStatus_description": "啟用後,播放器暫停時將顯示狀態",
"discordListening": "將狀態設為\"正在聽\"",
"discordListening_description": "將狀態顯示為\"正在聽\"而不是\"正在玩\"",
@@ -609,7 +607,7 @@
"homeFeature_description": "控制是否在首頁上顯示大型特色輪播",
"imageAspectRatio": "使用原生封面照長寬比",
"imageAspectRatio_description": "如果啟用,封面照將使用其原始長寬比顯示。對於非 1:1 的封面,剩餘空間將為空",
"lastfm": "顯示 Last.fm 連結",
"lastfm": "顯示 last.fm 連結",
"lastfm_description": "在藝人/專輯頁面顯示 Last.fm 連結",
"lastfmApiKey": "{{lastfm}} API金鑰",
"lastfmApiKey_description": "{{lastfm}}的API金鑰。用於封面照",
@@ -770,7 +768,7 @@
"automaticUpdates": "自動更新",
"automaticUpdates_description": "自動檢查並安裝更新",
"discordStateIcon": "顯示播放中圖示",
"discordStateIcon_description": "在 Rich Presence 狀態中顯示一個小的播放圖示。啟用「暫停時顯示 Rich Presence」時,會始終顯示暫停的圖示",
"discordStateIcon_description": "在 rich presence 狀態中顯示一個小的播放圖示。啟用「暫停時顯示 rich presence」時,會始終顯示暫停的圖示",
"useThemePrimaryShade": "套用主題主色調",
"useThemePrimaryShade_description": "使用所選主題中定義的主色調作為主色變體",
"primaryShade": "主要色調",
@@ -794,9 +792,7 @@
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
"qobuz": "顯示 Qobuz 連結",
"waveformLoadingDelay": "波形載入延遲",
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。",
"playerbarWaveformStretch": "波形拉伸",
"playerbarWaveformStretch_description": "拉伸波形來填補可用空間"
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。"
},
"table": {
"config": {
@@ -837,7 +833,7 @@
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
"bpm": "$t(common.BPM)",
"bpm": "$t(common.bpm)",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"channels": "$t(common.channel, {\"count\": 2})",
@@ -896,7 +892,7 @@
"releaseDate": "發布日期",
"releaseYear": "年份",
"genre": "$t(entity.genre, {\"count\": 1})",
"bpm": "BPM",
"bpm": "bpm",
"songCount": "$t(entity.track, {\"count\": 2})",
"title": "標題",
"trackNumber": "曲目編號",
@@ -978,7 +974,7 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "個人簡介",
"bitrate": "位元率",
"bpm": "BPM",
"bpm": "bpm",
"channels": "$t(common.channel, {\"count\": 2})",
"comment": "評論",
"communityRating": "社群評分",
@@ -986,7 +982,7 @@
"dateAdded": "已新增日期",
"disc": "光碟",
"duration": "時長",
"id": "ID",
"id": "id",
"fromYear": "從年份",
"genre": "$t(entity.genre, {\"count\": 1})",
"isCompilation": "為合輯",
@@ -1027,7 +1023,7 @@
"input_name": "伺服器名稱",
"input_password": "密碼",
"input_savePassword": "儲存密碼",
"input_url": "URL",
"input_url": "url",
"input_username": "使用者名稱",
"success": "伺服器新增成功",
"title": "新增伺服器",
@@ -1339,13 +1335,6 @@
"d": "D",
"z": "Z"
}
},
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
"systemAudioConsentAllow": "允許",
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
"systemAudioConsentDecline": "拒絕",
"systemAudioConsentTitle": "允許存取系統音訊?",
"systemAudioExclusiveModeNotSupported": "啟用音訊獨佔模式時,視覺化不可用。 在MPV設定中停用音訊獨佔模式,然後再試一次。"
}
}
}
+3 -30
View File
@@ -1,13 +1,13 @@
import console from 'console';
import { app, ipcMain } from 'electron';
import { access, rm } from 'fs/promises';
import { rm } from 'fs/promises';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { pid } from 'node:process';
import process from 'process';
import { getMainWindow, sendToastToRenderer } from '../../../index';
import { createLog, isMacOS, isWindows } from '../../../utils';
import { createLog, isWindows } from '../../../utils';
import { store } from '../settings';
import { PlayerData } from '/@/shared/types/domain-types';
@@ -69,7 +69,6 @@ const mpvLog = (
};
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const MACOS_MPV_BINARY_PATHS = ['/opt/homebrew/bin/mpv', '/usr/local/bin/mpv'];
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
@@ -87,38 +86,12 @@ const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
return parameters;
};
const resolveMpvBinaryPath = async (binaryPath?: string) => {
if (binaryPath) {
return binaryPath;
}
if (MPV_BINARY_PATH) {
return MPV_BINARY_PATH;
}
if (!isMacOS()) {
return undefined;
}
for (const candidate of MACOS_MPV_BINARY_PATHS) {
try {
await access(candidate);
return candidate;
} catch {
// Try the next common Homebrew location.
}
}
return undefined;
};
const createMpv = async (data: {
binaryPath?: string;
extraParameters?: string[];
properties?: Record<string, any>;
}): Promise<MpvAPI> => {
const { binaryPath, extraParameters, properties } = data;
const resolvedBinaryPath = await resolveMpvBinaryPath(binaryPath);
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
@@ -126,7 +99,7 @@ const createMpv = async (data: {
{
audio_only: true,
auto_restart: false,
binary: resolvedBinaryPath,
binary: binaryPath || MPV_BINARY_PATH || undefined,
socket: socketPath,
time_update: 1,
},
-1
View File
@@ -40,7 +40,6 @@ export const store = new Store<any>({
playbackType: 'web',
should_prompt_accessibility: true,
shown_accessibility_warning: false,
visualizer_system_audio_consent_granted: false,
window_enable_tray: true,
window_exit_to_tray: false,
window_minimize_to_tray: false,
+1 -8
View File
@@ -1,9 +1,2 @@
import './core';
if (process.platform === 'linux') {
import('./linux');
} else if (process.platform === 'darwin') {
import('./darwin');
} else if (process.platform === 'win32') {
import('./win32');
}
import(`./${process.platform}`);
-17
View File
@@ -150,23 +150,6 @@ ipcMain.on(
return;
}
// If the served id is an empty string, this is a radio
// Use a limited subset of the fields
if (song._serverId === '') {
// The id as passed in from use-mpris is radio- plus the radio ID
// If there are spaces or some other characters, this causes MPRIS to error and
// disconnect the bus. To prevent this, just use a fake track/radio
mprisPlayer.metadata = {
'mpris:trackid': mprisPlayer.objectPath(`track/radio`),
'xesam:album': song.album || null,
'xesam:artist': song.artists?.length
? song.artists.map((artist) => artist.name)
: null,
'xesam:title': song.name || null,
};
return;
}
mprisPlayer.metadata = {
'mpris:artUrl': imageUrl || null,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
-1
View File
@@ -1 +0,0 @@
export {};
+8 -123
View File
@@ -5,7 +5,6 @@ import {
app,
BrowserWindow,
BrowserWindowConstructorOptions,
desktopCapturer,
globalShortcut,
ipcMain,
Menu,
@@ -30,7 +29,7 @@ import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
import MenuBuilder, { MenuPlaybackState } from './menu';
import MenuBuilder from './menu';
import {
autoUpdaterLogInterface,
createLog,
@@ -42,7 +41,7 @@ import {
} from './utils';
import './features';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
import { PlayerType, TitleTheme } from '/@/shared/types/types';
const ALPHA_UPDATER_CONFIG: {
bucket: string;
@@ -278,13 +277,6 @@ let tray: null | Tray = null;
let exitFromTray = false;
let forceQuit = false;
let powerSaveBlockerId: null | number = null;
let menuBuilder: MenuBuilder | null = null;
let currentPlaybackStatus: PlayerStatus = PlayerStatus.PAUSED;
let currentPrivateMode = false;
let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE;
let currentSidebarCollapsed = false;
let currentShuffleEnabled = false;
let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {};
if (process.env.NODE_ENV === 'production') {
import('source-map-support').then((sourceMapSupport) => {
@@ -341,23 +333,6 @@ export const getMainWindow = () => {
return mainWindow;
};
const rebuildMainMenu = () => {
if (!menuBuilder || !mainWindow) return;
menuBuilder.buildMenu({
accelerators: playbackMenuAccelerators,
playbackStatus: currentPlaybackStatus,
privateMode: currentPrivateMode,
repeatMode: currentRepeatMode,
shuffleEnabled: currentShuffleEnabled,
sidebarCollapsed: currentSidebarCollapsed,
});
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null);
}
};
export const sendToastToRenderer = ({
message,
type,
@@ -724,8 +699,12 @@ async function createWindow(first = true): Promise<void> {
});
}
menuBuilder = new MenuBuilder(mainWindow);
rebuildMainMenu();
const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu();
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null);
}
// Open URLs in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
@@ -733,29 +712,6 @@ async function createWindow(first = true): Promise<void> {
return { action: 'deny' };
});
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
if (!isMacOS()) {
callback({ audio: 'loopback' });
return;
}
desktopCapturer
.getSources({ thumbnailSize: { height: 0, width: 0 }, types: ['screen'] })
.then((sources) => {
const source = sources[0];
if (!source) {
callback({});
return;
}
callback({ audio: 'loopback', video: source });
})
.catch((err) => {
log.warn('desktopCapturer.getSources failed', err);
callback({});
});
});
if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
new AppUpdater();
}
@@ -826,17 +782,6 @@ enum BindingActions {
VOLUME_UP = 'volumeUp',
}
const getMenuAccelerator = (
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
action: BindingActions,
) => {
const hotkey = data[action]?.hotkey;
if (!hotkey) return undefined;
return hotkeyToElectronAccelerator(hotkey);
};
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {},
@@ -890,26 +835,6 @@ ipcMain.on(
}
}
playbackMenuAccelerators = {
next: getMenuAccelerator(data, BindingActions.NEXT),
playPause:
getMenuAccelerator(data, BindingActions.PLAY_PAUSE) ||
getMenuAccelerator(data, BindingActions.PLAY) ||
getMenuAccelerator(data, BindingActions.PAUSE),
previous: getMenuAccelerator(data, BindingActions.PREVIOUS),
repeat: getMenuAccelerator(data, BindingActions.TOGGLE_REPEAT),
seekBackward: getMenuAccelerator(data, BindingActions.SKIP_BACKWARD),
seekForward: getMenuAccelerator(data, BindingActions.SKIP_FORWARD),
shuffle: getMenuAccelerator(data, BindingActions.SHUFFLE),
stop: getMenuAccelerator(data, BindingActions.STOP),
volumeDown: getMenuAccelerator(data, BindingActions.VOLUME_DOWN),
volumeUp: getMenuAccelerator(data, BindingActions.VOLUME_UP),
};
if (isMacOS()) {
rebuildMainMenu();
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) {
@@ -1050,43 +975,3 @@ if (!ipcMain.eventNames().includes('open-application-directory')) {
shell.openPath(userDataPath);
});
}
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
currentPlaybackStatus = status;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentRepeatMode = repeat;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentShuffleEnabled = shuffle;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-private-mode', (_event, privateMode: boolean) => {
currentPrivateMode = privateMode;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-sidebar-collapsed', (_event, collapsedSidebar: boolean) => {
currentSidebarCollapsed = collapsedSidebar;
if (!isMacOS()) return;
rebuildMainMenu();
});
+4 -190
View File
@@ -1,53 +1,18 @@
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron';
import packageJson from '../../package.json';
import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';
export type MenuPlaybackState = {
accelerators?: {
next?: string;
playPause?: string;
previous?: string;
repeat?: string;
seekBackward?: string;
seekForward?: string;
shuffle?: string;
stop?: string;
volumeDown?: string;
volumeUp?: string;
};
playbackStatus?: PlayerStatus;
privateMode?: boolean;
repeatMode?: PlayerRepeat;
shuffleEnabled?: boolean;
sidebarCollapsed?: boolean;
};
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
export default class MenuBuilder {
developmentEnvironmentSetup = false;
mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
buildDarwinTemplate({
accelerators,
playbackStatus = PlayerStatus.PAUSED,
privateMode = false,
repeatMode = PlayerRepeat.NONE,
shuffleEnabled = false,
sidebarCollapsed = false,
}: MenuPlaybackState = {}): MenuItemConstructorOptions[] {
const isPlaying = playbackStatus === PlayerStatus.PLAYING;
const isRepeatEnabled = repeatMode !== PlayerRepeat.NONE;
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
@@ -64,21 +29,6 @@ export default class MenuBuilder {
label: 'Settings',
},
{ type: 'separator' },
{
click: () => {
this.mainWindow.webContents.send('renderer-open-manage-servers');
},
label: 'Manage servers',
},
{
checked: privateMode,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-private-mode');
},
label: 'Private session',
type: 'checkbox',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
@@ -121,22 +71,6 @@ export default class MenuBuilder {
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+K',
click: () => {
this.mainWindow.webContents.send('renderer-open-command-palette');
},
label: 'Command Palette...',
},
{
checked: sidebarCollapsed,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-sidebar');
},
label: 'Collapse sidebar',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: 'Command+R',
click: () => {
@@ -163,22 +97,6 @@ export default class MenuBuilder {
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+K',
click: () => {
this.mainWindow.webContents.send('renderer-open-command-palette');
},
label: 'Command Palette...',
},
{
checked: sidebarCollapsed,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-sidebar');
},
label: 'Collapse sidebar',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: 'Ctrl+Command+F',
click: () => {
@@ -201,89 +119,6 @@ export default class MenuBuilder {
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuPlayback: MenuItemConstructorOptions = {
label: 'Playback',
submenu: [
{
accelerator: accelerators?.playPause,
click: () => {
this.mainWindow.webContents.send('renderer-player-play-pause');
},
label: isPlaying ? 'Pause' : 'Play',
},
{ type: 'separator' },
{
accelerator: accelerators?.next,
click: () => {
this.mainWindow.webContents.send('renderer-player-next');
},
label: 'Next',
},
{
accelerator: accelerators?.previous,
click: () => {
this.mainWindow.webContents.send('renderer-player-previous');
},
label: 'Previous',
},
{
accelerator: accelerators?.seekForward,
click: () => {
this.mainWindow.webContents.send('renderer-player-skip-forward');
},
label: 'Seek Forward',
},
{
accelerator: accelerators?.seekBackward,
click: () => {
this.mainWindow.webContents.send('renderer-player-skip-backward');
},
label: 'Seek Backforward',
},
{ type: 'separator' },
{
accelerator: accelerators?.shuffle,
checked: shuffleEnabled,
click: () => {
this.mainWindow.webContents.send('renderer-player-toggle-shuffle');
},
label: 'Shuffle',
type: 'checkbox',
},
{
accelerator: accelerators?.repeat,
checked: isRepeatEnabled,
click: () => {
this.mainWindow.webContents.send('renderer-player-toggle-repeat');
},
label: 'Repeat',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: accelerators?.stop,
click: () => {
this.mainWindow.webContents.send('renderer-player-stop');
},
label: 'Stop',
},
{ type: 'separator' },
{
accelerator: accelerators?.volumeUp,
click: () => {
this.mainWindow.webContents.send('renderer-player-volume-up');
},
label: 'Volume Up',
},
{
accelerator: accelerators?.volumeDown,
click: () => {
this.mainWindow.webContents.send('renderer-player-volume-down');
},
label: 'Volume Down',
},
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
@@ -313,13 +148,6 @@ export default class MenuBuilder {
},
label: 'Search Issues',
},
{ type: 'separator' },
{
click: () => {
this.mainWindow.webContents.send('renderer-open-release-notes');
},
label: 'Version ' + packageJson.version,
},
],
};
@@ -328,14 +156,7 @@ export default class MenuBuilder {
? subMenuViewDev
: subMenuViewProd;
return [
subMenuAbout,
subMenuEdit,
subMenuView,
subMenuPlayback,
subMenuWindow,
subMenuHelp,
];
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate(): MenuItemConstructorOptions[] {
@@ -441,14 +262,14 @@ export default class MenuBuilder {
return templateDefault;
}
buildMenu(playbackState: MenuPlaybackState = {}): Menu {
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate(playbackState)
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template);
@@ -458,13 +279,6 @@ export default class MenuBuilder {
}
setupDevelopmentEnvironment(): void {
// buildMenu can run multiple times as menu state updates; attach this once.
if (this.developmentEnvironmentSetup) {
return;
}
this.developmentEnvironmentSetup = true;
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
+1 -2
View File
@@ -1,5 +1,4 @@
import type { SetActivity } from '@xhayper/discord-rpc';
import { SetActivity } from '@xhayper/discord-rpc';
import { ipcRenderer } from 'electron';
const initialize = (clientId: string) => {
-25
View File
@@ -65,26 +65,6 @@ const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-settings', cb);
};
const rendererOpenCommandPalette = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-command-palette', cb);
};
const rendererOpenManageServers = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-manage-servers', cb);
};
const rendererTogglePrivateMode = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-toggle-private-mode', cb);
};
const rendererToggleSidebar = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-toggle-sidebar', cb);
};
const rendererOpenReleaseNotes = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-release-notes', cb);
};
export const utils = {
checkForUpdates,
disableAutoUpdates,
@@ -98,12 +78,7 @@ export const utils = {
openApplicationDirectory,
openItem,
playerErrorListener,
rendererOpenCommandPalette,
rendererOpenManageServers,
rendererOpenReleaseNotes,
rendererOpenSettings,
rendererTogglePrivateMode,
rendererToggleSidebar,
};
export type Utils = typeof utils;
+177 -85
View File
@@ -34,8 +34,10 @@ const apiController = <K extends keyof ControllerEndpoint>(
if (!serverType) {
toast.error({
message: i18n.t('error.serverNotSelectedError') as string,
title: i18n.t('error.apiRouteError') as string,
message: i18n.t('error.serverNotSelectedError', {
postProcess: 'sentenceCase',
}) as string,
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
});
throw new Error(`No server selected`);
}
@@ -45,13 +47,13 @@ const apiController = <K extends keyof ControllerEndpoint>(
if (typeof controllerFn !== 'function') {
toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: i18n.t('error.apiRouteError') as string,
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
});
throw new Error(
i18n.t('error.endpointNotImplementedError', {
endpoint,
postProcess: 'sentenceCase',
serverType,
}) as string,
);
@@ -90,7 +92,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: addToPlaylist`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: addToPlaylist`,
);
}
return apiController(
@@ -105,7 +109,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: createFavorite`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: createFavorite`,
);
}
return apiController(
@@ -117,7 +123,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: createInternetRadioStation`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: createInternetRadioStation`,
);
}
return apiController(
@@ -129,7 +137,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: createPlaylist`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: createPlaylist`,
);
}
return apiController(
@@ -137,23 +147,13 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: deleteArtistImage`);
}
return apiController(
'deleteArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteFavorite(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: deleteFavorite`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteFavorite`,
);
}
return apiController(
@@ -165,7 +165,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: deleteInternetRadioStation`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStation`,
);
}
return apiController(
@@ -177,7 +179,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: deleteInternetRadioStationImage`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStationImage`,
);
}
return apiController(
@@ -189,7 +193,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: deletePlaylist`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deletePlaylist`,
);
}
return apiController(
@@ -201,7 +207,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: deletePlaylistImage`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deletePlaylistImage`,
);
}
return apiController(
@@ -213,7 +221,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getAlbumArtistDetail`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumArtistDetail`,
);
}
return apiController(
@@ -237,7 +247,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getAlbumArtistList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumArtistList`,
);
}
return apiController(
@@ -255,7 +267,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getAlbumArtistListCount`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumArtistListCount`,
);
}
return apiController(
@@ -273,7 +287,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getAlbumDetail`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumDetail`,
);
}
return apiController(
@@ -285,7 +301,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getAlbumInfo`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumInfo`,
);
}
return apiController(
@@ -297,7 +315,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getAlbumList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumList`,
);
}
return apiController(
@@ -315,7 +335,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getAlbumListCount`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumListCount`,
);
}
return apiController(
@@ -333,7 +355,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getAlbumRadio`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumRadio`,
);
}
return apiController(
@@ -345,7 +369,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getArtistList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getArtistList`,
);
}
return apiController(
@@ -363,7 +389,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getArtistListCount`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getArtistListCount`,
);
}
return apiController(
@@ -381,7 +409,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getArtistRadio`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getArtistRadio`,
);
}
return apiController(
@@ -393,7 +423,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getDownloadUrl`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getDownloadUrl`,
);
}
return apiController(
@@ -405,7 +437,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getFolder`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getFolder`,
);
}
return apiController(
@@ -423,7 +457,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getGenreList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getGenreList`,
);
}
return apiController(
@@ -479,7 +515,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getInternetRadioStations`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getInternetRadioStations`,
);
}
return apiController(
'getInternetRadioStations',
@@ -490,7 +528,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getLyrics`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getLyrics`,
);
}
return apiController(
@@ -502,7 +542,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getMusicFolderList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getMusicFolderList`,
);
}
return apiController(
@@ -514,7 +556,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getPlaylistDetail`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlaylistDetail`,
);
}
return apiController(
@@ -526,7 +570,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getPlaylistList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlaylistList`,
);
}
return apiController(
@@ -538,7 +584,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getPlaylistListCount`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlaylistListCount`,
);
}
return apiController(
@@ -550,7 +598,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getPlaylistSongList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlaylistSongList`,
);
}
return apiController(
@@ -562,7 +612,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getPlayQueue`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlayQueue`,
);
}
return apiController(
@@ -574,7 +626,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getRandomSongList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getRandomSongList`,
);
}
return apiController(
@@ -592,7 +646,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getRoles`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getRoles`,
);
}
return apiController(
@@ -604,7 +660,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getServerInfo`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getServerInfo`,
);
}
return apiController(
@@ -616,7 +674,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getSimilarSongs`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getSimilarSongs`,
);
}
return apiController(
@@ -634,7 +694,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getSongDetail`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getSongDetail`,
);
}
return apiController(
@@ -646,7 +708,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getSongList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getSongList`,
);
}
return apiController(
@@ -664,7 +728,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getSongListCount`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getSongListCount`,
);
}
return apiController(
@@ -682,7 +748,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getStreamUrl`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`,
);
}
return apiController(
@@ -694,7 +762,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getStructuredLyrics`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStructuredLyrics`,
);
}
return apiController(
@@ -706,7 +776,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getTags`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getTags`,
);
}
return apiController(
@@ -718,7 +790,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getTopSongs`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getTopSongs`,
);
}
return apiController(
@@ -730,7 +804,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getUserInfo`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getUserInfo`,
);
}
return apiController(
@@ -742,7 +818,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getUserList`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getUserList`,
);
}
return apiController(
@@ -754,7 +832,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: movePlaylistItem`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: movePlaylistItem`,
);
}
return apiController(
@@ -766,7 +846,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: removeFromPlaylist`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: removeFromPlaylist`,
);
}
return apiController(
@@ -778,7 +860,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: replacePlaylist`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: replacePlaylist`,
);
}
return apiController(
@@ -790,7 +874,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: savePlayQueue`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: savePlayQueue`,
);
}
return apiController(
@@ -802,7 +888,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: scrobble`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: scrobble`,
);
}
return apiController(
@@ -814,7 +902,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: search`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: search`,
);
}
return apiController(
@@ -832,7 +922,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: setPlaylistSongs`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: setPlaylistSongs`,
);
}
return apiController(
@@ -844,7 +936,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: setRating`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: setRating`,
);
}
return apiController(
@@ -856,7 +950,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: shareItem`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: shareItem`,
);
}
return apiController(
@@ -868,7 +964,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: updateInternetRadioStation`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: updateInternetRadioStation`,
);
}
return apiController(
@@ -880,7 +978,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: updatePlaylist`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: updatePlaylist`,
);
}
return apiController(
@@ -888,23 +988,13 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: uploadArtistImage`);
}
return apiController(
'uploadArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: uploadInternetRadioStationImage`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadInternetRadioStationImage`,
);
}
return apiController(
@@ -916,7 +1006,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: uploadPlaylistImage`);
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadPlaylistImage`,
);
}
return apiController(
+5 -1
View File
@@ -447,7 +447,11 @@ export const jfApiClient = (args: {
} catch (e: any | AxiosError | Error) {
if (isAxiosError(e)) {
if (e.code === 'ERR_NETWORK') {
throw new Error(i18n.t('error.networkError') as string);
throw new Error(
i18n.t('error.networkError', {
postProcess: 'sentenceCase',
}) as string,
);
}
const error = e as AxiosError;
@@ -409,8 +409,6 @@ export const JellyfinController: InternalControllerEndpoint = {
return jfNormalize.album(
{ ...res.body, Songs: songsRes.body.Items },
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
);
},
getAlbumList: async (args) => {
@@ -582,8 +580,7 @@ export const JellyfinController: InternalControllerEndpoint = {
return `${apiClientProps.server?.url}/items/${query.id}/download?apiKey=${apiClientProps.server?.credential}`;
},
getFolder: async (args) => {
const { apiClientProps, query } = args;
getFolder: async ({ apiClientProps, query }) => {
const userId = apiClientProps.server?.userId;
if (!userId) throw new Error('No userId found');
@@ -745,8 +742,6 @@ export const JellyfinController: InternalControllerEndpoint = {
jfNormalize.song(
item as unknown as z.infer<typeof jfType._response.song>,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
);
+16 -22
View File
@@ -46,15 +46,6 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStation: {
body: null,
method: 'DELETE',
@@ -268,15 +259,6 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
uploadArtistImage: {
body: ndType._parameters.uploadArtistImage,
method: 'POST',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadInternetRadioStationImage: {
body: ndType._parameters.uploadInternetRadioStationImage,
method: 'POST',
@@ -405,8 +387,12 @@ axiosClient.interceptors.response.use(
if (res.status === 429) {
toast.error({
message: i18n.t('error.loginRateError') as string,
title: i18n.t('error.sessionExpiredError') as string,
message: i18n.t('error.loginRateError', {
postProcess: 'sentenceCase',
}) as string,
title: i18n.t('error.sessionExpiredError', {
postProcess: 'sentenceCase',
}) as string,
});
const serverId = currentServer.id;
@@ -421,7 +407,11 @@ axiosClient.interceptors.response.use(
throw TIMEOUT_ERROR;
}
if (res.status !== 200) {
throw new Error(i18n.t('error.authenticatedFailed') as string);
throw new Error(
i18n.t('error.authenticatedFailed', {
postProcess: 'sentenceCase',
}) as string,
);
}
const newCredential = res.data.token;
@@ -514,7 +504,11 @@ export const ndApiClient = (args: {
} catch (e: any | AxiosError | Error) {
if (isAxiosError(e)) {
if (e.code === 'ERR_NETWORK') {
throw new Error(i18n.t('error.networkError') as string);
throw new Error(
i18n.t('error.networkError', {
postProcess: 'sentenceCase',
}) as string,
);
}
const error = e as AxiosError;
@@ -13,8 +13,6 @@ import {
albumArtistListSortMap,
albumListSortMap,
AuthenticationResponse,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
@@ -30,8 +28,6 @@ import {
SortOrder,
sortOrderMap,
tagListSortMap,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
UploadPlaylistImageArgs,
@@ -46,7 +42,6 @@ const VERSION_INFO: VersionInfo = [
[
'0.61.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
},
@@ -191,21 +186,6 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id,
};
},
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteArtistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete artist image');
}
return res.body.data.status === 'ok';
},
deleteFavorite: SubsonicController.deleteFavorite,
deleteInternetRadioStation: async (args) => {
const { apiClientProps, query } = args;
@@ -317,8 +297,8 @@ export const NavidromeController: InternalControllerEndpoint = {
similarArtists:
artistInfo?.similarArtist?.map((artist) => ({
id: artist.id,
imageId: artist.id,
imageUrl: null,
imageId: null,
imageUrl: artist?.artistImageUrl?.replace(/\?size=\d+/, '') ?? null,
name: artist.name,
userFavorite: Boolean(artist.starred) || false,
userRating: artist.userRating ?? null,
@@ -496,12 +476,7 @@ export const NavidromeController: InternalControllerEndpoint = {
}
return res.body.similarSongs.song.map((song) =>
ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
ssNormalize.song(song, apiClientProps.server),
);
},
getArtistList: async (args) => {
@@ -571,12 +546,7 @@ export const NavidromeController: InternalControllerEndpoint = {
}
return res.body.similarSongs2.song.map((song) =>
ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
ssNormalize.song(song, apiClientProps.server),
);
},
getDownloadUrl: SubsonicController.getDownloadUrl,
@@ -833,14 +803,7 @@ export const NavidromeController: InternalControllerEndpoint = {
return (
(res.body.similarSongs?.song || [])
.filter((song) => song.id !== query.songId)
.map((song) =>
ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
) || []
.map((song) => ssNormalize.song(song, apiClientProps.server)) || []
);
},
getSongDetail: async (args) => {
@@ -1039,7 +1002,6 @@ export const NavidromeController: InternalControllerEndpoint = {
const res = await NavidromeController.getSongList({
apiClientProps,
context: args.context,
query: {
artistIds: [query.artistId],
sortBy: SongListSort.PLAY_COUNT,
@@ -1308,40 +1270,6 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/artist/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload artist image');
}
return res.data?.status === 'ok';
},
uploadInternetRadioStationImage: async (
args: UploadInternetRadioStationImageArgs,
): Promise<UploadInternetRadioStationImageResponse> => {
+9 -32
View File
@@ -1,5 +1,6 @@
import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios';
import omitBy from 'lodash/omitBy';
import qs from 'qs';
import { z } from 'zod';
@@ -361,7 +362,7 @@ axiosClient.interceptors.response.use(
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: i18n.t('error.genericError') as string,
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
// Since we do status === 200, override this value with the error code
@@ -376,39 +377,11 @@ axiosClient.interceptors.response.use(
},
);
const keysToSkipEmptyCheck = new Set([
'artist',
'comment',
'genre',
'name',
'query',
'u',
'username',
]);
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const url = new URLSearchParams(params);
const notNilParams: Record<string, string[]> = {};
for (const [key, value] of url) {
if (!keysToSkipEmptyCheck.has(key) && (value === 'undefined' || value === 'null')) {
continue;
}
let realKey = key;
if (key.includes('[') && key.includes(']')) {
realKey = key.split('[')[0];
}
if (realKey in notNilParams) {
notNilParams[realKey].push(value);
} else {
notNilParams[realKey] = [value];
}
}
const parsedParams = qs.parse(params, { arrayLimit: 99999, parameterLimit: 99999 });
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
return {
params: notNilParams,
@@ -523,7 +496,11 @@ export const ssApiClient = (args: {
} catch (e: any | AxiosError | Error) {
if (isAxiosError(e)) {
if (e.code === 'ERR_NETWORK') {
throw new Error(i18n.t('error.networkError') as string);
throw new Error(
i18n.t('error.networkError', {
postProcess: 'sentenceCase',
}) as string,
);
}
const error = e as AxiosError;
@@ -237,27 +237,6 @@ function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
return streamUrl;
}
function buildGetTranscodeStreamUrl(
server: null | undefined | { credential?: string; url?: string },
args: {
mediaId: string;
mediaType: 'podcast' | 'song';
offset: number;
transcodeParams: string;
},
): string {
const params = new URLSearchParams({
c: 'Feishin',
mediaId: args.mediaId,
mediaType: args.mediaType,
offset: String(args.offset),
transcodeParams: args.transcodeParams,
v: '1.13.0',
});
return `${server?.url}/rest/getTranscodeStream.view?${params.toString()}&${server?.credential}`;
}
function sortAndPaginate<T>(
items: T[],
options: {
@@ -508,7 +487,7 @@ export const SubsonicController: InternalControllerEndpoint = {
similarArtists:
artistInfo?.similarArtist?.map((artist) => ({
id: artist.id,
imageId: artist.coverArt ?? artist.id,
imageId: null,
imageUrl: null,
name: artist.name,
userFavorite: Boolean(artist.starred) || false,
@@ -2015,12 +1994,8 @@ export const SubsonicController: InternalControllerEndpoint = {
},
});
// If the server returns an error for transcodeDecision, fall back to direct stream so that we don't break the player
if (transcodeDecision.status !== 200) {
logFn.error(
`Failed to get transcode decision for song ${id}, falling back to direct stream`,
);
return streamUrl;
throw new Error('Failed to get transcode decision');
}
const td = transcodeDecision.body.transcodeDecision;
@@ -2038,14 +2013,20 @@ export const SubsonicController: InternalControllerEndpoint = {
return appendTranscodeParams(streamUrl, format, bitrate);
}
const transcodeStreamUrl = buildGetTranscodeStreamUrl(server, {
mediaId: String(id),
mediaType: (mediaType ?? 'song') as 'podcast' | 'song',
offset: 0,
transcodeParams: td.transcodeParams,
const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({
query: {
mediaId: id,
mediaType,
offset: 0,
transcodeParams: td.transcodeParams,
},
});
return transcodeStreamUrl;
if (transcodeStreamUrl.status !== 200) {
throw new Error('Failed to get transcode stream');
}
return transcodeStreamUrl.body;
}
return streamUrl;
@@ -2125,7 +2106,6 @@ export const SubsonicController: InternalControllerEndpoint = {
const res = await SubsonicController.getSongList({
apiClientProps,
context,
query: {
artistIds: [query.artistId],
sortBy: SongListSort.PLAY_COUNT,
+23 -6
View File
@@ -10,9 +10,9 @@ import isElectron from 'is-electron';
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n';
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
@@ -22,7 +22,12 @@ import { WebAudio } from '/@/shared/types/types';
import '/@/shared/styles/global.css';
import { PlayerProvider } from '/@/renderer/features/player/context/player-context';
import { AudioPlayers } from '/@/renderer/features/player/components/audio-players';
import { ReleaseNotesModal } from '/@/renderer/release-notes-modal';
const ReleaseNotesModal = lazy(() =>
import('./release-notes-modal').then((module) => ({
default: module.ReleaseNotesModal,
})),
);
const UpdateAvailableDialog = lazy(() =>
import('./update-available-dialog').then((module) => ({
@@ -77,8 +82,8 @@ const AppShell = memo(function AppShell() {
<AppRouter />
</PlayerProvider>
</WebAudioContext.Provider>
<ReleaseNotesModal />
<Suspense fallback={null}>
<ReleaseNotesModal />
<UpdateAvailableDialog />
</Suspense>
</>
@@ -92,7 +97,7 @@ const AppEffects = () => (
<CssSettingsEffect />
<GlobalShortcutsEffect />
<LanguageEffect />
<NativeMenuSyncEffect />
<OpenSettingsEffect />
</>
);
@@ -165,8 +170,20 @@ const LanguageEffect = () => {
return null;
};
const NativeMenuSyncEffect = () => {
useNativeMenuSync();
const OpenSettingsEffect = () => {
useEffect(() => {
if (isElectron()) {
window.api.utils.rendererOpenSettings(() => {
openSettingsModal();
});
return () => {
ipc?.removeAllListeners('renderer-open-settings');
};
}
return undefined;
}, []);
return null;
};
+302 -340
View File
@@ -169,292 +169,6 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
showRating: boolean;
}
type ItemCardData = NonNullable<ItemCardProps['data']>;
const ItemCardStandardImageArea = memo(function ItemCardStandardImageArea({
controls,
data,
enableExpansion,
enableImageViewport = true,
enableNavigation,
handleContextMenu,
handleImageClick,
handleLinkDragStart,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
navigationPath,
showRating,
variant,
withControls,
}: {
controls?: ItemControls;
data: ItemCardData;
enableExpansion?: boolean;
enableImageViewport?: boolean;
enableNavigation?: boolean;
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
navigationPath: null | string;
showRating: boolean;
variant: 'default' | 'poster';
withControls?: boolean;
}) {
const [showControls, setShowControls] = useState(false);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
{...(variant === 'poster' ? { enableViewport: enableImageViewport } : {})}
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
fetchPriority={imageFetchPriority}
id={(data as { imageId?: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl?: string })?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
{...(variant === 'poster' ? { internalState } : {})}
item={data}
itemType={itemType}
showRating={showRating}
type={variant}
/>
)}
</AnimatePresence>
</>
);
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
);
});
ItemCardStandardImageArea.displayName = 'ItemCardStandardImageArea';
const CompactItemCardImageArea = memo(function CompactItemCardImageArea({
controls,
data,
enableExpansion,
enableNavigation,
handleContextMenu,
handleImageClick,
handleLinkDragStart,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
navigationPath,
rows,
showRating,
withControls,
}: {
controls?: ItemControls;
data: ItemCardData;
enableExpansion?: boolean;
enableNavigation?: boolean;
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
navigationPath: null | string;
rows: DataRow[];
showRating: boolean;
withControls?: boolean;
}) {
const [showControls, setShowControls] = useState(false);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</>
);
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
);
});
CompactItemCardImageArea.displayName = 'CompactItemCardImageArea';
const CompactItemCard = ({
controls,
data,
@@ -471,6 +185,7 @@ const CompactItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -582,6 +297,18 @@ const CompactItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -611,6 +338,81 @@ const CompactItemCard = ({
e.stopPropagation();
};
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> =>
row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</>
);
return (
<div
className={clsx(styles.container, styles.compact, {
@@ -619,24 +421,31 @@ const CompactItemCard = ({
})}
ref={ref}
>
<CompactItemCardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
rows={rows}
showRating={showRating}
withControls={withControls}
/>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
</div>
);
}
@@ -682,6 +491,7 @@ const DefaultItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -728,6 +538,18 @@ const DefaultItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -757,30 +579,93 @@ const DefaultItemCard = ({
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
item={data}
itemType={itemType}
showRating={showRating}
type="default"
/>
)}
</AnimatePresence>
</>
);
return (
<div
className={clsx(styles.container, {
[styles.selected]: isSelected,
})}
>
<ItemCardStandardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
showRating={showRating}
variant="default"
withControls={withControls}
/>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
<div className={styles.detailContainer}>
{rows
.filter(
@@ -843,6 +728,7 @@ const PosterItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -954,6 +840,18 @@ const PosterItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -983,6 +881,63 @@ const PosterItemCard = ({
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={(data as { imageId: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl: string })?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="poster"
/>
)}
</AnimatePresence>
</>
);
return (
<div
className={clsx(styles.container, styles.poster, {
@@ -991,24 +946,31 @@ const PosterItemCard = ({
})}
ref={ref}
>
<ItemCardStandardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
showRating={showRating}
variant="poster"
withControls={withControls}
/>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
{data && (
<div className={styles.detailContainer}>
{rows
@@ -1,30 +0,0 @@
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { TableColumn } from '/@/shared/types/types';
const LAYOUT_FILL_COLUMN: ItemTableListColumnConfig = {
align: 'start',
autoSize: true,
id: TableColumn.LAYOUT_FILL,
isEnabled: true,
pinned: null,
width: 0,
};
export const appendLayoutFillColumn = (
columns: ItemTableListColumnConfig[],
autoFitColumns: boolean,
): ItemTableListColumnConfig[] => {
if (autoFitColumns || columns.length === 0) {
return columns;
}
const unpinnedEnabled = columns.filter((c) => c.pinned === null && c.isEnabled !== false);
if (unpinnedEnabled.length === 0) {
return columns;
}
if (unpinnedEnabled.some((c) => c.autoSize === true)) {
return columns;
}
return [...columns, LAYOUT_FILL_COLUMN];
};
@@ -40,13 +40,6 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const setFavorite = useSetFavorite();
const setRating = useSetRating();
const playerRef = useRef(player);
const setFavoriteRef = useRef(setFavorite);
const setRatingRef = useRef(setRating);
playerRef.current = player;
setFavoriteRef.current = setFavorite;
setRatingRef.current = setRating;
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
@@ -273,14 +266,14 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
playerRef.current.addToQueueByData(songsToAdd, playType, item.id);
player.addToQueueByData(songsToAdd, playType, item.id);
return;
}
if (itemType === LibraryItem.QUEUE_SONG) {
const queueSong = item as QueueSong;
if (queueSong._uniqueId) {
playerRef.current.mediaPlay(queueSong._uniqueId);
player.mediaPlay(queueSong._uniqueId);
}
}
},
@@ -323,7 +316,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
setFavoriteRef.current(item._serverId, [item.id], apiItemType, favorite);
setFavorite(item._serverId, [item.id], apiItemType, favorite);
},
onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
@@ -401,7 +394,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
playerRef.current.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
onRating: ({
@@ -427,12 +420,20 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
newRating = 0;
}
setRatingRef.current(item._serverId, [item.id], apiItemType, newRating);
setRating(item._serverId, [item.id], apiItemType, newRating);
},
...overrides,
};
}, [enableMultiSelect, overrides, onColumnReordered, onColumnResized]);
}, [
enableMultiSelect,
overrides,
onColumnReordered,
onColumnResized,
player,
setFavorite,
setRating,
]);
return controls;
};
@@ -349,12 +349,9 @@ export const useItemListInfiniteLoader = ({
mutationKey: getListRefreshMutationKey(eventKey),
});
const refreshMutationRef = useRef(refreshMutation);
refreshMutationRef.current = refreshMutation;
const refresh = useCallback(
async (force?: boolean) => refreshMutationRef.current.mutateAsync(force),
[],
async (force?: boolean) => refreshMutation.mutateAsync(force),
[refreshMutation],
);
const updateItems = useCallback(
@@ -386,7 +383,7 @@ export const useItemListInfiniteLoader = ({
return;
}
refreshMutationRef.current.mutate(true);
refreshMutation.mutate(true);
};
eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);
@@ -394,7 +391,7 @@ export const useItemListInfiniteLoader = ({
return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
};
}, [eventKey]);
}, [eventKey, refreshMutation]);
useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -5,7 +5,7 @@ import {
useSuspenseQuery,
UseSuspenseQueryOptions,
} from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context';
@@ -115,9 +115,6 @@ export const useItemListPaginatedLoader = ({
mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'),
});
const refreshMutationRef = useRef(refreshMutation);
refreshMutationRef.current = refreshMutation;
const updateItems = useCallback(
(indexes: number[], value: object) => {
return queryClient.setQueryData(
@@ -156,7 +153,7 @@ export const useItemListPaginatedLoader = ({
return;
}
refreshMutationRef.current.mutate(true);
refreshMutation.mutate(true);
};
const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -223,7 +220,7 @@ export const useItemListPaginatedLoader = ({
eventEmitter.off('USER_FAVORITE', handleFavorite);
eventEmitter.off('USER_RATING', handleRating);
};
}, [data, eventKey, itemType, serverId, updateItems]);
}, [data, eventKey, itemType, refreshMutation, serverId, updateItems]);
return { data: data?.items || [], pageCount, totalItemCount };
};
@@ -67,7 +67,6 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
[TableColumn.ID]: null,
[TableColumn.IMAGE]: null,
[TableColumn.LAST_PLAYED]: 'lastPlayedAt',
[TableColumn.LAYOUT_FILL]: null,
[TableColumn.OWNER]: null,
[TableColumn.PATH]: null,
[TableColumn.PLAY_COUNT]: 'playCount',
@@ -6,8 +6,8 @@ import {
ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { useHotkeySettings, usePlayButtonBehavior } from '/@/renderer/store';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@@ -179,14 +179,6 @@
opacity: 1;
}
.resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.track-header-cell:hover .resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.resize-handle:hover {
opacity: 1;
}
@@ -911,7 +911,8 @@ const DetailListHeaderCell = memo(
const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0;
const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId);
const currentWidth = col?.width ?? (fixedWidth || 100);
const showResizeHandle = enableColumnResize && !isFixedColumn;
const showResizeHandle =
enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized;
useEffect(() => {
if (!containerRef.current || !onColumnReordered) {
@@ -1025,7 +1026,6 @@ const DetailListHeaderCell = memo(
{showResizeHandle && (
<DetailListColumnResizeHandle
columnId={columnId}
disabled={!!col?.autoSize}
initialWidth={currentWidth}
onResize={handleResize}
side="right"
@@ -1040,7 +1040,6 @@ DetailListHeaderCell.displayName = 'DetailListHeaderCell';
interface DetailListColumnResizeHandleProps {
columnId: TableColumn;
disabled?: boolean;
initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right';
@@ -1048,7 +1047,6 @@ interface DetailListColumnResizeHandleProps {
const DetailListColumnResizeHandle = ({
columnId,
disabled = false,
initialWidth,
onResize,
side,
@@ -1093,11 +1091,6 @@ const DetailListColumnResizeHandle = ({
}, [isDragging, columnId, onResize]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
@@ -1110,7 +1103,6 @@ const DetailListColumnResizeHandle = ({
return (
<div
className={clsx(styles.resizeHandle, {
[styles.resizeHandleDisabled]: disabled,
[styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right',
@@ -4,7 +4,6 @@
flex-direction: column !important;
width: 100%;
height: 100%;
padding-block: var(--theme-spacing-xs);
padding-right: var(--theme-spacing-md);
outline: none;
border: none;
@@ -385,8 +385,8 @@ const BaseItemGridList = ({
rows,
size = 'default',
}: ItemGridListProps) => {
const rootRef = useRef<HTMLDivElement | null>(null);
const outerRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef(null);
const outerRef = useRef(null);
const listRef = useRef<FixedSizeList<GridItemProps>>(null);
const { ref: containerRef, width: containerWidth } = useElementSize();
const { focused, ref: containerFocusRef } = useFocusWithin();
@@ -486,7 +486,7 @@ const BaseItemGridList = ({
}, [itemsPerRow, rows?.length, size]);
useLayoutEffect(() => {
const container = rootRef.current;
const { current: container } = containerRef;
if (!container) return;
throttledSetTableMeta(containerWidth, resolvedItemCount, (meta) => {
@@ -500,15 +500,13 @@ const BaseItemGridList = ({
current.rowCount !== meta.rowCount
) {
tableMetaRef.current = meta;
const el = rootRef.current;
if (!el) return;
el.style.setProperty('--grid-column-count', String(meta.columnCount));
el.style.setProperty('--grid-item-height', `${meta.itemHeight}px`);
el.style.setProperty('--grid-row-count', String(meta.rowCount));
container.style.setProperty('--grid-column-count', String(meta.columnCount));
container.style.setProperty('--grid-item-height', `${meta.itemHeight}px`);
container.style.setProperty('--grid-row-count', String(meta.rowCount));
setTableMetaVersion((v) => v + 1);
}
});
}, [containerWidth, resolvedItemCount, throttledSetTableMeta]);
}, [containerWidth, resolvedItemCount, throttledSetTableMeta, containerRef]);
const controls = useDefaultItemListControls({
enableMultiSelect,
@@ -313,10 +313,12 @@ const PlaylistReorderColumnBase = (props: ItemTableListInnerColumn) => {
<>
<Stack gap="xs" justify="center">
<Text fw={500} ta="center">
{t('action.moveUp')}
{t('action.moveUp', { postProcess: 'sentenceCase' })}
</Text>
<Text fw={500} isMuted size="xs" ta="center">
{t('action.holdToMoveToTop')}
{t('action.holdToMoveToTop', {
postProcess: 'sentenceCase',
})}
</Text>
</Stack>
</>
@@ -334,10 +336,12 @@ const PlaylistReorderColumnBase = (props: ItemTableListInnerColumn) => {
<>
<Stack gap="xs" justify="center">
<Text fw={500} ta="center">
{t('action.moveDown')}
{t('action.moveDown', { postProcess: 'sentenceCase' })}
</Text>
<Text fw={500} isMuted size="xs" ta="center">
{t('action.holdToMoveToBottom')}
{t('action.holdToMoveToBottom', {
postProcess: 'sentenceCase',
})}
</Text>
</Stack>
</>
@@ -17,7 +17,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.albumGroup'),
label: i18n.t('table.config.label.albumGroup', { postProcess: 'titleCase' }),
pinned: 'left',
value: TableColumn.ALBUM_GROUP,
width: 200,
@@ -26,7 +26,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.rowIndex'),
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ROW_INDEX,
width: 60,
@@ -35,7 +35,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.image'),
label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.IMAGE,
width: 70,
@@ -44,7 +44,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.title'),
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE,
width: 300,
@@ -53,7 +53,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.titleCombined'),
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE_COMBINED,
width: 300,
@@ -62,7 +62,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.titleArtist'),
label: i18n.t('table.config.label.titleArtist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE_ARTIST,
width: 300,
@@ -71,7 +71,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.duration'),
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DURATION,
width: 100,
@@ -80,7 +80,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.album'),
label: i18n.t('table.config.label.album', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ALBUM,
width: 300,
@@ -89,7 +89,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: true,
isEnabled: false,
label: i18n.t('table.config.label.albumArtist'),
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ALBUM_ARTIST,
width: 300,
@@ -98,7 +98,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.artist'),
label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ARTIST,
width: 300,
@@ -107,7 +107,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.composer'),
label: i18n.t('table.config.label.composer', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.COMPOSER,
width: 300,
@@ -116,7 +116,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.genre'),
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.GENRE,
width: 300,
@@ -125,7 +125,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.genreBadge'),
label: i18n.t('table.config.label.genreBadge', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.GENRE_BADGE,
width: 300,
@@ -134,7 +134,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.year'),
label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.YEAR,
width: 200,
@@ -143,7 +143,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.releaseDate'),
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.RELEASE_DATE,
width: 240,
@@ -152,7 +152,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.discNumber'),
label: i18n.t('table.config.label.discNumber', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DISC_NUMBER,
width: 100,
@@ -161,7 +161,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.trackNumber'),
label: i18n.t('table.config.label.trackNumber', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TRACK_NUMBER,
width: 100,
@@ -170,7 +170,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.bitDepth'),
label: i18n.t('table.config.label.bitDepth', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.BIT_DEPTH,
width: 100,
@@ -179,7 +179,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.bitrate'),
label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.BIT_RATE,
width: 100,
@@ -188,7 +188,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.codec'),
label: i18n.t('table.config.label.codec', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.CODEC,
width: 100,
@@ -197,7 +197,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.sampleRate'),
label: i18n.t('table.config.label.sampleRate', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.SAMPLE_RATE,
width: 100,
@@ -206,7 +206,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.lastPlayed'),
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.LAST_PLAYED,
width: 150,
@@ -215,7 +215,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.note'),
label: i18n.t('table.config.label.note', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.COMMENT,
width: 300,
@@ -224,7 +224,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.channels'),
label: i18n.t('table.config.label.channels', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.CHANNELS,
width: 100,
@@ -233,7 +233,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.bpm'),
label: i18n.t('table.config.label.bpm', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.BPM,
width: 100,
@@ -242,7 +242,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.dateAdded'),
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DATE_ADDED,
width: 120,
@@ -251,7 +251,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.path'),
label: i18n.t('table.config.label.path', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.PATH,
width: 300,
@@ -260,7 +260,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.playCount'),
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.PLAY_COUNT,
width: 100,
@@ -269,7 +269,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.size'),
label: i18n.t('table.config.label.size', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.SIZE,
width: 100,
@@ -278,7 +278,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.favorite'),
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.USER_FAVORITE,
width: 60,
@@ -287,7 +287,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.rating'),
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.USER_RATING,
width: 100,
@@ -296,7 +296,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.actions'),
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
@@ -310,7 +310,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.rowIndex'),
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ROW_INDEX,
width: 60,
@@ -319,7 +319,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.image'),
label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.IMAGE,
width: 70,
@@ -328,7 +328,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.title'),
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE,
width: 300,
@@ -337,7 +337,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.titleCombined'),
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE_COMBINED,
width: 300,
@@ -346,7 +346,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.titleArtist'),
label: i18n.t('table.config.label.titleArtist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE_ARTIST,
width: 300,
@@ -355,7 +355,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.duration'),
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DURATION,
width: 100,
@@ -364,7 +364,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: true,
isEnabled: false,
label: i18n.t('table.config.label.albumArtist'),
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ALBUM_ARTIST,
width: 300,
@@ -373,7 +373,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.artist'),
label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ARTIST,
width: 300,
@@ -382,7 +382,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.composer'),
label: i18n.t('table.config.label.composer', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.COMPOSER,
width: 300,
@@ -391,7 +391,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.songCount'),
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.SONG_COUNT,
width: 100,
@@ -400,7 +400,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.genre'),
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.GENRE,
width: 300,
@@ -409,7 +409,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.genreBadge'),
label: i18n.t('table.config.label.genreBadge', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.GENRE_BADGE,
width: 300,
@@ -418,7 +418,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.year'),
label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.YEAR,
width: 200,
@@ -427,7 +427,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.releaseDate'),
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.RELEASE_DATE,
width: 240,
@@ -436,7 +436,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.lastPlayed'),
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.LAST_PLAYED,
width: 150,
@@ -445,7 +445,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.dateAdded'),
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DATE_ADDED,
width: 120,
@@ -454,7 +454,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.playCount'),
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.PLAY_COUNT,
width: 100,
@@ -463,7 +463,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.favorite'),
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.USER_FAVORITE,
width: 60,
@@ -472,7 +472,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.rating'),
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.USER_RATING,
width: 100,
@@ -481,7 +481,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.actions'),
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
@@ -493,7 +493,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.rowIndex'),
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ROW_INDEX,
width: 60,
@@ -502,7 +502,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.image'),
label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.IMAGE,
width: 70,
@@ -511,7 +511,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.title'),
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE,
width: 300,
@@ -520,7 +520,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.duration'),
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DURATION,
width: 100,
@@ -529,7 +529,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.biography'),
label: i18n.t('table.config.label.biography', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.BIOGRAPHY,
width: 300,
@@ -538,7 +538,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.genre'),
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.GENRE,
width: 300,
@@ -547,7 +547,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.lastPlayed'),
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.LAST_PLAYED,
width: 150,
@@ -556,7 +556,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.playCount'),
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.PLAY_COUNT,
width: 100,
@@ -565,7 +565,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('filter.albumCount'),
label: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ALBUM_COUNT,
width: 100,
@@ -574,7 +574,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.songCount'),
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.SONG_COUNT,
width: 100,
@@ -583,7 +583,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.favorite'),
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.USER_FAVORITE,
width: 60,
@@ -592,7 +592,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.rating'),
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.USER_RATING,
width: 100,
@@ -601,7 +601,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.actions'),
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
@@ -613,7 +613,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.rowIndex'),
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ROW_INDEX,
width: 60,
@@ -622,7 +622,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.image'),
label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.IMAGE,
width: 70,
@@ -631,7 +631,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.title'),
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE,
width: 300,
@@ -640,7 +640,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.titleCombined'),
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE_COMBINED,
width: 300,
@@ -649,7 +649,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.duration'),
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DURATION,
width: 100,
@@ -658,7 +658,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.owner'),
label: i18n.t('table.config.label.owner', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.OWNER,
width: 150,
@@ -667,7 +667,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.songCount'),
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.SONG_COUNT,
width: 100,
@@ -676,7 +676,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.actions'),
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
@@ -688,7 +688,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.rowIndex'),
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ROW_INDEX,
width: 60,
@@ -697,7 +697,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'start',
autoSize: true,
isEnabled: true,
label: i18n.t('table.config.label.title'),
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE,
width: 300,
@@ -706,7 +706,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.songCount'),
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.SONG_COUNT,
width: 100,
@@ -715,7 +715,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.albumCount'),
label: i18n.t('table.config.label.albumCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ALBUM_COUNT,
width: 100,
@@ -724,7 +724,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [
align: 'center',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.actions'),
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
@@ -0,0 +1,72 @@
import { useLayoutEffect, useState } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export interface ItemTableStickyLayoutOffsets {
inViewMarginTop: number;
stickyTop: number;
}
export function useItemTableStickyLayoutOffsets(): ItemTableStickyLayoutOffsets {
const { windowBarStyle } = useWindowSettings();
const isWinMac = windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
const [offsets, setOffsets] = useState(() => ({
inViewMarginTop: getFallbackInViewMargin(windowBarStyle),
stickyTop: getFallbackStickyTop(windowBarStyle),
}));
useLayoutEffect(() => {
const read = () => {
const topVar = isWinMac
? '--item-table-sticky-top-win-mac'
: '--item-table-sticky-top-default';
const marginVar = isWinMac
? '--item-table-sticky-inview-margin-win-mac'
: '--item-table-sticky-inview-margin-default';
setOffsets({
inViewMarginTop: resolveRootCssMarginLeftVar(
marginVar,
getFallbackInViewMargin(windowBarStyle),
),
stickyTop: resolveRootCssWidthVar(topVar, getFallbackStickyTop(windowBarStyle)),
});
};
read();
window.addEventListener('resize', read);
return () => window.removeEventListener('resize', read);
}, [isWinMac, windowBarStyle]);
return offsets;
}
function getFallbackInViewMargin(windowBarStyle: Platform): number {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? -130 : -100;
}
function getFallbackStickyTop(windowBarStyle: Platform): number {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}
function resolveRootCssMarginLeftVar(varName: string, fallback: number): number {
if (typeof document === 'undefined') return fallback;
const el = document.createElement('div');
el.style.cssText = `position:fixed;left:0;top:0;margin-left:var(${varName});width:1px;height:0;margin-top:0;margin-right:0;margin-bottom:0;padding:0;border:none;visibility:hidden;pointer-events:none;`;
document.body.appendChild(el);
const raw = getComputedStyle(el).marginLeft;
el.remove();
const v = parseFloat(raw);
return Number.isFinite(v) ? v : fallback;
}
function resolveRootCssWidthVar(varName: string, fallback: number): number {
if (typeof document === 'undefined') return fallback;
const el = document.createElement('div');
el.style.cssText = `position:fixed;left:-99999px;top:0;width:var(${varName});height:0;margin:0;padding:0;border:none;visibility:hidden;pointer-events:none;`;
document.body.appendChild(el);
const w = el.getBoundingClientRect().width;
el.remove();
return Number.isFinite(w) && w > 0 ? w : fallback;
}
@@ -1,9 +1,8 @@
import type { ItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useInView } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export interface GroupRowInfo {
groupIndex: number;
rowIndex: number;
@@ -18,6 +17,7 @@ export const useStickyTableGroupRows = ({
mainGridRef,
shouldShowStickyHeader,
stickyHeaderTop,
stickyLayout,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
enabled: boolean;
@@ -27,17 +27,14 @@ export const useStickyTableGroupRows = ({
mainGridRef: React.RefObject<HTMLDivElement | null>;
shouldShowStickyHeader?: boolean;
stickyHeaderTop?: number;
stickyLayout: ItemTableStickyLayoutOffsets;
}) => {
const { windowBarStyle } = useWindowSettings();
const { inViewMarginTop, stickyTop: layoutStickyTop } = stickyLayout;
const [stickyGroupIndex, setStickyGroupIndex] = useState<null | number>(null);
const topMargin =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? '-130px'
: '-100px';
const groupRowsInViewMargin = `${inViewMarginTop}px 0px 0px 0px`;
const isTableInView = useInView(containerRef, {
margin: `${topMargin} 0px 0px 0px`,
margin: groupRowsInViewMargin as NonNullable<Parameters<typeof useInView>[1]>['margin'],
});
const stickyTop = useMemo(() => {
@@ -46,8 +43,8 @@ export const useStickyTableGroupRows = ({
if (shouldShowStickyHeader && stickyHeaderTop !== undefined) {
return stickyHeaderTop + headerHeight + 1;
}
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}, [windowBarStyle, shouldShowStickyHeader, stickyHeaderTop, headerHeight]);
return layoutStickyTop;
}, [layoutStickyTop, shouldShowStickyHeader, stickyHeaderTop, headerHeight]);
// Calculate group row indexes
const groupRowIndexes = useMemo(() => {
@@ -1,9 +1,8 @@
import type { ItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useInView } from 'motion/react';
import { RefObject, useEffect, useMemo, useRef } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export const useStickyTableHeader = ({
containerRef,
enabled,
@@ -12,6 +11,7 @@ export const useStickyTableHeader = ({
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
stickyLayout,
}: {
containerRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
@@ -20,8 +20,9 @@ export const useStickyTableHeader = ({
pinnedLeftColumnRef?: RefObject<HTMLDivElement | null>;
pinnedRightColumnRef?: RefObject<HTMLDivElement | null>;
stickyHeaderMainRef?: RefObject<HTMLDivElement | null>;
stickyLayout: ItemTableStickyLayoutOffsets;
}) => {
const { windowBarStyle } = useWindowSettings();
const { inViewMarginTop, stickyTop } = stickyLayout;
const isScrollingRef = useRef({
main: false,
pinnedLeft: false,
@@ -29,27 +30,20 @@ export const useStickyTableHeader = ({
stickyHeader: false,
});
const topMargin =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? '-130px'
: '-100px';
const inViewRootMargin = `${inViewMarginTop}px 0px 0px 0px`;
const isTableHeaderInView = useInView(headerRef, {
margin: `${topMargin} 0px 0px 0px`,
});
const inViewOptions = { margin: inViewRootMargin } as {
margin: NonNullable<Parameters<typeof useInView>[1]>['margin'];
};
const isTableInView = useInView(containerRef, {
margin: `${topMargin} 0px 0px 0px`,
});
const isTableHeaderInView = useInView(headerRef, inViewOptions);
const isTableInView = useInView(containerRef, inViewOptions);
const shouldShowStickyHeader = useMemo(() => {
return enabled && !isTableHeaderInView && isTableInView;
}, [enabled, isTableHeaderInView, isTableInView]);
const stickyTop = useMemo(() => {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}, [windowBarStyle]);
// Sync scroll between sticky header and main grid/pinned columns
useEffect(() => {
if (!shouldShowStickyHeader || !stickyHeaderMainRef?.current || !mainGridRef?.current) {
@@ -1,5 +1,3 @@
import type { TableScrollShadowStore } from '/@/renderer/components/item-list/item-table-list/table-scroll-shadow-store';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import throttle from 'lodash/throttle';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
@@ -20,7 +18,9 @@ export const useTablePaneSync = ({
pinnedRowRef,
rowRef,
scrollContainerRef,
scrollShadowStore,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
}: {
enableDrag: boolean | undefined;
enableDragScroll: boolean | undefined;
@@ -36,7 +36,9 @@ export const useTablePaneSync = ({
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
scrollShadowStore: TableScrollShadowStore;
setShowLeftShadow: (v: boolean) => void;
setShowRightShadow: (v: boolean) => void;
setShowTopShadow: (v: boolean) => void;
}) => {
// Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist
const [initialize, osInstance] = useOverlayScrollbars({
@@ -469,10 +471,8 @@ export const useTablePaneSync = ({
if (!row) {
const timeout = setTimeout(() => {
scrollShadowStore.setSnapshot({
showLeftShadow: false,
showRightShadow: false,
});
setShowLeftShadow(false);
setShowRightShadow(false);
}, 0);
return () => clearTimeout(timeout);
@@ -482,10 +482,8 @@ export const useTablePaneSync = ({
const scrollLeft = row.scrollLeft;
const maxScrollLeft = row.scrollWidth - row.clientWidth;
scrollShadowStore.setSnapshot({
showLeftShadow: pinnedLeftColumnCount > 0 && scrollLeft > 0,
showRightShadow: pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft,
});
setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0);
setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft);
}, 50);
checkScrollPosition();
@@ -496,7 +494,13 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel();
row.removeEventListener('scroll', checkScrollPosition);
};
}, [pinnedLeftColumnCount, pinnedRightColumnCount, rowRef, scrollShadowStore]);
}, [
pinnedLeftColumnCount,
pinnedRightColumnCount,
rowRef,
setShowLeftShadow,
setShowRightShadow,
]);
// Handle top shadow visibility based on vertical scroll
useEffect(() => {
@@ -505,7 +509,7 @@ export const useTablePaneSync = ({
if (!row || !enableHeader) {
const timeout = setTimeout(() => {
scrollShadowStore.setSnapshot({ showTopShadow: false });
setShowTopShadow(false);
}, 0);
return () => clearTimeout(timeout);
@@ -515,7 +519,7 @@ export const useTablePaneSync = ({
const checkScrollPosition = throttle(() => {
const currentScrollTop = scrollElement.scrollTop;
scrollShadowStore.setSnapshot({ showTopShadow: currentScrollTop > 0 });
setShowTopShadow(currentScrollTop > 0);
}, 50);
checkScrollPosition();
@@ -526,5 +530,5 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel();
scrollElement.removeEventListener('scroll', checkScrollPosition);
};
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, scrollShadowStore]);
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, setShowTopShadow]);
};
@@ -366,14 +366,6 @@
opacity: 1;
}
.resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.header-container:hover .resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.resize-handle:hover {
opacity: 1;
}
@@ -57,7 +57,6 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
@@ -194,14 +193,6 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
);
}
if (type === TableColumn.LAYOUT_FILL) {
return (
<TableColumnContainer {...props} {...dragProps} controls={controls} type={type}>
{null}
</TableColumnContainer>
);
}
if (itemType !== LibraryItem.FOLDER) {
switch (type) {
case TableColumn.ACTIONS:
@@ -716,8 +707,6 @@ export const TableColumnContainer = (
interface ColumnResizeHandleProps {
columnId: TableColumn;
columnIndex: number;
disabled?: boolean;
initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right';
@@ -725,8 +714,6 @@ interface ColumnResizeHandleProps {
const ColumnResizeHandle = ({
columnId,
columnIndex,
disabled = false,
initialWidth,
onResize,
side,
@@ -736,17 +723,6 @@ const ColumnResizeHandle = ({
const startWidthRef = useRef<number>(initialWidth);
const startXRef = useRef<number>(0);
const finalWidthRef = useRef<number>(initialWidth);
const columnResizeLive = useItemTableListColumnResizeLive();
const onResizeRef = useRef(onResize);
const columnResizeLiveRef = useRef(columnResizeLive);
useEffect(() => {
onResizeRef.current = onResize;
}, [onResize]);
useEffect(() => {
columnResizeLiveRef.current = columnResizeLive;
}, [columnResizeLive]);
// Update the ref when initialWidth changes (but not during drag)
useEffect(() => {
@@ -762,7 +738,6 @@ const ColumnResizeHandle = ({
const deltaX = event.clientX - startXRef.current;
const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000);
finalWidthRef.current = newWidth;
columnResizeLiveRef.current?.scheduleColumnResizePreview(columnIndex, newWidth);
};
const handleMouseUp = () => {
@@ -771,8 +746,7 @@ const ColumnResizeHandle = ({
document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
onResizeRef.current(columnId, finalWidthRef.current);
columnResizeLiveRef.current?.clearColumnResizePreview();
onResize(columnId, finalWidthRef.current);
};
document.addEventListener('mousemove', handleMouseMove);
@@ -781,18 +755,10 @@ const ColumnResizeHandle = ({
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
columnResizeLiveRef.current?.clearColumnResizePreview();
};
}, [isDragging, columnId, columnIndex]);
}, [isDragging, columnId, onResize]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
@@ -805,7 +771,6 @@ const ColumnResizeHandle = ({
return (
<div
className={clsx(styles.resizeHandle, {
[styles.resizeHandleDisabled]: disabled,
[styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right',
@@ -837,11 +802,7 @@ export const TableColumnHeaderContainer = (
const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);
useEffect(() => {
if (
!containerRef.current ||
!props.enableColumnReorder ||
props.type === TableColumn.LAYOUT_FILL
) {
if (!containerRef.current || !props.enableColumnReorder) {
return;
}
@@ -956,11 +917,9 @@ export const TableColumnHeaderContainer = (
>
{columnLabelMap[props.type]}
</Text>
{props.enableColumnResize && (
{!columnConfig.autoSize && props.enableColumnResize && (
<ColumnResizeHandle
columnId={props.type}
columnIndex={props.columnIndex}
disabled={!!columnConfig.autoSize}
initialWidth={currentWidth}
onResize={handleResize}
side="right"
@@ -1023,7 +982,6 @@ export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
[TableColumn.LAST_PLAYED]: i18n.t('table.column.lastPlayed', {
postProcess: 'upperCase',
}) as string,
[TableColumn.LAYOUT_FILL]: '',
[TableColumn.OWNER]: i18n.t('table.column.owner', { postProcess: 'upperCase' }) as string,
[TableColumn.PATH]: i18n.t('table.column.path', { postProcess: 'upperCase' }) as string,
[TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', {
@@ -1,14 +1,6 @@
import type { ReactElement } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { useSyncExternalStore } from 'react';
import type { TableItemProps } from './item-table-list';
@@ -76,69 +68,6 @@ export const useItemTableListConfig = (): ItemTableListConfig | null => {
return useContext(ItemTableListConfigContext);
};
export type ItemTableListColumnResizeLiveContextValue = {
clearColumnResizePreview: () => void;
scheduleColumnResizePreview: (columnIndex: number, width: number) => void;
};
const ItemTableListColumnResizeLiveContext =
createContext<ItemTableListColumnResizeLiveContextValue | null>(null);
export const ItemTableListColumnResizeLiveProvider = ({
children,
value,
}: {
children: React.ReactNode;
value: ItemTableListColumnResizeLiveContextValue;
}) => {
return (
<ItemTableListColumnResizeLiveContext.Provider value={value}>
{children}
</ItemTableListColumnResizeLiveContext.Provider>
);
};
export const useItemTableListColumnResizeLive =
(): ItemTableListColumnResizeLiveContextValue | null => {
return useContext(ItemTableListColumnResizeLiveContext);
};
export const useItemTableListColumnResizeLiveState = () => {
const [columnResizePreview, setColumnResizePreview] = useState<null | {
columnIndex: number;
width: number;
}>(null);
const previewRafRef = useRef<null | number>(null);
const pendingPreviewRef = useRef<null | { columnIndex: number; width: number }>(null);
const scheduleColumnResizePreview = useCallback((columnIndex: number, width: number) => {
pendingPreviewRef.current = { columnIndex, width };
if (previewRafRef.current !== null) return;
previewRafRef.current = requestAnimationFrame(() => {
previewRafRef.current = null;
const pending = pendingPreviewRef.current;
if (pending) {
setColumnResizePreview(pending);
}
});
}, []);
const clearColumnResizePreview = useCallback(() => {
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current);
previewRafRef.current = null;
}
pendingPreviewRef.current = null;
setColumnResizePreview(null);
}, []);
return {
clearColumnResizePreview,
columnResizePreview,
scheduleColumnResizePreview,
};
};
type ItemTableListStoreContextValue = {
activeRowStore: ActiveRowStore;
};
@@ -52,7 +52,7 @@
.item-table-pinned-rows-grid-container.header-fixed {
position: fixed !important;
top: 65px;
top: var(--item-table-sticky-top-default);
z-index: 15;
background-color: var(--theme-bg-primary);
box-shadow: 0 -1px 0 0 var(--theme-colors-border);
@@ -60,7 +60,7 @@
}
.item-table-pinned-rows-grid-container.header-window-bar {
top: 95px;
top: var(--item-table-sticky-top-win-mac);
}
.item-table-list-container.header-fixed-margin {
@@ -72,7 +72,7 @@
z-index: 15;
display: flex;
flex-direction: row;
overflow: visible;
overflow: hidden;
pointer-events: none;
background-color: var(--theme-colors-background);
border-bottom: 1px solid var(--theme-colors-border);
@@ -168,7 +168,6 @@
min-width: 0;
height: 100%;
min-height: 0;
overflow-x: visible;
}
.no-scrollbar {
@@ -179,10 +178,6 @@
height: 100%;
}
.item-table-container :global(.os-scrollbar) {
z-index: 2;
}
.item-table-pinned-header-shadow {
position: absolute;
top: 100%;
@@ -14,14 +14,12 @@ import React, {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { useParams } from 'react-router';
import { type CellComponentProps, Grid } from 'react-window-v2';
import styles from './item-table-list.module.css';
import { appendLayoutFillColumn } from '/@/renderer/components/item-list/helpers/append-layout-fill-column';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
@@ -33,6 +31,7 @@ import { parseTableColumns } from '/@/renderer/components/item-list/helpers/pars
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
import { useContainerWidthTracking } from '/@/renderer/components/item-list/item-table-list/hooks/use-container-width-tracking';
import { useRowInteractionDelegate } from '/@/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate';
import { useItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useStickyGroupRowPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-group-row-positioning';
import { useStickyHeaderPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-header-positioning';
import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows';
@@ -46,20 +45,14 @@ import { useTableRowModel } from '/@/renderer/components/item-list/item-table-li
import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
ItemTableListColumnResizeLiveProvider,
type ItemTableListConfig,
ItemTableListConfigProvider,
ItemTableListStoreProvider,
useItemTableListColumnResizeLiveState,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import {
MemoizedCellRouter,
useColumnCellComponents,
} from '/@/renderer/components/item-list/item-table-list/memoized-cell-router';
import {
createTableScrollShadowStore,
type TableScrollShadowStore,
} from '/@/renderer/components/item-list/item-table-list/table-scroll-shadow-store';
import {
ItemControls,
ItemListHandle,
@@ -111,63 +104,6 @@ export enum TableItemSize {
LARGE = 88,
}
const ItemTableScrollShadowTop = memo(function ItemTableScrollShadowTop({
enableHeader,
enableScrollShadow,
scrollShadowStore,
}: {
enableHeader: boolean;
enableScrollShadow: boolean;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showTopShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (!enableHeader || !enableScrollShadow || !showTopShadow) return null;
return <div className={styles.itemTableTopScrollShadow} />;
});
ItemTableScrollShadowTop.displayName = 'ItemTableScrollShadowTop';
const ItemTableScrollShadowLeft = memo(function ItemTableScrollShadowLeft({
enableScrollShadow,
pinnedLeftColumnCount,
scrollShadowStore,
}: {
enableScrollShadow: boolean;
pinnedLeftColumnCount: number;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showLeftShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (pinnedLeftColumnCount <= 0 || !enableScrollShadow || !showLeftShadow) return null;
return <div className={styles.itemTableLeftScrollShadow} />;
});
ItemTableScrollShadowLeft.displayName = 'ItemTableScrollShadowLeft';
const ItemTableScrollShadowRight = memo(function ItemTableScrollShadowRight({
enableScrollShadow,
pinnedRightColumnCount,
scrollShadowStore,
}: {
enableScrollShadow: boolean;
pinnedRightColumnCount: number;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showRightShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (pinnedRightColumnCount <= 0 || !enableScrollShadow || !showRightShadow) return null;
return <div className={styles.itemTableRightScrollShadow} />;
});
ItemTableScrollShadowRight.displayName = 'ItemTableScrollShadowRight';
interface VirtualizedTableGridProps {
calculatedColumnWidths: number[];
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
@@ -185,7 +121,9 @@ interface VirtualizedTableGridProps {
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowCount: number;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
scrollShadowStore: TableScrollShadowStore;
showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
tableConfig: ItemTableListConfig;
totalColumnCount: number;
totalRowCount: number;
@@ -208,7 +146,9 @@ const VirtualizedTableGrid = ({
pinnedRightColumnRef,
pinnedRowCount,
pinnedRowRef,
scrollShadowStore,
showLeftShadow,
showRightShadow,
showTopShadow,
tableConfig,
totalColumnCount,
totalRowCount,
@@ -544,7 +484,7 @@ const VirtualizedTableGrid = ({
})}
style={{
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'visible',
overflow: 'hidden',
}}
>
<Grid
@@ -558,11 +498,9 @@ const VirtualizedTableGrid = ({
/>
</div>
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
{!!pinnedLeftColumnCount && (
<div
className={styles.itemTablePinnedColumnsContainer}
@@ -617,11 +555,9 @@ const VirtualizedTableGrid = ({
/>
</div>
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid
cellComponent={RowCell}
@@ -633,16 +569,12 @@ const VirtualizedTableGrid = ({
rowCount={totalRowCount}
rowHeight={rowHeightMemoized}
/>
<ItemTableScrollShadowLeft
enableScrollShadow={enableScrollShadow}
pinnedLeftColumnCount={pinnedLeftColumnCount}
scrollShadowStore={scrollShadowStore}
/>
<ItemTableScrollShadowRight
enableScrollShadow={enableScrollShadow}
pinnedRightColumnCount={pinnedRightColumnCount}
scrollShadowStore={scrollShadowStore}
/>
{pinnedLeftColumnCount > 0 && enableScrollShadow && showLeftShadow && (
<div className={styles.itemTableLeftScrollShadow} />
)}
{pinnedRightColumnCount > 0 && enableScrollShadow && showRightShadow && (
<div className={styles.itemTableRightScrollShadow} />
)}
</div>
</div>
{!!pinnedRightColumnCount && (
@@ -662,7 +594,7 @@ const VirtualizedTableGrid = ({
})}
style={{
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'visible',
overflow: 'hidden',
}}
>
<Grid
@@ -680,11 +612,9 @@ const VirtualizedTableGrid = ({
/>
</div>
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<div
className={styles.itemTablePinnedRightColumnsContainer}
ref={pinnedRightColumnRef}
@@ -737,7 +667,9 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef &&
prevProps.pinnedRowCount === nextProps.pinnedRowCount &&
prevProps.pinnedRowRef === nextProps.pinnedRowRef &&
prevProps.scrollShadowStore === nextProps.scrollShadowStore &&
prevProps.showLeftShadow === nextProps.showLeftShadow &&
prevProps.showRightShadow === nextProps.showRightShadow &&
prevProps.showTopShadow === nextProps.showTopShadow &&
prevProps.totalColumnCount === nextProps.totalColumnCount &&
prevProps.totalRowCount === nextProps.totalRowCount &&
prevProps.CellComponent === nextProps.CellComponent
@@ -898,6 +830,8 @@ const ItemTableListStickyUI = memo(
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);
const stickyLayout = useItemTableStickyLayoutOffsets();
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
containerRef,
enabled: enableHeader && enableStickyHeader,
@@ -906,6 +840,7 @@ const ItemTableListStickyUI = memo(
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
stickyLayout,
});
useStickyHeaderPositioning({
@@ -927,6 +862,7 @@ const ItemTableListStickyUI = memo(
mainGridRef: rowRef,
shouldShowStickyHeader,
stickyHeaderTop: stickyTop,
stickyLayout,
});
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
@@ -971,7 +907,7 @@ const ItemTableListStickyUI = memo(
style={{
flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`,
overflow: 'visible',
overflow: 'hidden',
}}
>
{parsedColumns
@@ -1055,7 +991,7 @@ const ItemTableListStickyUI = memo(
style={{
flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`,
overflow: 'visible',
overflow: 'hidden',
}}
>
{parsedColumns
@@ -1279,11 +1215,6 @@ const BaseItemTableList = ({
const [centerContainerWidth, setCenterContainerWidth] = useState(0);
const [totalContainerWidth, setTotalContainerWidth] = useState(0);
const columnsForLayout = useMemo(
() => appendLayoutFillColumn(columns, autoFitColumns),
[autoFitColumns, columns],
);
const {
calculatedColumnWidths,
parsedColumns,
@@ -1293,33 +1224,9 @@ const BaseItemTableList = ({
} = useTableColumnModel({
autoFitColumns,
centerContainerWidth,
columns: columnsForLayout,
columns,
totalContainerWidth,
});
const { clearColumnResizePreview, columnResizePreview, scheduleColumnResizePreview } =
useItemTableListColumnResizeLiveState();
const columnResizeLiveValue = useMemo(
() => ({
clearColumnResizePreview,
scheduleColumnResizePreview,
}),
[clearColumnResizePreview, scheduleColumnResizePreview],
);
const displayColumnWidths = useMemo(() => {
if (!columnResizePreview) {
return calculatedColumnWidths;
}
const next = calculatedColumnWidths.slice();
const { columnIndex, width } = columnResizePreview;
if (columnIndex >= 0 && columnIndex < next.length) {
next[columnIndex] = width;
}
return next;
}, [calculatedColumnWidths, columnResizePreview]);
const playerContext = usePlayer();
const {
@@ -1355,7 +1262,9 @@ const BaseItemTableList = ({
const pinnedRightColumnRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const mergedRowRef = useMergedRef(rowRef, scrollContainerRef);
const scrollShadowStore = useMemo(() => createTableScrollShadowStore(), []);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const [showRightShadow, setShowRightShadow] = useState(false);
const [showTopShadow, setShowTopShadow] = useState(false);
const handleRef = useRef<ItemListHandle | null>(null);
const { focused, ref: focusRef } = useFocusWithin();
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -1413,7 +1322,9 @@ const BaseItemTableList = ({
pinnedRowRef,
rowRef,
scrollContainerRef,
scrollShadowStore,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
});
const getRowHeight = useCallback(
@@ -1537,7 +1448,7 @@ const BaseItemTableList = ({
// Create itemProps for sticky header
const stickyHeaderItemProps: TableItemProps = useMemo(
() => ({
calculatedColumnWidths: displayColumnWidths,
calculatedColumnWidths,
cellPadding,
columns: parsedColumns,
controls,
@@ -1557,9 +1468,9 @@ const BaseItemTableList = ({
internalState,
itemType,
pinnedLeftColumnCount,
pinnedLeftColumnWidths: displayColumnWidths.slice(0, pinnedLeftColumnCount),
pinnedLeftColumnWidths: calculatedColumnWidths.slice(0, pinnedLeftColumnCount),
pinnedRightColumnCount,
pinnedRightColumnWidths: displayColumnWidths.slice(
pinnedRightColumnWidths: calculatedColumnWidths.slice(
pinnedLeftColumnCount + totalColumnCount,
),
playerContext,
@@ -1568,7 +1479,7 @@ const BaseItemTableList = ({
tableId,
}),
[
displayColumnWidths,
calculatedColumnWidths,
cellPadding,
controls,
parsedColumns,
@@ -1673,81 +1584,73 @@ const BaseItemTableList = ({
};
}, [CellComponent, columnCellComponents]);
const tableMotion = (
<motion.div
className={styles.itemTableListContainer}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
const element = e.currentTarget as HTMLDivElement;
// Focus without scrolling into view
if (element.focus) {
element.focus({ preventScroll: true });
}
}}
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
<ItemTableListStickyUI
calculatedColumnWidths={displayColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={displayColumnWidths}
CellComponent={optimizedCellComponent}
data={data}
dataWithGroups={dataWithGroups}
enableScrollShadow={enableScrollShadow}
getItem={getItem}
headerHeight={headerHeight}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
scrollShadowStore={scrollShadowStore}
tableConfig={tableConfigValue}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
</motion.div>
);
return (
<ItemTableListStoreProvider activeRowId={activeRowId}>
<ItemTableListConfigProvider value={tableConfigValue}>
{onColumnResized ? (
<ItemTableListColumnResizeLiveProvider value={columnResizeLiveValue}>
{tableMotion}
</ItemTableListColumnResizeLiveProvider>
) : (
tableMotion
)}
<motion.div
className={styles.itemTableListContainer}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
const element = e.currentTarget as HTMLDivElement;
// Focus without scrolling into view
if (element.focus) {
element.focus({ preventScroll: true });
}
}}
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
<ItemTableListStickyUI
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
data={data}
dataWithGroups={dataWithGroups}
enableScrollShadow={enableScrollShadow}
getItem={getItem}
headerHeight={headerHeight}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
showLeftShadow={showLeftShadow}
showRightShadow={showRightShadow}
showTopShadow={showTopShadow}
tableConfig={tableConfigValue}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
</motion.div>
</ItemTableListConfigProvider>
</ItemTableListStoreProvider>
);
@@ -1,36 +0,0 @@
export interface TableScrollShadowSnapshot {
showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
}
export type TableScrollShadowStore = ReturnType<typeof createTableScrollShadowStore>;
export function createTableScrollShadowStore() {
let snapshot: TableScrollShadowSnapshot = {
showLeftShadow: false,
showRightShadow: false,
showTopShadow: false,
};
const listeners = new Set<() => void>();
return {
getSnapshot: (): TableScrollShadowSnapshot => snapshot,
setSnapshot: (patch: Partial<TableScrollShadowSnapshot>) => {
const next: TableScrollShadowSnapshot = { ...snapshot, ...patch };
if (
next.showLeftShadow === snapshot.showLeftShadow &&
next.showRightShadow === snapshot.showRightShadow &&
next.showTopShadow === snapshot.showTopShadow
) {
return;
}
snapshot = next;
listeners.forEach((l) => l());
},
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
@@ -22,17 +22,17 @@ const controls = [
{
control1: <Kbd>CTRL</Kbd>,
control2: <Kbd>A</Kbd>,
label: i18n.t('action.selectAll'),
label: i18n.t('action.selectAll', { postProcess: 'sentenceCase' }),
},
{
control1: <Kbd>CTRL</Kbd>,
control2: <Icon fill="default" icon="mouseLeftClick" />,
label: i18n.t('action.addOrRemoveFromSelection'),
label: i18n.t('action.addOrRemoveFromSelection', { postProcess: 'sentenceCase' }),
},
{
control1: <Kbd>SHIFT</Kbd>,
control2: <Icon fill="default" icon="mouseLeftClick" />,
label: i18n.t('action.selectRangeOfItems'),
label: i18n.t('action.selectRangeOfItems', { postProcess: 'sentenceCase' }),
},
];
@@ -97,7 +97,7 @@
justify-content: center;
width: 100%;
height: 100%;
padding: 0 var(--theme-spacing-xs);
padding: var(--theme-spacing-sm);
}
.title-wrapper.hidden {
@@ -11,10 +11,8 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Flex, FlexProps } from '/@/shared/components/flex/flex';
import { Platform } from '/@/shared/types/types';
export interface PageHeaderProps extends Omit<
FlexProps,
'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'
> {
export interface PageHeaderProps
extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> {
animated?: boolean;
backgroundColor?: string;
children?: ReactNode;
@@ -79,12 +79,14 @@ export const QueryBuilder = ({
{
label: t('form.queryEditor.input', {
context: 'optionMatchAll',
postProcess: 'sentenceCase',
}),
value: 'all',
},
{
label: t('form.queryEditor.input', {
context: 'optionMatchAny',
postProcess: 'sentenceCase',
}),
value: 'any',
},
@@ -144,7 +146,9 @@ export const QueryBuilder = ({
leftSection={<Icon icon="add" />}
onClick={handleAddRuleGroup}
>
{t('form.queryEditor.addRuleGroup')}
{t('form.queryEditor.addRuleGroup', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
{level > 0 && (
@@ -152,7 +156,9 @@ export const QueryBuilder = ({
leftSection={<Icon icon="delete" />}
onClick={handleDeleteRuleGroup}
>
{t('form.queryEditor.removeRuleGroup')}
{t('form.queryEditor.removeRuleGroup', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
)}
{level === 0 && (
@@ -163,14 +169,18 @@ export const QueryBuilder = ({
leftSection={<Icon color="error" icon="refresh" />}
onClick={onResetFilters}
>
{t('form.queryEditor.resetToDefault')}
{t('form.queryEditor.resetToDefault', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
isDanger
leftSection={<Icon color="error" icon="delete" />}
onClick={onClearFilters}
>
{t('form.queryEditor.clearFilters')}
{t('form.queryEditor.clearFilters', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
</>
)}
@@ -28,7 +28,11 @@ export const SelectWithInvalidData = ({ data, defaultValue, ...props }: SelectPr
<Select
data={fullData}
defaultValue={defaultValue}
error={hasError ? t('error.badValue', { value: defaultValue }) : undefined}
error={
hasError
? t('error.badValue', { postProcess: 'sentenceCase', value: defaultValue })
: undefined
}
{...props}
/>
);
@@ -70,7 +74,10 @@ export const MultiSelectWithInvalidData = ({
}, [data, currentValue]);
const error = useMemo(
() => (missing.length ? t('error.badValue', { value: missing }) : undefined),
() =>
missing.length
? t('error.badValue', { postProcess: 'sentenceCase', value: missing })
: undefined,
[missing, t],
);
@@ -80,7 +80,7 @@ function ServerSelector() {
/>
),
size: 'sm',
title: t('form.updateServer.title'),
title: t('form.updateServer.title', { postProcess: 'titleCase' }),
});
};
@@ -31,12 +31,12 @@ const ActionRequiredRoute = () => {
const checks = [
{
component: <ServerCredentialRequired />,
title: t('error.credentialsRequired'),
title: t('error.credentialsRequired', { postProcess: 'sentenceCase' }),
valid: !isCredentialRequired,
},
{
component: <ServerRequired />,
title: t('error.serverRequired'),
title: t('error.serverRequired', { postProcess: 'serverRequired' }),
valid: !isServerRequired,
},
];
@@ -47,7 +47,7 @@ const ActionRequiredRoute = () => {
const handleManageServersModal = () => {
openModal({
children: <ServerList />,
title: t('page.appMenu.manageServers'),
title: t('page.appMenu.manageServers', { postProcess: 'sentenceCase' }),
});
};
@@ -79,7 +79,9 @@ const ActionRequiredRoute = () => {
onClick={handleManageServersModal}
variant="filled"
>
{t('page.appMenu.manageServers')}
{t('page.appMenu.manageServers', {
postProcess: 'sentenceCase',
})}
</Button>
</Group>
)}
@@ -21,7 +21,9 @@ const InvalidRoute = () => {
<Stack>
<Group justify="center" wrap="nowrap">
<Icon color="warn" icon="error" />
<Text size="xl">{t('error.apiRouteError')}</Text>
<Text size="xl">
{t('error.apiRouteError', { postProcess: 'sentenceCase' })}
</Text>
</Group>
<Text>{location.pathname}</Text>
<ActionIcon icon="arrowLeftS" onClick={() => navigate(-1)} variant="filled" />
@@ -28,10 +28,12 @@ const NoNetworkRoute = () => {
<Icon icon="wifiOff" size="4rem" />
<Stack gap="md">
<Text size="xl" weight={600}>
{t('error.noNetwork')}
{t('error.noNetwork', { postProcess: 'sentenceCase' })}
</Text>
<Text c="dimmed" size="sm">
{t('error.noNetworkDescription')}
{t('error.noNetworkDescription', {
postProcess: 'sentenceCase',
})}
</Text>
</Stack>
<Button
@@ -39,7 +41,7 @@ const NoNetworkRoute = () => {
onClick={handleRetry}
variant="filled"
>
{t('common.retry')}
{t('common.retry', { postProcess: 'sentenceCase' })}
</Button>
</Stack>
</Center>
@@ -31,7 +31,6 @@ import {
} from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, usePlayerSong } from '/@/renderer/store';
import { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store';
@@ -50,6 +49,7 @@ import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import {
Album,
AlbumListSort,
@@ -127,7 +127,9 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
...releaseTypes,
{
id: 'isCompilation',
value: album?.isCompilation ? t('filter.isCompilation') : undefined,
value: album?.isCompilation
? t('filter.isCompilation', { postProcess: 'sentenceCase' })
: undefined,
},
...releaseCountries,
...releaseStatuses,
@@ -135,9 +137,9 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
id: 'explicitStatus',
value:
album.explicitStatus === ExplicitStatus.EXPLICIT
? t('common.explicit')
? t('common.explicit', { postProcess: 'sentenceCase' })
: album.explicitStatus === ExplicitStatus.CLEAN
? t('common.clean')
? t('common.clean', { postProcess: 'sentenceCase' })
: undefined,
},
);
@@ -208,12 +210,15 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
return (
<>
<MetadataPillGroup items={defaultTagItems} title={t('common.tags')} />
<MetadataPillGroup
items={defaultTagItems}
title={t('common.tags', { postProcess: 'sentenceCase' })}
/>
{recordLabels.length > 0 && (
<Stack align="center" className={styles.metadataPillGroup} gap="xs">
<Text fw={600} isNoSelect size="sm" tt="uppercase">
{t('common.recordLabel')}
{t('common.recordLabel', { postProcess: 'sentenceCase' })}
</Text>
<div className={styles['pill-group-wrapper']}>
<Pill.Group>
@@ -237,12 +242,15 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
</Stack>
)}
<MetadataPillGroup items={moodTagItems} title={t('common.mood')} />
<MetadataPillGroup
items={moodTagItems}
title={t('common.mood', { postProcess: 'sentenceCase' })}
/>
{groupingItems.length > 0 && (
<Stack align="center" className={styles.metadataPillGroup} gap="xs">
<Text fw={600} isNoSelect size="sm" tt="uppercase">
{t('common.grouping')}
{t('common.grouping', { postProcess: 'sentenceCase' })}
</Text>
<div className={styles['pill-group-wrapper']}>
<Pill.Group>
@@ -394,7 +402,9 @@ const AlbumMetadataExternalLinks = ({
return (
<Stack gap="xs">
<Text fw={600} isNoSelect size="sm" tt="uppercase">
{t('common.externalLinks')}
{t('common.externalLinks', {
postProcess: 'sentenceCase',
})}
</Text>
<Group className={styles.externalLinksGroup} gap="xs">
{lastFM && (
@@ -624,7 +634,7 @@ const DiscGroupRow = ({ discGroup, groupItems, internalState, t }: DiscGroupRowP
id={`disc-${discGroup.discNumber}`}
label={
<Text component="label" size="sm" truncate>
{t('common.disc')} {discGroup.discNumber}
{t('common.disc', { postProcess: 'sentenceCase' })} {discGroup.discNumber}
{discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}
</Text>
}
@@ -681,7 +691,7 @@ function AlbumDetailCarousels({ data }: { data: Album }) {
rowCount: 1,
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
title: t('page.albumDetail.moreFromArtist'),
title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }),
uniqueId: moreFromArtistUniqueId,
},
...genreCarousels,
@@ -868,7 +878,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
flex={1}
leftSection={<Icon icon="search" />}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('common.search')}
placeholder={t('common.search', { postProcess: 'sentenceCase' })}
radius="xl"
ref={searchInputRef}
rightSection={

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