diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..8a678147 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +Dockerfile +docker-compose.* diff --git a/.erb/configs/webpack.config.renderer.dev.ts b/.erb/configs/webpack.config.renderer.dev.ts index f6ca25d8..46e51530 100644 --- a/.erb/configs/webpack.config.renderer.dev.ts +++ b/.erb/configs/webpack.config.renderer.dev.ts @@ -16,7 +16,7 @@ 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; @@ -28,171 +28,174 @@ const requiredByDLLConfig = module.parent!.filename.includes('webpack.config.ren * Warn if the DLL is not built */ if (!requiredByDLLConfig && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) { - console.log( - chalk.black.bgYellow.bold( - 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', - ), - ); - execSync('npm run postinstall'); + console.log( + chalk.black.bgYellow.bold( + 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', + ), + ); + execSync('npm run postinstall'); } 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: [ - ...(requiredByDLLConfig - ? [] - : [ - new webpack.DllReferencePlugin({ - context: webpackPaths.dllPath, - manifest: require(manifest), - sourceType: 'var', - }), - ]), - 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, - }, - setupMiddlewares(middlewares) { - console.log('Starting preload.js builder...'); - const preloadProcess = spawn('npm', ['run', 'start:preload'], { - shell: true, - stdio: 'inherit', - }) - .on('close', (code: number) => process.exit(code!)) - .on('error', (spawnError) => console.error(spawnError)); - console.log('Starting remote.js builder...'); - const remoteProcess = spawn('npm', ['run', 'start:remote'], { - shell: true, - stdio: 'inherit', - }) - .on('close', (code: number) => process.exit(code!)) - .on('error', (spawnError) => console.error(spawnError)); - - console.log('Starting Main Process...'); - spawn('npm', ['run', 'start:main'], { - shell: true, - stdio: 'inherit', - }) - .on('close', (code: number) => { - preloadProcess.kill(); - remoteProcess.kill(); - process.exit(code!); - }) - .on('error', (spawnError) => console.error(spawnError)); - return middlewares; + 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', + }, + ], + }, + plugins: [ + ...(requiredByDLLConfig + ? [] + : [ + new webpack.DllReferencePlugin({ + context: webpackPaths.dllPath, + manifest: require(manifest), + sourceType: 'var', + }), + ]), + + 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, + templateParameters: { + web: false, + }, + }), + ], + + node: { + __dirname: false, + __filename: false, + }, + + devServer: { + port, + compress: true, + hot: true, + headers: { 'Access-Control-Allow-Origin': '*' }, + static: { + publicPath: '/', + }, + historyApiFallback: { + verbose: true, + }, + setupMiddlewares(middlewares) { + console.log('Starting preload.js builder...'); + const preloadProcess = spawn('npm', ['run', 'start:preload'], { + shell: true, + stdio: 'inherit', + }) + .on('close', (code: number) => process.exit(code!)) + .on('error', (spawnError) => console.error(spawnError)); + + console.log('Starting remote.js builder...'); + const remoteProcess = spawn('npm', ['run', 'start:remote'], { + shell: true, + stdio: 'inherit', + }) + .on('close', (code: number) => process.exit(code!)) + .on('error', (spawnError) => console.error(spawnError)); + + console.log('Starting Main Process...'); + spawn('npm', ['run', 'start:main'], { + shell: true, + stdio: 'inherit', + }) + .on('close', (code: number) => { + preloadProcess.kill(); + remoteProcess.kill(); + process.exit(code!); + }) + .on('error', (spawnError) => console.error(spawnError)); + return middlewares; + }, }, - }, }; export default merge(baseConfig, configuration); diff --git a/.erb/configs/webpack.config.renderer.prod.ts b/.erb/configs/webpack.config.renderer.prod.ts index 324630a7..379bb2d0 100644 --- a/.erb/configs/webpack.config.renderer.prod.ts +++ b/.erb/configs/webpack.config.renderer.prod.ts @@ -21,114 +21,117 @@ checkNodeEnv('production'); deleteSourceMaps(); const devtoolsConfig = - process.env.DEBUG_PROD === 'true' - ? { - devtool: 'source-map', - } - : {}; + process.env.DEBUG_PROD === 'true' + ? { + devtool: 'source-map', + } + : {}; const configuration: webpack.Configuration = { - ...devtoolsConfig, + ...devtoolsConfig, - mode: 'production', + mode: 'production', - target: ['web', 'electron-renderer'], + target: ['web', 'electron-renderer'], - entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], + entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], - output: { - path: webpackPaths.distRendererPath, - publicPath: './', - filename: 'renderer.js', - library: { - type: 'umd', + output: { + path: webpackPaths.distRendererPath, + publicPath: './', + filename: 'renderer.js', + library: { + type: 'umd', + }, }, - }, - module: { - rules: [ - { - test: /\.s?(a|c)ss$/, - use: [ - MiniCssExtractPlugin.loader, - { - loader: 'css-loader', - options: { - modules: { - localIdentName: '[name]__[local]--[hash:base64:5]', - exportLocalsConvention: 'camelCaseOnly', - }, - sourceMap: true, - importLoaders: 1, + module: { + rules: [ + { + test: /\.s?(a|c)ss$/, + use: [ + MiniCssExtractPlugin.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?(a|c)ss$/, + use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], + exclude: /\.module\.s?(c|a)ss$/, + }, + // Fonts + { + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: 'asset/resource', + }, + // Images + { + test: /\.(png|svg|jpg|jpeg|gif)$/i, + type: 'asset/resource', }, - }, - 'sass-loader', ], - include: /\.module\.s?(c|a)ss$/, - }, - { - test: /\.s?(a|c)ss$/, - use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], - exclude: /\.module\.s?(c|a)ss$/, - }, - // Fonts - { - test: /\.(woff|woff2|eot|ttf|otf)$/i, - type: 'asset/resource', - }, - // Images - { - test: /\.(png|svg|jpg|jpeg|gif)$/i, - type: 'asset/resource', - }, + }, + + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + parallel: true, + }), + new CssMinimizerPlugin(), + ], + }, + + plugins: [ + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behaviour between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'production', + DEBUG_PROD: false, + }), + + new MiniCssExtractPlugin({ + filename: 'style.css', + }), + + new BundleAnalyzerPlugin({ + analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', + }), + + new HtmlWebpackPlugin({ + filename: 'index.html', + template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), + minify: { + collapseWhitespace: true, + removeAttributeQuotes: true, + removeComments: true, + }, + isBrowser: false, + isDevelopment: process.env.NODE_ENV !== 'production', + templateParameters: { + web: false, + }, + }), ], - }, - - optimization: { - minimize: true, - minimizer: [ - new TerserPlugin({ - parallel: true, - }), - new CssMinimizerPlugin(), - ], - }, - - plugins: [ - /** - * Create global constants which can be configured at compile time. - * - * Useful for allowing different behaviour between development builds and - * release builds - * - * NODE_ENV should be production so that modules do not perform certain - * development checks - */ - new webpack.EnvironmentPlugin({ - NODE_ENV: 'production', - DEBUG_PROD: false, - }), - - new MiniCssExtractPlugin({ - filename: 'style.css', - }), - - new BundleAnalyzerPlugin({ - analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', - }), - - new HtmlWebpackPlugin({ - filename: 'index.html', - template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), - minify: { - collapseWhitespace: true, - removeAttributeQuotes: true, - removeComments: true, - }, - isBrowser: false, - isDevelopment: process.env.NODE_ENV !== 'production', - }), - ], }; export default merge(baseConfig, configuration); diff --git a/.erb/configs/webpack.config.renderer.web.ts b/.erb/configs/webpack.config.renderer.web.ts index 39fca511..82b5f79a 100644 --- a/.erb/configs/webpack.config.renderer.web.ts +++ b/.erb/configs/webpack.config.renderer.web.ts @@ -116,6 +116,9 @@ const configuration: webpack.Configuration = { env: process.env.NODE_ENV, isDevelopment: process.env.NODE_ENV !== 'production', nodeModules: webpackPaths.appNodeModulesPath, + templateParameters: { + web: false, // with hot reload, we don't have NGINX injecting variables + }, }), ], diff --git a/.erb/configs/webpack.config.web.prod.ts b/.erb/configs/webpack.config.web.prod.ts index 73cc32fc..ed935fd6 100644 --- a/.erb/configs/webpack.config.web.prod.ts +++ b/.erb/configs/webpack.config.web.prod.ts @@ -128,6 +128,9 @@ const configuration: webpack.Configuration = { }, isBrowser: false, isDevelopment: process.env.NODE_ENV !== 'production', + templateParameters: { + web: true, + }, }), ], }; diff --git a/Dockerfile b/Dockerfile index 856af4d2..fdf2c6ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,20 @@ # --- Builder stage FROM node:18-alpine as builder WORKDIR /app -COPY . /app +#Copy package.json first to cache node_modules +COPY package.json package-lock.json . # Scripts include electron-specific dependencies, which we don't need RUN npm install --legacy-peer-deps --ignore-scripts +#Copy code and build with cached modules +COPY . . RUN npm run build:web # --- Production stage FROM nginx:alpine-slim COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html +COPY ./settings.js.template /etc/nginx/templates/settings.js.template COPY ng.conf.template /etc/nginx/templates/default.conf.template ENV PUBLIC_PATH="/" diff --git a/README.md b/README.md index faa5513b..a4a28652 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ docker run --name feishin -p 9180:9180 feishin 3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`. +4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set. + ## FAQ ### MPV is either not working or is rapidly switching between pause/play states diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..44308381 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,13 @@ +version: '3.5' +services: + feishin: + container_name: feishin + image: jeffvli/feishin + restart: unless-stopped + ports: + - 9180:9180 + environment: + - SERVER_NAME=jellyfin # pre defined server name + - SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled + - SERVER_TYPE=jellyfin # navidrome also works + - SERVER_URL= # http://address:port diff --git a/ng.conf.template b/ng.conf.template index 3fed35b8..eb46ff72 100644 --- a/ng.conf.template +++ b/ng.conf.template @@ -16,4 +16,12 @@ server { alias /usr/share/nginx/html/; try_files $uri $uri/ /index.html =404; } + + location ${PUBLIC_PATH}settings.js { + alias /etc/nginx/conf.d/settings.js; + } + + location ${PUBLIC_PATH}/settings.js { + alias /etc/nginx/conf.d/settings.js; + } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4c84db10..7a7f0940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12532,9 +12532,9 @@ } }, "node_modules/ip": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", - "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", "dev": true }, "node_modules/ipaddr.js": { @@ -30639,9 +30639,9 @@ } }, "ip": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", - "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", "dev": true }, "ipaddr.js": { diff --git a/package.json b/package.json index 5148067a..dfe340cf 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "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 .", + "build:docker": "docker build -t jeffvli/feishin .", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"", "lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix", diff --git a/settings.js.template b/settings.js.template new file mode 100644 index 00000000..782f0d7e --- /dev/null +++ b/settings.js.template @@ -0,0 +1 @@ +"use strict";window.SERVER_URL="${SERVER_URL}";window.SERVER_NAME="${SERVER_NAME}";window.SERVER_TYPE="${SERVER_TYPE}";window.SERVER_LOCK=${SERVER_LOCK}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 34b77a0b..438e44a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -38,6 +38,7 @@ "channel_other": "channels", "clear": "clear", "close": "close", + "codec": "codec", "collapse": "collapse", "comingSoon": "coming soon…", "configure": "configure", @@ -567,6 +568,8 @@ "skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar", "skipPlaylistPage": "skip playlist page", "skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page", + "startMinimized": "start minimized", + "startMinimized_description": "start the application in system tray", "theme": "theme", "theme_description": "sets the theme to use for the application", "themeDark": "theme (dark)", @@ -592,6 +595,7 @@ "bitrate": "bitrate", "bpm": "bpm", "channels": "$t(common.channel_other)", + "codec": "$t(common.codec)", "comment": "comment", "dateAdded": "date added", "discNumber": "disc", @@ -626,6 +630,7 @@ "bitrate": "$t(common.bitrate)", "bpm": "$t(common.bpm)", "channels": "$t(common.channel_other)", + "codec": "$t(common.codec)", "dateAdded": "date added", "discNumber": "disc number", "duration": "$t(common.duration)", diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index 26307093..f6d6f4b3 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -323,13 +323,16 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) await getMpvInstance()?.load(data.queue.next.streamUrl, 'append'); } } + + if (pause) { + await getMpvInstance()?.pause(); + } else if (pause === false) { + // Only force play if pause is explicitly false + await getMpvInstance()?.play(); + } } catch (err: NodeMpvError | any) { mpvLog({ action: `Failed to set play queue` }, err); } - - if (pause) { - getMpvInstance()?.pause(); - } }); // Replaces the queue in position 1 to the given data diff --git a/src/main/main.ts b/src/main/main.ts index 848fc3a8..107c6562 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -206,7 +206,7 @@ const createTray = () => { tray.setContextMenu(contextMenu); }; -const createWindow = async () => { +const createWindow = async (first = true) => { if (isDevelopment) { await installExtensions(); } @@ -350,19 +350,21 @@ const createWindow = async () => { mainWindow.loadURL(resolveHtmlPath('index.html')); + const startWindowMinimized = store.get('window_start_minimized', false) as boolean; + mainWindow.on('ready-to-show', () => { if (!mainWindow) { throw new Error('"mainWindow" is not defined'); } - if (process.env.START_MINIMIZED) { - mainWindow.minimize(); - } else { + + if (!first || !startWindowMinimized) { mainWindow.show(); createWinThumbarButtons(); } }); mainWindow.on('closed', () => { + ipcMain.removeHandler('window-clear-cache'); mainWindow = null; }); @@ -552,6 +554,7 @@ app.on('window-all-closed', () => { // Respect the OSX convention of having the application in memory even // after all windows have been closed if (isMacOS()) { + ipcMain.removeHandler('window-clear-cache'); mainWindow = null; } else { app.quit(); @@ -606,7 +609,11 @@ if (!singleInstance) { app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. - if (mainWindow === null) createWindow(); + if (mainWindow === null) createWindow(false); + else if (!mainWindow.isVisible()) { + mainWindow.show(); + createWinThumbarButtons(); + } }); }) .catch(console.log); diff --git a/src/main/preload/local-settings.ts b/src/main/preload/local-settings.ts index b8aafba0..c6269411 100644 --- a/src/main/preload/local-settings.ts +++ b/src/main/preload/local-settings.ts @@ -1,6 +1,6 @@ import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron'; import Store from 'electron-store'; -import type { TitleTheme } from '/@/renderer/types'; +import { toServerType, type TitleTheme } from '/@/renderer/types'; const store = new Store(); @@ -56,9 +56,20 @@ const themeSet = (theme: TitleTheme): void => { ipcRenderer.send('theme-set', theme); }; +const SERVER_TYPE = toServerType(process.env.SERVER_TYPE); + +const env = { + SERVER_LOCK: + SERVER_TYPE !== null ? process.env.SERVER_LOCK?.toLocaleLowerCase() === 'true' : false, + SERVER_NAME: process.env.SERVER_NAME ?? '', + SERVER_TYPE, + SERVER_URL: process.env.SERVER_URL ?? 'http://', +}; + export const localSettings = { disableMediaKeys, enableMediaKeys, + env, fontError, get, passwordGet, diff --git a/src/renderer/components/grid-carousel/index.tsx b/src/renderer/components/grid-carousel/index.tsx index 2f31aae5..8ae02e66 100644 --- a/src/renderer/components/grid-carousel/index.tsx +++ b/src/renderer/components/grid-carousel/index.tsx @@ -4,6 +4,7 @@ import { ReactNode, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -114,9 +115,11 @@ export const SwiperGridCarousel = ({ isLoading, uniqueId, }: SwiperGridCarouselProps) => { + const containerRef = useRef(null); const swiperRef = useRef(null); const playButtonBehavior = usePlayButtonBehavior(); const handlePlayQueueAdd = usePlayQueueAdd(); + const [slideCount, setSlideCount] = useState(4); useEffect(() => { swiperRef.current?.slideTo(0, 0); @@ -191,23 +194,24 @@ export const SwiperGridCarousel = ({ const handleNext = useCallback(() => { const activeIndex = swiperRef?.current?.activeIndex || 0; - const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4)); + const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount)); swiperRef?.current?.slideTo(activeIndex + slidesPerView); - }, [swiperProps?.slidesPerView]); + }, [slideCount, swiperProps?.slidesPerView]); const handlePrev = useCallback(() => { const activeIndex = swiperRef?.current?.activeIndex || 0; - const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4)); + const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount)); swiperRef?.current?.slideTo(activeIndex - slidesPerView); - }, [swiperProps?.slidesPerView]); + }, [slideCount, swiperProps?.slidesPerView]); const handleOnSlideChange = useCallback((e: SwiperCore) => { const { slides, isEnd, isBeginning, params } = e; if (isEnd || isBeginning) return; + const slideCount = (params.slidesPerView as number | undefined) || 4; setPagination({ - hasNextPage: (params?.slidesPerView || 4) < slides.length, - hasPreviousPage: (params?.slidesPerView || 4) < slides.length, + hasNextPage: slideCount < slides.length, + hasPreviousPage: slideCount < slides.length, }); }, []); @@ -215,82 +219,106 @@ export const SwiperGridCarousel = ({ const { slides, isEnd, isBeginning, params } = e; if (isEnd || isBeginning) return; + const slideCount = (params.slidesPerView as number | undefined) || 4; setPagination({ - hasNextPage: (params.slidesPerView || 4) < slides.length, - hasPreviousPage: (params.slidesPerView || 4) < slides.length, + hasNextPage: slideCount < slides.length, + hasPreviousPage: slideCount < slides.length, }); }, []); const handleOnReachEnd = useCallback((e: SwiperCore) => { const { slides, params } = e; + const slideCount = (params.slidesPerView as number | undefined) || 4; setPagination({ hasNextPage: false, - hasPreviousPage: (params.slidesPerView || 4) < slides.length, + hasPreviousPage: slideCount < slides.length, }); }, []); const handleOnReachBeginning = useCallback((e: SwiperCore) => { const { slides, params } = e; + const slideCount = (params.slidesPerView as number | undefined) || 4; setPagination({ - hasNextPage: (params.slidesPerView || 4) < slides.length, + hasNextPage: slideCount < slides.length, hasPreviousPage: false, }); }, []); - const handleOnResize = useCallback((e: SwiperCore) => { - if (!e) return; - const { width } = e; - const slidesPerView = getSlidesPerView(width); - if (!e.params) return; - e.params.slidesPerView = slidesPerView; - }, []); + useLayoutEffect(() => { + const handleResize = () => { + // Use the container div ref and not swiper width, as this value is more accurate + const width = containerRef.current?.clientWidth; + const { activeIndex, params, slides } = + (swiperRef.current as SwiperCore | undefined) ?? {}; - const throttledOnResize = throttle(handleOnResize, 200); + if (width) { + const slidesPerView = getSlidesPerView(width); + setSlideCount(slidesPerView); + } + + if (activeIndex !== undefined && slides && params?.slidesPerView) { + const slideCount = (params.slidesPerView as number | undefined) || 4; + setPagination({ + hasNextPage: activeIndex + slideCount < slides.length, + hasPreviousPage: activeIndex > 0, + }); + } + }; + + handleResize(); + + const throttledResize = throttle(handleResize, 200); + window.addEventListener('resize', throttledResize); + + return () => { + window.removeEventListener('resize', throttledResize); + }; + }, []); return ( - {title ? ( - - ) : null} - <Swiper - ref={swiperRef} - resizeObserver - modules={[Virtual]} - slidesPerView={4} - spaceBetween={20} - style={{ height: '100%', width: '100%' }} - onBeforeInit={(swiper) => { - swiperRef.current = swiper; - }} - onBeforeResize={handleOnResize} - onReachBeginning={handleOnReachBeginning} - onReachEnd={handleOnReachEnd} - onResize={throttledOnResize} - onSlideChange={handleOnSlideChange} - onZoomChange={handleOnZoomChange} - {...swiperProps} - > - {slides.map((slideContent, index) => { - return ( - <SwiperSlide - key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`} - virtualIndex={index} - > - {slideContent} - </SwiperSlide> - ); - })} - </Swiper> + <div ref={containerRef}> + {title ? ( + <Title + {...title} + handleNext={handleNext} + handlePrev={handlePrev} + pagination={pagination} + /> + ) : null} + <Swiper + ref={swiperRef} + resizeObserver + modules={[Virtual]} + slidesPerView={slideCount} + spaceBetween={20} + style={{ height: '100%', width: '100%' }} + onBeforeInit={(swiper) => { + swiperRef.current = swiper; + }} + onReachBeginning={handleOnReachBeginning} + onReachEnd={handleOnReachEnd} + onSlideChange={handleOnSlideChange} + onZoomChange={handleOnZoomChange} + {...swiperProps} + > + {slides.map((slideContent, index) => { + return ( + <SwiperSlide + key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`} + virtualIndex={index} + > + {slideContent} + </SwiperSlide> + ); + })} + </Swiper> + </div> </CarouselContainer> ); }; diff --git a/src/renderer/components/virtual-table/index.tsx b/src/renderer/components/virtual-table/index.tsx index 1f1039b6..42dc722a 100644 --- a/src/renderer/components/virtual-table/index.tsx +++ b/src/renderer/components/virtual-table/index.tsx @@ -158,6 +158,14 @@ const tableColumns: { [key: string]: ColDef } = { params.data ? params.data.channels : undefined, width: 100, }, + codec: { + cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }), + colId: TableColumn.CODEC, + headerName: i18n.t('table.column.codec'), + valueGetter: (params: ValueGetterParams) => + params.data ? params.data.container : undefined, + width: 60, + }, comment: { cellRenderer: NoteCell, colId: TableColumn.COMMENT, diff --git a/src/renderer/components/virtual-table/table-config-dropdown.tsx b/src/renderer/components/virtual-table/table-config-dropdown.tsx index 199534a5..6f09ff13 100644 --- a/src/renderer/components/virtual-table/table-config-dropdown.tsx +++ b/src/renderer/components/virtual-table/table-config-dropdown.tsx @@ -60,6 +60,10 @@ export const SONG_TABLE_COLUMNS = [ label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }), value: TableColumn.BIT_RATE, }, + { + label: i18n.t('table.config.label.codec', { postProcess: 'titleCase' }), + value: TableColumn.CODEC, + }, { label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }), value: TableColumn.LAST_PLAYED, diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 83cce029..34ebc325 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -522,7 +522,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { const handleUpdateRating = useCallback( (rating: number) => { - if (!ctx.dataNodes || !ctx.data) return; + if (!ctx.dataNodes && !ctx.data) return; let uniqueServerIds: string[] = []; let items: AnyLibraryItems = []; diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index 1c98b6c3..69588792 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -89,8 +89,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => { if (playbackType === PlaybackType.LOCAL) { mpvPlayer!.volume(volume); - mpvPlayer!.setQueue(playerData); - mpvPlayer!.play(); + mpvPlayer!.setQueue(playerData, false); } play(); diff --git a/src/renderer/features/player/components/full-screen-player-image.tsx b/src/renderer/features/player/components/full-screen-player-image.tsx index d3053a90..d3c51564 100644 --- a/src/renderer/features/player/components/full-screen-player-image.tsx +++ b/src/renderer/features/player/components/full-screen-player-image.tsx @@ -134,7 +134,7 @@ export const FullScreenPlayerImage = () => { const albumArtRes = useSettingsStore((store) => store.general.albumArtRes); const { queue } = usePlayerData(); - const { opacity, useImageAspectRatio } = useFullScreenPlayerStore(); + const { useImageAspectRatio } = useFullScreenPlayerStore(); const currentSong = queue.current; const { color: background } = useFastAverageColor({ algorithm: 'dominant', @@ -250,7 +250,6 @@ export const FullScreenPlayerImage = () => { <MetadataContainer className="full-screen-player-image-metadata" maw="100%" - opacity={opacity} spacing="xs" > <TextTitle @@ -278,7 +277,6 @@ export const FullScreenPlayerImage = () => { to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentSong?.albumId || '', })} - transform="uppercase" w="100%" weight={600} > @@ -292,7 +290,6 @@ export const FullScreenPlayerImage = () => { style={{ textShadow: 'var(--fullscreen-player-text-shadow)', }} - transform="uppercase" > {index > 0 && ( <Text @@ -313,7 +310,6 @@ export const FullScreenPlayerImage = () => { to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: artist.id, })} - transform="uppercase" weight={600} > {artist.name} diff --git a/src/renderer/features/player/components/full-screen-player-queue.tsx b/src/renderer/features/player/components/full-screen-player-queue.tsx index 031c4ea2..f4d1a022 100644 --- a/src/renderer/features/player/components/full-screen-player-queue.tsx +++ b/src/renderer/features/player/components/full-screen-player-queue.tsx @@ -42,11 +42,11 @@ const HeaderItemWrapper = styled.div` z-index: 2; `; -interface TransparendGridContainerProps { +interface TransparentGridContainerProps { opacity: number; } -const GridContainer = styled.div<TransparendGridContainerProps>` +const GridContainer = styled.div<TransparentGridContainerProps>` display: grid; grid-template-rows: auto minmax(0, 1fr); grid-template-columns: 1fr; @@ -82,8 +82,6 @@ export const FullScreenPlayerQueue = () => { }, ]; - console.log('opacity', opacity); - return ( <GridContainer className="full-screen-player-queue-container" diff --git a/src/renderer/features/player/components/full-screen-player.tsx b/src/renderer/features/player/components/full-screen-player.tsx index 9cf06f1a..970f52e4 100644 --- a/src/renderer/features/player/components/full-screen-player.tsx +++ b/src/renderer/features/player/components/full-screen-player.tsx @@ -153,7 +153,7 @@ const Controls = () => { defaultValue={opacity} label={(e) => `${e} %`} max={100} - min={1} + min={0} w="100%" onChangeEnd={(e) => setStore({ opacity: Number(e) })} /> diff --git a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts index d4e37433..2ebf6bea 100644 --- a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts +++ b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts @@ -175,8 +175,7 @@ export const useHandlePlayQueueAdd = () => { if (playType === Play.NOW || !hadSong) { mpvPlayer!.pause(); - mpvPlayer!.setQueue(playerData); - mpvPlayer!.play(); + mpvPlayer!.setQueue(playerData, false); } else { mpvPlayer!.setQueueNext(playerData); } diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index 8edb8126..3f1e7ca7 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -8,6 +8,7 @@ import isElectron from 'is-electron'; import { nanoid } from 'nanoid/non-secure'; import { AuthenticationResponse, ServerType } from '/@/renderer/api/types'; import { useAuthStoreActions } from '/@/renderer/store'; +import { ServerType, toServerType } from '/@/renderer/types'; import { api } from '/@/renderer/api'; import { useTranslation } from 'react-i18next'; @@ -32,15 +33,27 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { const form = useForm({ initialValues: { legacyAuth: false, - name: '', + name: (localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) ?? '', password: '', savePassword: false, - type: ServerType.JELLYFIN, - url: 'http://', + type: + (localSettings + ? localSettings.env.SERVER_TYPE + : toServerType(window.SERVER_TYPE)) ?? ServerType.JELLYFIN, + url: (localSettings ? localSettings.env.SERVER_URL : window.SERVER_URL) ?? 'https://', username: '', }, }); + // server lock for web is only true if lock is true *and* all other properties are set + const serverLock = + (localSettings + ? !!localSettings.env.SERVER_LOCK + : !!window.SERVER_LOCK && + window.SERVER_TYPE && + window.SERVER_NAME && + window.SERVER_URL) || false; + const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username; const handleSubmit = form.onSubmit(async (values) => { @@ -61,7 +74,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { password: values.password, username: values.username, }, - values.type, + values.type as ServerType, ); if (!data) { @@ -75,7 +88,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { id: nanoid(), name: values.name, ndCredential: data.ndCredential, - type: values.type, + type: values.type as ServerType, url: values.url.replace(/\/$/, ''), userId: data.userId, username: data.username, @@ -116,11 +129,13 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { > <SegmentedControl data={SERVER_TYPES} + disabled={serverLock} {...form.getInputProps('type')} /> <Group grow> <TextInput data-autofocus + disabled={serverLock} label={t('form.addServer.input', { context: 'name', postProcess: 'titleCase', @@ -128,6 +143,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { {...form.getInputProps('name')} /> <TextInput + disabled={serverLock} label={t('form.addServer.input', { context: 'url', postProcess: 'titleCase', diff --git a/src/renderer/features/settings/components/window/window-settings.tsx b/src/renderer/features/settings/components/window/window-settings.tsx index 899b328b..2b60be69 100644 --- a/src/renderer/features/settings/components/window/window-settings.tsx +++ b/src/renderer/features/settings/components/window/window-settings.tsx @@ -131,6 +131,31 @@ export const WindowSettings = () => { isHidden: !isElectron(), title: t('setting.exitToTray', { postProcess: 'sentenceCase' }), }, + { + control: ( + <Switch + aria-label="Toggle start in tray" + defaultChecked={settings.startMinimized} + disabled={!isElectron()} + onChange={(e) => { + if (!e) return; + localSettings?.set('window_start_minimized', e.currentTarget.checked); + setSettings({ + window: { + ...settings, + startMinimized: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.startMinimized', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !isElectron(), + title: t('setting.startMinimized', { postProcess: 'sentenceCase' }), + }, ]; return <SettingsSection options={windowOptions} />; diff --git a/src/renderer/features/sidebar/components/sidebar-icon.tsx b/src/renderer/features/sidebar/components/sidebar-icon.tsx index 7d48e723..ef87e702 100644 --- a/src/renderer/features/sidebar/components/sidebar-icon.tsx +++ b/src/renderer/features/sidebar/components/sidebar-icon.tsx @@ -38,6 +38,9 @@ export const SidebarIcon = ({ active, route, size }: SidebarIconProps) => { case AppRoute.LIBRARY_ALBUMS: if (active) return <RiAlbumFill size={size} />; return <RiAlbumLine size={size} />; + case AppRoute.LIBRARY_ALBUM_ARTISTS: + if (active) return <RiUserVoiceFill size={size} />; + return <RiUserVoiceLine size={size} />; case AppRoute.LIBRARY_ARTISTS: if (active) return <RiUserVoiceFill size={size} />; return <RiUserVoiceLine size={size} />; diff --git a/src/renderer/index.ejs b/src/renderer/index.ejs index ca220526..4d5ecd15 100644 --- a/src/renderer/index.ejs +++ b/src/renderer/index.ejs @@ -6,6 +6,9 @@ <meta http-equiv="Content-Security-Policy" /> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Feishin + <% if (web) { %> + + <% } %> diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index db69c9ba..7d8c9258 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -13,6 +13,10 @@ import { Browser } from '/@/main/preload/browser'; declare global { interface Window { + SERVER_LOCK?: boolean; + SERVER_NAME?: string; + SERVER_TYPE?: string; + SERVER_URL?: string; electron: { browser: Browser; discordRpc: DiscordRpc; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index bf475b19..1d5aed8c 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -267,6 +267,7 @@ export interface SettingsState { disableAutoUpdate: boolean; exitToTray: boolean; minimizeToTray: boolean; + startMinimized: boolean; windowBarStyle: Platform; }; } @@ -575,6 +576,7 @@ const initialState: SettingsState = { disableAutoUpdate: false, exitToTray: false, minimizeToTray: false, + startMinimized: false, windowBarStyle: platformDefaultWindowBarStyle, }, }; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index f962a32b..fe683f30 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -54,6 +54,37 @@ export enum Platform { WINDOWS = 'windows', } +export enum ServerType { + JELLYFIN = 'jellyfin', + NAVIDROME = 'navidrome', + SUBSONIC = 'subsonic', +} + +export const toServerType = (value?: string): ServerType | null => { + switch (value?.toLowerCase()) { + case ServerType.JELLYFIN: + return ServerType.JELLYFIN; + case ServerType.NAVIDROME: + return ServerType.NAVIDROME; + default: + return null; + } +}; + +export type ServerListItem = { + credential: string; + features?: Record; + id: string; + name: string; + ndCredential?: string; + savePassword?: boolean; + type: ServerType; + url: string; + userId: string | null; + username: string; + version?: string; +}; + export enum PlayerStatus { PAUSED = 'paused', PLAYING = 'playing', @@ -124,6 +155,7 @@ export enum TableColumn { BIT_RATE = 'bitRate', BPM = 'bpm', CHANNELS = 'channels', + CODEC = 'codec', COMMENT = 'comment', DATE_ADDED = 'dateAdded', DISC_NUMBER = 'discNumber',