Compare commits

..

55 Commits

Author SHA1 Message Date
jeffvli d51ca37e1b Bump to v0.5.0 2023-10-31 03:10:51 -07:00
jeffvli 9d8dcc7ade Add weblate notice 2023-10-31 00:24:29 -07:00
Jeff 8430b1ec95 Add localization support (#333)
* Add updated i18n config and en locale
2023-10-30 19:22:45 -07:00
Martin Pauli 11863fd4c1 Fix clear rating hotkey (#338) 2023-10-30 18:50:48 -07:00
Nicholas Malcolm cf9c7e2640 Build docker container for arm (#336)
* Build all supported container platforms

* Remove less popular platforms
2023-10-30 18:50:21 -07:00
Kendall Garner 9d780e0342 [bugfix]: prevent default (#334)
* [bugfix]: prevent default on rating
2023-10-28 21:10:52 -07:00
Kendall Garner 4ec981df83 [bugfix/feature]: Improve ratings (#332)
* [bugfix/feature]: Improve ratings

Fix: add preventDefault/stopPropagation to prevent scrolling to top in queue
Feat: instead of double click for clear, click on same value
2023-10-28 20:00:01 -07:00
jeffvli e5564c2ac2 Add additional dependencies to linux build (#320) 2023-10-28 16:51:07 -07:00
Martin Pauli 7a580c2c65 Add favorite hotkey options (#326)
* Add favorite hotkey options

* Update wording

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-10-27 18:22:16 -07:00
jeffvli ac84088c69 Set owner field edit to Navidrome only (#327) 2023-10-26 16:42:21 -07:00
jeffvli 3c2e4d40ec Update play button for dynamic theme 2023-10-23 15:45:47 -07:00
jeffvli fdff79496a Set pause status on last track end (#291) 2023-10-23 09:02:48 -07:00
jeffvli ccfadda729 Add play count to jellyfin album sort (#324) 2023-10-23 08:37:04 -07:00
jeffvli f21b8d6bbd Update base button styles
- Use brightness filter for hover/focus styles
- Re-add default active style
2023-10-23 08:24:23 -07:00
jeffvli 244c00c4c6 Add discord rich presence (#72) 2023-10-23 06:58:39 -07:00
Kendall Garner 2664a80851 Support changing playback rate (#275)
* initial idea for playback rate

* Add transparency to dropdown

* Move playback speed component to right controls

* Set mpv speed on startup

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-22 17:47:44 -07:00
Kendall Garner 742b13d65e [Feature] Support changing accent/primary color (#282)
* [Feature] Support changing accent/primary color

- adds color picker to settings with five swatches (blue default, yellow green and red imported from sonixd, purple new)
- changing color will change the appropriate css variable

* Remove hover styles that use an alternate primary

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-22 17:46:28 -07:00
jeffvli 8dcd49d574 Fix add to playlist from artist page (#296) 2023-10-22 16:18:55 -07:00
jeffvli 02c8cbcad6 Revert jellyfin getPlaylistList implementation (#272) 2023-10-22 16:00:41 -07:00
jeffvli 86fb52f6d4 Fix current song row when queue is empty 2023-10-22 15:57:15 -07:00
Kendall Garner 452ef783f2 [bugfix/feat]: always fetch artist image for Navidrome (#317)
* [bugfix/feat]: always fetch artist image for Navidrome

* Add error fallback to library header image

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-22 15:46:48 -07:00
Kendall Garner 74cab01013 [feature]: Support using system fonts (#304)
* [feature]: Support using system fonts

Uses the **experimental** queryLocalFonts API, when prompted, to get the fonts and do CSS.
Resolves #270 and #288 (by proxy)

Caveats/notes:
- This is experimental, and is only supported by Chrome/Chromium/Edgeium (see https://caniuse.com/?search=querylocalfonts)
- As far as I can tell, the only way to dynamically change the font (shown in https://wicg.github.io/local-font-access/#example-style-with-local-fonts) was by DOM manipulation; css variables did not seem to work
- This shows **all** fonts, including their variants (bold/italic/etc); given that the style names could be localized, not sure of a way to parse this (on my system, for instance, I had 859 different combinations)
- I made fonts a separate top-level setting because it was easier to manipulate without causing as many rerenders; feel free to put that back

* add permission chec

* add electron magic to support custom font

* restrict content types
2023-10-22 15:25:17 -07:00
Kendall Garner e6ed9229c2 [bugfix]: fix queue offset when removing tracks (#301)
* [bugfix]: fix queue offset when removing tracks

* Fix song index numbers when removing songs

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-22 15:21:31 -07:00
jeffvli 3a144ab821 Fix query editor not scrolling (#322) 2023-10-22 15:09:48 -07:00
jeffvli 913e89b01b Set column defs on play queue to use correct row index 2023-10-19 04:33:09 -07:00
jeffvli 768a88de8f Fix row refresh on status change for current song 2023-10-19 03:37:17 -07:00
jeffvli 8e2a107d4a Fix className clash on current song 2023-10-19 03:36:16 -07:00
jeffvli e77efcf836 Add artist name to window title 2023-10-18 20:49:50 -07:00
jeffvli 818f155993 Hide playing icon if player is paused 2023-10-18 19:55:57 -07:00
jeffvli b28fe4cbc9 Convert play icon from base64 to svg 2023-10-18 19:51:55 -07:00
Kendall Garner 8a53fab751 add more emphasis to current song (#283)
* add more emphasis to current song

* add css indicator (rivolumelineup)

* don't use absolute position, support album track number

* Respect order of set-queue function (fix race condition)

* Fix table row actions button on album detail and play queue

* Fix album detail table customizations

* Bump to v0.4.1

* Fix opacity mask for unsynced lyrics container

* Separate sidebar icons to new component

- Fixes react render issue

* Add app focus hook

* Remove css play image

* Add player status as cell refresh condition for queue

* Add current song images

* Add current song styles for all song tables

* Revert row index cell width

* Remove animated svg on browser

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-10-18 18:32:11 -07:00
Kendall Garner 9964f95d5d [Remote] Full PWA support, misc bugfixes (#280)
- Fix setting remote port properly
- Add web worker support (so it can be installed as an "app")
- build fixes/removing stray console.log
2023-10-18 10:49:09 -07:00
Kendall Garner fe298d3232 Reset Carousel position on data refresh (#303)
* Reset Carousel position on data refresh

* add refresh for all carousels
2023-10-18 10:47:55 -07:00
Kendall Garner 03e582f301 [feature]: support running feishin on custom path (#307)
* [feature]: support running feishin on custom path

* add details in readme
2023-10-17 22:10:53 -07:00
Kendall Garner d7b3d5c0bd [bugfix]: do not duplicate tracks when adding to multiple playlists (#300) 2023-10-17 16:11:14 -07:00
Kendall Garner 5fdf4c06f9 properly implement Jellyfin getSongDetail (#298) 2023-10-17 16:05:44 -07:00
TacoCake c7aa5d09c9 In the fullscreen player use dynamic resolution for the main image (#290)
* In the fullscreen player use dynamic resolution for the main image

* Use ceil instead of round

* Add types and lint

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-10-17 06:47:50 -07:00
Kendall Garner f4f73289c9 [bugfix]: remove ignore CORS/SSL switches from web version (#305) 2023-10-17 06:21:36 -07:00
Lily Rose ac7ec133db Replace hardcoded Jellyfin authentication DeviceId to include hostname and username (#312) 2023-10-17 06:21:15 -07:00
Kendall Garner 1a948ab86b fix artist discography year filter (#299) 2023-10-17 06:05:36 -07:00
Kendall Garner f6667a39a0 fix toggle replay (#292) 2023-10-17 05:49:29 -07:00
jeffvli cbeb4ab7d8 Separate sidebar icons to new component
- Fixes react render issue
2023-10-17 05:46:42 -07:00
jeffvli 3675146f1f Fix opacity mask for unsynced lyrics container 2023-10-07 19:58:04 -07:00
jeffvli 946f4ff306 Bump to v0.4.1 2023-10-07 19:06:30 -07:00
jeffvli 277669c413 Fix album detail table customizations 2023-10-07 18:11:02 -07:00
jeffvli 49b6478b72 Fix table row actions button on album detail and play queue 2023-10-07 17:32:59 -07:00
jeffvli ca39409cc3 Respect order of set-queue function (fix race condition) 2023-10-07 16:46:23 -07:00
jeffvli cca6fa21db Adjust scrobble duration to check in ms 2023-10-05 22:11:48 -07:00
jeffvli 5e1059870c Fix second song on startup not playing 2023-10-05 21:54:11 -07:00
Kendall Garner 6bac172bbe fix scrobble durations (#269)
* fix scrobble durations

* Fix scrobble condition on last song in queue, normalize ms

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-05 21:45:47 -07:00
Kendall Garner 118a9f73d1 fix unsynced lyrics (#279) 2023-10-04 22:02:42 -07:00
jeffvli c464be8cea Fix quit functionality (#184) 2023-09-27 02:37:03 -07:00
jeffvli 3bbe696f4c Update react-router and add useTransition support 2023-09-25 16:13:27 -07:00
jeffvli f7cacd2b73 Remove page fade in transition 2023-09-25 16:12:51 -07:00
jeffvli 62794623a3 Fix tracks list refresh on search 2023-09-25 15:57:48 -07:00
158 changed files with 4863 additions and 1629 deletions
+97 -90
View File
@@ -9,112 +9,119 @@ import checkNodeEnv from '../scripts/check-node-env';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
const { version } = require('../../package.json');
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
checkNodeEnv('development');
}
const port = process.env.PORT || 4343;
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
devtool: 'inline-source-map',
mode: 'development',
mode: 'development',
target: ['web'],
target: ['web'],
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
output: {
path: webpackPaths.dllPath,
publicPath: '/',
filename: 'remote.js',
library: {
type: 'umd',
entry: {
remote: path.join(webpackPaths.srcRemotePath, 'index.tsx'),
worker: path.join(webpackPaths.srcRemotePath, 'service-worker.ts'),
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
output: {
path: webpackPaths.dllPath,
publicPath: '/',
filename: '[name].js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: true,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
templateParameters: {
version,
prod: false,
},
}),
],
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
node: {
__dirname: false,
__filename: false,
},
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: true,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
watch: true,
watch: true,
};
export default merge(baseConfig, configuration);
+106 -96
View File
@@ -17,116 +17,126 @@ import deleteSourceMaps from '../scripts/delete-source-maps';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
const { version } = require('../../package.json');
checkNodeEnv('production');
deleteSourceMaps();
const devtoolsConfig =
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
const configuration: webpack.Configuration = {
...devtoolsConfig,
...devtoolsConfig,
mode: 'production',
mode: 'production',
target: ['web'],
target: ['web'],
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
output: {
path: webpackPaths.distRemotePath,
publicPath: './',
filename: 'remote.js',
library: {
type: 'umd',
entry: {
remote: path.join(webpackPaths.srcRemotePath, 'index.tsx'),
worker: path.join(webpackPaths.srcRemotePath, 'service-worker.ts'),
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
output: {
path: webpackPaths.distRemotePath,
publicPath: './',
filename: '[name].js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'remote.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: true,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
templateParameters: {
version,
prod: true,
},
}),
],
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'remote.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
}),
],
};
export default merge(baseConfig, configuration);
+1 -1
View File
@@ -38,7 +38,7 @@ const configuration: webpack.Configuration = {
output: {
path: webpackPaths.distWebPath,
publicPath: '/',
publicPath: 'auto',
filename: 'renderer.js',
library: {
type: 'umd',
@@ -37,6 +37,10 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
@@ -44,3 +48,7 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+8
View File
@@ -29,6 +29,10 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
@@ -36,3 +40,7 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+3 -2
View File
@@ -10,8 +10,9 @@ RUN npm run build:web
# --- Production stage
FROM nginx:alpine-slim
COPY --from=builder /app/release/app/dist/web /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html
COPY ng.conf.template /etc/nginx/templates/default.conf.template
ENV PUBLIC_PATH="/"
EXPOSE 9180
CMD ["nginx", "-g", "daemon off;"]
+6
View File
@@ -74,6 +74,8 @@ docker run --name feishin --port 9180:9180 feishin
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`.
## FAQ
### MPV is either not working or is rapidly switching between pause/play states
@@ -95,6 +97,10 @@ Built and tested using Node `v16.15.0`.
This project is built off of [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) v4.6.0.
## Translation
This project uses [Weblate](https://hosted.weblate.org/projects/feishin/) for translations. If you would like to contribute, please visit the link and submit a translation.
## License
[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)
+2 -3
View File
@@ -12,9 +12,8 @@ server {
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
gzip_comp_level 9;
root /usr/share/nginx/html;
location / {
location ${PUBLIC_PATH} {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html =404;
}
}
+106 -56
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.4.0",
"version": "0.5.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.4.0",
"version": "0.5.0",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
@@ -27,6 +27,7 @@
"@tanstack/react-query-devtools": "^4.32.1",
"@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"axios": "^1.4.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
@@ -41,7 +42,7 @@
"framer-motion": "^10.13.0",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"i18next": "^21.6.16",
"i18next": "^21.10.0",
"idb-keyval": "^6.2.1",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
@@ -56,11 +57,11 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-i18next": "^11.18.6",
"react-icons": "^4.10.1",
"react-player": "^2.11.0",
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
@@ -124,7 +125,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.3.0",
"i18next-parser": "^6.6.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
@@ -4336,11 +4337,11 @@
}
},
"node_modules/@remix-run/router": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.1.0.tgz",
"integrity": "sha512-rGl+jH/7x1KBCQScz9p54p0dtPLNeKGb3e0wD2H5/oZj41bwQUnXdzbj2TbUAFhvD7cp9EyEQA4dEgpUFa1O7Q==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz",
"integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==",
"engines": {
"node": ">=14"
"node": ">=14.0.0"
}
},
"node_modules/@sindresorhus/is": {
@@ -5613,6 +5614,38 @@
}
}
},
"node_modules/@xhayper/discord-rpc": {
"version": "1.0.24",
"resolved": "https://registry.npmjs.org/@xhayper/discord-rpc/-/discord-rpc-1.0.24.tgz",
"integrity": "sha512-gzC8OaOSz7cGALSHyyq6nANQvBfyfntbSq+Qh+cNanoKX8ybOj+jWKmDP6PbLVDWoBftTU3JYsWXrLml2df2Hw==",
"dependencies": {
"axios": "^1.5.1",
"ws": "^8.14.2"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/@xhayper/discord-rpc/node_modules/ws": {
"version": "8.14.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@@ -6292,9 +6325,9 @@
}
},
"node_modules/axios": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
@@ -11838,7 +11871,8 @@
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"node_modules/html-minifier-terser": {
"version": "6.1.0",
@@ -12096,9 +12130,9 @@
}
},
"node_modules/i18next": {
"version": "21.6.16",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz",
"integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==",
"version": "21.10.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
"integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==",
"funding": [
{
"type": "individual",
@@ -16935,12 +16969,11 @@
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
},
"node_modules/react-i18next": {
"version": "11.16.7",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz",
"integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==",
"version": "11.18.6",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",
"integrity": "sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==",
"dependencies": {
"@babel/runtime": "^7.14.5",
"html-escaper": "^2.0.2",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
@@ -17055,29 +17088,29 @@
}
},
"node_modules/react-router": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.5.0.tgz",
"integrity": "sha512-fqqUSU0NC0tSX0sZbyuxzuAzvGqbjiZItBQnyicWlOUmzhAU8YuLgRbaCL2hf3sJdtRy4LP/WBrWtARkMvdGPQ==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz",
"integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==",
"dependencies": {
"@remix-run/router": "1.1.0"
"@remix-run/router": "1.9.0"
},
"engines": {
"node": ">=14"
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.5.0.tgz",
"integrity": "sha512-/XzRc5fq80gW1ctiIGilyKFZC/j4kfe75uivMsTChFbkvrK4ZrF3P3cGIc1f/SSkQ4JiJozPrf+AwUHHWVehVg==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz",
"integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==",
"dependencies": {
"@remix-run/router": "1.1.0",
"react-router": "6.5.0"
"@remix-run/router": "1.9.0",
"react-router": "6.16.0"
},
"engines": {
"node": ">=14"
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8",
@@ -24366,9 +24399,9 @@
}
},
"@remix-run/router": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.1.0.tgz",
"integrity": "sha512-rGl+jH/7x1KBCQScz9p54p0dtPLNeKGb3e0wD2H5/oZj41bwQUnXdzbj2TbUAFhvD7cp9EyEQA4dEgpUFa1O7Q=="
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz",
"integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="
},
"@sindresorhus/is": {
"version": "4.6.0",
@@ -25433,6 +25466,23 @@
"dev": true,
"requires": {}
},
"@xhayper/discord-rpc": {
"version": "1.0.24",
"resolved": "https://registry.npmjs.org/@xhayper/discord-rpc/-/discord-rpc-1.0.24.tgz",
"integrity": "sha512-gzC8OaOSz7cGALSHyyq6nANQvBfyfntbSq+Qh+cNanoKX8ybOj+jWKmDP6PbLVDWoBftTU3JYsWXrLml2df2Hw==",
"requires": {
"axios": "^1.5.1",
"ws": "^8.14.2"
},
"dependencies": {
"ws": {
"version": "8.14.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
"requires": {}
}
}
},
"@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@@ -25951,9 +26001,9 @@
"dev": true
},
"axios": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
"requires": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
@@ -30183,7 +30233,8 @@
"html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"html-minifier-terser": {
"version": "6.1.0",
@@ -30369,9 +30420,9 @@
"dev": true
},
"i18next": {
"version": "21.6.16",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz",
"integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==",
"version": "21.10.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
"integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==",
"requires": {
"@babel/runtime": "^7.17.2"
}
@@ -33902,12 +33953,11 @@
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
},
"react-i18next": {
"version": "11.16.7",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz",
"integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==",
"version": "11.18.6",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",
"integrity": "sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==",
"requires": {
"@babel/runtime": "^7.14.5",
"html-escaper": "^2.0.2",
"html-parse-stringify": "^3.0.1"
}
},
@@ -33977,20 +34027,20 @@
}
},
"react-router": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.5.0.tgz",
"integrity": "sha512-fqqUSU0NC0tSX0sZbyuxzuAzvGqbjiZItBQnyicWlOUmzhAU8YuLgRbaCL2hf3sJdtRy4LP/WBrWtARkMvdGPQ==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz",
"integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==",
"requires": {
"@remix-run/router": "1.1.0"
"@remix-run/router": "1.9.0"
}
},
"react-router-dom": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.5.0.tgz",
"integrity": "sha512-/XzRc5fq80gW1ctiIGilyKFZC/j4kfe75uivMsTChFbkvrK4ZrF3P3cGIc1f/SSkQ4JiJozPrf+AwUHHWVehVg==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz",
"integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==",
"requires": {
"@remix-run/router": "1.1.0",
"react-router": "6.5.0"
"@remix-run/router": "1.9.0",
"react-router": "6.16.0"
}
},
"react-shallow-renderer": {
+41 -7
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.4.0",
"version": "0.5.0",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
@@ -26,7 +26,7 @@
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
"test": "jest",
"prepare": "husky install",
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
},
@@ -93,6 +93,39 @@
],
"icon": "assets/icons/icon.ico"
},
"deb": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"rpm": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"freebsd": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"linux": {
"target": [
"AppImage",
@@ -219,7 +252,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.3.0",
"i18next-parser": "^6.6.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
@@ -273,6 +306,7 @@
"@tanstack/react-query-devtools": "^4.32.1",
"@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"axios": "^1.4.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
@@ -287,7 +321,7 @@
"framer-motion": "^10.13.0",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"i18next": "^21.6.16",
"i18next": "^21.10.0",
"idb-keyval": "^6.2.1",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
@@ -302,11 +336,11 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-i18next": "^11.18.6",
"react-icons": "^4.10.1",
"react-player": "^2.11.0",
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.4.0",
"version": "0.5.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.4.0",
"version": "0.5.0",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.4.0",
"version": "0.5.0",
"description": "",
"main": "./dist/main/main.js",
"author": {
-32
View File
@@ -1,32 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const en = require('./locales/en.json');
const resources = {
en: { translation: en },
};
export const Languages = [
{
label: 'English',
value: 'en',
},
];
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
fallbackLng: 'en',
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
lng: 'en',
resources,
});
export default i18n;
+72
View File
@@ -0,0 +1,72 @@
import { PostProcessorModule } from 'i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
const resources = {
en: { translation: en },
};
export const languages = [
{
label: 'English',
value: 'en',
},
];
const lowerCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'lowerCase',
process: (value: string) => {
return value.toLocaleLowerCase();
},
};
const upperCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'upperCase',
process: (value: string) => {
return value.toLocaleUpperCase();
},
};
const titleCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'titleCase',
process: (value: string) => {
return value.replace(/\w\S*/g, (txt) => {
return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase();
});
},
};
const sentenceCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'sentenceCase',
process: (value: string) => {
const sentences = value.split('. ');
return sentences
.map((sentence) => {
return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLocaleLowerCase();
})
.join('. ');
},
};
i18n.use(lowerCasePostProcessor)
.use(upperCasePostProcessor)
.use(titleCasePostProcessor)
.use(sentenceCasePostProcessor)
.use(initReactI18next) // passes i18n down to react-i18next
.init({
fallbackLng: 'en',
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
resources,
});
export default i18n;
+41 -114
View File
@@ -1,117 +1,44 @@
// i18next-parser.config.js
// Reference: https://github.com/i18next/i18next-parser#options
module.exports = {
contextSeparator: '_',
// Key separator used in your translation keys
createOldCatalogs: true,
// Exit with an exit code of 1 when translations are updated (for CI purpose)
customValueTemplate: null,
// Save the \_old files
defaultNamespace: 'translation',
// Default namespace used in your i18next config
defaultValue: '',
// Exit with an exit code of 1 on warnings
failOnUpdate: false,
// Display info about the parsing including some stats
failOnWarnings: false,
// The locale to compare with default values to determine whether a default value has been changed.
// If this is set and a default value differs from a translation in the specified locale, all entries
// for that key across locales are reset to the default value, and existing translations are moved to
// the `_old` file.
i18nextOptions: null,
// Default value to give to empty keys
// You may also specify a function accepting the locale, namespace, and key as arguments
indentation: 2,
// Plural separator used in your translation keys
// If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
input: [
'../components/**/*.{js,jsx,ts,tsx}',
'../features/**/*.{js,jsx,ts,tsx}',
'../layouts/**/*.{js,jsx,ts,tsx}',
'!../../src/node_modules/**',
'!../../src/**/*.prod.js',
],
// Indentation of the catalog files
keepRemoved: false,
// Keep keys from the catalog that are no longer in code
keySeparator: '.',
// Key separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
// see below for more details
lexers: {
default: ['JavascriptLexer'],
handlebars: ['HandlebarsLexer'],
hbs: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
js: ['JavascriptLexer'],
jsx: ['JsxLexer'],
mjs: ['JavascriptLexer'],
// if you're writing jsx inside .js files, change this to JsxLexer
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
lineEnding: 'auto',
// Control the line ending. See options at https://github.com/ryanve/eol
locales: ['en'],
// An array of the locales in your applications
namespaceSeparator: false,
// Namespace separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
output: 'src/renderer/i18n/locales/$LOCALE.json',
// Supports $LOCALE and $NAMESPACE injection
// Supports JSON (.json) and YAML (.yml) file formats
// Where to write the locale files relative to process.cwd()
pluralSeparator: '_',
// If you wish to customize the value output the value as an object, you can set your own format.
// ${defaultValue} is the default value you set in your translation function.
// Any other custom property will be automatically extracted.
//
// Example:
// {
// message: "${defaultValue}",
// description: "${maxLength}", //
// }
resetDefaultValueLocale: 'en',
// Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
skipDefaultValues: false,
// An array of globs that describe where to look for source files
// relative to the location of the configuration file
sort: true,
// Whether to ignore default values
// You may also specify a function accepting the locale and namespace as arguments
useKeysAsDefaultValue: true,
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
// You may also specify a function accepting the locale and namespace as arguments
verbose: false,
// If you wish to customize options in internally used i18next instance, you can define an object with any
// configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
// { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
contextSeparator: '_',
createOldCatalogs: true,
customValueTemplate: null,
defaultNamespace: 'translation',
defaultValue: '',
failOnUpdate: false,
failOnWarnings: false,
i18nextOptions: null,
indentation: 4,
input: [
'../renderer/components/**/*.{js,jsx,ts,tsx}',
'../renderer/features/**/*.{js,jsx,ts,tsx}',
'../renderer/layouts/**/*.{js,jsx,ts,tsx}',
'!../src/node_modules/**',
'!../src/**/*.prod.js',
],
keepRemoved: false,
keySeparator: '.',
lexers: {
default: ['JavascriptLexer'],
handlebars: ['HandlebarsLexer'],
hbs: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
js: ['JavascriptLexer'],
jsx: ['JsxLexer'],
mjs: ['JavascriptLexer'],
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
lineEnding: 'auto',
locales: ['en'],
namespaceSeparator: false,
output: 'src/renderer/i18n/locales/$LOCALE.json',
pluralSeparator: '_',
resetDefaultValueLocale: 'en',
skipDefaultValues: false,
sort: true,
useKeysAsDefaultValue: true,
verbose: false,
};
+604 -7
View File
@@ -1,9 +1,606 @@
{
"player": {
"next": "player.next",
"play": "player.play",
"prev": "player.prev",
"seekBack": "player.seekBack",
"seekForward": "player.seekForward"
}
"action": {
"addToFavorites": "add to $t(entity.favorite_other)",
"addToPlaylist": "add to $t(entity.playlist_one)",
"clearQueue": "clear queue",
"createPlaylist": "create $t(entity.playlist_one)",
"deletePlaylist": "delete $t(entity.playlist_one)",
"deselectAll": "deselect all",
"editPlaylist": "edit $t(entity.playlist_one)",
"goToPage": "go to page",
"moveToBottom": "move to bottom",
"moveToTop": "move to top",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "remove from $t(entity.favorite_other)",
"removeFromPlaylist": "remove from $t(entity.playlist_one)",
"removeFromQueue": "remove from queue",
"setRating": "set rating",
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
"viewPlaylists": "view $t(entity.playlist_other)"
},
"common": {
"action_one": "action",
"action_other": "actions",
"add": "add",
"areYouSure": "are you sure?",
"ascending": "ascending",
"backward": "backward",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"cancel": "cancel",
"center": "center",
"channel_one": "channel",
"channel_other": "channels",
"clear": "clear",
"collapse": "collapse",
"comingSoon": "coming soon...",
"configure": "configure",
"confirm": "confirm",
"create": "create",
"currentSong": "current $t(entity.track_one)",
"decrease": "decrease",
"delete": "delete",
"descending": "descending",
"description": "description",
"disable": "disable",
"disc": "disc",
"dismiss": "dismiss",
"duration": "duration",
"edit": "edit",
"enable": "enable",
"expand": "expand",
"favorite": "favorite",
"filter_one": "filter",
"filter_other": "filters",
"filters": "filters",
"forceRestartRequired": "restart to apply changes... close the notification to restart",
"forward": "forward",
"gap": "gap",
"home": "home",
"increase": "increase",
"left": "left",
"limit": "limit",
"manage": "manage",
"maximize": "maximize",
"menu": "menu",
"minimize": "minimize",
"modified": "modified",
"name": "name",
"no": "no",
"none": "none",
"noResultsFromQuery": "the query returned no results",
"note": "note",
"ok": "ok",
"owner": "owner",
"path": "path",
"playerMustBePaused": "player must be paused",
"previousSong": "previous $t(entity.track_one)",
"quit": "quit",
"random": "random",
"rating": "rating",
"refresh": "refresh",
"reset": "reset",
"resetToDefault": "reset to default",
"restartRequired": "restart required",
"right": "right",
"save": "save",
"saveAndReplace": "save and replace",
"saveAs": "save as",
"search": "search",
"setting": "setting",
"setting_other": "settings",
"size": "size",
"sortOrder": "order",
"title": "title",
"trackNumber": "track",
"unknown": "unknown",
"version": "version",
"year": "year",
"yes": "yes"
},
"entity": {
"album_one": "album",
"album_other": "albums",
"albumArtist_one": "album artist",
"albumArtist_other": "album artists",
"albumArtistCount_one": "{{count}} album artist",
"albumArtistCount_other": "{{count}} album artists",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albums",
"artist_one": "artist",
"artist_other": "artists",
"artistWithCount_one": "{{count}} artist",
"artistWithCount_other": "{{count}} artists",
"favorite_one": "favorite",
"favorite_other": "favorites",
"folder_one": "folder",
"folder_other": "folders",
"folderWithCount_one": "{{count}} folder",
"folderWithCount_other": "{{count}} folders",
"genre_one": "genre",
"genre_other": "genres",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genres",
"playlist_one": "playlist",
"playlist_other": "playlists",
"playlistWithCount_one": "{{count}} playlist",
"playlistWithCount_other": "{{count}} playlists",
"smartPlaylist": "smart $t(entity.playlist_one)",
"track_one": "track",
"track_other": "tracks",
"trackWithCount_one": "{{count}} track",
"trackWithCount_other": "{{count}} tracks"
},
"error": {
"apiRouteError": "unable to route request",
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
"authenticationFailed": "authentication failed",
"credentialsRequired": "credentials required",
"endpointNotImplementedError": "endpoint {{endpoint} is not implemented for {{serverType}}",
"genericError": "an error occurred",
"invalidServer": "invalid server",
"localFontAccessDenied": "access denied to local fonts",
"loginRateError": "too many login attempts, please try again in a few seconds",
"mpvRequired": "MPV required",
"playbackError": "an error occurred when trying to play the media",
"remoteDisableError": "an error occurred when trying to $t(common.disable) the remote server",
"remoteEnableError": "an error occurred when trying to $t(common.enable) the remote server",
"remotePortError": "an error occurred when trying to set the remote server port",
"remotePortWarning": "restart the server to apply the new port",
"serverNotSelectedError": "no server selected",
"serverRequired": "server required",
"sessionExpiredError": "your session has expired",
"systemFontError": "an error occurred when trying to get system fonts"
},
"filter": {
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"communityRating": "community rating",
"criticRating": "critic rating",
"dateAdded": "date added",
"disc": "disc",
"duration": "duration",
"favorited": "favorited",
"fromYear": "from year",
"isCompilation": "is compilation",
"isFavorited": "is favorited",
"isRated": "is rated",
"isRecentlyPlayed": "is recently played",
"lastPlayed": "last played",
"mostPlayed": "most played",
"name": "name",
"note": "note",
"path": "path",
"playCount": "play count",
"random": "random",
"rating": "rating",
"recentlyAdded": "recently added",
"recentlyPlayed": "recently played",
"releaseDate": "release date",
"releaseYear": "release year",
"search": "search",
"songCount": "song count",
"title": "title",
"toYear": "to year",
"trackNumber": "track"
},
"form": {
"addServer": {
"error_savePassword": "an error occurred when trying to save the password",
"ignoreCors": "ignore cors ($t(common.restartRequired))",
"ignoreSsl": "ignore ssl ($t(common.restartRequired))",
"input_legacyAuthentication": "enable legacy authentication",
"input_name": "server name",
"input_password": "password",
"input_savePassword": "save password",
"input_url": "url",
"input_username": "username",
"success": "server added successfully",
"title": "add server"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
"input_skipDuplicates": "skip duplicates",
"success": "added {{message}} $t(entity.song_other) to {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "add to $t(entity.playlist_one)"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "public",
"success": "$t(entity.playlist_one) created successfully",
"title": "create $t(entity.playlist_one)"
},
"deletePlaylist": {
"input_confirm": "type the name of the $t(entity.playlist_one) to confirm",
"success": "$t(entity.playlist_one) deleted successfully",
"title": "delete $t(entity.playlist_one)"
},
"editPlaylist": {
"title": "edit $t(entity.playlist_one)"
},
"lyricSearch": {
"input_artist": "$t(entity.artist_one)",
"input_name": "$t(common.name)",
"title": "lyric search"
},
"queryEditor": {
"input_optionMatchAll": "match all",
"input_optionMatchAny": "match any"
},
"updateServer": {
"success": "server updated successfully",
"title": "update server"
}
},
"page": {
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumDetail": {
"moreFromArtist": "more from this $t(entity.genre_one)",
"moreFromGeneric": "more from {{item}}"
},
"albumList": {
"title": "$t(entity.album_other)"
},
"appMenu": {
"collapseSidebar": "collapse sidebar",
"expandSidebar": "expand sidebar",
"goBack": "go back",
"goForward": "go forward",
"manageServers": "manage servers",
"openBrowserDevtools": "open browser devtools",
"quit": "$t(common.quit)",
"selectServer": "select server",
"settings": "$t(common.setting_other)",
"version": "version {{version}}"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} selected",
"play": "$t(player.play)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "dynamic background",
"followCurrentLyric": "follow current lyric",
"lyricAlignment": "lyric alignment",
"lyricGap": "lyric gap",
"lyricSize": "lyric size",
"opacity": "opacity",
"showLyricMatch": "show lyric match",
"showLyricProvider": "show lyric provider",
"synchronized": "synchronized",
"unsynchronized": "unsynchronized",
"useImageAspectRatio": "use image aspect ratio"
},
"lyrics": "lyrics",
"related": "related",
"upNext": "up next"
},
"genreList": {
"title": "$t(entity.genre_other)"
},
"globalSearch": {
"commands": {
"goToPage": "go to page",
"searchFor": "search for {{query}}",
"serverCommands": "server commands"
},
"title": "commands"
},
"home": {
"explore": "explore from your library",
"mostPlayed": "most played",
"newlyAdded": "newly added releases",
"recentlyPlayed": "recently played",
"title": "$t(common.home)"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"setting": {
"generalTab": "general",
"hotkeysTab": "hotkeys",
"playbackTab": "playback",
"windowTab": "window"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist_other)",
"albums": "$t(entity.album_other)",
"artists": "$t(entity.artist_other)",
"folders": "$t(entity.folder_other)",
"genres": "$t(entity.genre_other)",
"home": "$t(common.home)",
"nowPlaying": "now playing",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(entity.setting_other)",
"tracks": "$t(entity.track_other)"
},
"trackList": {
"title": "$t(entity.track_other)"
}
},
"player": {
"addLast": "add last",
"addNext": "add next",
"favorite": "favorite",
"mute": "mute",
"muted": "muted",
"next": "next",
"play": "play",
"playbackFetchCancel": "this is taking a while... close the notification to cancel",
"playbackFetchInProgress": "loading songs...",
"playbackFetchNoResults": "no songs found",
"playbackSpeed": "playback speed",
"playRandom": "play random",
"previous": "previous",
"queue_clear": "clear queue",
"queue_moveToBottom": "move selected to top",
"queue_moveToTop": "move selected to bottom",
"queue_remove": "remove selected",
"repeat": "repeat",
"repeat_all": "repeat all",
"repeat_off": "repeat disabled",
"repeat_one": "repeat one",
"shuffle": "shuffle",
"shuffle_off": "shuffle disabled",
"skip": "skip",
"skip_back": "skip backwards",
"skip_forward": "skip forwards",
"stop": "stop",
"toggleFullscreenPlayer": "toggle fullscreen player",
"unfavorite": "unfavorite"
},
"setting": {
"accentColor": "accent color",
"accentColor_description": "sets the accent color for the application",
"applicationHotkeys": "application hotkeys",
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
"audioDevice": "audio device",
"audioDevice_description": "select the audio device to use for playback (web player only)",
"audioExclusiveMode": "audio exclusive mode",
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio",
"audioPlayer": "audio player",
"audioPlayer_description": "select the audio player to use for playback",
"crossfadeDuration": "crossfade duration",
"crossfadeDuration_description": "sets the duration of the crossfade effect",
"crossfadeStyle": "crossfade style",
"crossfadeStyle_description": "select the crossfade style to use for the audio player",
"customFontPath": "custom font path",
"customFontPath_description": "sets the path to the custom font to use for the application",
"disableAutomaticUpdates": "disable automatic updates",
"disableLibraryUpdateOnStartup": "disable checking for new versions on startup",
"discordApplicationId": "{{discord}} application id",
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}}",
"discordIdleStatus": "show rich presence idle status",
"discordIdleStatus_description": "when enabled, update status while player is idle",
"discordRichPresence": "{{discord}} rich presence",
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
"discordUpdateInterval": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"enableRemote": "enable remote control server",
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
"exitToTray": "exit to tray",
"exitToTray_description": "exit the application to the system tray",
"floatingQueueArea": "show floating queue hover area",
"floatingQueueArea_description": "display a hover icon on the right side of the screen to view the play queue",
"followLyric": "follow current lyric",
"followLyric_description": "scroll the lyric to the current playing position",
"font": "font",
"font_description": "sets the font to use for the application",
"fontType": "font type",
"fontType_description": "built-in font selects one of the fonts provided by Feishin. system font allows you to select any font provided by your operating system. custom allows you to provide your own font",
"fontType_optionBuiltIn": "built-in font",
"fontType_optionCustom": "custom font",
"fontType_optionSystem": "system font",
"gaplessAudio": "gapless audio",
"gaplessAudio_description": "sets the gapless audio setting for mpv",
"gaplessAudio_optionWeak": "weak (recommended)",
"globalMediaHotkeys": "global media hotkeys",
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
"hotkey_browserBack": "browser back",
"hotkey_browserForward": "browser forward",
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
"hotkey_favoritePreviousSong": "favorite $t(common.previousSong)",
"hotkey_globalSearch": "global search",
"hotkey_localSearch": "in-page search",
"hotkey_playbackNext": "next track",
"hotkey_playbackPause": "pause",
"hotkey_playbackPlay": "play",
"hotkey_playbackPlayPause": "play / pause",
"hotkey_playbackPrevious": "previous track",
"hotkey_playbackStop": "stop",
"hotkey_rate0": "rating clear",
"hotkey_rate1": "rating 1 star",
"hotkey_rate2": "rating 2 stars",
"hotkey_rate3": "rating 3 stars",
"hotkey_rate4": "rating 4 stars",
"hotkey_rate5": "rating 5 stars",
"hotkey_skipBackward": "skip backward",
"hotkey_skipForward": "skip forward",
"hotkey_toggleCurrentSongFavorite": "toggle $t(common.currentSong) favorite",
"hotkey_toggleFullScreenPlayer": "toggle full screen player",
"hotkey_togglePreviousSongFavorite": "toggle $t(common.previousSong) favorite",
"hotkey_toggleQueue": "toggle queue",
"hotkey_toggleRepeat": "toggle repeat",
"hotkey_toggleShuffle": "toggle shuffle",
"hotkey_unfavoriteCurrentSong": "unfavorite $t(common.currentSong)",
"hotkey_unfavoritePreviousSong": "unfavorite $t(common.previousSong)",
"hotkey_volumeDown": "volume down",
"hotkey_volumeMute": "volume mute",
"hotkey_volumeUp": "volume up",
"hotkey_zoomIn": "zoom in",
"hotkey_zoomOut": "zoom out",
"language": "language",
"language_description": "sets the language for the application ($t(common.restartRequired))",
"lyricFetch": "fetch lyrics from the internet",
"lyricFetch_description": "fetch lyrics from various internet sources",
"lyricFetchProvider": "providers to fetch lyrics from",
"lyricFetchProvider_description": "select the providers to fetch lyrics from. the order of the providers is the order in which they will be queried",
"lyricOffset": "lyric offset (ms)",
"lyricOffset_description": "offset the lyric by the specified amount of milliseconds",
"minimizeToTray": "minimize to tray",
"minimizeToTray_description": "minimize the application to the system tray",
"minimumScrobblePercentage": "minimum scrobble duration (percentage)",
"minimumScrobblePercentage_description": "the minimum percentage of the song that must be played before it is scrobbled",
"minimumScrobbleSeconds": "minimum scrobble (seconds)",
"minimumScrobbleSeconds_description": "the minimum duration in seconds of the song that must be played before it is scrobbled",
"mpvExecutablePath": "mpv executable path",
"mpvExecutablePath_description": "sets the path to the mpv executable",
"mpvExecutablePath_help": "one per line",
"mpvExtraParameters": "mpv parameters",
"playbackStyle": "playback style",
"playbackStyle_description": "select the playback style to use for the audio player",
"playbackStyle_optionCrossFade": "crossfade",
"playbackStyle_optionNormal": "normal",
"playButtonBehavior": "play button behavior",
"playButtonBehavior_description": "sets the default behavior of the play button when adding songs to the queue",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"remotePassword": "remote control server password",
"remotePassword_description": "sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about",
"remotePort": "remote control server port",
"remotePort_description": "sets the port for the remote control server",
"remoteUsername": "remote control server username",
"remoteUsername_description": "sets the username for the remote control server. if both username and password are empty, authentication will be disabled",
"replayGainClipping": "{{ReplayGain}} clipping",
"replayGainClipping_description": "Prevent clipping caused by {{ReplayGain}} by automatically lowering the gain",
"replayGainFallback": "{{ReplayGain}} fallback",
"replayGainFallback_description": "gain in db to apply if the file has no {{ReplayGain}} tags",
"replayGainMode": "{{ReplayGain}} mode",
"replayGainMode_description": "adjust volume gain according to {{ReplayGain}} values stored in the file metadata",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"replayGainPreamp": "{{ReplayGain}} preamp (dB)",
"replayGainPreamp_description": "adjust the preamp gain applied to the {{ReplayGain}} values",
"sampleRate": "sample rate",
"sampleRate_description": "select the output sample rate to be used if the sample frequency selected is different from that of the current media",
"savePlayQueue": "save play queue",
"savePlayQueue_description": "save the play queue when the application is closed and restore it when the application is opened",
"scrobble": "scrobble",
"scrobble_description": "scrobble plays to your media server",
"showSkipButton": "show skip buttons",
"showSkipButton_description": "show or hide the skip buttons on the player bar",
"showSkipButtons": "show skip buttons",
"showSkipButtons_description": "show or hide the skip buttons on the player bar",
"sidebarCollapsedNavigation": "sidebar (collapsed) navigation",
"sidebarCollapsedNavigation_description": "show or hide the navigation in the collapsed sidebar",
"sidebarConfiguration": "sidebar configuration",
"sidebarConfiguration_description": "select the items and order in which they appear in the sidebar",
"sidebarPlaylistList": "sidebar playlist list",
"sidebarPlaylistList_description": "show or hide the playlist list in the sidebar",
"sidePlayQueueStyle": "side play queue style",
"sidePlayQueueStyle_description": "sets the style of the side play queue",
"sidePlayQueueStyle_optionAttached": "attached",
"sidePlayQueueStyle_optionDetached": "detached",
"skipDuration": "skip duration",
"skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar",
"skipPlaylistPage": "skip playlist page",
"skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page",
"theme": "theme",
"theme_description": "sets the theme to use for the application",
"themeDark": "theme (dark)",
"themeDark_description": "sets the dark theme to use for the application",
"themeLight": "theme (light)",
"themeLight_description": "sets the light theme to use for the application",
"useSystemTheme": "use system theme",
"useSystemTheme_description": "follow the system-defined light or dark preference",
"volumeWheelStep": "volume wheel step",
"volumeWheelStep_description": "the amount of volume to change when scrolling the mouse wheel on the volume slider",
"windowBarStyle": "window bar style",
"windowBarStyle_description": "select the style of the window bar",
"zoom": "zoom percentage",
"zoom_description": "sets the zoom percentage for the application"
},
"table": {
"column": {
"album": "album",
"albumArtist": "album artist",
"albumCount": "$t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"comment": "comment",
"dateAdded": "date added",
"discNumber": "disc",
"favorite": "favorite",
"genre": "$t(entity.genre_one)",
"lastPlayed": "last played",
"path": "path",
"playCount": "plays",
"rating": "rating",
"releaseDate": "release date",
"releaseYear": "year",
"songCount": "$t(entity.track_other)",
"title": "title",
"trackNumber": "track"
},
"config": {
"general": {
"autoFitColumns": "auto fit columns",
"displayType": "display type",
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"tableColumns": "table columns"
},
"label": {
"actions": "$t(common.action_other)",
"album": "$t(entity.album_one)",
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"dateAdded": "date added",
"discNumber": "disc number",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"genre": "$t(entity.genre_one)",
"lastPlayed": "last played",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"playCount": "play count",
"rating": "$t(common.rating)",
"releaseDate": "release date",
"rowIndex": "row index",
"size": "$t(common.size)",
"title": "$t(common.title)",
"titleCombined": "$t(common.title) (combined)",
"trackNumber": "track number",
"year": "$t(common.year)"
},
"view": {
"card": "card",
"poster": "poster",
"table": "table"
}
}
}
}
@@ -0,0 +1,63 @@
import { Client, SetActivity } from '@xhayper/discord-rpc';
import { ipcMain } from 'electron';
const FEISHIN_DISCORD_APPLICATION_ID = '1165957668758900787';
let client: Client | null = null;
const createClient = (clientId?: string) => {
client = new Client({
clientId: clientId || FEISHIN_DISCORD_APPLICATION_ID,
});
client.login();
return client;
};
const setActivity = (activity: SetActivity) => {
if (client) {
client.user?.setActivity({
...activity,
});
}
};
const clearActivity = () => {
if (client) {
client.user?.clearActivity();
}
};
const quit = () => {
if (client) {
client?.destroy();
}
};
ipcMain.handle('discord-rpc-initialize', (_event, clientId?: string) => {
createClient(clientId);
});
ipcMain.handle('discord-rpc-set-activity', (_event, activity: SetActivity) => {
if (client) {
setActivity(activity);
}
});
ipcMain.handle('discord-rpc-clear-activity', () => {
if (client) {
clearActivity();
}
});
ipcMain.handle('discord-rpc-quit', () => {
quit();
});
export const discordRpc = {
clearActivity,
createClient,
quit,
setActivity,
};
+1
View File
@@ -2,3 +2,4 @@ import './lyrics';
import './player';
import './remote';
import './settings';
import './discord-rpc';
+5 -7
View File
@@ -112,18 +112,16 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
try {
if (data.queue.current) {
getMpvInstance()
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
.then(() => {
// eslint-disable-next-line promise/always-return
if (data.queue.next) {
getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
})
.catch((err) => {
console.log('MPV failed to load song', err);
getMpvInstance()?.play();
});
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
}
} catch (err) {
console.error(err);
+14 -3
View File
@@ -6,6 +6,7 @@ import { deflate, gzip } from 'zlib';
import axios from 'axios';
import { app, ipcMain } from 'electron';
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
import manifest from './manifest.json';
import { ClientEvent, ServerEvent } from '../../../../remote/types';
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
import { getMainWindow } from '../../../main';
@@ -297,6 +298,12 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
await serveFile(req, 'remote', 'js', res);
break;
}
case '/manifest.json': {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(manifest));
break;
}
case '/credentials': {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
@@ -304,9 +311,13 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
break;
}
default: {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not FOund');
if (req.url?.startsWith('/worker.js')) {
await serveFile(req, 'worker', 'js', res);
} else {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not Found');
}
}
}
} catch (error) {
@@ -0,0 +1,17 @@
{
"name": "Feishin Remote",
"short_name": "Feishin Remote",
"start_url": "/",
"background_color": "#000100",
"theme_color": "#E7E7E7",
"icons": [
{
"src": "favicon.ico",
"sizes": "32x32",
"type": "image/png",
"purpose": "maskable any"
}
],
"display": "standalone",
"orientation": "portrait"
}
+35
View File
@@ -21,6 +21,8 @@ import {
Menu,
nativeImage,
BrowserWindowConstructorOptions,
protocol,
net,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log';
@@ -43,6 +45,8 @@ export default class AppUpdater {
}
}
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
process.on('uncaughtException', (error: any) => {
console.log('Error in main process', error);
});
@@ -259,6 +263,11 @@ const createWindow = async () => {
mainWindow?.close();
});
ipcMain.on('window-quit', () => {
mainWindow?.close();
app.exit();
});
ipcMain.on('app-restart', () => {
// Fix for .AppImage
if (process.env.APPIMAGE) {
@@ -648,8 +657,34 @@ app.on('window-all-closed', () => {
}
});
const FONT_HEADERS = [
'font/collection',
'font/otf',
'font/sfnt',
'font/ttf',
'font/woff',
'font/woff2',
];
app.whenReady()
.then(() => {
protocol.handle('feishin', async (request) => {
const filePath = `file://${request.url.slice('feishin://'.length)}`;
const response = await net.fetch(filePath);
const contentType = response.headers.get('content-type');
if (!contentType || !FONT_HEADERS.includes(contentType)) {
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
status: 403,
statusText: 'Forbidden',
});
}
return response;
});
createWindow();
createTray();
app.on('activate', () => {
+2
View File
@@ -1,5 +1,6 @@
import { contextBridge } from 'electron';
import { browser } from './preload/browser';
import { discordRpc } from './preload/discord-rpc';
import { ipc } from './preload/ipc';
import { localSettings } from './preload/local-settings';
import { lyrics } from './preload/lyrics';
@@ -10,6 +11,7 @@ import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', {
browser,
discordRpc,
ipc,
localSettings,
lyrics,
+8
View File
@@ -3,16 +3,23 @@ import { ipcRenderer } from 'electron';
const exit = () => {
ipcRenderer.send('window-close');
};
const maximize = () => {
ipcRenderer.send('window-maximize');
};
const minimize = () => {
ipcRenderer.send('window-minimize');
};
const unmaximize = () => {
ipcRenderer.send('window-unmaximize');
};
const quit = () => {
ipcRenderer.send('window-quit');
};
const devtools = () => {
ipcRenderer.send('window-dev-tools');
};
@@ -22,5 +29,6 @@ export const browser = {
exit,
maximize,
minimize,
quit,
unmaximize,
};
+28
View File
@@ -0,0 +1,28 @@
import { SetActivity } from '@xhayper/discord-rpc';
import { ipcRenderer } from 'electron';
const initialize = (clientId: string) => {
const client = ipcRenderer.invoke('discord-rpc-initialize', clientId);
return client;
};
const clearActivity = () => {
ipcRenderer.invoke('discord-rpc-clear-activity');
};
const setActivity = (activity: SetActivity) => {
ipcRenderer.invoke('discord-rpc-set-activity', activity);
};
const quit = () => {
ipcRenderer.invoke('discord-rpc-quit');
};
export const discordRpc = {
clearActivity,
initialize,
quit,
setActivity,
};
export type DiscordRpc = typeof discordRpc;
+6 -1
View File
@@ -1,4 +1,4 @@
import { ipcRenderer, webFrame } from 'electron';
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store';
const store = new Store();
@@ -39,9 +39,14 @@ const setZoomFactor = (zoomFactor: number) => {
webFrame.setZoomFactor(zoomFactor / 100);
};
const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
ipcRenderer.on('custom-font-error', cb);
};
export const localSettings = {
disableMediaKeys,
enableMediaKeys,
fontError,
get,
passwordGet,
passwordRemove,
+2 -1
View File
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { Group, Image, Rating, Text, Title } from '@mantine/core';
import { Group, Image, Text, Title } from '@mantine/core';
import { useInfo, useSend, useShowImage } from '/@/remote/store';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import formatDuration from 'format-duration';
@@ -18,6 +18,7 @@ import {
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
import { Tooltip } from '/@/renderer/components/tooltip';
import { Rating } from '/@/renderer/components';
export const RemoteContainer = () => {
const { repeat, shuffle, song, status, volume } = useInfo();
+8
View File
@@ -6,6 +6,14 @@
<meta http-equiv="Content-Security-Policy" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Feishin Remote</title>
<link rel="manifest" href="manifest.json">
<script>
if ('serviceWorker' in navigator) {
const version = encodeURIComponent("<%= version %>");
const prod = encodeURIComponent("<%= prod %>");
navigator.serviceWorker.register(`/worker.js?version=${version}&prod=${prod}`);
}
</script>
</head>
<body>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Feishin Remote",
"short_name": "Feishin Remote",
"start_url": "/",
"background_color": "#FFDCB5",
"theme_color": "#1E003D",
"icons": [
{
"src": "favicon.ico",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
}
],
"display": "standalone",
"orientation": "portrait"
}
+48
View File
@@ -0,0 +1,48 @@
/// <reference lib="WebWorker" />
export type {};
// eslint-disable-next-line no-undef
declare const self: ServiceWorkerGlobalScope;
// eslint-disable-next-line no-restricted-globals
const url = new URL(location.toString());
const version = url.searchParams.get('version');
const prod = url.searchParams.get('prod') === 'true';
const cacheName = `Feishin-remote-${version}`;
const resourcesToCache = ['./', './remote.js', './favicon.ico'];
if (prod) {
resourcesToCache.push('./remote.css');
}
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(cacheName).then((cache) => {
return cache.addAll(resourcesToCache);
}),
);
});
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then((response) => {
return response || fetch(e.request);
}),
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keyList) => {
return Promise.all(
keyList.map((key) => {
if (key !== cacheName) {
return caches.delete(key);
}
return Promise.resolve();
}),
);
}),
);
});
-1
View File
@@ -194,7 +194,6 @@ export const useRemoteStore = create<SettingsSlice>()(
});
},
send: (data: ClientEvent) => {
console.log(data, get().socket);
get().socket?.send(JSON.stringify(data));
},
toggleIsDark: () => {
View File
+16 -4
View File
@@ -54,6 +54,7 @@ import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import i18n from '/@/i18n/i18n';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
@@ -128,7 +129,7 @@ const endpoints: ApiController = {
getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getSongDetail: undefined,
getSongDetail: jfController.getSongDetail,
getSongList: jfController.getSongList,
getTopSongs: jfController.getTopSongList,
getUserList: undefined,
@@ -212,7 +213,12 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' });
toast.error({
message: i18n.t('error.serverNotSelectedError', {
postProcess: 'sentenceCase',
}) as string,
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
});
throw new Error(`No server selected`);
}
@@ -221,10 +227,16 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
if (typeof controllerFn !== 'function') {
toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request',
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
});
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
throw new Error(
i18n.t('error.endpointNotImplementedError', {
endpoint,
postProcess: 'sentenceCase',
serverType,
}) as string,
);
}
return endpoints[serverType][endpoint];
+1
View File
@@ -547,6 +547,7 @@ export enum JFAlbumListSort {
COMMUNITY_RATING = 'CommunityRating,SortName',
CRITIC_RATING = 'CriticRating,SortName',
NAME = 'SortName',
PLAY_COUNT = 'PlayCount',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',
+1 -1
View File
@@ -160,7 +160,7 @@ export const contract = c.router({
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
path: 'users/:userId/items/:id',
responses: {
200: jfType._response.song,
400: jfType._response.error,
@@ -47,6 +47,8 @@ import {
LyricsArgs,
LyricsResponse,
genreListSortMap,
SongDetailArgs,
SongDetailResponse,
} from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize';
@@ -100,9 +102,9 @@ const authenticate = async (
Username: body.username,
},
headers: {
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin", Version="${
packageJson.version
}"`,
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin-${getHostname()}-${
body.username
}", Version="${packageJson.version}"`,
},
});
@@ -559,20 +561,6 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
throw new Error('No userId found');
}
const musicFoldersRes = await jfApiClient(apiClientProps).getMusicFolderList({
params: {
userId: apiClientProps.server?.userId,
},
});
if (musicFoldersRes.status !== 200) {
throw new Error('Failed playlist folder');
}
const playlistFolder = musicFoldersRes.body.Items.filter(
(folder) => folder.CollectionType === jfType._enum.collection.PLAYLISTS,
)?.[0];
const res = await jfApiClient(apiClientProps).getPlaylistList({
params: {
userId: apiClientProps.server?.userId,
@@ -581,7 +569,8 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
IncludeItemTypes: 'Playlist',
Limit: query.limit,
ParentId: playlistFolder?.Id,
MediaTypes: 'Audio',
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: playlistListSortMap.jellyfin[query.sortBy],
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
@@ -934,12 +923,29 @@ const getLyrics = async (args: LyricsArgs): Promise<LyricsResponse> => {
}
if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) {
return res.body.Lyrics[0].Text;
return res.body.Lyrics.map((lyric) => lyric.Text).join('\n');
}
return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]);
};
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).getSongDetail({
params: {
id: query.id,
userId: apiClientProps.server?.userId ?? '',
},
});
if (res.status !== 200) {
throw new Error('Failed to get song detail');
}
return jfNormalize.song(res.body, apiClientProps.server, '');
};
export const jfController = {
addToPlaylist,
authenticate,
@@ -959,6 +965,7 @@ export const jfController = {
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getSongDetail,
getSongList,
getTopSongList,
removeFromPlaylist,
@@ -478,6 +478,7 @@ const albumListSort = {
COMMUNITY_RATING: 'CommunityRating,SortName',
CRITIC_RATING: 'CriticRating,SortName',
NAME: 'SortName',
PLAY_COUNT: 'PlayCount',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
RELEASE_DATE: 'ProductionYear,PremiereDate,SortName',
+12 -4
View File
@@ -9,6 +9,7 @@ import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils'
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { toast } from '/@/renderer/components';
import i18n from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null;
@@ -276,9 +277,12 @@ axiosClient.interceptors.response.use(
if (res.status === 429) {
toast.error({
message:
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
title: 'Your session has expired.',
message: i18n.t('error.loginRateError', {
postProcess: 'sentenceCase',
}) as string,
title: i18n.t('error.sessionExpiredError', {
postProcess: 'sentenceCase',
}) as string,
});
const serverId = currentServer.id;
@@ -292,7 +296,11 @@ axiosClient.interceptors.response.use(
throw TIMEOUT_ERROR;
}
if (res.status !== 200) {
throw new Error('Failed to authenticate');
throw new Error(
i18n.t('error.authenticatedFailed', {
postProcess: 'sentenceCase',
}) as string,
);
}
const newCredential = res.data.token;
@@ -20,10 +20,6 @@ const getImageUrl = (args: { url: string | null }) => {
return null;
}
if (url?.match('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')) {
return null;
}
return url;
};
@@ -186,7 +182,16 @@ const normalizeAlbumArtist = (
},
server: ServerListItem | null,
): AlbumArtist => {
const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
let imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
if (!imageUrl) {
imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: `ar-${item.id}`,
credential: server?.credential,
size: 100,
});
}
return {
albumCount: item.albumCount,
+2 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast/index';
import i18n from '/@/i18n/i18n';
const c = initContract();
@@ -106,7 +107,7 @@ axiosClient.interceptors.response.use(
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
}
}
+10 -1
View File
@@ -395,7 +395,7 @@ export const albumListSortMap: AlbumListSortMap = {
duration: undefined,
favorited: undefined,
name: JFAlbumListSort.NAME,
playCount: undefined,
playCount: JFAlbumListSort.PLAY_COUNT,
random: JFAlbumListSort.RANDOM,
rating: undefined,
recentlyAdded: JFAlbumListSort.RECENTLY_ADDED,
@@ -1130,3 +1130,12 @@ export enum LyricSource {
}
export type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { id: string };
// This type from https://wicg.github.io/local-font-access/#fontdata
// NOTE: it is still experimental, so this should be updates as appropriate
export type FontData = {
family: string;
fullName: string;
postscriptName: string;
style: string;
};
+54 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
@@ -23,8 +23,10 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
import '@ag-grid-community/styles/ag-grid.css';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import i18n from '/@/i18n/i18n';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@@ -37,17 +39,56 @@ const remote = isElectron() ? window.electron.remote : null;
export const App = () => {
const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent);
const accent = useSettingsStore((store) => store.general.accent);
const language = useSettingsStore((store) => store.general.language);
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls();
const remoteSettings = useRemoteSettings();
const textStyleRef = useRef<HTMLStyleElement>();
useDiscordRpc();
useEffect(() => {
if (type === FontType.SYSTEM && system) {
const root = document.documentElement;
root.style.setProperty('--content-font-family', 'dynamic-font');
if (!textStyleRef.current) {
textStyleRef.current = document.createElement('style');
document.body.appendChild(textStyleRef.current);
}
textStyleRef.current.textContent = `
@font-face {
font-family: "dynamic-font";
src: local("${system}");
}`;
} else if (type === FontType.CUSTOM && custom) {
const root = document.documentElement;
root.style.setProperty('--content-font-family', 'dynamic-font');
if (!textStyleRef.current) {
textStyleRef.current = document.createElement('style');
document.body.appendChild(textStyleRef.current);
}
textStyleRef.current.textContent = `
@font-face {
font-family: "dynamic-font";
src: url("feishin://${custom}");
}`;
} else {
const root = document.documentElement;
root.style.setProperty('--content-font-family', builtIn);
}
}, [builtIn, custom, system, type]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--content-font-family', contentFont);
}, [contentFont]);
root.style.setProperty('--primary-color', accent);
}, [accent]);
const providerValue = useMemo(() => {
return { handlePlayQueueAdd };
@@ -62,7 +103,8 @@ export const App = () => {
if (!isRunning) {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties = {
const properties: Record<string, any> = {
speed: usePlayerStore.getState().current.speed,
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
};
@@ -138,6 +180,12 @@ export const App = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
}, [language]);
return (
<MantineProvider
withGlobalStyles
@@ -10,6 +10,7 @@ import {
import { useSettingsStore } from '/@/renderer/store/settings.store';
import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
import { useSpeed } from '/@/renderer/store';
interface AudioPlayerProps extends ReactPlayerProps {
crossfadeDuration: number;
@@ -59,6 +60,7 @@ export const AudioPlayer = forwardRef(
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
const playback = useSettingsStore((state) => state.playback.mpvProperties);
const playbackSpeed = useSpeed();
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
@@ -307,6 +309,7 @@ export const AudioPlayer = forwardRef(
}}
height={0}
muted={muted}
playbackRate={playbackSpeed}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player1?.streamUrl}
@@ -325,6 +328,7 @@ export const AudioPlayer = forwardRef(
}}
height={0}
muted={muted}
playbackRate={playbackSpeed}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player2?.streamUrl}
@@ -23,7 +23,7 @@ export const gaplessHandler = (args: {
const durationPadding = isFlac ? 0.065 : 0.116;
if (currentTime + durationPadding >= duration) {
return nextPlayerRef.current.getInternalPlayer().play();
return nextPlayerRef.current.getInternalPlayer()?.play();
}
return null;
+5 -7
View File
@@ -39,8 +39,9 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
}
&:not([data-disabled])&:hover {
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
color: ${(props) => `var(--btn-${props.variant}-fg) !important`};
background: ${(props) => `var(--btn-${props.variant}-bg)`};
filter: brightness(85%);
border: ${(props) => `var(--btn-${props.variant}-border-hover)`};
svg {
@@ -50,11 +51,8 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
&:not([data-disabled])&:focus-visible {
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
}
&:active {
transform: none;
background: ${(props) => `var(--btn-${props.variant}-bg)`};
filter: brightness(85%);
}
& .mantine-Button-centerLoader {
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Group, Image, Stack } from '@mantine/core';
import type { Variants } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { Link, generatePath } from 'react-router-dom';
import styled from 'styled-components';
@@ -109,6 +110,7 @@ interface FeatureCarouselProps {
}
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
const { t } = useTranslation();
const handlePlayQueueAdd = usePlayQueueAdd();
const [itemIndex, setItemIndex] = useState(0);
const [direction, setDirection] = useState(0);
@@ -224,7 +226,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
});
}}
>
Play
{t('player.play', { postProcess: 'titleCase' })}
</Button>
<Group spacing="sm">
<Button
@@ -1,4 +1,13 @@
import { isValidElement, memo, ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import {
isValidElement,
memo,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Group, Stack } from '@mantine/core';
import throttle from 'lodash/throttle';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
@@ -109,6 +118,10 @@ export const SwiperGridCarousel = ({
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
useEffect(() => {
swiperRef.current?.slideTo(0, 0);
}, [data]);
const [pagination, setPagination] = useState({
hasNextPage: (data?.length || 0) > Math.round(3),
hasPreviousPage: false,
@@ -6,14 +6,21 @@ import { Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTER_GROUP_OPTIONS_DATA = [
{
label: 'Match all',
label: i18n.t('form.queryEditor.input', {
context: 'optionMatchAll',
postProcess: 'sentenceCase',
}),
value: 'all',
},
{
label: 'Match any',
label: i18n.t('form.queryEditor.input', {
context: 'optionMatchAny',
postProcess: 'sentenceCase',
}),
value: 'any',
},
];
+28 -18
View File
@@ -1,12 +1,8 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { MouseEvent } from 'react';
import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core';
import { useCallback } from 'react';
import { Rating as MantineRating, RatingProps } from '@mantine/core';
import debounce from 'lodash/debounce';
import styled from 'styled-components';
import { Tooltip } from '/@/renderer/components/tooltip';
interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
}
const StyledRating = styled(MantineRating)`
& .mantine-Rating-symbolBody {
@@ -16,18 +12,32 @@ const StyledRating = styled(MantineRating)`
}
`;
export const Rating = ({ onClick, ...props }: RatingProps) => {
// const debouncedOnClick = debounce(onClick, 100);
export const Rating = ({ onChange, ...props }: RatingProps) => {
const valueChange = useCallback(
(rating: number) => {
if (onChange) {
if (rating === props.value) {
onChange(0);
} else {
onChange(rating);
}
}
},
[onChange, props.value],
);
const debouncedOnChange = debounce(valueChange, 100);
return (
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<StyledRating
{...props}
onDoubleClick={(e) => onClick(e, props.value)}
/>
</Tooltip>
<StyledRating
{...props}
onChange={(e) => {
debouncedOnChange(e);
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
);
};
+1
View File
@@ -42,6 +42,7 @@ const StyledTabs = styled(MantineTabs)`
&:hover {
background: none;
border-color: var(--primary-color);
}
}
`;
@@ -1,5 +1,4 @@
/* eslint-disable import/no-cycle */
import { MouseEvent } from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Rating } from '/@/renderer/components/rating';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
@@ -9,8 +8,6 @@ export const RatingCell = ({ value, node }: ICellRendererParams) => {
const updateRatingMutation = useSetRating({});
const handleUpdateRating = (rating: number) => {
if (!value) return;
updateRatingMutation.mutate(
{
query: {
@@ -27,32 +24,12 @@ export const RatingCell = ({ value, node }: ICellRendererParams) => {
);
};
const handleClearRating = (e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
updateRatingMutation.mutate(
{
query: {
item: [value],
rating: 0,
},
serverId: value?.serverId,
},
{
onSuccess: () => {
node.setData({ ...node.data, userRating: 0 });
},
},
);
};
return (
<CellContainer $position="center">
<Rating
size="xs"
value={value?.userRating}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</CellContainer>
);
@@ -0,0 +1,161 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
const AnimatedSvg = () => {
return (
<div style={{ height: '1rem', transform: 'rotate(180deg)', width: '1rem' }}>
<svg
viewBox="100 130 57 80"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<rect
fill="var(--primary-color)"
height="80"
id="bar-1"
width="12"
x="100"
y="130"
>
<animate
attributeName="height"
begin="0.1s"
calcMode="spline"
dur="0.95s"
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
keyTimes="0; 0.47368; 1"
repeatCount="indefinite"
values="80;15;80"
/>
</rect>
<rect
fill="var(--primary-color)"
height="80"
id="bar-2"
width="12"
x="115"
y="130"
>
<animate
attributeName="height"
begin="0.1s"
calcMode="spline"
dur="0.95s"
keySplines="0.45 0 0.55 1; 0.45 0 0.55 1"
keyTimes="0; 0.44444; 1"
repeatCount="indefinite"
values="25;80;25"
/>
</rect>
<rect
fill="var(--primary-color)"
height="80"
id="bar-3"
width="12"
x="130"
y="130"
>
<animate
attributeName="height"
begin="0.1s"
calcMode="spline"
dur="0.85s"
keySplines="0.65 0 0.35 1; 0.65 0 0.35 1"
keyTimes="0; 0.42105; 1"
repeatCount="indefinite"
values="80;10;80"
/>
</rect>
<rect
fill="var(--primary-color)"
height="80"
id="bar-4"
width="12"
x="145"
y="130"
>
<animate
attributeName="height"
begin="0.1s"
calcMode="spline"
dur="1.05s"
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
keyTimes="0; 0.31579; 1"
repeatCount="indefinite"
values="30;80;30"
/>
</rect>
</g>
</svg>
</div>
);
};
const StaticSvg = () => {
return (
<div style={{ height: '1rem', transform: 'rotate(180deg)', width: '1rem' }}>
<svg
viewBox="100 130 57 80"
xmlns="http://www.w3.org/2000/svg"
>
<rect
fill="var(--primary-color)"
height="20"
width="12"
x="100"
y="130"
/>
<rect
fill="var(--primary-color)"
height="60"
width="12"
x="115"
y="130"
/>
<rect
fill="var(--primary-color)"
height="80"
width="12"
x="130"
y="130"
/>
<rect
fill="var(--primary-color)"
height="45"
width="12"
x="145"
y="130"
/>
</svg>
</div>
);
};
export const RowIndexCell = ({ value, eGridCell }: ICellRendererParams) => {
const classList = eGridCell.classList;
const isFocused = classList.contains('focused');
const isPlaying = classList.contains('playing');
const isCurrentSong =
classList.contains('current-song-cell') || classList.contains('current-playlist-song-cell');
return (
<CellContainer $position="right">
{isPlaying &&
(isFocused && isCurrentSong ? (
<AnimatedSvg />
) : isCurrentSong ? (
<StaticSvg />
) : null)}
<Text
$secondary
align="right"
className="current-song-child current-song-index"
overflow="hidden"
size="md"
>
{value}
</Text>
</CellContainer>
);
};
@@ -1,8 +1,10 @@
import { MutableRefObject, useEffect, useMemo, useRef } from 'react';
import { RowClassRules, RowNode } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { MutableRefObject, useEffect, useMemo } from 'react';
import { Song } from '/@/renderer/api/types';
import { useAppFocus } from '/@/renderer/hooks';
import { useCurrentSong, usePlayerStore } from '/@/renderer/store';
import { PlayerStatus } from '/@/renderer/types';
interface UseCurrentSongRowStylesProps {
tableRef: MutableRefObject<AgGridReactType | null>;
@@ -10,17 +12,44 @@ interface UseCurrentSongRowStylesProps {
export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesProps) => {
const currentSong = useCurrentSong();
const isFocused = useAppFocus();
const isFocusedRef = useRef<boolean>(isFocused);
useEffect(() => {
// Redraw rows if the app focus changes
if (isFocusedRef.current !== isFocused) {
isFocusedRef.current = isFocused;
if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {};
if (api == null || columnApi == null) {
return;
}
const currentNode = currentSong?.id ? api.getRowNode(currentSong.id) : undefined;
const rowNodes = [currentNode].filter((e) => e !== undefined) as RowNode<any>[];
if (rowNodes) {
api.redrawRows({ rowNodes });
}
}
}
}, [currentSong?.id, isFocused, tableRef]);
const rowClassRules = useMemo<RowClassRules<Song> | undefined>(() => {
return {
'current-song': (params) => {
return params?.data?.id === currentSong?.id;
return (
currentSong?.id !== undefined &&
params?.data?.id === currentSong?.id &&
params?.data?.albumId === currentSong?.albumId
);
},
};
}, [currentSong?.id]);
}, [currentSong?.albumId, currentSong?.id]);
// Redraw song rows when current song changes
useEffect(() => {
// Redraw song rows when current song changes
const unsubSongChange = usePlayerStore.subscribe(
(state) => state.current.song,
(song, previousSong) => {
@@ -46,8 +75,32 @@ export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesPro
{ equalityFn: (a, b) => a?.id === b?.id },
);
// Redraw song rows when the status changes
const unsubStatusChange = usePlayerStore.subscribe(
(state) => [state.current.status, state.current.song],
(current: (PlayerStatus | Song | undefined)[]) => {
const song = current[1] as Song;
if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {};
if (api == null || columnApi == null) {
return;
}
const currentNode = song?.id ? api.getRowNode(song.id) : undefined;
const rowNodes = [currentNode].filter((e) => e !== undefined) as RowNode<any>[];
api.redrawRows({ rowNodes });
}
},
{
equalityFn: (a, b) => (a[0] as PlayerStatus) === (b[0] as PlayerStatus),
},
);
return () => {
unsubSongChange();
unsubStatusChange();
};
}, [tableRef]);
@@ -31,6 +31,7 @@ export type AgGridFetchFn<TResponse, TFilter> = (
) => Promise<TResponse>;
interface UseAgGridProps<TFilter> {
columnType?: 'albumDetail' | 'generic';
contextMenu: SetContextMenuItems;
customFilters?: Partial<TFilter>;
isClientSideSort?: boolean;
@@ -52,6 +53,7 @@ export const useVirtualTable = <TFilter>({
customFilters,
isSearchParams,
isClientSideSort,
columnType,
}: UseAgGridProps<TFilter>) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -75,8 +77,8 @@ export const useVirtualTable = <TFilter>({
const isPaginationEnabled = properties.display === ListDisplayType.TABLE_PAGINATED;
const columnDefs: ColDef[] = useMemo(() => {
return getColumnDefs(properties.table.columns, true);
}, [properties.table.columns]);
return getColumnDefs(properties.table.columns, true, columnType);
}, [columnType, properties.table.columns]);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
+109 -25
View File
@@ -29,7 +29,11 @@ import { GenreCell } from '/@/renderer/components/virtual-table/cells/genre-cell
import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers/generic-table-header';
import { AppRoute } from '/@/renderer/router/routes';
import { PersistedTableColumn } from '/@/renderer/store/settings.store';
import { TableColumn, TablePagination as TablePaginationType } from '/@/renderer/types';
import {
PlayerStatus,
TableColumn,
TablePagination as TablePaginationType,
} from '/@/renderer/types';
import { FavoriteCell } from '/@/renderer/components/virtual-table/cells/favorite-cell';
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
import { TablePagination } from '/@/renderer/components/virtual-table/table-pagination';
@@ -37,6 +41,8 @@ import { ActionsCell } from '/@/renderer/components/virtual-table/cells/actions-
import { TitleCell } from '/@/renderer/components/virtual-table/cells/title-cell';
import { useFixedTableHeader } from '/@/renderer/components/virtual-table/hooks/use-fixed-table-header';
import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell';
import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell';
import i18n from '/@/i18n/i18n';
export * from './table-config-dropdown';
export * from './table-pagination';
@@ -72,7 +78,7 @@ const tableColumns: { [key: string]: ColDef } = {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { isLink: true, position: 'left' }),
colId: TableColumn.ALBUM,
headerName: 'Album',
headerName: i18n.t('table.column.album'),
valueGetter: (params: ValueGetterParams) =>
params.data
? {
@@ -87,7 +93,7 @@ const tableColumns: { [key: string]: ColDef } = {
albumArtist: {
cellRenderer: AlbumArtistCell,
colId: TableColumn.ALBUM_ARTIST,
headerName: 'Album Artist',
headerName: i18n.t('table.column.albumArtist'),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.albumArtists : undefined,
width: 150,
@@ -98,7 +104,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'albumCount',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Albums',
headerName: i18n.t('table.column.albumCount'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.albumCount : undefined,
@@ -107,7 +113,7 @@ const tableColumns: { [key: string]: ColDef } = {
artist: {
cellRenderer: ArtistCell,
colId: TableColumn.ARTIST,
headerName: 'Artist',
headerName: i18n.t('table.column.artist'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
width: 150,
},
@@ -115,7 +121,7 @@ const tableColumns: { [key: string]: ColDef } = {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
colId: TableColumn.BIOGRAPHY,
field: 'biography',
headerName: 'Biography',
headerName: i18n.t('table.column.biography'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : ''),
width: 200,
},
@@ -125,6 +131,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'bitRate',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.bitrate'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bitRate : undefined),
@@ -135,7 +142,7 @@ const tableColumns: { [key: string]: ColDef } = {
colId: TableColumn.BPM,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'BPM',
headerName: i18n.t('table.column.bpm'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bpm : undefined),
width: 60,
@@ -146,6 +153,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'channels',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.channels'),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.channels : undefined,
width: 100,
@@ -153,7 +161,7 @@ const tableColumns: { [key: string]: ColDef } = {
comment: {
cellRenderer: NoteCell,
colId: TableColumn.COMMENT,
headerName: 'Note',
headerName: i18n.t('table.column.comment'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
width: 150,
},
@@ -163,7 +171,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'createdAt',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Date Added',
headerName: i18n.t('table.column.dateAdded'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
@@ -177,7 +185,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'discNumber',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Disc',
headerName: i18n.t('table.column.discNumber'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.discNumber : undefined,
@@ -198,7 +206,7 @@ const tableColumns: { [key: string]: ColDef } = {
genre: {
cellRenderer: GenreCell,
colId: TableColumn.GENRE,
headerName: 'Genre',
headerName: i18n.t('table.column.genre'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.genres : undefined),
width: 100,
},
@@ -207,7 +215,7 @@ const tableColumns: { [key: string]: ColDef } = {
colId: TableColumn.LAST_PLAYED,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Last Played',
headerName: i18n.t('table.column.lastPlayed'),
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).fromNow() : '',
valueGetter: (params: ValueGetterParams) =>
@@ -217,7 +225,7 @@ const tableColumns: { [key: string]: ColDef } = {
path: {
cellRenderer: GenericCell,
colId: TableColumn.PATH,
headerName: 'Path',
headerName: i18n.t('table.column.path'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.path : undefined),
width: 200,
},
@@ -227,7 +235,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'playCount',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Plays',
headerName: i18n.t('table.column.playCount'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.playCount : undefined,
@@ -239,7 +247,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'releaseDate',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Release Date',
headerName: i18n.t('table.column.releaseDate'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
@@ -253,13 +261,14 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'releaseYear',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Year',
headerName: i18n.t('table.column.releaseYear'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.releaseYear : undefined,
width: 80,
},
rowIndex: {
cellClass: 'row-index',
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
colId: TableColumn.ROW_INDEX,
headerComponent: (params: IHeaderParams) =>
@@ -270,13 +279,46 @@ const tableColumns: { [key: string]: ColDef } = {
},
width: 65,
},
rowIndexGeneric: {
cellClass: 'row-index',
cellClassRules: {
'current-playlist-song-cell': (params) => {
return (
params.context?.currentSong?.uniqueId !== undefined &&
params.data?.uniqueId === params.context?.currentSong?.uniqueId
);
},
'current-song-cell': (params) => {
return (
params.context?.currentSong?.id !== undefined &&
params.data?.id === params.context?.currentSong?.id &&
params.data?.albumId === params.context?.currentSong?.albumId
);
},
focused: (params) => {
return params.context?.isFocused;
},
playing: (params) => {
return params.context?.status === PlayerStatus.PLAYING;
},
},
cellRenderer: RowIndexCell,
colId: TableColumn.ROW_INDEX,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'right', preset: 'rowIndex' }),
suppressSizeToFit: true,
valueGetter: (params) => {
return (params.node?.rowIndex || 0) + 1;
},
width: 65,
},
songCount: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.SONG_COUNT,
field: 'songCount',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Songs',
headerName: i18n.t('table.column.songCount'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.songCount : undefined,
@@ -286,14 +328,14 @@ const tableColumns: { [key: string]: ColDef } = {
cellRenderer: TitleCell,
colId: TableColumn.TITLE,
field: 'name',
headerName: 'Title',
headerName: i18n.t('table.column.title'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.name : undefined),
width: 250,
},
titleCombined: {
cellRenderer: CombinedTitleCell,
colId: TableColumn.TITLE_COMBINED,
headerName: 'Title',
headerName: i18n.t('table.column.title'),
initialWidth: 500,
minWidth: 150,
valueGetter: (params: ValueGetterParams) =>
@@ -311,12 +353,41 @@ const tableColumns: { [key: string]: ColDef } = {
width: 250,
},
trackNumber: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
cellClass: 'track-number',
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
colId: TableColumn.TRACK_NUMBER,
field: 'trackNumber',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Track',
headerName: i18n.t('table.column.trackNumber'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.trackNumber : undefined,
width: 80,
},
trackNumberDetail: {
cellClass: 'row-index',
cellClassRules: {
'current-song-cell': (params) => {
return (
params.context?.currentSong?.id !== undefined &&
params.data?.id === params.context?.currentSong?.id &&
params.data?.albumId === params.context?.currentSong?.albumId
);
},
focused: (params) => {
return params.context?.isFocused;
},
playing: (params) => {
return params.context?.status === PlayerStatus.PLAYING;
},
},
cellRenderer: RowIndexCell,
colId: TableColumn.TRACK_NUMBER,
field: 'trackNumber',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.trackNumber'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.trackNumber : undefined,
@@ -329,7 +400,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'userFavorite',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'userFavorite' }),
headerName: 'Favorite',
headerName: i18n.t('table.column.favorite'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.userFavorite : undefined,
@@ -343,7 +414,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'userRating',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
headerName: 'Rating',
headerName: i18n.t('table.column.rating'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
width: 95,
@@ -354,10 +425,23 @@ export const getColumnDef = (column: TableColumn) => {
return tableColumns[column as keyof typeof tableColumns];
};
export const getColumnDefs = (columns: PersistedTableColumn[], useWidth?: boolean) => {
export const getColumnDefs = (
columns: PersistedTableColumn[],
useWidth?: boolean,
type?: 'albumDetail' | 'generic',
) => {
const columnDefs: ColDef[] = [];
for (const column of columns) {
const presetColumn = tableColumns[column.column as keyof typeof tableColumns];
let presetColumn = tableColumns[column.column as keyof typeof tableColumns];
if (column.column === TableColumn.TRACK_NUMBER && type === 'albumDetail') {
presetColumn = tableColumns['trackNumberDetail' as keyof typeof tableColumns];
}
if (column.column === TableColumn.ROW_INDEX && type === 'generic') {
presetColumn = tableColumns['rowIndexGeneric' as keyof typeof tableColumns];
}
if (presetColumn) {
columnDefs.push({
...presetColumn,
@@ -5,88 +5,278 @@ import { Switch } from '/@/renderer/components/switch';
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
import { TableColumn, TableType } from '/@/renderer/types';
import { Option } from '/@/renderer/components/option';
import i18n from '/@/i18n/i18n';
export const SONG_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Album', value: TableColumn.ALBUM },
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
{ label: 'Artist', value: TableColumn.ARTIST },
{ label: 'Genre', value: TableColumn.GENRE },
{ label: 'Year', value: TableColumn.YEAR },
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
{ label: 'Disc Number', value: TableColumn.DISC_NUMBER },
{ label: 'Track Number', value: TableColumn.TRACK_NUMBER },
{ label: 'Bitrate', value: TableColumn.BIT_RATE },
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
{ label: 'Note', value: TableColumn.COMMENT },
{ label: 'Channels', value: TableColumn.CHANNELS },
{ label: 'BPM', value: TableColumn.BPM },
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
{ label: 'Path', value: TableColumn.PATH },
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
{ label: 'Size', value: TableColumn.SIZE },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
value: TableColumn.TITLE_COMBINED,
},
{
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
value: TableColumn.DURATION,
},
{
label: i18n.t('table.config.label.album', { postProcess: 'titleCase' }),
value: TableColumn.ALBUM,
},
{
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
value: TableColumn.ALBUM_ARTIST,
},
{
label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),
value: TableColumn.ARTIST,
},
{
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
value: TableColumn.GENRE,
},
{
label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),
value: TableColumn.YEAR,
},
{
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
value: TableColumn.RELEASE_DATE,
},
{
label: i18n.t('table.config.label.discNumber', { postProcess: 'titleCase' }),
value: TableColumn.DISC_NUMBER,
},
{
label: i18n.t('table.config.label.trackNumber', { postProcess: 'titleCase' }),
value: TableColumn.TRACK_NUMBER,
},
{
label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),
value: TableColumn.BIT_RATE,
},
{
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED,
},
{
label: i18n.t('table.config.label.note', { postProcess: 'titleCase' }),
value: TableColumn.COMMENT,
},
{
label: i18n.t('table.config.label.channels', { postProcess: 'titleCase' }),
value: TableColumn.CHANNELS,
},
{
label: i18n.t('table.config.label.bpm', { postProcess: 'titleCase' }),
value: TableColumn.BPM,
},
{
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
value: TableColumn.DATE_ADDED,
},
{
label: i18n.t('table.config.label.path', { postProcess: 'titleCase' }),
value: TableColumn.PATH,
},
{
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
value: TableColumn.PLAY_COUNT,
},
{
label: i18n.t('table.config.label.size', { postProcess: 'titleCase' }),
value: TableColumn.SIZE,
},
{
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
value: TableColumn.USER_FAVORITE,
},
{
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
value: TableColumn.USER_RATING,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
// { label: 'Skip', value: TableColumn.SKIP },
];
export const ALBUM_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
{ label: 'Artist', value: TableColumn.ARTIST },
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
{ label: 'Genre', value: TableColumn.GENRE },
{ label: 'Year', value: TableColumn.YEAR },
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
value: TableColumn.TITLE_COMBINED,
},
{
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
value: TableColumn.DURATION,
},
{
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
value: TableColumn.ALBUM_ARTIST,
},
{
label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),
value: TableColumn.ARTIST,
},
{
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
value: TableColumn.SONG_COUNT,
},
{
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
value: TableColumn.GENRE,
},
{
label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),
value: TableColumn.YEAR,
},
{
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
value: TableColumn.RELEASE_DATE,
},
{
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED,
},
{
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
value: TableColumn.DATE_ADDED,
},
{
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
value: TableColumn.PLAY_COUNT,
},
{
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
value: TableColumn.USER_FAVORITE,
},
{
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
value: TableColumn.USER_RATING,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
];
export const ALBUMARTIST_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Biography', value: TableColumn.BIOGRAPHY },
{ label: 'Genre', value: TableColumn.GENRE },
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
{ label: 'Album Count', value: TableColumn.ALBUM_COUNT },
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
value: TableColumn.TITLE_COMBINED,
},
{
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
value: TableColumn.DURATION,
},
{
label: i18n.t('table.config.label.biography', { postProcess: 'titleCase' }),
value: TableColumn.BIOGRAPHY,
},
{
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
value: TableColumn.GENRE,
},
{
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED,
},
{
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
value: TableColumn.PLAY_COUNT,
},
{
label: i18n.t('table.config.label.albumCount', { postProcess: 'titleCase' }),
value: TableColumn.ALBUM_COUNT,
},
{
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
value: TableColumn.SONG_COUNT,
},
{
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
value: TableColumn.USER_FAVORITE,
},
{
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
value: TableColumn.USER_RATING,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
];
export const PLAYLIST_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Owner', value: TableColumn.OWNER },
// { label: 'Genre', value: TableColumn.GENRE },
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
value: TableColumn.TITLE_COMBINED,
},
{
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
value: TableColumn.DURATION,
},
{
label: i18n.t('table.config.label.owner', { postProcess: 'titleCase' }),
value: TableColumn.OWNER,
},
{
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
value: TableColumn.SONG_COUNT,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
];
export const GENRE_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
];
interface TableConfigDropdownProps {
// tableRef?: MutableRefObject<AgGridReactType<any> | null>;
type: TableType;
}
@@ -1,8 +1,9 @@
import { MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { RiHashtag } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button';
import { MotionFlex } from '../motion';
@@ -29,6 +30,7 @@ export const TablePagination = ({
setPagination,
setIdPagination,
}: TablePaginationProps) => {
const { t } = useTranslation();
const [isGoToPageOpen, handlers] = useDisclosure(false);
const containerQuery = useContainerQuery();
@@ -115,7 +117,9 @@ export const TablePagination = ({
radius="sm"
size="sm"
sx={{ height: '26px', padding: '0', width: '26px' }}
tooltip={{ label: 'Go to page' }}
tooltip={{
label: t('action.goToPage', { postProcess: 'sentenceCase' }),
}}
variant="default"
onClick={() => handlers.toggle()}
>
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Center, Group, Stack } from '@mantine/core';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiCheckFill } from 'react-icons/ri';
import { Link, Navigate } from 'react-router-dom';
import { Button, PageHeader, Text } from '/@/renderer/components';
@@ -15,6 +16,7 @@ import { useCurrentServer } from '/@/renderer/store';
const localSettings = isElectron() ? window.electron.localSettings : null;
const ActionRequiredRoute = () => {
const { t } = useTranslation();
const currentServer = useCurrentServer();
const [isMpvRequired, setIsMpvRequired] = useState(false);
const isServerRequired = !currentServer;
@@ -38,17 +40,17 @@ const ActionRequiredRoute = () => {
const checks = [
{
component: <MpvRequired />,
title: 'MPV required',
title: t('error.mpvRequired', { postProcess: 'sentenceCase' }),
valid: !isMpvRequired,
},
{
component: <ServerCredentialRequired />,
title: 'Credentials required',
title: t('error.credentialsRequired', { postProcess: 'sentenceCase' }),
valid: !isCredentialRequired,
},
{
component: <ServerRequired />,
title: 'Server required',
title: t('error.serverRequired', { postProcess: 'serverRequired' }),
valid: !isServerRequired,
},
];
@@ -1,8 +1,9 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import { RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
@@ -12,9 +13,9 @@ import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/ap
import { Button, Popover } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import {
getColumnDefs,
TableConfigDropdown,
VirtualTable,
getColumnDefs,
} from '/@/renderer/components/virtual-table';
import { FullWidthDiscCell } from '/@/renderer/components/virtual-table/cells/full-width-disc-cell';
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
@@ -31,10 +32,14 @@ import {
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
import { useContainerQuery } from '/@/renderer/hooks';
import { useAppFocus, useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior, useTableSettings } from '/@/renderer/store/settings.store';
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store';
import {
usePlayButtonBehavior,
useSettingsStoreActions,
useTableSettings,
} from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
const isFullWidthRow = (node: RowNode) => {
@@ -59,22 +64,33 @@ interface AlbumDetailContentProps {
}
export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentProps) => {
const { t } = useTranslation();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const tableConfig = useTableSettings('albumDetail');
const { setTable } = useSettingsStoreActions();
const status = useCurrentStatus();
const isFocused = useAppFocus();
const currentSong = useCurrentSong();
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
const columnDefs = useMemo(
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
[tableConfig.columns],
);
const getRowHeight = useCallback((params: RowHeightParams) => {
if (isFullWidthRow(params.node)) {
return 45;
}
const getRowHeight = useCallback(
(params: RowHeightParams) => {
if (isFullWidthRow(params.node)) {
return 45;
}
return 60;
}, []);
return tableConfig.rowHeight;
},
[tableConfig.rowHeight],
);
const songsRowData = useMemo(() => {
if (!detailQuery.data?.songs) {
@@ -192,7 +208,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
handlePreviousPage: () => handlePreviousPage('artist'),
hasPreviousPage: pagination.artist > 0,
},
title: 'More from this artist',
title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }),
uniqueId: 'mostPlayed',
},
{
@@ -203,7 +219,10 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
(a) => a.id !== detailQuery?.data?.id,
).length,
loading: relatedAlbumGenresQuery?.isLoading || relatedAlbumGenresQuery.isFetching,
title: `More from ${detailQuery?.data?.genres?.[0]?.name}`,
title: t('page.albumDetail.moreFromGeneric', {
item: detailQuery?.data?.genres?.[0]?.name,
postProcess: 'sentenceCase',
}),
uniqueId: 'relatedGenres',
},
];
@@ -216,7 +235,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
});
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data || e.node.isFullWidthCell()) return;
@@ -266,6 +285,32 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
ALBUM_CONTEXT_MENU_ITEMS,
);
const onColumnMoved = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = tableConfig.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.getColDef().colId,
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!tableConfig.autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setTable('albumDetail', { ...tableConfig, columns: updatedColumns });
}, [setTable, tableConfig, tableRef]);
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
return (
@@ -352,6 +397,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
)}
<Box style={{ minHeight: '300px' }}>
<VirtualTable
key={`table-${tableConfig.rowHeight}`}
ref={tableRef}
autoHeight
stickyHeader
@@ -360,6 +406,12 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
suppressRowDrag
autoFitColumns={tableConfig.autoFit}
columnDefs={columnDefs}
context={{
currentSong,
isFocused,
onCellContextMenu,
status,
}}
enableCellChangeFlash={false}
fullWidthCellRenderer={FullWidthDiscCell}
getRowHeight={getRowHeight}
@@ -374,7 +426,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
rowClassRules={rowClassRules}
rowData={songsRowData}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onCellContextMenu={onCellContextMenu}
onColumnMoved={onColumnMoved}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
@@ -55,18 +55,6 @@ export const AlbumDetailHeader = forwardRef(
});
};
const handleClearRating = () => {
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating: 0,
},
serverId: detailQuery.data.serverId,
});
};
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
return (
@@ -96,7 +84,6 @@ export const AlbumDetailHeader = forwardRef(
}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
@@ -1,8 +1,9 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
RiAddCircleFill,
@@ -31,46 +32,112 @@ import {
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {
jellyfin: [
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: 'Community Rating',
name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }),
value: AlbumListSort.COMMUNITY_RATING,
},
{ defaultOrder: SortOrder.DESC, name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
{
defaultOrder: SortOrder.DESC,
name: 'Recently Added',
name: i18n.t('filter.criticRating', { postProcess: 'titleCase' }),
value: AlbumListSort.CRITIC_RATING,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
value: AlbumListSort.PLAY_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
value: AlbumListSort.RANDOM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_ADDED,
},
{ defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RELEASE_DATE,
},
],
navidrome: [
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: AlbumListSort.ARTIST },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumListSort.DURATION },
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumListSort.RATING },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
value: AlbumListSort.ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: 'Recently Added',
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
value: AlbumListSort.DURATION,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.PLAY_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
value: AlbumListSort.RANDOM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: AlbumListSort.RATING,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: 'Recently Played',
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_PLAYED,
},
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumListSort.SONG_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumListSort.FAVORITED },
{ defaultOrder: SortOrder.DESC, name: 'Year', value: AlbumListSort.YEAR },
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
value: AlbumListSort.SONG_COUNT,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
value: AlbumListSort.FAVORITED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
value: AlbumListSort.YEAR,
},
],
};
@@ -80,6 +147,7 @@ interface AlbumListHeaderFiltersProps {
}
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { pageKey, customFilters, handlePlay } = useListContext();
const server = useCurrentServer();
@@ -361,7 +429,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
fill: isFilterApplied ? 'var(--primary-color) !important' : undefined,
},
}}
tooltip={{ label: 'Filters' }}
tooltip={{
label: t('common.filter', { count: 2, postProcess: 'sentenceCase' }),
}}
variant="subtle"
onClick={handleOpenFiltersModal}
>
@@ -371,7 +441,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'sentenceCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@@ -393,26 +463,26 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
icon={<RiPlayFill />}
onClick={() => handlePlay?.({ playType: Play.NOW })}
>
Play
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay?.({ playType: Play.LAST })}
>
Add to queue
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay?.({ playType: Play.NEXT })}
>
Add to queue next
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('common.refresh', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@@ -429,7 +499,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
tooltip={{
label: t('common.configure', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
@@ -442,21 +514,21 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
@@ -1,7 +1,8 @@
import type { ChangeEvent, MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import type { ChangeEvent, MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
@@ -18,6 +19,7 @@ import {
usePlayButtonBehavior,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { titleCase } from '/@/renderer/utils';
interface AlbumListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -27,6 +29,7 @@ interface AlbumListHeaderProps {
}
export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumListHeaderProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
@@ -69,7 +72,10 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
<LibraryHeaderBar.PlayButton
onClick={() => handlePlay?.({ playType: playButtonBehavior })}
/>
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{title ||
titleCase(t('page.albumList.title', { postProcess: 'titleCase' }))}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>
@@ -1,6 +1,7 @@
import { ChangeEvent, useMemo, useState } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListFilterByKey } from '../../../store/list.store';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
@@ -23,6 +24,7 @@ export const JellyfinAlbumFilters = ({
pageKey,
serverId,
}: JellyfinAlbumFiltersProps) => {
const { t } = useTranslation();
const filter = useListFilterByKey({ key: pageKey });
const { setFilter } = useListStoreActions();
@@ -51,7 +53,7 @@ export const JellyfinAlbumFilters = ({
const toggleFilters = [
{
label: 'Is favorited',
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@@ -193,7 +195,7 @@ export const JellyfinAlbumFilters = ({
<NumberInput
defaultValue={filter?._custom?.jellyfin?.minYear}
hideControls={false}
label="From year"
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?._custom?.jellyfin?.maxYear}
@@ -202,7 +204,7 @@ export const JellyfinAlbumFilters = ({
<NumberInput
defaultValue={filter?._custom?.jellyfin?.maxYear}
hideControls={false}
label="To year"
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?._custom?.jellyfin?.minYear}
@@ -215,7 +217,7 @@ export const JellyfinAlbumFilters = ({
searchable
data={genreList}
defaultValue={selectedGenres}
label="Genres"
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
/>
</Group>
@@ -227,7 +229,7 @@ export const JellyfinAlbumFilters = ({
data={selectableAlbumArtists}
defaultValue={filter?._custom?.jellyfin?.AlbumArtistIds?.split(',')}
disabled={disableArtistFilter}
label="Artist"
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
@@ -6,6 +6,7 @@ import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next';
interface NavidromeAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
@@ -22,6 +23,7 @@ export const NavidromeAlbumFilters = ({
pageKey,
serverId,
}: NavidromeAlbumFiltersProps) => {
const { t } = useTranslation();
const { filter } = useListStoreByKey({ key: pageKey });
const { setFilter } = useListStoreActions();
@@ -62,7 +64,7 @@ export const NavidromeAlbumFilters = ({
const toggleFilters = [
{
label: 'Is rated',
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@@ -83,7 +85,7 @@ export const NavidromeAlbumFilters = ({
value: filter._custom?.navidrome?.has_rating,
},
{
label: 'Is favorited',
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@@ -104,7 +106,7 @@ export const NavidromeAlbumFilters = ({
value: filter._custom?.navidrome?.starred,
},
{
label: 'Is compilation',
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@@ -125,7 +127,7 @@ export const NavidromeAlbumFilters = ({
value: filter._custom?.navidrome?.compilation,
},
{
label: 'Is recently played',
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@@ -152,11 +154,11 @@ export const NavidromeAlbumFilters = ({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
year: e === '' ? undefined : (e as number),
},
...filter._custom,
},
},
itemType: LibraryItem.ALBUM,
@@ -226,7 +228,7 @@ export const NavidromeAlbumFilters = ({
<NumberInput
defaultValue={filter._custom?.navidrome?.year}
hideControls={false}
label="Year"
label={t('common.year', { postProcess: 'titleCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e)}
@@ -236,7 +238,7 @@ export const NavidromeAlbumFilters = ({
searchable
data={genreList}
defaultValue={filter._custom?.navidrome?.genre_id}
label="Genre"
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
/>
</Group>
@@ -247,9 +249,8 @@ export const NavidromeAlbumFilters = ({
data={selectableAlbumArtists}
defaultValue={filter._custom?.navidrome?.artist_id}
disabled={disableArtistFilter}
label="Artist"
label={t('entity.artist', { count: 1, postProcess: 'titleCase' })}
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchValue={albumArtistSearchTerm}
onChange={handleAlbumArtistFilter}
@@ -1,4 +1,4 @@
import { forwardRef, Fragment, Ref, MouseEvent } from 'react';
import { forwardRef, Fragment, Ref } from 'react';
import { Group, Rating, Stack } from '@mantine/core';
import { useParams } from 'react-router';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
@@ -55,21 +55,6 @@ export const AlbumArtistDetailHeader = forwardRef(
});
};
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
const isSameRatingAsPrevious = rating === detailQuery.data.userRating;
if (!isSameRatingAsPrevious) return;
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating: 0,
},
serverId: detailQuery.data.serverId,
});
};
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
return (
@@ -99,7 +84,6 @@ export const AlbumArtistDetailHeader = forwardRef(
}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { RiAddBoxFill, RiAddCircleFill, RiMoreFill, RiPlayFill } from 'react-icons/ri';
import { QueueSong } from '/@/renderer/api/types';
import { Button, DropdownMenu, PageHeader, SpinnerIcon, Paper } from '/@/renderer/components';
@@ -17,6 +18,7 @@ export const AlbumArtistDetailTopSongsListHeader = ({
itemCount,
data,
}: AlbumArtistDetailTopSongsListHeaderProps) => {
const { t } = useTranslation();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
@@ -55,19 +57,19 @@ export const AlbumArtistDetailTopSongsListHeader = ({
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
Play
{t('player.add', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@@ -1,9 +1,10 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { useListContext } from '../../../context/list-context';
import { api } from '/@/renderer/api';
@@ -62,6 +63,7 @@ export const AlbumArtistListHeaderFilters = ({
gridRef,
tableRef,
}: AlbumArtistListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const server = useCurrentServer();
const { pageKey } = useListContext();
@@ -77,7 +79,7 @@ export const AlbumArtistListHeaderFilters = ({
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)
?.name) ||
'Unknown';
t('common.unknown', { postProcess: 'titleCase' });
const handleItemSize = (e: number) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
@@ -359,7 +361,7 @@ export const AlbumArtistListHeaderFilters = ({
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@@ -407,21 +409,27 @@ export const AlbumArtistListHeaderFilters = ({
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
@@ -465,7 +473,11 @@ export const AlbumArtistListHeaderFilters = ({
)}
{!isGrid && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.tableColumns', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
@@ -482,7 +494,11 @@ export const AlbumArtistListHeaderFilters = ({
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'sentenceCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
@@ -1,7 +1,8 @@
import type { ChangeEvent, MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import type { ChangeEvent, MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useListContext } from '../../../context/list-context';
import { useListStoreByKey } from '../../../store/list.store';
import { FilterBar } from '../../shared/components/filter-bar';
@@ -26,6 +27,7 @@ export const AlbumArtistListHeader = ({
gridRef,
tableRef,
}: AlbumArtistListHeaderProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { pageKey } = useListContext();
const { display, filter } = useListStoreByKey({ key: pageKey });
@@ -64,7 +66,9 @@ export const AlbumArtistListHeader = ({
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Album Artists</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.albumArtistList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>
@@ -11,6 +11,7 @@ import {
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { AnimatePresence } from 'framer-motion';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
RiAddCircleFill,
@@ -87,6 +88,7 @@ export interface ContextMenuProviderProps {
}
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const clickOutsideRef = useClickOutside(() => setOpened(false));
@@ -229,7 +231,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
@@ -245,14 +247,14 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}
closeAllModals();
}, [ctx, deletePlaylistMutation]);
}, [ctx, deletePlaylistMutation, t]);
const openDeletePlaylistModal = useCallback(() => {
openModal({
children: (
<ConfirmModal onConfirm={handleDeletePlaylist}>
<Stack>
<Text>Are you sure you want to delete the following playlist(s)?</Text>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
<ul>
{ctx.data.map((item) => (
<li key={item.id}>
@@ -265,9 +267,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
</Stack>
</ConfirmModal>
),
title: 'Delete playlist(s)',
title: t('page.contextMenu.deletePlaylist', { postProcess: 'titleCase' }),
});
}, [ctx.data, handleDeletePlaylist]);
}, [ctx.data, handleDeletePlaylist, t]);
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
@@ -301,7 +303,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onError: (err) => {
toast.error({
message: err.message,
title: 'Error adding to favorites',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
@@ -337,14 +339,14 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onError: (err) => {
toast.error({
message: err.message,
title: 'Error adding to favorites',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
},
);
}
}
}, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type]);
}, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type, t]);
const handleRemoveFromFavorites = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
@@ -434,6 +436,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
case LibraryItem.ALBUM:
albumId.push(item.id);
break;
case LibraryItem.ALBUM_ARTIST:
artistId.push(item.id);
break;
case LibraryItem.ARTIST:
artistId.push(item.id);
break;
@@ -456,9 +461,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
});
}, [ctx.data, ctx.dataNodes]);
}, [ctx.data, ctx.dataNodes, t]);
const removeFromPlaylistMutation = useRemoveFromPlaylist();
@@ -481,13 +486,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onError: (err) => {
toast.error({
message: err.message,
title: 'Error removing song(s) from playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: `${songId.length} song(s) were removed from the playlist`,
});
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
closeAllModals();
},
@@ -501,10 +503,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
loading={removeFromPlaylistMutation.isLoading}
onConfirm={confirm}
>
Are you sure you want to remove the following song(s) from the playlist?
{t('common.areYouSure', { postProcess: 'sentenceCase' })}
</ConfirmModal>
),
title: 'Remove song(s) from playlist',
title: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }),
});
}, [
ctx.context?.playlistId,
@@ -513,6 +515,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
ctx.dataNodes,
removeFromPlaylistMutation,
serverType,
t,
]);
const updateRatingMutation = useSetRating({});
@@ -613,10 +616,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}
}
ctx.tableApi?.redrawRows();
if (isCurrentSongRemoved) {
remote?.updateSong({ song: playerData.current.song });
}
}, [ctx.dataNodes, playerType, removeFromQueue]);
}, [ctx.dataNodes, ctx.tableApi, playerType, removeFromQueue]);
const handleDeselectAll = useCallback(() => {
ctx.tableApi?.deselectAll();
@@ -626,74 +631,78 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
return {
addToFavorites: {
id: 'addToFavorites',
label: 'Add favorite',
label: t('page.contextMenu.addToFavorites', { postProcess: 'sentenceCase' }),
leftIcon: <RiHeartFill size="1.1rem" />,
onClick: handleAddToFavorites,
},
addToPlaylist: {
id: 'addToPlaylist',
label: 'Add to playlist',
label: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
leftIcon: <RiPlayListAddFill size="1.1rem" />,
onClick: handleAddToPlaylist,
},
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
createPlaylist: {
id: 'createPlaylist',
label: t('page.contextMenu.createPlaylist', { postProcess: 'sentenceCase' }),
onClick: () => {},
},
deletePlaylist: {
id: 'deletePlaylist',
label: 'Delete playlist',
label: t('page.contextMenu.deletePlaylist', { postProcess: 'sentenceCase' }),
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: openDeletePlaylistModal,
},
deselectAll: {
id: 'deselectAll',
label: 'Deselect all',
label: t('page.contextMenu.deselectAll', { postProcess: 'sentenceCase' }),
leftIcon: <RiCloseCircleLine size="1.1rem" />,
onClick: handleDeselectAll,
},
moveToBottomOfQueue: {
id: 'moveToBottomOfQueue',
label: 'Move to bottom',
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
leftIcon: <RiArrowDownLine size="1.1rem" />,
onClick: handleMoveToBottom,
},
moveToTopOfQueue: {
id: 'moveToTopOfQueue',
label: 'Move to top',
label: t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' }),
leftIcon: <RiArrowUpLine size="1.1rem" />,
onClick: handleMoveToTop,
},
play: {
id: 'play',
label: 'Play',
label: t('page.contextMenu.play', { postProcess: 'sentenceCase' }),
leftIcon: <RiPlayFill size="1.1rem" />,
onClick: () => handlePlay(Play.NOW),
},
playLast: {
id: 'playLast',
label: 'Add to queue',
label: t('page.contextMenu.addLast', { postProcess: 'sentenceCase' }),
leftIcon: <RiAddBoxFill size="1.1rem" />,
onClick: () => handlePlay(Play.LAST),
},
playNext: {
id: 'playNext',
label: 'Add to queue next',
label: t('page.contextMenu.addNext', { postProcess: 'sentenceCase' }),
leftIcon: <RiAddCircleFill size="1.1rem" />,
onClick: () => handlePlay(Play.NEXT),
},
removeFromFavorites: {
id: 'removeFromFavorites',
label: 'Remove favorite',
label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }),
leftIcon: <RiDislikeFill size="1.1rem" />,
onClick: handleRemoveFromFavorites,
},
removeFromPlaylist: {
id: 'removeFromPlaylist',
label: 'Remove from playlist',
label: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }),
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: handleRemoveFromPlaylist,
},
removeFromQueue: {
id: 'moveToBottomOfQueue',
label: 'Remove songs',
id: 'removeSongs',
label: t('page.contextMenu.removeFromQueue', { postProcess: 'sentenceCase' }),
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: handleRemoveSelected,
},
@@ -705,7 +714,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
<Rating
readOnly
value={0}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(0),
@@ -716,7 +724,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
<Rating
readOnly
value={1}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(1),
@@ -727,7 +734,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
<Rating
readOnly
value={2}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(2),
@@ -738,7 +744,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
<Rating
readOnly
value={3}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(3),
@@ -749,7 +754,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
<Rating
readOnly
value={4}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(4),
@@ -760,7 +764,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
<Rating
readOnly
value={5}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(5),
@@ -785,6 +788,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
handleRemoveSelected,
handleUpdateRating,
openDeletePlaylistModal,
t,
]);
const mergedRef = useMergedRef(ref, clickOutsideRef);
@@ -889,7 +893,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
size="sm"
/>
<ContextMenuButton disabled>
{ctx.data?.length} selected
{t('page.contextMenu.numberSelected', {
count: ctx.data?.length || 0,
postProcess: 'lowerCase',
})}
</ContextMenuButton>
</Stack>
</ContextMenu>
@@ -0,0 +1,122 @@
/* eslint-disable consistent-return */
import isElectron from 'is-electron';
import { useCallback, useEffect, useRef } from 'react';
import {
useCurrentSong,
useCurrentStatus,
useDiscordSetttings,
usePlayerStore,
} from '/@/renderer/store';
import { SetActivity } from '@xhayper/discord-rpc';
import { PlayerStatus, ServerType } from '/@/renderer/types';
const discordRpc = isElectron() ? window.electron.discordRpc : null;
export const useDiscordRpc = () => {
const intervalRef = useRef(0);
const discordSettings = useDiscordSetttings();
const currentSong = useCurrentSong();
const currentStatus = useCurrentStatus();
const setActivity = useCallback(async () => {
if (!discordSettings.enableIdle && currentStatus === PlayerStatus.PAUSED) {
discordRpc?.clearActivity();
return;
}
const currentTime = usePlayerStore.getState().current.time;
const now = Date.now();
const start = currentTime ? Math.round(now - currentTime * 1000) : null;
const end =
currentSong?.duration && start ? Math.round(start + currentSong.duration) : null;
const artists = currentSong?.artists.map((artist) => artist.name).join(', ');
const activity: SetActivity = {
details: currentSong?.name.padEnd(2, ' ') || 'Idle',
instance: false,
largeImageKey: undefined,
largeImageText: currentSong?.album || 'Unknown album',
smallImageKey: undefined,
smallImageText: currentStatus,
state: artists && `By ${artists}`,
};
if (currentStatus === PlayerStatus.PLAYING) {
if (start && end) {
activity.startTimestamp = start;
activity.endTimestamp = end;
}
activity.smallImageKey = 'playing';
} else {
activity.smallImageKey = 'paused';
}
if (
currentSong?.serverType === ServerType.JELLYFIN &&
discordSettings.showServerImage &&
currentSong?.imageUrl
) {
activity.largeImageKey = currentSong?.imageUrl;
}
// Fall back to default icon if not set
if (!activity.largeImageKey) {
activity.largeImageKey = 'icon';
}
discordRpc?.setActivity(activity);
}, [currentSong, currentStatus, discordSettings.enableIdle, discordSettings.showServerImage]);
useEffect(() => {
const initializeDiscordRpc = async () => {
discordRpc?.initialize(discordSettings.clientId);
};
if (discordSettings.enabled) {
initializeDiscordRpc();
} else {
discordRpc?.quit();
}
return () => {
discordRpc?.quit();
};
}, [discordSettings.clientId, discordSettings.enabled]);
useEffect(() => {
if (discordSettings.enabled) {
let intervalSeconds = discordSettings.updateInterval;
if (intervalSeconds < 15) {
intervalSeconds = 15;
}
intervalRef.current = window.setInterval(setActivity, intervalSeconds * 1000);
return () => clearInterval(intervalRef.current);
}
return () => {};
}, [discordSettings.enabled, discordSettings.updateInterval, setActivity]);
// useEffect(() => {
// console.log(
// 'currentStatus, discordSettings.enableIdle',
// currentStatus,
// discordSettings.enableIdle,
// );
// if (discordSettings.enableIdle === false && currentStatus === PlayerStatus.PAUSED) {
// console.log('removing activity');
// clearActivity();
// clearInterval(intervalRef.current);
// }
// }, [
// clearActivity,
// currentStatus,
// discordSettings.enableIdle,
// discordSettings.enabled,
// setActivity,
// ]);
};
@@ -1,7 +1,8 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiFolder2Fill, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
@@ -31,6 +32,7 @@ interface GenreListHeaderFiltersProps {
}
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { pageKey, customFilters } = useListContext();
const server = useCurrentServer();
@@ -269,7 +271,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@@ -291,7 +293,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('common.refresh', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@@ -308,37 +310,43 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
tooltip={{
label: t('common.configure', { postProcess: 'titleCase' }),
}}
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'titleCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>Item size</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.size', { postProcess: 'titleCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
@@ -363,7 +371,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
{(display === ListDisplayType.TABLE ||
display === ListDisplayType.TABLE_PAGINATED) && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.tableColumns', {
postProcess: 'titleCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
@@ -380,7 +392,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'titleCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
@@ -1,7 +1,7 @@
import { ChangeEvent, MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, MutableRefObject } from 'react';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
@@ -17,6 +17,7 @@ import {
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
interface GenreListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -25,6 +26,7 @@ interface GenreListHeaderProps {
}
export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeaderProps) => {
const { t } = useTranslation();
const cq = useContainerQuery();
const server = useCurrentServer();
const { pageKey } = useListContext();
@@ -66,7 +68,9 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Genres</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.genreList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>
@@ -11,9 +11,11 @@ import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel
import { Platform } from '/@/renderer/types';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
import { RiRefreshLine } from 'react-icons/ri';
const HomeRoute = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
@@ -103,32 +105,9 @@ const HomeRoute = () => {
const carousels = [
{
data: random?.data?.items,
title: (
<Group>
<TextTitle
order={2}
weight={700}
>
Explore from your library
</TextTitle>
<ActionIcon
onClick={() =>
queryClient.invalidateQueries({
exact: false,
queryKey: queryKeys.albums.list(server?.id, {
limit: itemsPerPage,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
}),
})
}
>
<RiRefreshLine />
</ActionIcon>
</Group>
),
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
title: t('page.home.explore', { postProcess: 'sentenceCase' }),
uniqueId: 'random',
},
{
@@ -136,7 +115,9 @@ const HomeRoute = () => {
pagination: {
itemsPerPage,
},
title: 'Recently played',
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.DESC,
title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }),
uniqueId: 'recentlyPlayed',
},
{
@@ -144,7 +125,9 @@ const HomeRoute = () => {
pagination: {
itemsPerPage,
},
title: 'Newly added releases',
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }),
uniqueId: 'recentlyAdded',
},
{
@@ -152,7 +135,9 @@ const HomeRoute = () => {
pagination: {
itemsPerPage,
},
title: 'Most played',
sortBy: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),
uniqueId: 'mostPlayed',
},
];
@@ -165,7 +150,9 @@ const HomeRoute = () => {
backgroundColor: 'var(--titlebar-bg)',
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Home</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.home.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
offset: 200,
@@ -220,7 +207,37 @@ const HomeRoute = () => {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
title={{ label: carousel.title }}
title={{
label: (
<Group>
<TextTitle
order={2}
weight={700}
>
{carousel.title}
</TextTitle>
<ActionIcon
onClick={() =>
queryClient.invalidateQueries({
exact: false,
queryKey: queryKeys.albums.list(
server?.id,
{
limit: itemsPerPage,
sortBy: carousel.sortBy,
sortOrder: carousel.sortOrder,
startIndex: 0,
},
),
})
}
>
<RiRefreshLine />
</ActionIcon>
</Group>
),
}}
uniqueId={carousel.uniqueId}
/>
))}
@@ -4,6 +4,7 @@ import { useForm } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { openModal } from '@mantine/modals';
import orderBy from 'lodash/orderBy';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import {
InternetProviderLyricSearchResponse,
@@ -12,6 +13,7 @@ import {
} from '../../../api/types';
import { useLyricSearch } from '../queries/lyric-search-query';
import { ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components';
import i18n from '/@/i18n/i18n';
const SearchItem = styled.button`
all: unset;
@@ -84,6 +86,7 @@ interface LyricSearchFormProps {
}
export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
const { t } = useTranslation();
const form = useForm({
initialValues: {
artist: artist || '',
@@ -117,11 +120,17 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
<Group grow>
<TextInput
data-autofocus
label="Name"
label={t('form.lyricSearch.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
label="Artist"
label={t('form.lyricSearch.input', {
context: 'artist',
postProcess: 'titleCase',
})}
{...form.getInputProps('artist')}
/>
</Group>
@@ -170,6 +179,6 @@ export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSe
/>
),
size: 'lg',
title: 'Lyrics Search',
title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,
});
};
@@ -1,5 +1,6 @@
import { Box, Group } from '@mantine/core';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiAddFill, RiSubtractFill } from 'react-icons/ri';
import { LyricsOverride } from '/@/renderer/api/types';
import { Button, NumberInput, Tooltip } from '/@/renderer/components';
@@ -22,6 +23,7 @@ export const LyricsActions = ({
onResetLyric,
onSearchOverride,
}: LyricsActionsProps) => {
const { t } = useTranslation();
const currentSong = useCurrentSong();
const { setSettings } = useSettingsStoreActions();
const { delayMs, sources } = useLyricsSettings();
@@ -54,7 +56,7 @@ export const LyricsActions = ({
})
}
>
Search
{t('common.search', { postProcess: 'titleCase' })}
</Button>
) : null}
<Button
@@ -65,7 +67,7 @@ export const LyricsActions = ({
<RiSubtractFill />
</Button>
<Tooltip
label="Offset (ms)"
label={t('setting.lyricOffset', { postProcess: 'sentenceCase' })}
openDelay={500}
>
<NumberInput
@@ -90,7 +92,7 @@ export const LyricsActions = ({
variant="subtle"
onClick={onResetLyric}
>
Reset
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
) : null}
</Group>
@@ -104,7 +106,7 @@ export const LyricsActions = ({
variant="subtle"
onClick={onRemoveLyric}
>
Clear
{t('common.clear', { postProcess: 'sentenceCase' })}
</Button>
) : null}
</Box>
@@ -18,6 +18,14 @@ const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
overflow: scroll;
transform: translateY(-2rem);
-webkit-mask-image: linear-gradient(
180deg,
transparent 5%,
rgb(0 0 0 / 100%) 20%,
rgb(0 0 0 / 100%) 85%,
transparent 95%
);
mask-image: linear-gradient(
180deg,
transparent 5%,
@@ -3,6 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Group } from '@mantine/core';
import { Button, Popover } from '/@/renderer/components';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import {
RiArrowDownLine,
RiArrowUpLine,
@@ -27,6 +28,7 @@ interface PlayQueueListOptionsProps {
}
export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsProps) => {
const { t } = useTranslation();
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
useQueueControls();
@@ -115,7 +117,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Shuffle queue' }}
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
variant="default"
onClick={handleShuffleQueue}
>
@@ -124,7 +126,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Move selected to bottom' }}
tooltip={{ label: t('action.moveToBottom', { postProcess: 'sentenceCase' }) }}
variant="default"
onClick={handleMoveToBottom}
>
@@ -133,7 +135,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Move selected to top' }}
tooltip={{ label: t('action.moveToTop', { postProcess: 'sentenceCase' }) }}
variant="default"
onClick={handleMoveToTop}
>
@@ -142,7 +144,9 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Remove selected' }}
tooltip={{
label: t('action.removeFromQueue', { postProcess: 'sentenceCase' }),
}}
variant="default"
onClick={handleRemoveSelected}
>
@@ -151,7 +155,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Clear queue' }}
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
variant="default"
onClick={handleClearQueue}
>
@@ -167,7 +171,9 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
tooltip={{
label: t('common.configure', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
>
<RiListSettingsLine size="1.1rem" />
@@ -11,6 +11,7 @@ import '@ag-grid-community/styles/ag-theme-alpine.css';
import {
useAppStoreActions,
useCurrentSong,
useCurrentStatus,
useDefaultQueue,
usePlayerControls,
usePreviousSong,
@@ -34,6 +35,7 @@ import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { useAppFocus } from '/@/renderer/hooks';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const remote = isElectron() ? window.electron.remote : null;
@@ -49,6 +51,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const { reorderQueue, setCurrentTrack } = useQueueControls();
const currentSong = useCurrentSong();
const previousSong = usePreviousSong();
const status = useCurrentStatus();
const { setSettings } = useSettingsStoreActions();
const { setAppStore } = useAppStoreActions();
const tableConfig = useTableSettings(type);
@@ -56,6 +59,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const playerType = usePlayerType();
const { play } = usePlayerControls();
const volume = useVolume();
const isFocused = useAppFocus();
useEffect(() => {
if (tableRef.current) {
@@ -69,7 +73,10 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
},
}));
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
const columnDefs = useMemo(
() => getColumnDefs(tableConfig.columns, false, 'generic'),
[tableConfig.columns],
);
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
const playerData = setCurrentTrack(e.data.uniqueId);
@@ -204,9 +211,9 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
}
}
}
}, [currentSong, previousSong, tableConfig.followCurrentSong]);
}, [currentSong, previousSong, tableConfig.followCurrentSong, status, isFocused]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
@@ -218,6 +225,12 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
rowDragMultiRow
autoFitColumns={tableConfig.autoFit}
columnDefs={columnDefs}
context={{
currentSong,
isFocused,
onCellContextMenu,
status,
}}
deselectOnClickOutside={type === 'fullScreen'}
getRowId={(data) => data.data.uniqueId}
rowBuffer={50}
@@ -225,7 +238,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
rowData={queue}
rowHeight={tableConfig.rowHeight || 40}
suppressCellFocus={type === 'fullScreen'}
onCellContextMenu={handleContextMenu}
onCellContextMenu={onCellContextMenu}
onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
@@ -3,6 +3,7 @@ import { useHotkeys } from '@mantine/hooks';
import { useQueryClient } from '@tanstack/react-query';
import formatDuration from 'format-duration';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { IoIosPause } from 'react-icons/io';
import {
RiMenuAddFill,
@@ -92,6 +93,7 @@ const ControlsContainer = styled.div`
`;
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isSeeking, setIsSeeking] = useState(false);
const currentSong = useCurrentSong();
@@ -171,7 +173,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={<RiStopFill size={15} />}
tooltip={{
label: 'Stop',
label: t('player.stop', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"
@@ -183,10 +185,11 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
tooltip={{
label:
shuffle === PlayerShuffle.NONE
? 'Shuffle disabled'
: shuffle === PlayerShuffle.TRACK
? 'Shuffle tracks'
: 'Shuffle albums',
? t('player.shuffle', {
context: 'off',
postProcess: 'sentenceCase',
})
: t('player.shuffle', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"
@@ -194,7 +197,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
<PlayerButton
icon={<RiSkipBackFill size={15} />}
tooltip={{ label: 'Previous track', openDelay: 500 }}
tooltip={{
label: t('player.previous', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="secondary"
onClick={handlePrevTrack}
/>
@@ -202,7 +208,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={<RiRewindFill size={15} />}
tooltip={{
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
label: t('player.skip', {
context: 'back',
postProcess: 'sentenceCase',
}),
openDelay: 500,
}}
variant="secondary"
@@ -218,7 +227,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
)
}
tooltip={{
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
label:
status === PlayerStatus.PAUSED
? t('player.play', { postProcess: 'sentenceCase' })
: t('player.pause', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="main"
@@ -228,7 +240,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={<RiSpeedFill size={15} />}
tooltip={{
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
label: t('player.stop', {
context: 'forward',
postProcess: 'sentenceCase',
}),
openDelay: 500,
}}
variant="secondary"
@@ -237,7 +252,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
)}
<PlayerButton
icon={<RiSkipForwardFill size={15} />}
tooltip={{ label: 'Next track', openDelay: 500 }}
tooltip={{
label: t('player.next', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="secondary"
onClick={handleNextTrack}
/>
@@ -253,10 +271,19 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
tooltip={{
label: `${
repeat === PlayerRepeat.NONE
? 'Repeat disabled'
? t('player.repeat', {
context: 'off',
postProcess: 'sentenceCase',
})
: repeat === PlayerRepeat.ALL
? 'Repeat all'
: 'Repeat one'
? t('player.repeat', {
context: 'all',
postProcess: 'sentenceCase',
})
: t('player.repeat', {
context: 'one',
postProcess: 'sentenceCase',
})
}`,
openDelay: 500,
}}
@@ -267,7 +294,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={<RiMenuAddFill size={15} />}
tooltip={{
label: 'Shuffle all',
label: t('player.playRandom', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"
@@ -1,7 +1,7 @@
import { Flex, Stack, Group, Center } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'framer-motion';
import { useEffect } from 'react';
import { useEffect, useRef, useLayoutEffect, useState, useCallback } from 'react';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
@@ -89,11 +89,11 @@ const imageVariants: Variants = {
},
};
const scaleImageUrl = (url?: string | null) => {
const scaleImageUrl = (imageSize: number, url?: string | null) => {
return url
?.replace(/&size=\d+/, '&size=500')
.replace(/\?width=\d+/, '?width=500')
.replace(/&height=\d+/, '&height=500');
?.replace(/&size=\d+/, `&size=${imageSize}`)
.replace(/\?width=\d+/, `?width=${imageSize}`)
.replace(/&height=\d+/, `&height=${imageSize}`);
};
const ImageWithPlaceholder = ({
@@ -127,6 +127,9 @@ const ImageWithPlaceholder = ({
};
export const FullScreenPlayerImage = () => {
const mainImageRef = useRef<HTMLImageElement | null>(null);
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1 });
const { queue } = usePlayerData();
const { opacity, useImageAspectRatio } = useFullScreenPlayerStore();
const currentSong = queue.current;
@@ -136,13 +139,31 @@ export const FullScreenPlayerImage = () => {
srcLoaded: true,
});
const imageKey = `image-${background}`;
const [imageState, setImageState] = useSetState({
bottomImage: scaleImageUrl(queue.next?.imageUrl),
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, queue.next?.imageUrl),
current: 0,
topImage: scaleImageUrl(queue.current?.imageUrl),
topImage: scaleImageUrl(mainImageDimensions.idealSize, queue.current?.imageUrl),
});
const updateImageSize = useCallback(() => {
if (mainImageRef.current) {
setMainImageDimensions({
idealSize:
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100,
});
setImageState({
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, queue.next?.imageUrl),
current: 0,
topImage: scaleImageUrl(mainImageDimensions.idealSize, queue.current?.imageUrl),
});
}
}, [mainImageDimensions.idealSize, queue, setImageState]);
useLayoutEffect(() => {
updateImageSize();
}, [updateImageSize]);
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => [state.current.song, state.actions.getPlayerData().queue],
@@ -150,8 +171,14 @@ export const FullScreenPlayerImage = () => {
const isTop = imageState.current === 0;
const queue = state[1] as PlayerData['queue'];
const currentImageUrl = scaleImageUrl(queue.current?.imageUrl);
const nextImageUrl = scaleImageUrl(queue.next?.imageUrl);
const currentImageUrl = scaleImageUrl(
mainImageDimensions.idealSize,
queue.current?.imageUrl,
);
const nextImageUrl = scaleImageUrl(
mainImageDimensions.idealSize,
queue.next?.imageUrl,
);
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -165,7 +192,7 @@ export const FullScreenPlayerImage = () => {
return () => {
unsubSongChange();
};
}, [imageState, queue, setImageState]);
}, [imageState, mainImageDimensions.idealSize, queue, setImageState]);
return (
<PlayerContainer
@@ -175,7 +202,10 @@ export const FullScreenPlayerImage = () => {
justify="flex-start"
p="1rem"
>
<ImageContainer>
<ImageContainer
ref={mainImageRef}
onLoad={updateImageSize}
>
<AnimatePresence
initial={false}
mode="sync"
@@ -1,5 +1,6 @@
import { Group, Center } from '@mantine/core';
import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { HiOutlineQueueList } from 'react-icons/hi2';
import { RiFileMusicLine, RiFileTextLine, RiInformationFill } from 'react-icons/ri';
import styled from 'styled-components';
@@ -50,11 +51,12 @@ const GridContainer = styled.div<TransparendGridContainerProps>`
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;
padding: 1rem;
background: rgb(var(--main-bg-transparent), ${({ opacity }) => opacity}%);
background: rgb(var(--main-bg-transparent) ${({ opacity }) => opacity}%);
border-radius: 5px;
`;
export const FullScreenPlayerQueue = () => {
const { t } = useTranslation();
const { activeTab, opacity } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
@@ -62,19 +64,19 @@ export const FullScreenPlayerQueue = () => {
{
active: activeTab === 'queue',
icon: <RiFileMusicLine size="1.5rem" />,
label: 'Up Next',
label: t('page.fullScreenPlayer.upNext'),
onClick: () => setStore({ activeTab: 'queue' }),
},
{
active: activeTab === 'related',
icon: <HiOutlineQueueList size="1.5rem" />,
label: 'Related',
label: t('page.fullScreenPlayer.related'),
onClick: () => setStore({ activeTab: 'related' }),
},
{
active: activeTab === 'lyrics',
icon: <RiFileTextLine size="1.5rem" />,
label: 'Lyrics',
label: t('page.fullScreenPlayer.lyrics'),
onClick: () => setStore({ activeTab: 'lyrics' }),
},
];
@@ -125,7 +127,7 @@ export const FullScreenPlayerQueue = () => {
order={3}
weight={700}
>
COMING SOON
{t('common.comingSoon', { postProcess: 'upperCase' })}
</TextTitle>
</Group>
</Center>
@@ -2,6 +2,7 @@ import { useLayoutEffect, useRef } from 'react';
import { Divider, Group } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { Variants, motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
import { useLocation } from 'react-router';
import styled from 'styled-components';
@@ -70,6 +71,7 @@ const BackgroundImageOverlay = styled.div`
`;
const Controls = () => {
const { t } = useTranslation();
const { dynamicBackground, expanded, opacity, useImageAspectRatio } =
useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
@@ -104,7 +106,7 @@ const Controls = () => {
<Button
compact
size="sm"
tooltip={{ label: 'Minimize' }}
tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleToggleFullScreenPlayer}
>
@@ -115,7 +117,7 @@ const Controls = () => {
<Button
compact
size="sm"
tooltip={{ label: 'Configure' }}
tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}
variant="subtle"
>
<RiSettings3Line size="1.5rem" />
@@ -123,7 +125,11 @@ const Controls = () => {
</Popover.Target>
<Popover.Dropdown>
<Option>
<Option.Label>Dynamic Background</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.dynamicBackground', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
defaultChecked={dynamicBackground}
@@ -137,7 +143,11 @@ const Controls = () => {
</Option>
{dynamicBackground && (
<Option>
<Option.Label>Opacity</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.opacity', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Slider
defaultValue={opacity}
@@ -151,7 +161,11 @@ const Controls = () => {
</Option>
)}
<Option>
<Option.Label>Use image aspect ratio</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.useImageAspectRatio', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
checked={useImageAspectRatio}
@@ -165,7 +179,11 @@ const Controls = () => {
</Option>
<Divider my="sm" />
<Option>
<Option.Label>Follow current lyrics</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.followCurrentLyric', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.follow}
@@ -176,7 +194,11 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Show lyrics provider</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.showLyricProvider', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.showProvider}
@@ -187,7 +209,11 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Show lyrics match</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.showLyricMatch', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.showMatch}
@@ -198,7 +224,11 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics size</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.lyric', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Group
noWrap
@@ -206,7 +236,11 @@ const Controls = () => {
>
<Slider
defaultValue={lyricConfig.fontSize}
label={(e) => `Synchronized: ${e}px`}
label={(e) =>
`${t('page.fullscreenPlayer.synchronized', {
postProcess: 'titleCase',
})}: ${e}px`
}
max={72}
min={8}
w="100%"
@@ -214,7 +248,11 @@ const Controls = () => {
/>
<Slider
defaultValue={lyricConfig.fontSize}
label={(e) => `Unsynchronized: ${e}px`}
label={(e) =>
`${t('page.fullscreenPlayer.unsynchronized', {
postProcess: 'sentenceCase',
})}: ${e}px`
}
max={72}
min={8}
w="100%"
@@ -226,7 +264,11 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics gap</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.lyricGap', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Group
noWrap
@@ -254,13 +296,32 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics alignment</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.lyricAlignment', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Select
data={[
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
{
label: t('common.left', {
postProcess: 'titleCase',
}),
value: 'left',
},
{
label: t('common.center', {
postProcess: 'titleCase',
}),
value: 'center',
},
{
label: t('common.right', {
postProcess: 'titleCase',
}),
value: 'right',
},
]}
value={lyricConfig.alignment}
onChange={(e) => handleLyricsSettings('alignment', e)}
@@ -2,6 +2,7 @@ import React, { MouseEvent } from 'react';
import { Center, Group } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
import { generatePath, Link } from 'react-router-dom';
import styled from 'styled-components';
@@ -92,6 +93,7 @@ const LeftControlsContainer = styled.div`
`;
export const LeftControls = () => {
const { t } = useTranslation();
const { setSideBar } = useAppStoreActions();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
@@ -147,7 +149,9 @@ export const LeftControls = () => {
onClick={handleToggleFullScreenPlayer}
>
<Tooltip
label="Toggle fullscreen player"
label={t('player.toggleFullscreenPlayer', {
postProcess: 'sentenceCase',
})}
openDelay={500}
>
{currentSong?.imageUrl ? (
@@ -182,7 +186,12 @@ export const LeftControls = () => {
right: 2,
top: 2,
}}
tooltip={{ label: 'Expand', openDelay: 500 }}
tooltip={{
label: t('common.expand', {
postProcess: 'titleCase',
}),
openDelay: 500,
}}
variant="default"
onClick={handleToggleSidebarImage}
>
@@ -103,6 +103,7 @@ const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
}
&:hover {
color: var(--playerbar-btn-fg-hover);
background: var(--playerbar-btn-bg-hover);
svg {
@@ -1,7 +1,8 @@
import { MouseEvent, useEffect } from 'react';
import { useEffect } from 'react';
import { Flex, Group } from '@mantine/core';
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { HiOutlineQueueList } from 'react-icons/hi2';
import {
RiVolumeUpFill,
@@ -16,44 +17,58 @@ import {
useCurrentSong,
useHotkeySettings,
useMuted,
usePreviousSong,
useSidebarStore,
useSpeed,
useVolume,
} from '/@/renderer/store';
import { useRightControls } from '../hooks/use-right-controls';
import { PlayerButton } from './player-button';
import { LibraryItem, ServerType, Song } from '/@/renderer/api/types';
import { LibraryItem, QueueSong, ServerType, Song } from '/@/renderer/api/types';
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
import { Rating } from '/@/renderer/components';
import { DropdownMenu, Rating } from '/@/renderer/components';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
const ipc = isElectron() ? window.electron.ipc : null;
const remote = isElectron() ? window.electron.remote : null;
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
export const RightControls = () => {
const { t } = useTranslation();
const isMinWidth = useMediaQuery('(max-width: 480px)');
const volume = useVolume();
const muted = useMuted();
const server = useCurrentServer();
const currentSong = useCurrentSong();
const previousSong = usePreviousSong();
const { setSideBar } = useAppStoreActions();
const { rightExpanded: isQueueExpanded } = useSidebarStore();
const { bindings } = useHotkeySettings();
const { handleVolumeSlider, handleVolumeWheel, handleMute, handleVolumeDown, handleVolumeUp } =
useRightControls();
const {
handleVolumeSlider,
handleVolumeWheel,
handleMute,
handleVolumeDown,
handleVolumeUp,
handleSpeed,
} = useRightControls();
const speed = useSpeed();
const updateRatingMutation = useSetRating({});
const addToFavoritesMutation = useCreateFavorite({});
const removeFromFavoritesMutation = useDeleteFavorite({});
const handleAddToFavorites = () => {
if (!currentSong) return;
const handleAddToFavorites = (song: QueueSong | undefined) => {
if (!song?.id) return;
addToFavoritesMutation.mutate({
query: {
id: [currentSong.id],
id: [song.id],
type: LibraryItem.SONG,
},
serverId: currentSong?.serverId,
serverId: song?.serverId,
});
};
@@ -69,37 +84,25 @@ export const RightControls = () => {
});
};
const handleClearRating = (_e: MouseEvent<HTMLDivElement> | null, rating?: number) => {
if (!currentSong || !rating) return;
updateRatingMutation.mutate({
query: {
item: [currentSong],
rating: 0,
},
serverId: currentSong?.serverId,
});
};
const handleRemoveFromFavorites = () => {
if (!currentSong) return;
const handleRemoveFromFavorites = (song: QueueSong | undefined) => {
if (!song?.id) return;
removeFromFavoritesMutation.mutate({
query: {
id: [currentSong.id],
id: [song.id],
type: LibraryItem.SONG,
},
serverId: currentSong?.serverId,
serverId: song?.serverId,
});
};
const handleToggleFavorite = () => {
if (!currentSong) return;
const handleToggleFavorite = (song: QueueSong | undefined) => {
if (!song?.id) return;
if (currentSong.userFavorite) {
handleRemoveFromFavorites();
if (song.userFavorite) {
handleRemoveFromFavorites(song);
} else {
handleAddToFavorites();
handleAddToFavorites(song);
}
};
@@ -115,7 +118,31 @@ export const RightControls = () => {
[bindings.volumeUp.isGlobal ? '' : bindings.volumeUp.hotkey, handleVolumeUp],
[bindings.volumeMute.isGlobal ? '' : bindings.volumeMute.hotkey, handleMute],
[bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],
[bindings.rate0.isGlobal ? '' : bindings.rate0.hotkey, () => handleClearRating(null, 0)],
[
bindings.favoriteCurrentAdd.isGlobal ? '' : bindings.favoriteCurrentAdd.hotkey,
() => handleAddToFavorites(currentSong),
],
[
bindings.favoriteCurrentRemove.isGlobal ? '' : bindings.favoriteCurrentRemove.hotkey,
() => handleRemoveFromFavorites(currentSong),
],
[
bindings.favoriteCurrentToggle.isGlobal ? '' : bindings.favoriteCurrentToggle.hotkey,
() => handleToggleFavorite(currentSong),
],
[
bindings.favoritePreviousAdd.isGlobal ? '' : bindings.favoritePreviousAdd.hotkey,
() => handleAddToFavorites(previousSong),
],
[
bindings.favoritePreviousRemove.isGlobal ? '' : bindings.favoritePreviousRemove.hotkey,
() => handleRemoveFromFavorites(previousSong),
],
[
bindings.favoritePreviousToggle.isGlobal ? '' : bindings.favoritePreviousToggle.hotkey,
() => handleToggleFavorite(previousSong),
],
[bindings.rate0.isGlobal ? '' : bindings.rate0.hotkey, () => handleUpdateRating(0)],
[bindings.rate1.isGlobal ? '' : bindings.rate1.hotkey, () => handleUpdateRating(1)],
[bindings.rate2.isGlobal ? '' : bindings.rate2.hotkey, () => handleUpdateRating(2)],
[bindings.rate3.isGlobal ? '' : bindings.rate3.hotkey, () => handleUpdateRating(3)],
@@ -175,7 +202,6 @@ export const RightControls = () => {
size="sm"
value={currentSong?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
)}
</Group>
@@ -184,6 +210,28 @@ export const RightControls = () => {
align="center"
spacing="xs"
>
<DropdownMenu>
<DropdownMenu.Target>
<PlayerButton
icon={<>{speed} x</>}
tooltip={{
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="secondary"
/>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAYBACK_SPEEDS.map((speed) => (
<DropdownMenu.Item
key={`speed-select-${speed}`}
onClick={() => handleSpeed(Number(speed))}
>
{speed}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<PlayerButton
icon={
currentSong?.userFavorite ? (
@@ -203,18 +251,22 @@ export const RightControls = () => {
},
}}
tooltip={{
label: currentSong?.userFavorite ? 'Unfavorite' : 'Favorite',
label: currentSong?.userFavorite
? t('player.unfavorite', { postProcess: 'titleCase' })
: t('player.favorite', { postProcess: 'titleCase' }),
openDelay: 500,
}}
variant="secondary"
onClick={handleToggleFavorite}
/>
<PlayerButton
icon={<HiOutlineQueueList size="1.1rem" />}
tooltip={{ label: 'View queue', openDelay: 500 }}
variant="secondary"
onClick={handleToggleQueue}
onClick={() => handleToggleFavorite(currentSong)}
/>
{!isMinWidth ? (
<PlayerButton
icon={<HiOutlineQueueList size="1.1rem" />}
tooltip={{ label: 'View queue', openDelay: 500 }}
variant="secondary"
onClick={handleToggleQueue}
/>
) : null}
<Group
noWrap
spacing="xs"
@@ -229,7 +281,10 @@ export const RightControls = () => {
<RiVolumeDownFill size="1.2rem" />
)
}
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
tooltip={{
label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume,
openDelay: 500,
}}
variant="secondary"
onClick={handleMute}
onWheel={handleVolumeWheel}
@@ -20,6 +20,7 @@ import { api } from '/@/renderer/api';
import { useAuthStore } from '/@/renderer/store';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Play, PlayQueueAddOptions, ServerListItem } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
interface ShuffleAllSlice extends RandomSongListQuery {
actions: {
@@ -260,6 +261,6 @@ export const openShuffleAllModal = async (
/>
),
size: 'sm',
title: 'Shuffle all',
title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,
});
};
@@ -18,6 +18,7 @@ import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import debounce from 'lodash/debounce';
import { QueueSong } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import { useTranslation } from 'react-i18next';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
@@ -28,6 +29,7 @@ const remote = isElectron() ? window.electron.remote : null;
const mediaSession = !isElectron() || !utils?.isLinux() ? navigator.mediaSession : null;
export const useCenterControls = (args: { playersRef: any }) => {
const { t } = useTranslation();
const { playersRef } = args;
const settings = useSettingsStore((state) => state.playback);
@@ -191,8 +193,9 @@ export const useCenterControls = (args: { playersRef: any }) => {
return mpvPlayer?.setQueueNext(playerData);
}
const playerData = setRepeat(PlayerRepeat.NONE);
remote?.updateRepeat(PlayerRepeat.NONE);
return setRepeat(PlayerRepeat.NONE);
return mpvPlayer?.setQueueNext(playerData);
}, [repeatStatus, setRepeat]);
const checkIsLastTrack = useCallback(() => {
@@ -612,11 +615,15 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleError = useCallback(
(message: string) => {
toast.error({ id: 'mpv-error', message, title: 'An error occurred during playback' });
toast.error({
id: 'mpv-error',
message,
title: t('error.playbackError', { postProcess: 'sentenceCase' }),
});
pause();
mpvPlayer!.pause();
},
[pause],
[pause, t],
);
useEffect(() => {
@@ -28,6 +28,7 @@ import {
getGenreSongsById,
} from '/@/renderer/features/player/utils';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
let queryKey;
@@ -62,6 +63,7 @@ const remote = isElectron() ? window.electron.remote : null;
const addToQueue = usePlayerStore.getState().actions.addToQueue;
export const useHandlePlayQueueAdd = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const playerType = usePlayerType();
const server = useCurrentServer();
@@ -86,15 +88,18 @@ export const useHandlePlayQueueAdd = () => {
toast.info({
autoClose: false,
id: fetchId,
message:
'This is taking a while... close the notification to cancel the request',
message: t('player.playbackFetchCancel', {
postProcess: 'sentenceCase',
}),
onClose: () => {
queryClient.cancelQueries({
exact: false,
queryKey: getRootQueryKey(itemType, server?.id),
});
},
title: 'Adding to queue',
title: t('player.playbackFetchInProgress', {
postProcess: 'sentenceCase',
}),
});
}, 2000),
};
@@ -140,7 +145,7 @@ export const useHandlePlayQueueAdd = () => {
return toast.error({
message: err.message,
title: 'Play queue add failed',
title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
}
@@ -152,8 +157,8 @@ export const useHandlePlayQueueAdd = () => {
if (!songs || songs?.length === 0)
return toast.warn({
message: 'The query returned no results',
title: 'No tracks added',
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
title: t('player.playbackFetchNoResults'),
});
if (initialIndex) {
@@ -190,7 +195,7 @@ export const useHandlePlayQueueAdd = () => {
return null;
},
[play, playerType, queryClient, server],
[play, playerType, queryClient, server, t],
);
return handlePlayQueueAdd;
@@ -1,6 +1,12 @@
import { useCallback, useEffect, WheelEvent } from 'react';
import isElectron from 'is-electron';
import { useMuted, usePlayerControls, useVolume } from '/@/renderer/store';
import {
useMuted,
usePlayerControls,
useSetCurrentSpeed,
useSpeed,
useVolume,
} from '/@/renderer/store';
import { useGeneralSettings } from '/@/renderer/store/settings.store';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@@ -37,6 +43,8 @@ export const useRightControls = () => {
const volume = useVolume();
const muted = useMuted();
const { volumeWheelStep } = useGeneralSettings();
const speed = useSpeed();
const setCurrentSpeed = useSetCurrentSpeed();
// Ensure that the mpv player volume is set on startup
useEffect(() => {
@@ -44,6 +52,7 @@ export const useRightControls = () => {
if (mpvPlayer) {
mpvPlayer.volume(volume);
mpvPlayer.setProperties({ speed });
if (muted) {
mpvPlayer.mute(muted);
@@ -53,6 +62,16 @@ export const useRightControls = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSpeed = useCallback(
(e: number) => {
setCurrentSpeed(e);
if (mpvPlayer) {
mpvPlayer?.setProperties({ speed: e });
}
},
[setCurrentSpeed],
);
const handleVolumeSlider = (e: number) => {
mpvPlayer?.volume(e);
remote?.updateVolume(e);
@@ -123,6 +142,7 @@ export const useRightControls = () => {
return {
handleMute,
handleSpeed,
handleVolumeDown,
handleVolumeSlider,
handleVolumeSliderState,
@@ -34,20 +34,21 @@ Progress Events (Jellyfin only):
*/
const checkScrobbleConditions = (args: {
scrobbleAtDuration: number;
scrobbleAtDurationMs: number;
scrobbleAtPercentage: number;
songCompletedDuration: number;
songDuration: number;
songCompletedDurationMs: number;
songDurationMs: number;
}) => {
const { scrobbleAtDuration, scrobbleAtPercentage, songCompletedDuration, songDuration } = args;
const percentageOfSongCompleted = songDuration
? (songCompletedDuration / songDuration) * 100
const { scrobbleAtDurationMs, scrobbleAtPercentage, songCompletedDurationMs, songDurationMs } =
args;
const percentageOfSongCompleted = songDurationMs
? (songCompletedDurationMs / songDurationMs) * 100
: 0;
return (
percentageOfSongCompleted >= scrobbleAtPercentage ||
songCompletedDuration >= scrobbleAtDuration
);
const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage;
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration;
};
export const useScrobble = () => {
@@ -97,15 +98,15 @@ export const useScrobble = () => {
// const currentSong = current[0] as QueueSong | undefined;
const previousSong = previous[0] as QueueSong;
const previousSongTime = previous[1] as number;
const previousSongTimeSec = previous[1] as number;
// Send completion scrobble when song changes and a previous song exists
if (previousSong?.id) {
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: previousSongTime,
songDuration: previousSong.duration,
songCompletedDurationMs: previousSongTimeSec * 1000,
songDurationMs: previousSong.duration,
});
if (
@@ -114,7 +115,7 @@ export const useScrobble = () => {
) {
const position =
previousSong?.serverType === ServerType.JELLYFIN
? previousSongTime * 1e7
? previousSongTimeSec * 1e7
: undefined;
sendScrobble.mutate({
@@ -168,7 +169,10 @@ export const useScrobble = () => {
);
const handleScrobbleFromStatusChange = useCallback(
(status: PlayerStatus | undefined) => {
(
current: (PlayerStatus | number | undefined)[],
previous: (PlayerStatus | number | undefined)[],
) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
@@ -180,8 +184,11 @@ export const useScrobble = () => {
? usePlayerStore.getState().current.time * 1e7
: undefined;
const currentStatus = current[0] as PlayerStatus;
const currentTimeSec = current[1] as number;
// Whenever the player is restarted, send a 'start' scrobble
if (status === PlayerStatus.PLAYING) {
if (currentStatus === PlayerStatus.PLAYING) {
sendScrobble.mutate({
query: {
event: 'unpause',
@@ -194,7 +201,7 @@ export const useScrobble = () => {
if (currentSong?.serverType === ServerType.JELLYFIN) {
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
const currentTime = currentTimeSec;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
@@ -215,12 +222,17 @@ export const useScrobble = () => {
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
}
} else {
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
const previousTimeSec = previous[1] as number;
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: usePlayerStore.getState().current.time,
songDuration: currentSong.duration,
// If scrobbling the last song in the queue, use the previous time instead of the current time since otherwise time value will be 0
songCompletedDurationMs:
(isLastTrackInQueue ? previousTimeSec : currentTimeSec) * 1000,
songDurationMs: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
@@ -261,10 +273,10 @@ export const useScrobble = () => {
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: currentTime,
songDuration: currentSong.duration,
songCompletedDurationMs: currentTime,
songDurationMs: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
@@ -313,8 +325,11 @@ export const useScrobble = () => {
);
const unsubStatusChange = usePlayerStore.subscribe(
(state) => state.current.status,
(state) => [state.current.status, state.current.time],
handleScrobbleFromStatusChange,
{
equalityFn: (a, b) => (a[0] as PlayerStatus) === (b[0] as PlayerStatus),
},
);
return () => {
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { Box, Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { useMemo, useState } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { PlaylistListSort, SongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
@@ -11,6 +11,7 @@ import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-t
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
export const AddToPlaylistContextModal = ({
id,
@@ -21,6 +22,7 @@ export const AddToPlaylistContextModal = ({
genreId?: string[];
songId?: string[];
}>) => {
const { t } = useTranslation();
const { albumId, artistId, genreId, songId } = innerProps;
const server = useCurrentServer();
const [isLoading, setIsLoading] = useState(false);
@@ -98,7 +100,7 @@ export const AddToPlaylistContextModal = ({
const handleSubmit = form.onSubmit(async (values) => {
setIsLoading(true);
const allSongIds: string[] = [];
const uniqueSongIds: string[] = [];
let totalUniquesAdded = 0;
if (albumId && albumId.length > 0) {
for (const id of albumId) {
@@ -129,6 +131,8 @@ export const AddToPlaylistContextModal = ({
}
for (const playlistId of values.playlistId) {
const uniqueSongIds: string[] = [];
if (values.skipDuplicates) {
const query = {
id: playlistId,
@@ -138,7 +142,10 @@ export const AddToPlaylistContextModal = ({
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) throw new Error('No server');
if (!server)
throw new Error(
t('error.serverNotSelectedError', { postProcess: 'sentenceCase' }),
);
return api.controller.getPlaylistSongList({
apiClientProps: {
server,
@@ -155,6 +162,7 @@ export const AddToPlaylistContextModal = ({
uniqueSongIds.push(songId);
}
}
totalUniquesAdded += uniqueSongIds.length;
}
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
@@ -172,7 +180,7 @@ export const AddToPlaylistContextModal = ({
playlistSelect.find((playlist) => playlist.value === playlistId)
?.label
}] ${err.message}`,
title: 'Failed to add songs to playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
},
@@ -180,11 +188,19 @@ export const AddToPlaylistContextModal = ({
}
}
const addMessage =
values.skipDuplicates &&
allSongIds.length * values.playlistId.length !== totalUniquesAdded
? `${Math.floor(totalUniquesAdded / values.playlistId.length)}`
: allSongIds.length;
setIsLoading(false);
toast.success({
message: `Added ${
values.skipDuplicates ? uniqueSongIds.length : allSongIds.length
} songs to ${values.playlistId.length} playlist(s)`,
message: t('form.addToPlaylist', {
message: addMessage,
numOfPlaylists: values.playlistId.length,
postProcess: 'sentenceCase',
}),
});
closeModal(id);
return null;
@@ -199,12 +215,18 @@ export const AddToPlaylistContextModal = ({
searchable
data={playlistSelect}
disabled={playlistList.isLoading}
label="Playlists"
label={t('form.addToPlaylist.input', {
context: 'playlists',
postProcess: 'titleCase',
})}
size="md"
{...form.getInputProps('playlistId')}
/>
<Switch
label="Skip duplicates"
label={t('form.addToPlaylist.input', {
context: 'skipDuplicates',
postProcess: 'titleCase',
})}
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
/>
<Group position="right">
@@ -215,7 +237,7 @@ export const AddToPlaylistContextModal = ({
variant="subtle"
onClick={() => closeModal(id)}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
@@ -224,7 +246,7 @@ export const AddToPlaylistContextModal = ({
type="submit"
variant="filled"
>
Add
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
</Group>
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useRef, useState } from 'react';
import { CreatePlaylistBody, ServerType, SongListSort } from '/@/renderer/api/types';
import { Button, Switch, Text, TextInput, toast } from '/@/renderer/components';
import {
@@ -10,12 +10,14 @@ import {
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface CreatePlaylistFormProps {
onCancel: () => void;
}
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
const { t } = useTranslation();
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
@@ -69,10 +71,15 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
},
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({ message: `Playlist has been created` });
toast.success({
message: t('form.createPlaylist.success', { postProcess: 'sentenceCase' }),
});
onCancel();
},
},
@@ -88,17 +95,26 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
<TextInput
data-autofocus
required
label="Name"
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
{...form.getInputProps('comment')}
/>
<Group>
{isPublicDisplayed && (
<Switch
label="Is public?"
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('_custom.navidrome.public', {
type: 'checkbox',
})}
@@ -130,7 +146,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
variant="subtle"
onClick={onCancel}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
@@ -138,7 +154,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
type="submit"
variant="filled"
>
Save
{t('common.create', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
@@ -1,8 +1,9 @@
import { MutableRefObject, useMemo, useRef } from 'react';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { MutableRefObject, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { RiMoreFill } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
@@ -45,6 +46,7 @@ interface PlaylistDetailContentProps {
}
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { playlistId } = useParams() as { playlistId: string };
const { table } = useListStoreByKey({ key: LibraryItem.SONG });
@@ -102,13 +104,10 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
closeAllModals();
navigate(AppRoute.PLAYLISTS);
},
@@ -126,7 +125,7 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
Are you sure you want to delete this playlist?
</ConfirmModal>
),
title: 'Delete playlist',
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
});
};
@@ -22,7 +22,7 @@ import {
SortOrder,
} from '/@/renderer/api/types';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table';
import { TablePagination, VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table';
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import {
@@ -34,6 +34,8 @@ import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playli
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
import {
useCurrentServer,
useCurrentSong,
useCurrentStatus,
usePlaylistDetailStore,
usePlaylistDetailTablePagination,
useSetPlaylistDetailTable,
@@ -41,6 +43,7 @@ import {
} from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { ListDisplayType } from '/@/renderer/types';
import { useAppFocus } from '/@/renderer/hooks';
interface PlaylistDetailContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
@@ -49,6 +52,9 @@ interface PlaylistDetailContentProps {
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => {
const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient();
const status = useCurrentStatus();
const isFocused = useAppFocus();
const currentSong = useCurrentSong();
const server = useCurrentServer();
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
@@ -86,7 +92,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
});
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
() => getColumnDefs(page.table.columns, false, 'generic'),
[page.table.columns],
);
@@ -236,6 +242,12 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
alwaysShowHorizontalScroll
autoFitColumns={page.table.autoFit}
columnDefs={columnDefs}
context={{
currentSong,
isFocused,
onCellContextMenu: handleContextMenu,
status,
}}
getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
pagination={isPaginationEnabled}
@@ -4,6 +4,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
RiMoreFill,
RiSettings3Fill,
@@ -101,6 +102,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
tableRef,
handleToggleShowQueryBuilder,
}: PlaylistDetailSongListHeaderFiltersProps) => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -267,19 +269,16 @@ export const PlaylistDetailSongListHeaderFilters = ({
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
navigate(AppRoute.PLAYLISTS, { replace: true });
},
},
);
closeAllModals();
}, [deletePlaylistMutation, detailQuery.data, navigate]);
}, [deletePlaylistMutation, detailQuery.data, navigate, t]);
const openDeletePlaylistModal = () => {
openModal({
@@ -288,7 +287,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
<Text>Are you sure you want to delete this playlist?</Text>
</ConfirmModal>
),
title: 'Delete playlist(s)',
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
});
};
@@ -345,19 +344,19 @@ export const PlaylistDetailSongListHeaderFilters = ({
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
Play
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
@@ -369,20 +368,20 @@ export const PlaylistDetailSongListHeaderFilters = ({
})
}
>
Edit playlist
{t('action.editPlaylist', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiDeleteBinFill />}
onClick={openDeletePlaylistModal}
>
Delete playlist
{t('action.deletePlaylist', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('action.refresh', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
<>
@@ -391,7 +390,9 @@ export const PlaylistDetailSongListHeaderFilters = ({
$danger
onClick={handleToggleShowQueryBuilder}
>
Toggle smart playlist editor
{t('action.toggleSmartPlaylistEditor', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
</>
)}
@@ -1,6 +1,7 @@
import { MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { LibraryItem } from '/@/renderer/api/types';
import { Badge, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components';
@@ -23,6 +24,7 @@ export const PlaylistDetailSongListHeader = ({
itemCount,
handleToggleShowQueryBuilder,
}: PlaylistDetailHeaderProps) => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
@@ -58,7 +60,7 @@ export const PlaylistDetailSongListHeader = ({
itemCount
)}
</Paper>
{isSmartPlaylist && <Badge size="lg">Smart playlist</Badge>}
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
</LibraryHeaderBar>
</PageHeader>
<Paper p="1rem">
@@ -1,8 +1,9 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { useListContext } from '../../../context/list-context';
import { useListStoreByKey } from '../../../store/list.store';
@@ -42,6 +43,7 @@ export const PlaylistListHeaderFilters = ({
gridRef,
tableRef,
}: PlaylistListHeaderFiltersProps) => {
const { t } = useTranslation();
const { pageKey } = useListContext();
const queryClient = useQueryClient();
const server = useCurrentServer();
@@ -285,7 +287,7 @@ export const PlaylistListHeaderFilters = ({
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@@ -308,7 +310,7 @@ export const PlaylistListHeaderFilters = ({
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('common.refresh', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@@ -328,27 +330,29 @@ export const PlaylistListHeaderFilters = ({
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'titleCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
@@ -382,7 +386,11 @@ export const PlaylistListHeaderFilters = ({
</DropdownMenu.Item>
{!isGrid && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.generaltableColumns', {
postProcess: 'titleCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
@@ -399,7 +407,11 @@ export const PlaylistListHeaderFilters = ({
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'titleCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
@@ -11,6 +11,7 @@ import { useContainerQuery } from '/@/renderer/hooks';
import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, ServerType } from '/@/renderer/types';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { RiFileAddFill } from 'react-icons/ri';
import { LibraryItem } from '/@/renderer/api/types';
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
@@ -24,6 +25,7 @@ interface PlaylistListHeaderProps {
}
export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistListHeaderProps) => {
const { t } = useTranslation();
const { pageKey } = useListContext();
const cq = useContainerQuery();
const server = useCurrentServer();
@@ -37,7 +39,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
tableRef?.current?.api?.purgeInfiniteCache();
},
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: 'Create Playlist',
title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }),
});
};
@@ -74,7 +76,9 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Playlists</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.playlistList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
@@ -88,7 +92,10 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
)}
</Paper>
<Button
tooltip={{ label: 'Create playlist', openDelay: 500 }}
tooltip={{
label: t('action.createPlaylist', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="filled"
onClick={handleCreatePlaylistModal}
>
@@ -19,6 +19,7 @@ import {
convertQueryGroupToNDQuery,
} from '/@/renderer/features/playlists/utils';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
import { SongListSort } from '/@/renderer/api/types';
import {
@@ -86,6 +87,7 @@ export const PlaylistQueryBuilder = forwardRef(
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>,
) => {
const { t } = useTranslation();
const [filters, setFilters] = useState<QueryBuilderGroup>(
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
);
@@ -354,7 +356,11 @@ export const PlaylistQueryBuilder = forwardRef(
};
const sortOptions = [
{ label: 'Random', type: 'string', value: 'random' },
{
label: t('filter.random', { postProcess: 'titleCase' }),
type: 'string',
value: 'random',
},
...NDSongQueryFields,
];
@@ -414,21 +420,21 @@ export const PlaylistQueryBuilder = forwardRef(
<Select
data={[
{
label: 'Ascending',
label: t('common.ascending', { postProcess: 'titleCase' }),
value: 'asc',
},
{
label: 'Descending',
label: t('common.descending', { postProcess: 'titleCase' }),
value: 'desc',
},
]}
label="Order"
label={t('common.order', { postProcess: 'titleCase' })}
maxWidth="20%"
width={125}
{...extraFiltersForm.getInputProps('sortOrder')}
/>
<NumberInput
label="Limit"
label={t('common.limit', { postProcess: 'titleCase' })}
maxWidth="20%"
width={75}
{...extraFiltersForm.getInputProps('limit')}
@@ -444,7 +450,7 @@ export const PlaylistQueryBuilder = forwardRef(
variant="filled"
onClick={handleSaveAs}
>
Save as
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
@@ -462,7 +468,7 @@ export const PlaylistQueryBuilder = forwardRef(
icon={<RiSaveLine color="var(--danger-color)" />}
onClick={handleSave}
>
Save and replace
{t('common.saveAndReplace', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@@ -4,6 +4,7 @@ import { CreatePlaylistBody, CreatePlaylistResponse, ServerType } from '/@/rende
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface SaveAsPlaylistFormProps {
body: Partial<CreatePlaylistBody>;
@@ -18,6 +19,7 @@ export const SaveAsPlaylistForm = ({
onSuccess,
onCancel,
}: SaveAsPlaylistFormProps) => {
const { t } = useTranslation();
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
@@ -40,10 +42,15 @@ export const SaveAsPlaylistForm = ({
{ body: values, serverId },
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: (data) => {
toast.success({ message: `Playlist has been created` });
toast.success({
message: t('form.createPlaylist.success', { postProcess: 'sentenceCase' }),
});
onSuccess(data);
onCancel();
},
@@ -60,16 +67,25 @@ export const SaveAsPlaylistForm = ({
<TextInput
data-autofocus
required
label="Name"
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
{...form.getInputProps('comment')}
/>
{isPublicDisplayed && (
<Switch
label="Is Public?"
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
/>
)}
@@ -78,7 +94,7 @@ export const SaveAsPlaylistForm = ({
variant="subtle"
onClick={onCancel}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
@@ -86,7 +102,7 @@ export const SaveAsPlaylistForm = ({
type="submit"
variant="filled"
>
Save
{t('common.save', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>

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