Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3e038d42 | |||
| b375238baf | |||
| 02b06a07be | |||
| d7f21b3c6b | |||
| 1113ef972f | |||
| 46a2c29b22 | |||
| ebcb7bc4d1 | |||
| 0b62bee3a6 | |||
| d3503af12c | |||
| 571ea3c653 | |||
| f0e518d3c8 | |||
| 47dc83f360 | |||
| 8cbc25a932 | |||
| 0cba405b45 | |||
| 45b80ac395 | |||
| 8b0fe69e1c | |||
| 25e621372c | |||
| 14f4649b93 | |||
| 1a87adb728 | |||
| bb9bf7ba6a | |||
| fb7e7bfa3e | |||
| a8a14a62c0 | |||
| cd836d54db | |||
| c90c43944d | |||
| fd7468a4fe | |||
| c4f9868a6b | |||
| fbb0907a70 | |||
| 201ee895f9 | |||
| 51be0153d3 | |||
| 29a9a11085 | |||
| 65f28bb9dc | |||
| fd264daffc | |||
| 18e35f2ba9 | |||
| 487e9be8ec | |||
| d9049ed066 | |||
| 6e62448b88 | |||
| ec457d5125 | |||
| d45b01625b | |||
| 2defa5cc13 | |||
| 9cc9c3a87f | |||
| 153d8ce6ce | |||
| 5e33212112 | |||
| 7d6990eb90 | |||
| d75ea94161 | |||
| 1badecc20a | |||
| c90a56811d | |||
| 4e5e3bc9a1 | |||
| c8397bb5ef | |||
| 0ae53b023c | |||
| 1acfa93f1a | |||
| b60ba27892 | |||
| 7ddba8ede7 | |||
| a8bd53b757 | |||
| 877b2e9f3b | |||
| 663893dccb |
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;"]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 521 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 980 B After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,18 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
"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",
|
||||
@@ -304,13 +311,13 @@
|
||||
"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",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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,27 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
|
||||
return;
|
||||
}
|
||||
|
||||
let complete = false;
|
||||
let tryAttempts = 0;
|
||||
|
||||
while (!complete) {
|
||||
if (tryAttempts > 3) {
|
||||
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
|
||||
complete = true;
|
||||
} else {
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.current.streamUrl, 'replace')
|
||||
.catch((err) => {
|
||||
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);
|
||||
}
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
getMpvInstance()
|
||||
?.load(data.queue.current.streamUrl, 'replace')
|
||||
.then(() => {
|
||||
// eslint-disable-next-line promise/always-return
|
||||
if (data.queue.next) {
|
||||
getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load song', err);
|
||||
getMpvInstance()?.play();
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
if (pause) {
|
||||
await getMpvInstance()?.pause();
|
||||
getMpvInstance()?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -186,6 +172,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) {
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import Store from 'electron-store';
|
||||
import { ipcMain, safeStorage } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
export const store = new Store();
|
||||
|
||||
|
||||
@@ -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('-', '')}`)
|
||||
: '',
|
||||
|
||||
@@ -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,
|
||||
@@ -426,7 +428,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 +445,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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'>> = {
|
||||
|
||||
@@ -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§ion=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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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);
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,8 +66,6 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const tableConfig = useTableSettings('albumDetail');
|
||||
|
||||
console.log('tableConfig :>> ', tableConfig);
|
||||
|
||||
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
|
||||
|
||||
const getRowHeight = useCallback((params: RowHeightParams) => {
|
||||
@@ -90,9 +88,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);
|
||||
}
|
||||
@@ -266,7 +270,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<LibraryBackgroundOverlay backgroundColor={background} />
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
<DetailContainer>
|
||||
<Box component="section">
|
||||
<Group
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,8 +17,8 @@ const SearchItem = styled.button`
|
||||
all: unset;
|
||||
box-sizing: border-box !important;
|
||||
padding: 0.5rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
|
||||