Compare commits

..

68 Commits

Author SHA1 Message Date
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
Kendall Garner 9e3e038d42 [Remote] Actually fix auth (#260)
* fix favicon, basic auth

* actual fix......
2023-09-24 17:31:33 -07:00
jeffvli b375238baf Bump to v0.4.0 2023-09-24 17:23:39 -07:00
Kendall Garner 02b06a07be fix favicon, basic auth (#259) 2023-09-24 17:02:25 -07:00
Kendall Garner d7f21b3c6b special socket for dev; defer to default otherwise (#258)
* special socket for dev; defer to default otherwise

* Add write-all permissions to docker push

* special socket for dev; defer to default otherwise

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-24 16:56:45 -07:00
jeffvli 1113ef972f Remove docker push on pr 2023-09-24 16:54:43 -07:00
jeffvli 46a2c29b22 Add write-all permissions to docker push 2023-09-23 22:36:56 -07:00
jeffvli ebcb7bc4d1 Add path param for ND song list api 2023-09-23 15:37:16 -07:00
jeffvli 0b62bee3a6 Add grid view for tracks (#128) 2023-09-23 15:36:57 -07:00
jeffvli d3503af12c Add song count column to albums list 2023-09-23 04:05:15 -07:00
jeffvli 571ea3c653 Add rating hotkeys (#208) 2023-09-23 03:20:04 -07:00
jeffvli f0e518d3c8 Add quit button to menu (#184) 2023-09-22 18:04:15 -07:00
jeffvli 47dc83f360 Make collapsed sidebar navigation configurable 2023-09-22 17:55:03 -07:00
jeffvli 8cbc25a932 Add browser forward/back hotkeys (#155) 2023-09-22 17:52:00 -07:00
jeffvli 0cba405b45 Add navigation buttons to the collapsed sidebar (#203) 2023-09-22 15:33:28 -07:00
jeffvli 45b80ac395 Add discsubtitle for navidrome (#217) 2023-09-22 15:12:23 -07:00
jeffvli 8b0fe69e1c Fix alignment of button leftIcon 2023-09-22 15:11:27 -07:00
jeffvli 25e621372c Parse URLs from note field (#154) 2023-09-22 04:25:16 -07:00
jeffvli 14f4649b93 Move drag container to scrollarea component 2023-09-22 02:40:27 -07:00
jeffvli 1a87adb728 Fix transient props for styled-components v6 2023-09-22 02:34:57 -07:00
jeffvli bb9bf7ba6a Add docker run instructions 2023-09-22 00:31:56 -07:00
jeffvli fb7e7bfa3e Add latest tag 2023-09-21 21:44:03 -07:00
jeffvli a8a14a62c0 Separate auto and manual docker pushes 2023-09-21 21:13:49 -07:00
jeffvli cd836d54db Add docker publish workflow 2023-09-21 20:46:48 -07:00
jeffvli c90c43944d Fix logo path 2023-09-21 20:31:39 -07:00
jeffvli fd7468a4fe Add drag container for web library headers (#206) 2023-09-21 18:46:47 -07:00
jeffvli c4f9868a6b Revert library header line clamp to 2 lines (#215) 2023-09-21 17:52:14 -07:00
jeffvli fbb0907a70 Fix lyrics mask 2023-09-21 17:41:27 -07:00
jeffvli 201ee895f9 Allow css vendor-prefix 2023-09-21 17:41:19 -07:00
jeffvli 51be0153d3 Adjust fullscreen player styles
- Remove opacity on metadata section
- Add text shadow to metadata text
- Add padding under title
- Uppercase artists and album name
2023-09-21 17:35:22 -07:00
jeffvli 29a9a11085 Fix subsonic song duration 2023-09-21 17:35:22 -07:00
Kendall Garner 65f28bb9dc Replaygain support for Web Player (#243)
* replaygain!

* resume context

* don't fire both players

* replaygain for jellyfin

* actually remove console.log

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-21 17:06:13 -07:00
jeffvli fd264daffc Add new app icon (#232) 2023-09-21 11:24:20 -07:00
Alberto Rodríguez 18e35f2ba9 Added docker image build script (#245)
* Added docker image build script

* Changed to alpine docker and expose port 9180

* Use multi-stage build

---------

Co-authored-by: = <=>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-09-20 18:01:47 -07:00
Kendall Garner 487e9be8ec Invalidate playlist song list on update (#248) 2023-09-20 16:28:59 -07:00
Benjamin d9049ed066 Prevent MPV from loading user config/scripts (#247) 2023-09-20 16:27:36 -07:00
Kendall Garner 6e62448b88 fix other places of duration display (and other minor fixes) (#249)
* fix other places of duration display

* add back comma

* add max-width for image
2023-09-20 16:07:40 -07:00
jeffvli ec457d5125 Lint files based on updated rules 2023-09-15 20:42:38 -07:00
jeffvli d45b01625b Re-add linting for styled-components
- Update styled-components to v6
- Update stylelint to v15
- Add styled-components css plugin
2023-09-15 20:42:03 -07:00
jeffvli 2defa5cc13 Fix seek slider from duration normalizations 2023-09-15 19:31:34 -07:00
jeffvli 9cc9c3a87f Bump electron to v25.8.1 2023-09-15 16:54:17 -07:00
jeffvli 153d8ce6ce Fix nd/jf duration normalizations 2023-09-15 16:52:14 -07:00
jeffvli 5e33212112 Add dedicated refresh button to list views (#235) 2023-09-15 13:47:39 -07:00
jeffvli 7d6990eb90 Add notice regarding broken MPV version 2023-09-15 12:52:03 -07:00
jeffvli d75ea94161 Fix first launch mpv playback (#210) 2023-09-15 03:08:17 -07:00
Kendall Garner 1badecc20a always call autoNext, even if not used (#241) 2023-09-10 15:08:48 -07:00
Kendall Garner c90a56811d [bugfix]: support final lyric with no newline (#240) 2023-09-10 15:07:21 -07:00
nate contino 4e5e3bc9a1 Adjust quarantine bit warning wording to include all Macs running 12+ (#236) 2023-09-10 15:04:24 -07:00
Kendall Garner c8397bb5ef Add transparency/opacity for queue sidebar (#231)
* add opacity

* add background for song metadata

* Add padding and border radius to opacity elements

* Remove font-weight transition on active lyrics (#233)

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-10 15:03:46 -07:00
Alberto Rodríguez 0ae53b023c improved client detection (#229)
Co-authored-by: = <=>
2023-09-10 13:01:32 -07:00
Kendall Garner 1acfa93f1a Improve MPV initialization and restore (#222)
- set mpv settings only after it has successfully started (at least on linux, settings were not taken)
- change timing of restore queue to behave properly
2023-08-24 18:28:50 -07:00
jeffvli b60ba27892 Allow reuathentication for jellyfin (#214) 2023-08-24 18:17:20 -07:00
jeffvli 7ddba8ede7 Fix JF song filter import (#223) 2023-08-24 18:10:58 -07:00
jeffvli a8bd53b757 Adjust jellyfin playlist fetch 2023-08-24 18:04:01 -07:00
jeffvli 877b2e9f3b Fix normalized album duration values (#205) 2023-08-11 21:08:13 -07:00
jeffvli 663893dccb Fix missing related artist images 2023-08-09 21:30:27 -07:00
159 changed files with 5747 additions and 7850 deletions
+45 -40
View File
@@ -7,53 +7,58 @@ import { dependencies as externals } from '../../release/app/package.json';
import webpackPaths from './webpack.paths';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default;
const styledComponentsTransformer = createStyledComponentsTransformer();
const configuration: webpack.Configuration = {
externals: [...Object.keys(externals || {})],
externals: [...Object.keys(externals || {})],
module: {
rules: [
{
exclude: /node_modules/,
test: /\.[jt]sx?$/,
use: {
loader: 'ts-loader',
options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true,
},
module: {
rules: [
{
exclude: /node_modules/,
test: /\.[jt]sx?$/,
use: {
loader: 'ts-loader',
options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true,
getCustomTransformers: () => ({ before: [styledComponentsTransformer] }),
},
},
},
],
},
output: {
// https://github.com/webpack/webpack/issues/1114
library: {
type: 'commonjs2',
},
},
path: webpackPaths.srcPath,
},
plugins: [
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
}),
],
},
output: {
// https://github.com/webpack/webpack/issues/1114
library: {
type: 'commonjs2',
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
fallback: {
child_process: false,
},
plugins: [new TsconfigPathsPlugin({ baseUrl: webpackPaths.srcPath })],
modules: [webpackPaths.srcPath, 'node_modules'],
},
path: webpackPaths.srcPath,
},
plugins: [
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
}),
],
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
fallback: {
child_process: false,
},
plugins: [new TsconfigPathsPlugin({ baseUrl: webpackPaths.srcPath })],
modules: [webpackPaths.srcPath, 'node_modules'],
},
stats: 'errors-only',
stats: 'errors-only',
};
export default configuration;
@@ -96,6 +96,7 @@ const configuration: webpack.Configuration = {
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,
@@ -117,6 +117,7 @@ const configuration: webpack.Configuration = {
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,
+113 -112
View File
@@ -13,131 +13,132 @@ import webpackPaths from './webpack.paths';
// 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', 'electron-renderer'],
target: ['web', 'electron-renderer'],
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
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',
},
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
},
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 ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
historyApiFallback: {
verbose: true,
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
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',
},
],
},
setupMiddlewares(middlewares) {
return middlewares;
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 ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('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,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
return middlewares;
},
},
},
};
export default merge(baseConfig, configuration);
+1
View File
@@ -120,6 +120,7 @@ const configuration: webpack.Configuration = {
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,
+19 -17
View File
@@ -5,6 +5,7 @@ const rootPath = path.join(__dirname, '../..');
const dllPath = path.join(__dirname, '../dll');
const srcPath = path.join(rootPath, 'src');
const assetsPath = path.join(rootPath, 'assets');
const srcMainPath = path.join(srcPath, 'main');
const srcRemotePath = path.join(srcPath, 'remote');
const srcRendererPath = path.join(srcPath, 'renderer');
@@ -24,21 +25,22 @@ const distWebPath = path.join(distPath, 'web');
const buildPath = path.join(releasePath, 'build');
export default {
rootPath,
dllPath,
srcPath,
srcMainPath,
srcRemotePath,
srcRendererPath,
releasePath,
appPath,
appPackagePath,
appNodeModulesPath,
srcNodeModulesPath,
distPath,
distMainPath,
distRemotePath,
distRendererPath,
distWebPath,
buildPath,
assetsPath,
rootPath,
dllPath,
srcPath,
srcMainPath,
srcRemotePath,
srcRendererPath,
releasePath,
appPath,
appPackagePath,
appNodeModulesPath,
srcNodeModulesPath,
distPath,
distMainPath,
distRemotePath,
distRendererPath,
distWebPath,
buildPath,
};
+46
View File
@@ -0,0 +1,46 @@
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
name: Publish Docker to GHCR
permissions: write-all
on:
push:
tags:
- 'v*.*.*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+38
View File
@@ -0,0 +1,38 @@
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
name: Publish Docker to GHCR (Manual)
on: workflow_dispatch
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+6 -16
View File
@@ -1,27 +1,17 @@
{
"customSyntax": "postcss-styled-syntax",
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-css-modules",
"stylelint-config-rational-order"
"stylelint-config-standard",
"stylelint-config-styled-components",
"stylelint-config-recess-order"
],
"rules": {
"indentation": 4,
"color-function-notation": ["legacy"],
"declaration-empty-line-before": null,
"order/properties-order": [],
"plugin/rational-order": [
true,
{
"border-in-box-model": false,
"empty-line-between-groups": false
}
],
"string-quotes": "single",
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
"selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }],
"value-keyword-case": ["lower", { "ignoreKeywords": ["dummyValue"] }],
"declaration-colon-newline-after": null
"declaration-colon-newline-after": null,
"property-no-vendor-prefix": null
}
}
+1 -1
View File
@@ -38,7 +38,7 @@
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"],
"typescript.tsdk": "node_modules\\typescript\\lib",
"typescript.preferences.importModuleSpecifier": "non-relative",
"stylelint.validate": ["css", "less", "postcss", "scss"],
"stylelint.validate": ["css", "scss", "typescript", "typescriptreact"],
"typescript.updateImportsOnFileMove.enabled": "always",
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
+17
View File
@@ -0,0 +1,17 @@
# --- Builder stage
FROM node:18-alpine as builder
WORKDIR /app
COPY . /app
# Scripts include electron-specific dependencies, which we don't need
RUN npm install --legacy-peer-deps --ignore-scripts
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
EXPOSE 9180
CMD ["nginx", "-g", "daemon off;"]
+37 -14
View File
@@ -1,3 +1,5 @@
<img src="assets/icons/icon.png" alt="logo" title="feishin" align="right" height="60px" />
# Feishin
<p align="center">
@@ -29,13 +31,13 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
## Features
- [x] MPV player backend
- [x] Web player backend
- [x] Modern UI
- [x] Scrobble playback to your server
- [x] Smart playlist editor (Navidrome)
- [x] Synchronized and unsynchronized lyrics support
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
- [x] MPV player backend
- [x] Web player backend
- [x] Modern UI
- [x] Scrobble playback to your server
- [x] Smart playlist editor (Navidrome)
- [x] Synchronized and unsynchronized lyrics support
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
## Screenshots
@@ -43,9 +45,26 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
## Getting Started
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
### Desktop (recommended)
If you're using an M1 macOS device, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
### Web and Docker
Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.
Feishin is also available as a Docker image. The images are hosted via `ghcr.io` and are available to view [here](https://github.com/jeffvli/feishin/pkgs/container/feishin). You can run the container using the following commands:
```bash
# Run the latest version
docker run --name feishin --port 9180:9180 ghcr.io/jeffvli/feishin:latest
# Build the image locally
docker build -t feishin .
docker run --name feishin --port 9180:9180 feishin
```
### Configuration
@@ -53,18 +72,22 @@ If you're using an M1 macOS device, [check here](https://github.com/jeffvli/feis
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
- **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).
- **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).
## FAQ
### MPV is either not working or is rapidly switching between pause/play states
First thing to do is check that your MPV binary path is correct. Navigate to the settings page and re-set the path and restart the app. If your issue still isn't resolved, try reinstalling MPV. Known working versions include `v0.35.x` and `v0.36.x`. `v0.34.x` is a known broken version.
### What music servers does Feishin support?
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
- [Navidrome](https://github.com/navidrome/navidrome)
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- [Funkwhale](https://funkwhale.audio/) - TBD
- Subsonic-compatible servers - TBD
- [Navidrome](https://github.com/navidrome/navidrome)
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- [Funkwhale](https://funkwhale.audio/) - TBD
- Subsonic-compatible servers - TBD
## Development
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+20
View File
@@ -0,0 +1,20 @@
server {
listen 9180;
sendfile on;
default_type application/octet-stream;
gzip on;
gzip_http_version 1.1;
gzip_disable "MSIE [1-6]\.";
gzip_min_length 256;
gzip_vary on;
gzip_proxied expired no-cache no-store private auth;
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 / {
try_files $uri $uri/ /index.html =404;
}
}
+3763 -7135
View File
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -2,17 +2,18 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.3.0",
"version": "0.4.1",
"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",
"build:remote": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"build:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts",
"build:docker": "npm run build:web && docker build -t jeffvli/feishin .",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx",
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
@@ -55,7 +56,7 @@
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "25.3.0",
"electronVersion": "25.8.1",
"mac": {
"target": {
"target": "default",
@@ -64,6 +65,7 @@
"x64"
]
},
"icon": "assets/icons/icon.icns",
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
@@ -88,14 +90,15 @@
"target": [
"nsis",
"zip"
]
],
"icon": "assets/icons/icon.ico"
},
"linux": {
"target": [
"AppImage",
"tar.xz"
],
"icon": "assets/icons/placeholder.png",
"icon": "assets/icons/icon.png",
"category": "Development"
},
"directories": {
@@ -194,7 +197,7 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^25.3.0",
"electron": "^25.8.1",
"electron-builder": "^24.6.3",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
@@ -222,6 +225,7 @@
"lint-staged": "^12.3.7",
"mini-css-extract-plugin": "^2.6.0",
"postcss-scss": "^4.0.4",
"postcss-styled-syntax": "^0.5.0",
"postcss-syntax": "^0.36.2",
"prettier": "^2.6.2",
"react-refresh": "^0.12.0",
@@ -231,16 +235,19 @@
"sass": "^1.49.11",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"stylelint": "^14.9.1",
"stylelint": "^15.10.3",
"stylelint-config-css-modules": "^4.3.0",
"stylelint-config-rational-order": "^0.1.2",
"stylelint-config-recess-order": "^4.3.0",
"stylelint-config-standard": "^34.0.0",
"stylelint-config-standard-scss": "^4.0.0",
"stylelint-config-styled-components": "^0.1.1",
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.8.4",
"typescript": "^5.2.2",
"typescript-plugin-styled-components": "^3.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.71.0",
"webpack-bundle-analyzer": "^4.5.0",
@@ -298,19 +305,19 @@
"react-i18next": "^11.16.7",
"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",
"react-window-infinite-loader": "^1.0.9",
"styled-components": "^5.3.11",
"styled-components": "^6.0.8",
"swiper": "^9.3.1",
"zod": "^3.21.4",
"zustand": "^4.3.9"
},
"resolutions": {
"styled-components": "^5"
"styled-components": "^6"
},
"devEngines": {
"node": ">=14.x",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.3.0",
"version": "0.4.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.3.0",
"version": "0.4.1",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.3.0",
"version": "0.4.1",
"description": "",
"main": "./dist/main/main.js",
"author": {
+1 -1
View File
@@ -1,12 +1,12 @@
import axios, { AxiosResponse } from 'axios';
import { load } from 'cheerio';
import { orderSearchResults } from './shared';
import {
LyricSource,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '../../../../renderer/api/types';
import { orderSearchResults } from './shared';
const SEARCH_URL = 'https://genius.com/api/search/song';
+1 -1
View File
@@ -1,12 +1,12 @@
// Credits to https://github.com/tranxuanthang/lrcget for API implementation
import axios, { AxiosResponse } from 'axios';
import { orderSearchResults } from './shared';
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
} from '../../../../renderer/api/types';
import { orderSearchResults } from './shared';
const FETCH_URL = 'https://lrclib.net/api/get';
const SEEARCH_URL = 'https://lrclib.net/api/search';
+23 -38
View File
@@ -1,17 +1,17 @@
import console from 'console';
import { ipcMain } from 'electron';
import { getMainWindow, getMpvInstance } from '../../../main';
import { getMpvInstance } from '../../../main';
import { PlayerData } from '/@/renderer/store';
declare module 'node-mpv';
function wait(timeout: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve('resolved');
}, timeout);
});
}
// function wait(timeout: number) {
// return new Promise((resolve) => {
// setTimeout(() => {
// resolve('resolved');
// }, timeout);
// });
// }
ipcMain.handle('player-is-running', async () => {
return getMpvInstance()?.isRunning();
@@ -101,6 +101,7 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
.catch((err) => {
console.log('MPV failed to clear playlist', err);
});
await getMpvInstance()
?.pause()
.catch((err) => {
@@ -109,42 +110,25 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
return;
}
let complete = false;
let tryAttempts = 0;
try {
if (data.queue.current) {
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
.catch((err) => {
console.log('MPV failed to load song', err);
getMpvInstance()?.play();
});
while (!complete) {
if (tryAttempts > 3) {
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
complete = true;
} else {
try {
if (data.queue.current) {
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
.catch((err) => {
console.log('MPV failed to load song', err);
});
}
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
}
complete = true;
} catch (err) {
console.error(err);
tryAttempts += 1;
await wait(500);
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
}
} catch (err) {
console.error(err);
}
if (pause) {
await getMpvInstance()?.pause();
getMpvInstance()?.pause();
}
});
@@ -186,6 +170,7 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
?.playlistRemove(0)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
getMpvInstance()?.pause();
});
if (data.queue.next) {
+45 -19
View File
@@ -34,6 +34,7 @@ interface MimeType {
interface StatefulWebSocket extends WebSocket {
alive: boolean;
auth: boolean;
}
let server: Server | undefined;
@@ -52,7 +53,9 @@ type SendData = ServerEvent & {
function send({ client, event, data }: SendData): void {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ data, event }));
if (client.alive && client.auth) {
client.send(JSON.stringify({ data, event }));
}
}
}
@@ -141,17 +144,9 @@ async function serveFile(
res: ServerResponse,
): Promise<void> {
const fileName = `${file}.${extension}`;
let path: string;
if (extension === 'ico') {
path = app.isPackaged
? join(process.resourcesPath, 'assets', fileName)
: join(__dirname, '../../../../../assets', fileName);
} else {
path = app.isPackaged
? join(__dirname, '../remote', fileName)
: join(__dirname, '../../../../../.erb/dll', fileName);
}
const path = app.isPackaged
? join(__dirname, '../remote', fileName)
: join(__dirname, '../../../../../.erb/dll', fileName);
let stats: Stats;
@@ -291,7 +286,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
break;
}
case '/favicon.ico': {
await serveFile(req, 'icon', 'ico', res);
await serveFile(req, 'favicon', 'ico', res);
break;
}
case '/remote.css': {
@@ -302,6 +297,12 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
await serveFile(req, 'remote', 'js', res);
break;
}
case '/credentials': {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(req.headers.authorization);
break;
}
default: {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
@@ -318,14 +319,20 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
server.listen(config.port, resolve);
wsServer = new WebSocketServer({ server });
wsServer.on('connection', (ws, req) => {
if (!authorize(req)) {
ws.close(4003);
return;
}
wsServer.on('connection', (ws) => {
let authFail: number | undefined;
ws.alive = true;
if (!settings.username && !settings.password) {
ws.auth = true;
} else {
authFail = setTimeout(() => {
if (!ws.auth) {
ws.close();
}
}, 10000) as unknown as number;
}
ws.on('error', console.error);
ws.on('message', (data) => {
@@ -333,6 +340,25 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
const json = JSON.parse(data.toString()) as ClientEvent;
const event = json.event;
if (!ws.auth) {
if (event === 'authenticate') {
const auth = json.header.split(' ')[1];
const [login, password] = Buffer.from(auth, 'base64')
.toString()
.split(':');
if (login === settings.username && password === settings.password) {
ws.auth = true;
} else {
ws.close();
}
clearTimeout(authFail);
} else {
return;
}
}
switch (event) {
case 'pause': {
getMainWindow()?.webContents.send('renderer-player-pause');
+1 -1
View File
@@ -1,5 +1,5 @@
import Store from 'electron-store';
import { ipcMain, safeStorage } from 'electron';
import Store from 'electron-store';
export const store = new Store();
+1 -1
View File
@@ -145,7 +145,7 @@ ipcMain.on('update-song', (_event, args: SongUpdate) => {
mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
'mpris:trackid': song.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '',
+27 -14
View File
@@ -11,11 +11,6 @@
import { access, constants, readFile, writeFile } from 'fs';
import path, { join } from 'path';
import { deflate, inflate } from 'zlib';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log';
import { autoUpdater } from 'electron-updater';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import {
app,
BrowserWindow,
@@ -27,6 +22,11 @@ import {
nativeImage,
BrowserWindowConstructorOptions,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log';
import { autoUpdater } from 'electron-updater';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { store } from './features/core/settings/index';
import MenuBuilder from './menu';
@@ -129,7 +129,9 @@ const createTray = () => {
return;
}
tray = isLinux() ? new Tray(getAssetPath('icon.png')) : new Tray(getAssetPath('icon.ico'));
tray = isLinux()
? new Tray(getAssetPath('icons/icon.png'))
: new Tray(getAssetPath('icons/icon.ico'));
const contextMenu = Menu.buildFromTemplate([
{
click: () => {
@@ -212,7 +214,7 @@ const createWindow = async () => {
autoHideMenuBar: true,
frame: false,
height: 900,
icon: getAssetPath('icon.png'),
icon: getAssetPath('icons/icon.png'),
minHeight: 640,
minWidth: 480,
show: false,
@@ -257,6 +259,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) {
@@ -426,7 +433,7 @@ const prefetchPlaylistParams = [
];
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
const parameters = ['--idle=yes'];
const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
parameters.push('--prefetch-playlist=yes');
@@ -443,22 +450,28 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record<strin
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
console.log('Setting mpv params: ', params);
const extra = isDevelopment ? '-dev' : '';
const mpv = new MpvAPI(
{
audio_only: true,
auto_restart: false,
binary: MPV_BINARY_PATH || '',
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
time_update: 1,
},
params,
);
console.log('Setting MPV properties: ', properties);
mpv.setMultipleProperties(properties || {});
mpv.start().catch((error) => {
console.log('MPV failed to start', error);
});
// eslint-disable-next-line promise/catch-or-return
mpv.start()
.catch((error) => {
console.log('MPV failed to start', error);
})
.finally(() => {
console.log('Setting MPV properties: ', properties);
mpv.setMultipleProperties(properties || {});
});
mpv.on('status', (status, ...rest) => {
console.log('MPV Event: status', status.property, status.value, rest);
+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,
};
+1 -1
View File
@@ -1,5 +1,5 @@
import Store from 'electron-store';
import { ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store';
const store = new Store();
@@ -1,13 +1,13 @@
import { Ref, forwardRef } from 'react';
import { MouseEvent, ReactNode, Ref, forwardRef } from 'react';
import { Button, type ButtonProps as MantineButtonProps } from '@mantine/core';
import { Tooltip } from '/@/renderer/components/tooltip';
import styled from 'styled-components';
interface StyledButtonProps extends MantineButtonProps {
$active?: boolean;
children: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children: ReactNode;
onClick?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
ref: Ref<HTMLButtonElement>;
}
+1 -1
View File
@@ -45,7 +45,7 @@ export const RemoteContainer = () => {
<Title order={2}>Artist: {song.artistName}</Title>
</Group>
<Group position="apart">
<Title order={3}>Duration: {formatDuration(song.duration * 1000)}</Title>
<Title order={3}>Duration: {formatDuration(song.duration)}</Title>
{song.releaseDate && (
<Title order={3}>
Released: {new Date(song.releaseDate).toLocaleDateString()}
-1
View File
@@ -26,7 +26,6 @@ export const Shell = () => {
<Grid.Col span="auto">
<div>
<Image
bg="rgb(25, 25, 25)"
fit="contain"
height={60}
src="/favicon.ico"
+7 -7
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, ReactNode } from 'react';
import { SliderProps } from '@mantine/core';
import styled from 'styled-components';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
@@ -7,10 +7,10 @@ const SliderContainer = styled.div`
display: flex;
width: 95%;
height: 20px;
margin: 10px 0px;
margin: 10px 0;
`;
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>`
display: flex;
flex: 1;
align-self: flex-end;
@@ -26,9 +26,9 @@ const SliderWrapper = styled.div`
`;
export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
leftLabel?: JSX.Element;
leftLabel?: ReactNode;
onChangeEnd: (value: number) => void;
rightLabel?: JSX.Element;
rightLabel?: ReactNode;
value: number;
}
@@ -38,7 +38,7 @@ export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe
return (
<SliderContainer>
{leftLabel && <SliderValueWrapper position="left">{leftLabel}</SliderValueWrapper>}
{leftLabel && <SliderValueWrapper $position="left">{leftLabel}</SliderValueWrapper>}
<SliderWrapper>
<PlayerbarSlider
{...props}
@@ -56,7 +56,7 @@ export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe
}}
/>
</SliderWrapper>
{rightLabel && <SliderValueWrapper position="right">{rightLabel}</SliderValueWrapper>}
{rightLabel && <SliderValueWrapper $position="right">{rightLabel}</SliderValueWrapper>}
</SliderContainer>
);
};
+20 -1
View File
@@ -87,7 +87,7 @@ export const useRemoteStore = create<SettingsSlice>()(
devtools(
immer((set, get) => ({
actions: {
reconnect: () => {
reconnect: async () => {
const existing = get().socket;
if (existing) {
@@ -99,6 +99,16 @@ export const useRemoteStore = create<SettingsSlice>()(
existing.close(4001);
}
}
let authHeader: string | undefined;
try {
const credentials = await fetch('/credentials');
authHeader = await credentials.text();
} catch (error) {
console.error('Failed to get credentials');
}
set((state) => {
const socket = new WebSocket(
// eslint-disable-next-line no-restricted-globals
@@ -148,6 +158,14 @@ export const useRemoteStore = create<SettingsSlice>()(
});
socket.addEventListener('open', () => {
if (authHeader) {
socket.send(
JSON.stringify({
event: 'authenticate',
header: authHeader,
}),
);
}
set({ connected: true });
});
@@ -176,6 +194,7 @@ export const useRemoteStore = create<SettingsSlice>()(
});
},
send: (data: ClientEvent) => {
console.log(data, get().socket);
get().socket?.send(JSON.stringify(data));
},
toggleIsDark: () => {
+11 -1
View File
@@ -53,4 +53,14 @@ export interface ClientVolume {
volume: number;
}
export type ClientEvent = ClientSimpleEvent | ClientFavorite | ClientRating | ClientVolume;
export interface ClientAuth {
event: 'authenticate';
header: string;
}
export type ClientEvent =
| ClientAuth
| ClientSimpleEvent
| ClientFavorite
| ClientRating
| ClientVolume;
@@ -272,6 +272,12 @@ axiosClient.interceptors.response.use(
if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer;
if (currentServer) {
useAuthStore
.getState()
.actions.updateServer(currentServer.id, { credential: undefined });
}
authenticationFailure(currentServer);
}
@@ -54,11 +54,37 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import packageJson from '../../../../package.json';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import isElectron from 'is-electron';
const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
};
function getHostname(): string {
if (isElectron()) {
return 'Desktop Client';
}
const agent = navigator.userAgent;
switch (true) {
case agent.toLowerCase().indexOf('edge') > -1:
return 'Microsoft Edge';
case agent.toLowerCase().indexOf('edg/') > -1:
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
case agent.toLowerCase().indexOf('opr') > -1:
return 'Opera';
case agent.toLowerCase().indexOf('chrome') > -1:
return 'Chrome';
case agent.toLowerCase().indexOf('trident') > -1:
return 'Internet Explorer';
case agent.toLowerCase().indexOf('firefox') > -1:
return 'Firefox';
case agent.toLowerCase().indexOf('safari') > -1:
return 'Safari';
default:
return 'PC';
}
}
const authenticate = async (
url: string,
body: {
@@ -74,7 +100,9 @@ const authenticate = async (
Username: body.username,
},
headers: {
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin", Version="${
packageJson.version
}"`,
},
});
@@ -411,7 +439,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
}
return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
items: res.body.Items.map((item) =>
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
};
@@ -529,6 +559,20 @@ 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,
@@ -537,8 +581,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
IncludeItemTypes: 'Playlist',
Limit: query.limit,
MediaTypes: 'Audio',
Recursive: true,
ParentId: playlistFolder?.Id,
SearchTerm: query.searchTerm,
SortBy: playlistListSortMap.jellyfin[query.sortBy],
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
@@ -891,7 +934,7 @@ 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]);
@@ -150,7 +150,13 @@ const normalizeSong = (
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
duration: item.RunTimeTicks / 10000000,
discSubtitle: null,
duration: item.RunTimeTicks / 10000,
gain: item.LUFS
? {
track: -18 - item.LUFS,
}
: null,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
@@ -165,6 +171,7 @@ const normalizeSong = (
lyrics: null,
name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
@@ -208,7 +215,7 @@ const normalizeAlbum = (
})),
backdropImageUrl: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000000,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
@@ -406,6 +406,7 @@ const song = z.object({
ImageTags: imageTags,
IndexNumber: z.number(),
IsFolder: z.boolean(),
LUFS: z.number().optional(),
LocationType: z.string(),
MediaSources: z.array(mediaSources),
MediaType: z.string(),
@@ -267,7 +267,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
}
return {
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
items: res.body.data.map((song) =>
ndNormalize.song(song, apiClientProps.server, '', query.imageSize),
),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
@@ -74,7 +74,6 @@ const normalizeSong = (
});
const imagePlaceholderUrl = null;
return {
album: item.album,
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
@@ -89,7 +88,12 @@ const normalizeSong = (
container: item.suffix,
createdAt: item.createdAt.split('T')[0],
discNumber: item.discNumber,
duration: item.duration,
discSubtitle: item.discSubtitle ? item.discSubtitle : null,
duration: item.duration * 1000,
gain:
item.rgAlbumGain || item.rgTrackGain
? { album: item.rgAlbumGain, track: item.rgTrackGain }
: null,
genres: item.genres?.map((genre) => ({
id: genre.id,
imageUrl: null,
@@ -104,6 +108,10 @@ const normalizeSong = (
lyrics: item.lyrics ? item.lyrics : null,
name: item.title,
path: item.path,
peak:
item.rgAlbumPeak || item.rgTrackPeak
? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
: null,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
@@ -202,7 +210,7 @@ const normalizeAlbumArtist = (
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: getImageUrl({ url: artist?.artistImageUrl || null }),
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.songCount,
@@ -181,22 +181,30 @@ const song = z.object({
bitRate: z.number(),
bookmarkPosition: z.number(),
bpm: z.number().optional(),
catalogNum: z.string().optional(),
channels: z.number().optional(),
comment: z.string().optional(),
compilation: z.boolean(),
createdAt: z.string(),
discNumber: z.number(),
discSubtitle: z.string().optional(),
duration: z.number(),
embedArtPath: z.string().optional(),
externalInfoUpdatedAt: z.string().optional(),
externalUrl: z.string().optional(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
hasCoverArt: z.boolean(),
id: z.string(),
imageFiles: z.string().optional(),
largeImageUrl: z.string().optional(),
lyrics: z.string().optional(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
mbzArtistId: z.string().optional(),
mbzTrackId: z.string().optional(),
mediumImageUrl: z.string().optional(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
orderArtistName: z.string(),
@@ -205,7 +213,12 @@ const song = z.object({
playCount: z.number(),
playDate: z.string(),
rating: z.number().optional(),
rgAlbumGain: z.number().optional(),
rgAlbumPeak: z.number().optional(),
rgTrackGain: z.number().optional(),
rgTrackPeak: z.number().optional(),
size: z.number(),
smallImageUrl: z.string().optional(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
@@ -246,6 +259,7 @@ const songListParameters = paginationParameters.extend({
album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(),
genre_id: z.string().optional(),
path: z.string().optional(),
starred: z.boolean().optional(),
title: z.string().optional(),
year: z.number().optional(),
@@ -67,7 +67,9 @@ const normalizeSong = (
container: item.contentType,
createdAt: item.created,
discNumber: item.discNumber || 1,
duration: item.duration || 0,
discSubtitle: null,
duration: item.duration ? item.duration * 1000 : 0,
gain: null,
genres: item.genre
? [
{
@@ -86,6 +88,7 @@ const normalizeSong = (
lyrics: null,
name: item.title,
path: item.path,
peak: null,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
+9
View File
@@ -171,6 +171,11 @@ export type Album = {
userRating: number | null;
} & { songs?: Song[] };
export type GainInfo = {
album?: number;
track?: number;
};
export type Song = {
album: string | null;
albumArtists: RelatedArtist[];
@@ -185,7 +190,9 @@ export type Song = {
container: string | null;
createdAt: string;
discNumber: number;
discSubtitle: string | null;
duration: number;
gain: GainInfo | null;
genres: Genre[];
id: string;
imagePlaceholderUrl: string | null;
@@ -195,6 +202,7 @@ export type Song = {
lyrics: string | null;
name: string;
path: string | null;
peak: GainInfo | null;
playCount: number;
playlistItemId?: string;
releaseDate: string | null;
@@ -473,6 +481,7 @@ export type SongListQuery = {
};
albumIds?: string[];
artistIds?: string[];
imageSize?: number;
limit?: number;
musicFolderId?: string;
searchTerm?: string;
+1 -2
View File
@@ -73,6 +73,7 @@ export const App = () => {
mpvPlayer?.volume(properties.volume);
}
mpvPlayer?.restoreQueue();
};
if (isElectron() && playbackType === PlaybackType.LOCAL) {
@@ -94,8 +95,6 @@ export const App = () => {
useEffect(() => {
if (isElectron()) {
mpvPlayer!.restoreQueue();
mpvPlayerListener!.rendererSaveQueue(() => {
const { current, queue } = usePlayerStore.getState();
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
+150 -3
View File
@@ -1,7 +1,7 @@
import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react';
import isElectron from 'is-electron';
import type { ReactPlayerProps } from 'react-player';
import ReactPlayer from 'react-player';
import ReactPlayer from 'react-player/lazy';
import type { Song } from '/@/renderer/api/types';
import {
crossfadeHandler,
@@ -33,6 +33,11 @@ const getDuration = (ref: any) => {
return ref.current?.player?.player?.player?.duration;
};
type WebAudio = {
context: AudioContext;
gain: GainNode;
};
export const AudioPlayer = forwardRef(
(
{
@@ -49,10 +54,86 @@ export const AudioPlayer = forwardRef(
}: AudioPlayerProps,
ref: any,
) => {
const player1Ref = useRef<any>(null);
const player2Ref = useRef<any>(null);
const player1Ref = useRef<ReactPlayer>(null);
const player2Ref = useRef<ReactPlayer>(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
const playback = useSettingsStore((state) => state.playback.mpvProperties);
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
null,
);
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(
null,
);
const calculateReplayGain = useCallback(
(song: Song): number => {
if (playback.replayGainMode === 'no') {
return 1;
}
let gain: number | undefined;
let peak: number | undefined;
if (playback.replayGainMode === 'track') {
gain = song.gain?.track ?? song.gain?.album;
peak = song.peak?.track ?? song.peak?.album;
} else {
gain = song.gain?.album ?? song.gain?.track;
peak = song.peak?.album ?? song.peak?.track;
}
if (gain === undefined) {
gain = playback.replayGainFallbackDB;
if (!gain) {
return 1;
}
}
if (peak === undefined) {
peak = 1;
}
const preAmp = playback.replayGainPreampDB ?? 0;
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification&section=19
// Normalized to max gain
const expectedGain = 10 ** ((gain + preAmp) / 20);
if (playback.replayGainClip) {
return Math.min(expectedGain, 1 / peak);
}
return expectedGain;
},
[
playback.replayGainClip,
playback.replayGainFallbackDB,
playback.replayGainMode,
playback.replayGainPreampDB,
],
);
useEffect(() => {
if ('AudioContext' in window) {
const context = new AudioContext({
latencyHint: 'playback',
sampleRate: playback.audioSampleRateHz || undefined,
});
const gain = context.createGain();
gain.connect(context.destination);
setWebAudio({ context, gain });
return () => {
return context.close();
};
}
return () => {};
// Intentionally ignore the sample rate dependency, as it makes things really messy
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useImperativeHandle(ref, () => ({
get player1() {
@@ -159,10 +240,71 @@ export const AudioPlayer = forwardRef(
}
}, [audioDeviceId]);
useEffect(() => {
if (webAudio && player1Source) {
if (player1 === undefined) {
player1Source.disconnect();
setPlayer1Source(null);
} else if (currentPlayer === 1) {
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player1), 0);
}
}
}, [calculateReplayGain, currentPlayer, player1, player1Source, webAudio]);
useEffect(() => {
if (webAudio && player2Source) {
if (player2 === undefined) {
player2Source.disconnect();
setPlayer2Source(null);
} else if (currentPlayer === 2) {
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player2), 0);
}
}
}, [calculateReplayGain, currentPlayer, player2, player2Source, webAudio]);
const handlePlayer1Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio || player1Source) return;
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
}
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
if (internal) {
const { context, gain } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gain);
setPlayer1Source(source);
}
},
[player1Source, webAudio],
);
const handlePlayer2Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio || player2Source) return;
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
}
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
if (internal) {
const { context, gain } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gain);
setPlayer2Source(source);
}
},
[player2Source, webAudio],
);
return (
<>
<ReactPlayer
ref={player1Ref}
config={{
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
}}
height={0}
muted={muted}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
@@ -174,9 +316,13 @@ export const AudioPlayer = forwardRef(
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
}
onReady={handlePlayer1Start}
/>
<ReactPlayer
ref={player2Ref}
config={{
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
}}
height={0}
muted={muted}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
@@ -188,6 +334,7 @@ export const AudioPlayer = forwardRef(
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
}
onReady={handlePlayer2Start}
/>
</>
);
+1 -2
View File
@@ -27,8 +27,8 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
svg {
transition: fill 0.2s ease-in-out;
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
transition: fill 0.2s ease-in-out;
}
&:disabled {
@@ -65,7 +65,6 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
display: flex;
height: 100%;
margin-right: 0.5rem;
transform: translateY(-0.1rem);
}
.mantine-Button-rightIcon {
+6 -6
View File
@@ -14,9 +14,9 @@ const CardWrapper = styled.div<{
link?: boolean;
}>`
padding: 1rem;
cursor: ${({ link }) => link && 'pointer'};
background: var(--card-default-bg);
border-radius: var(--card-default-radius);
cursor: ${({ link }) => link && 'pointer'};
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
&:hover {
@@ -61,17 +61,17 @@ const ImageSection = styled.div`
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
}
`;
const Image = styled(SimpleImg)`
border-radius: var(--card-default-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 20%);
`;
const ControlsContainer = styled.div`
@@ -95,8 +95,8 @@ const Row = styled.div<{ $secondary?: boolean }>`
padding: 0 0.2rem;
overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
`;
@@ -23,7 +23,7 @@ const PlayButton = styled.button<PlayButtonType>`
justify-content: center;
width: 50px;
height: 50px;
background-color: rgb(255, 255, 255);
background-color: rgb(255 255 255);
border: none;
border-radius: 50%;
opacity: 0.8;
@@ -41,8 +41,8 @@ const PlayButton = styled.button<PlayButtonType>`
}
svg {
fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0);
fill: rgb(0 0 0);
stroke: rgb(0 0 0);
}
`;
+56 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Album, AlbumArtist, Artist, Playlist } from '/@/renderer/api/types';
import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
import { AppRoute } from '/@/renderer/router/routes';
import { CardRow } from '/@/renderer/types';
@@ -14,8 +14,8 @@ const Row = styled.div<{ $secondary?: boolean }>`
padding: 0 0.2rem;
overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
`;
@@ -183,6 +183,60 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
},
};
export const SONG_CARD_ROWS: { [key: string]: CardRow<Song> } = {
album: {
property: 'album',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'albumId', slugProperty: 'albumId' }],
},
},
albumArtists: {
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
artists: {
arrayProperty: 'name',
property: 'artists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
createdAt: {
property: 'createdAt',
},
duration: {
property: 'duration',
},
lastPlayedAt: {
property: 'lastPlayedAt',
},
name: {
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'albumId', slugProperty: 'albumId' }],
},
},
playCount: {
property: 'playCount',
},
rating: {
property: 'userRating',
},
releaseDate: {
property: 'releaseDate',
},
releaseYear: {
property: 'releaseYear',
},
};
export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
albumCount: {
property: 'albumCount',
+4 -4
View File
@@ -33,8 +33,8 @@ const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
width: 100%;
height: 100%;
overflow: hidden;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
.card-controls {
opacity: 0;
@@ -57,11 +57,11 @@ const ImageContainerStyles = css`
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
}
&:hover {
@@ -1,4 +1,4 @@
import { forwardRef, ReactNode, Ref } from 'react';
import { ComponentPropsWithoutRef, forwardRef, ReactNode, Ref } from 'react';
import { Box, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
import { motion, Variants } from 'framer-motion';
import styled from 'styled-components';
@@ -20,7 +20,7 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
max-width: ${({ maxWidth }) => maxWidth}px;
background: var(--dropdown-menu-bg);
border-radius: var(--dropdown-menu-border-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
button:first-child {
border-top-left-radius: var(--dropdown-menu-border-radius);
@@ -35,13 +35,13 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
export const StyledContextMenuButton = styled(UnstyledButton)`
padding: var(--dropdown-menu-item-padding);
color: var(--dropdown-menu-fg);
font-weight: 500;
font-family: var(--content-font-family);
font-weight: 500;
color: var(--dropdown-menu-fg);
text-align: left;
cursor: default;
background: var(--dropdown-menu-bg);
border: none;
cursor: default;
& .mantine-Button-inner {
justify-content: flex-start;
@@ -65,7 +65,7 @@ export const ContextMenuButton = forwardRef(
leftIcon,
...props
}: UnstyledButtonProps &
React.ComponentPropsWithoutRef<'button'> & {
ComponentPropsWithoutRef<'button'> & {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
},
+1 -1
View File
@@ -5,7 +5,7 @@ import styled from 'styled-components';
const StyledDialog = styled(MantineDialog)`
&.mantine-Dialog-root {
background-color: var(--modal-bg);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
}
`;
@@ -30,8 +30,10 @@ const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
position: relative;
padding: var(--dropdown-menu-item-padding);
font-size: var(--dropdown-menu-item-font-size);
font-family: var(--content-font-family);
font-size: var(--dropdown-menu-item-font-size);
cursor: default;
&:disabled {
opacity: 0.6;
@@ -50,17 +52,15 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
& .mantine-Menu-itemRightSection {
display: flex;
}
cursor: default;
`;
const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
margin: 0;
padding: 0;
margin: 0;
background: var(--dropdown-menu-bg);
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
border: var(--dropdown-menu-border);
border-radius: var(--dropdown-menu-border-radius);
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
/* *:first-child {
border-top-left-radius: var(--dropdown-menu-border-radius);
@@ -74,8 +74,8 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
`;
const StyledMenuDivider = styled(MantineMenu.Divider)`
margin: 0;
padding: 0;
margin: 0;
`;
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
@@ -20,16 +20,16 @@ const Carousel = styled(motion.div)`
min-height: 250px;
padding: 2rem;
overflow: hidden;
background: linear-gradient(180deg, var(--main-bg), rgba(25, 26, 28, 60%));
background: linear-gradient(180deg, var(--main-bg), rgb(25 26 28 / 60%));
border-radius: 1rem;
`;
const Grid = styled.div`
display: grid;
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: 200px minmax(0, 1fr);
grid-auto-columns: 1fr;
width: 100%;
max-width: 100%;
height: 100%;
@@ -59,10 +59,10 @@ const BackgroundImage = styled.img`
z-index: 0;
width: 150%;
height: 150%;
user-select: none;
filter: blur(24px);
object-fit: cover;
object-position: 0 30%;
filter: blur(24px);
user-select: none;
`;
const BackgroundImageOverlay = styled.div`
@@ -72,7 +72,7 @@ const BackgroundImageOverlay = styled.div`
z-index: 10;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(25, 26, 28, 30%), var(--main-bg));
background: linear-gradient(180deg, rgb(25 26 28 / 30%), var(--main-bg));
`;
const Wrapper = styled(Link)`
+39 -37
View File
@@ -1,19 +1,19 @@
import { Flex, FlexProps } from '@mantine/core';
import { AnimatePresence, motion, Variants } from 'framer-motion';
import { useRef } from 'react';
import { ReactNode, useRef } from 'react';
import styled from 'styled-components';
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
const Container = styled(motion(Flex))<{
height?: string;
position?: string;
$height?: string;
$position?: string;
}>`
position: ${(props) => props.position || 'relative'};
position: ${(props) => props.$position || 'relative'};
z-index: 200;
width: 100%;
height: ${(props) => props.height || '65px'};
height: ${(props) => props.$height || '65px'};
background: var(--titlebar-bg);
`;
@@ -27,8 +27,8 @@ const Header = styled(motion.div)<{
width: 100%;
height: 100%;
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
-webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
button {
@@ -40,13 +40,13 @@ const Header = styled(motion.div)<{
}
`;
const BackgroundImage = styled.div<{ background: string }>`
const BackgroundImage = styled.div<{ $background: string }>`
position: absolute;
top: 0;
z-index: 1;
width: 100%;
height: 100%;
background: ${(props) => props.background || 'var(--titlebar-bg)'};
background: ${(props) => props.$background || 'var(--titlebar-bg)'};
`;
const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
@@ -66,7 +66,7 @@ export interface PageHeaderProps
extends Omit<FlexProps, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
animated?: boolean;
backgroundColor?: string;
children?: React.ReactNode;
children?: ReactNode;
height?: string;
isHidden?: boolean;
position?: string;
@@ -106,34 +106,36 @@ export const PageHeader = ({
const theme = useTheme();
return (
<Container
ref={ref}
height={height}
position={position}
{...props}
>
<Header
$isDraggable={windowBarStyle === Platform.WEB}
$isHidden={isHidden}
$padRight={padRight}
<>
<Container
ref={ref}
$height={height}
$position={position}
{...props}
>
<AnimatePresence initial={animated ?? false}>
<TitleWrapper
animate="animate"
exit="exit"
initial="initial"
variants={variants}
>
{children}
</TitleWrapper>
</AnimatePresence>
</Header>
{backgroundColor && (
<>
<BackgroundImage background={backgroundColor || 'var(--titlebar-bg)'} />
<BackgroundImageOverlay theme={theme} />
</>
)}
</Container>
<Header
$isDraggable={windowBarStyle === Platform.WEB}
$isHidden={isHidden}
$padRight={padRight}
>
<AnimatePresence initial={animated ?? false}>
<TitleWrapper
animate="animate"
exit="exit"
initial="initial"
variants={variants}
>
{children}
</TitleWrapper>
</AnimatePresence>
</Header>
{backgroundColor && (
<>
<BackgroundImage $background={backgroundColor || 'var(--titlebar-bg)'} />
<BackgroundImageOverlay theme={theme as 'light' | 'dark'} />
</>
)}
</Container>
</>
);
};
+2 -1
View File
@@ -1,9 +1,10 @@
import { ReactNode } from 'react';
import type { PaperProps as MantinePaperProps } from '@mantine/core';
import { Paper as MantinePaper } from '@mantine/core';
import styled from 'styled-components';
export interface PaperProps extends MantinePaperProps {
children: React.ReactNode;
children: ReactNode;
}
const StyledPaper = styled(MantinePaper)<PaperProps>`
+1 -1
View File
@@ -12,8 +12,8 @@ const StyledPopover = styled(MantinePopover)``;
const StyledDropdown = styled(MantinePopover.Dropdown)<PopoverDropdownProps>`
padding: 0.5rem;
font-size: 0.9em;
font-family: var(--content-font-family);
font-size: 0.9em;
background-color: var(--dropdown-menu-bg);
border: var(--dropdown-menu-border);
`;
+25 -3
View File
@@ -9,6 +9,24 @@ import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header'
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
const DragContainer = styled.div`
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: calc(100% - 130px);
height: 65px;
-webkit-app-region: drag;
button {
-webkit-app-region: no-drag;
}
input {
-webkit-app-region: no-drag;
}
`;
interface ScrollAreaProps extends MantineScrollAreaProps {
children: ReactNode;
}
@@ -29,7 +47,10 @@ const StyledScrollArea = styled(MantineScrollArea)`
}
`;
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>`
const StyledNativeScrollArea = styled.div<{
$scrollBarOffset?: string;
$windowBarStyle?: Platform;
}>`
height: 100%;
`;
@@ -125,6 +146,7 @@ export const NativeScrollArea = forwardRef(
return (
<>
{windowBarStyle === Platform.WEB && <DragContainer />}
{shouldShowHeader && (
<PageHeader
animated
@@ -135,8 +157,8 @@ export const NativeScrollArea = forwardRef(
)}
<StyledNativeScrollArea
ref={mergedRef}
scrollBarOffset={scrollBarOffset}
windowBarStyle={windowBarStyle}
$scrollBarOffset={scrollBarOffset}
$windowBarStyle={windowBarStyle}
{...props}
>
{children}
@@ -7,8 +7,8 @@ type SegmentedControlProps = MantineSegmentedControlProps;
const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedControlProps>`
& .mantine-SegmentedControl-label {
color: var(--input-fg);
font-family: var(--content-font-family);
color: var(--input-fg);
}
background-color: var(--input-bg);
+1 -1
View File
@@ -23,8 +23,8 @@ const StyledSlider = styled(MantineSlider)`
& .mantine-Slider-label {
padding: 0 1rem;
color: var(--tooltip-fg);
font-size: 1em;
color: var(--tooltip-fg);
background: var(--tooltip-bg);
}
`;
+1 -1
View File
@@ -13,8 +13,8 @@ const StyledTabs = styled(MantineTabs)`
&.mantine-Tabs-tab {
padding: 0.5rem 1rem;
font-weight: 600;
font-size: 1rem;
font-weight: 600;
background-color: var(--main-bg);
}
+1 -2
View File
@@ -20,8 +20,8 @@ const StyledTextTitle = styled(MantineHeader)<TextTitleProps>`
overflow: ${(props) => props.overflow};
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
cursor: ${(props) => props.$link && 'cursor'};
transition: color 0.2s ease-in-out;
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
transition: color 0.2s ease-in-out;
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
&:hover {
@@ -49,7 +49,6 @@ _TextTitle.defaultProps = {
$link: false,
$noSelect: false,
$secondary: false,
font: undefined,
overflow: 'visible',
to: '',
weight: 400,
+1 -1
View File
@@ -20,8 +20,8 @@ interface TextProps extends MantineTextDivProps {
const StyledText = styled(MantineText)<TextProps>`
overflow: ${(props) => props.overflow};
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
font-family: ${(props) => props.font};
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
cursor: ${(props) => props.$link && 'cursor'};
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
@@ -38,11 +38,11 @@ const DefaultCardContainer = styled.div<{ $isHidden?: boolean; $itemGap: number
height: calc(100% - 2rem);
margin: ${({ $itemGap }) => $itemGap}px;
overflow: hidden;
pointer-events: auto;
cursor: pointer;
background: var(--card-default-bg);
border-radius: var(--card-default-radius);
cursor: pointer;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
&:hover {
background: var(--card-default-bg-hover);
@@ -89,11 +89,11 @@ const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
}
${(props) =>
props.$isFavorite &&
@@ -23,7 +23,7 @@ const PlayButton = styled.button<PlayButtonType>`
justify-content: center;
width: 50px;
height: 50px;
background-color: rgb(255, 255, 255);
background-color: rgb(255 255 255);
border: none;
border-radius: 50%;
opacity: 0.8;
@@ -41,8 +41,8 @@ const PlayButton = styled.button<PlayButtonType>`
}
svg {
fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0);
fill: rgb(0 0 0);
stroke: rgb(0 0 0);
}
`;
@@ -79,11 +79,11 @@ const FavoriteBanner = styled.div`
left: -50px;
width: 80px;
height: 80px;
background-color: var(--primary-color);
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
transform: rotate(-45deg);
content: '';
pointer-events: none;
content: '';
background-color: var(--primary-color);
box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%);
transform: rotate(-45deg);
`;
const ControlsRow = styled.div`
@@ -38,8 +38,8 @@ const PosterCardContainer = styled.div<{ $isHidden?: boolean; $itemGap: number }
height: 100%;
margin: ${({ $itemGap }) => $itemGap}px;
overflow: hidden;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
.card-controls {
opacity: 0;
@@ -66,11 +66,11 @@ const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
}
${(props) =>
@@ -5,7 +5,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi
export const ActionsCell = ({ context, api }: ICellRendererParams) => {
return (
<CellContainer position="center">
<CellContainer $position="center">
<Button
compact
variant="subtle"
@@ -11,7 +11,7 @@ import { Skeleton } from '/@/renderer/components/skeleton';
export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<CellContainer $position="left">
<Skeleton
height="1rem"
width="80%"
@@ -21,7 +21,7 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
}
return (
<CellContainer position="left">
<CellContainer $position="left">
<Text
$secondary
overflow="hidden"
@@ -11,7 +11,7 @@ import { Skeleton } from '/@/renderer/components/skeleton';
export const ArtistCell = ({ value, data }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<CellContainer $position="left">
<Skeleton
height="1rem"
width="80%"
@@ -21,7 +21,7 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
}
return (
<CellContainer position="left">
<CellContainer $position="left">
<Text
$secondary
overflow="hidden"
@@ -15,10 +15,10 @@ import { Skeleton } from '/@/renderer/components/skeleton';
const CellContainer = styled(motion.div)<{ height: number }>`
display: grid;
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: ${(props) => props.height}px minmax(0, 1fr);
grid-auto-columns: 1fr;
gap: 0.5rem;
width: 100%;
max-width: 100%;
@@ -46,7 +46,7 @@ export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
};
return (
<CellContainer position="center">
<CellContainer $position="center">
<Button
compact
sx={{
@@ -4,13 +4,13 @@ import styled from 'styled-components';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Text } from '/@/renderer/components/text';
export const CellContainer = styled.div<{ position?: 'left' | 'center' | 'right' }>`
export const CellContainer = styled.div<{ $position?: 'left' | 'center' | 'right' }>`
display: flex;
align-items: center;
justify-content: ${(props) =>
props.position === 'right'
props.$position === 'right'
? 'flex-end'
: props.position === 'center'
: props.$position === 'center'
? 'center'
: 'flex-start'};
width: 100%;
@@ -34,7 +34,7 @@ export const GenericCell = (
if (value === undefined) {
return (
<CellContainer position={position || 'left'}>
<CellContainer $position={position || 'left'}>
<Skeleton
height="1rem"
width="80%"
@@ -44,7 +44,7 @@ export const GenericCell = (
}
return (
<CellContainer position={position || 'left'}>
<CellContainer $position={position || 'left'}>
{isLink ? (
<Text
$link={isLink}
@@ -58,6 +58,7 @@ export const GenericCell = (
</Text>
) : (
<Text
$noSelect={false}
$secondary={!primary}
overflow="hidden"
size="md"
@@ -8,7 +8,7 @@ import { AppRoute } from '/@/renderer/router/routes';
export const GenreCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<CellContainer $position="left">
<Text
$secondary
overflow="hidden"
@@ -0,0 +1,47 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Skeleton } from '/@/renderer/components/skeleton';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useMemo } from 'react';
import { Text } from '/@/renderer/components/text';
const URL_REGEX =
/((?:https?:\/\/)?(?:[\w-]{1,32}(?:\.[\w-]{1,32})+)(?:\/[\w\-./?%&=][^.|^\s]*)?)/g;
const replaceURLWithHTMLLinks = (text: string) => {
const urlRegex = new RegExp(URL_REGEX, 'g');
return text.replaceAll(
urlRegex,
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
);
};
export const NoteCell = ({ value }: ICellRendererParams) => {
const formattedValue = useMemo(() => {
if (!value) {
return '';
}
return replaceURLWithHTMLLinks(value);
}, [value]);
if (value === undefined) {
return (
<CellContainer $position="left">
<Skeleton
height="1rem"
width="80%"
/>
</CellContainer>
);
}
return (
<CellContainer $position="left">
<Text
$secondary
dangerouslySetInnerHTML={{ __html: formattedValue }}
overflow="hidden"
/>
</CellContainer>
);
};
@@ -47,7 +47,7 @@ export const RatingCell = ({ value, node }: ICellRendererParams) => {
};
return (
<CellContainer position="center">
<CellContainer $position="center">
<Rating
size="xs"
value={value?.userRating}
@@ -6,7 +6,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi
export const TitleCell = ({ value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<CellContainer $position="left">
<Skeleton
height="1rem"
width="80%"
@@ -16,7 +16,7 @@ export const TitleCell = ({ value }: ICellRendererParams) => {
}
return (
<CellContainer position="left">
<CellContainer $position="left">
<Text
className="current-song-child"
overflow="hidden"
@@ -14,12 +14,12 @@ type Options = {
preset?: Presets;
};
export const HeaderWrapper = styled.div<{ position: Options['position'] }>`
export const HeaderWrapper = styled.div<{ $position: Options['position'] }>`
display: flex;
justify-content: ${(props) =>
props.position === 'right'
props.$position === 'right'
? 'flex-end'
: props.position === 'center'
: props.$position === 'center'
? 'center'
: 'flex-start'};
width: 100%;
@@ -27,16 +27,16 @@ export const HeaderWrapper = styled.div<{ position: Options['position'] }>`
text-transform: uppercase;
`;
const HeaderText = styled(_Text)<{ position: Options['position'] }>`
const HeaderText = styled(_Text)<{ $position: Options['position'] }>`
width: 100%;
height: 100%;
color: var(--ag-header-foreground-color);
font-weight: 500;
line-height: inherit;
color: var(--ag-header-foreground-color);
text-align: ${(props) =>
props.position === 'right'
props.$position === 'right'
? 'flex-end'
: props.position === 'center'
: props.$position === 'center'
? 'center'
: 'flex-start'};
text-transform: uppercase;
@@ -80,14 +80,14 @@ export const GenericTableHeader = (
{ preset, children, position }: Options,
) => {
if (preset) {
return <HeaderWrapper position={position}>{headerPresets[preset]}</HeaderWrapper>;
return <HeaderWrapper $position={position}>{headerPresets[preset]}</HeaderWrapper>;
}
return (
<HeaderWrapper position={position}>
<HeaderWrapper $position={position}>
<HeaderText
$position={position}
overflow="hidden"
position={position}
weight={500}
>
{children || displayName}
@@ -36,6 +36,7 @@ import { TablePagination } from '/@/renderer/components/virtual-table/table-pagi
import { ActionsCell } from '/@/renderer/components/virtual-table/cells/actions-cell';
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';
export * from './table-config-dropdown';
export * from './table-pagination';
@@ -53,7 +54,7 @@ const TableWrapper = styled.div`
const DummyHeader = styled.div<{ height?: number }>`
position: absolute;
height: ${({ height }) => height || 36}px};
height: ${({ height }) => height || 36}px;
`;
dayjs.extend(relativeTime);
@@ -150,7 +151,7 @@ const tableColumns: { [key: string]: ColDef } = {
width: 100,
},
comment: {
cellRenderer: GenericCell,
cellRenderer: NoteCell,
colId: TableColumn.COMMENT,
headerName: 'Note',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
@@ -189,7 +190,7 @@ const tableColumns: { [key: string]: ColDef } = {
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'duration' }),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) => formatDuration(params.value * 1000),
valueFormatter: (params: ValueFormatterParams) => formatDuration(Number(params.value)),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.duration : undefined,
width: 70,
@@ -41,6 +41,7 @@ export const ALBUM_TABLE_COLUMNS = [
{ 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 },
@@ -86,6 +87,7 @@ export const GENRE_TABLE_COLUMNS = [
];
interface TableConfigDropdownProps {
// tableRef?: MutableRefObject<AgGridReactType<any> | null>;
type: TableType;
}
@@ -1,9 +1,10 @@
import { Stack, Group } from '@mantine/core';
import { RiAlertFill } from 'react-icons/ri';
import { Text } from '/@/renderer/components';
import { ReactNode } from 'react';
interface ActionRequiredContainerProps {
children: React.ReactNode;
children: ReactNode;
title: string;
}
@@ -12,9 +12,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';
@@ -34,7 +34,11 @@ import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior, useTableSettings } from '/@/renderer/store/settings.store';
import {
usePlayButtonBehavior,
useSettingsStoreActions,
useTableSettings,
} from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
const isFullWidthRow = (node: RowNode) => {
@@ -65,18 +69,20 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const tableConfig = useTableSettings('albumDetail');
console.log('tableConfig :>> ', tableConfig);
const { setTable } = useSettingsStoreActions();
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [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) {
@@ -90,9 +96,15 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
const songsByDiscNumber = detailQuery.data?.songs.filter(
(s) => s.discNumber === discNumber,
);
const discSubtitle = songsByDiscNumber?.[0]?.discSubtitle;
const discName = [`Disc ${discNumber}`.toLocaleUpperCase(), discSubtitle]
.filter(Boolean)
.join(': ');
rowData.push({
id: `disc-${discNumber}`,
name: `Disc ${discNumber}`.toLocaleUpperCase(),
name: discName,
});
rowData.push(...songsByDiscNumber);
}
@@ -212,7 +224,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;
@@ -262,11 +274,37 @@ 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 (
<ContentContainer>
<LibraryBackgroundOverlay backgroundColor={background} />
<LibraryBackgroundOverlay $backgroundColor={background} />
<DetailContainer>
<Box component="section">
<Group
@@ -348,6 +386,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
)}
<Box style={{ minHeight: '300px' }}>
<VirtualTable
key={`table-${tableConfig.rowHeight}`}
ref={tableRef}
autoHeight
stickyHeader
@@ -356,6 +395,9 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
suppressRowDrag
autoFitColumns={tableConfig.autoFit}
columnDefs={columnDefs}
context={{
onCellContextMenu,
}}
enableCellChangeFlash={false}
fullWidthCellRenderer={FullWidthDiscCell}
getRowHeight={getRowHeight}
@@ -370,7 +412,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
rowClassRules={rowClassRules}
rowData={songsRowData}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onCellContextMenu={onCellContextMenu}
onColumnMoved={onColumnMoved}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
@@ -37,8 +37,7 @@ export const AlbumDetailHeader = forwardRef(
id: 'duration',
secondary: false,
value:
detailQuery?.data?.duration &&
formatDurationString(detailQuery.data.duration * 1000),
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
@@ -368,6 +368,16 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
<RiFilterFill size="1.3rem" />
</Button>
<Divider orientation="vertical" />
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
variant="subtle"
onClick={handleRefresh}
>
<RiRefreshLine size="1.3rem" />
</Button>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
@@ -50,7 +50,7 @@ const DetailContainer = styled.div`
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
}
`;
@@ -333,7 +333,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
return (
<ContentContainer ref={cq.ref}>
<LibraryBackgroundOverlay backgroundColor={background} />
<LibraryBackgroundOverlay $backgroundColor={background} />
<DetailContainer>
<Stack spacing="lg">
<Group spacing="md">
@@ -356,6 +356,16 @@ export const AlbumArtistListHeaderFilters = ({
</>
)}
<Divider orientation="vertical" />
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
variant="subtle"
onClick={handleRefresh}
>
<RiRefreshLine size="1.3rem" />
</Button>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
@@ -266,6 +266,16 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
</>
)}
<Divider orientation="vertical" />
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
variant="subtle"
onClick={handleRefresh}
>
<RiRefreshLine size="1.3rem" />
</Button>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button

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