Compare commits

...

18 Commits

Author SHA1 Message Date
jeffvli 93f2573847 Update to v0.9.0 2024-09-10 22:37:48 -07:00
Kendall Garner 03d97c6b1e use unique id for paginated playlist 2024-09-10 22:37:24 -07:00
Hosted Weblate 0b86cb51d3 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (634 of 634 strings)

Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Hosted Weblate ee54b8219b Translated using Weblate (French)
Currently translated at 96.8% (610 of 630 strings)

Co-authored-by: Evan <evan_g@orange.fr>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Hosted Weblate 25b593aadd Translated using Weblate (Spanish)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (632 of 632 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (631 of 632 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Hosted Weblate 5253e32b67 Translated using Weblate (Czech)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (632 of 632 strings)

Translated using Weblate (Czech)

Currently translated at 99.6% (630 of 632 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Hosted Weblate b0b558c90a Translated using Weblate (German)
Currently translated at 85.0% (536 of 630 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Schroti <schrotihd@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Kendall Garner f11a53c1a4 fix suspense 2024-09-09 19:01:07 -07:00
Kendall Garner e2a05f4204 add track normalization for jellyfin as well 2024-09-09 07:15:26 -07:00
Kendall Garner fcc010eb54 move transcoding placeholder 2024-09-08 22:05:44 -07:00
Kendall Garner 1b41a5a674 enable disabling tray 2024-09-08 20:55:07 -07:00
Kendall Garner 74aa88e082 add web visualizer (#314)
* add web visualizer

* fallback to simple model

* less samples, hopefully more efficient

* Use audiomotion analyzer

- Note: fixed to 4.1.1 because 4.2.0 uses esm which breaks in the current workflow...

* revert publish changes

* r2

* don't massively change package.json

* lazy
2024-09-09 01:25:01 +00:00
Kendall Garner fbac33ceba add shuffle context menu item 2024-09-07 21:31:01 -07:00
Kendall Garner 42ba5a531c use feishin switch instead of default 2024-09-05 18:08:37 -07:00
Kendall Garner 257e1e2cd9 ... 2024-09-05 07:06:37 -07:00
Kendall Garner 3025e84c58 remove height everywhere for jellyfin images 2024-09-04 22:30:50 -07:00
Kendall Garner 4a111d9cf2 don't make artist clickable if no id 2024-09-04 20:01:45 -07:00
Kendall Garner e6bd8deb0c use unique id for places that may have duplicates 2024-09-04 19:34:07 -07:00
35 changed files with 399 additions and 96 deletions
+4 -1
View File
@@ -71,7 +71,9 @@ docker run --name feishin -p 9180:9180 feishin
```
#### Docker Compose
To install via Docker Compose use the following snippit. This also works on Portainer.
```
version: '3'
services:
@@ -92,7 +94,6 @@ services:
restart: unless-stopped
```
### Configuration
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
@@ -130,6 +131,8 @@ chmod 4755 chrome-sandbox
sudo chown root:root chrome-sandbox
```
Ubunutu 24.04 specifically introduced breaking changes that affect how namespaces work. Please see https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890#:~:text=security%20improvements%20 for possible fixes.
## Development
Built and tested using Node `v16.15.0`.
+18 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.8.1",
"version": "0.9.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.8.1",
"version": "0.9.0",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
@@ -28,6 +28,7 @@
"@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"audiomotion-analyzer": "^4.5.0",
"auto-text-size": "^0.2.3",
"axios": "^1.6.0",
"clsx": "^2.0.0",
@@ -6740,6 +6741,16 @@
"node": ">=10.12.0"
}
},
"node_modules/audiomotion-analyzer": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/audiomotion-analyzer/-/audiomotion-analyzer-4.5.0.tgz",
"integrity": "sha512-qnmB8TSbrxYkTbFgsQeeym0Z/suQx4c0jFg9Yh5+gaPw6J4AFLdfFpagdnDbtNEsj6K7BntgsC3bkdut5rxozg==",
"license": "AGPL-3.0-or-later",
"funding": {
"type": "Ko-fi",
"url": "https://ko-fi.com/hvianna"
}
},
"node_modules/auto-text-size": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/auto-text-size/-/auto-text-size-0.2.3.tgz",
@@ -28672,6 +28683,11 @@
"resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz",
"integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="
},
"audiomotion-analyzer": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/audiomotion-analyzer/-/audiomotion-analyzer-4.5.0.tgz",
"integrity": "sha512-qnmB8TSbrxYkTbFgsQeeym0Z/suQx4c0jFg9Yh5+gaPw6J4AFLdfFpagdnDbtNEsj6K7BntgsC3bkdut5rxozg=="
},
"auto-text-size": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/auto-text-size/-/auto-text-size-0.2.3.tgz",
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.8.1",
"version": "0.9.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",
@@ -310,6 +310,7 @@
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"auto-text-size": "^0.2.3",
"audiomotion-analyzer": "^4.5.0",
"axios": "^1.6.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.8.1",
"version": "0.9.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.8.1",
"version": "0.9.0",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.8.1",
"version": "0.9.0",
"description": "",
"main": "./dist/main/main.js",
"author": {
+7 -3
View File
@@ -11,7 +11,7 @@
"skip_back": "přeskočit dozadu",
"favorite": "oblíbené",
"next": "další",
"shuffle": "náhodně",
"shuffle": "přehrát náhodně",
"playbackFetchNoResults": "nenalezeny žádné skladby",
"playbackFetchInProgress": "načítání skladeb…",
"addNext": "přidat další",
@@ -245,7 +245,10 @@
"playerbarOpenDrawer": "lišta přehrávače jako přepínač celé obrazovky",
"playerbarOpenDrawer_description": "umožňuje kliknutí na lištu přehrávače pro otevření celoobrazovkového přehrávače",
"artistConfiguration": "nastavení stránky umělce alba",
"artistConfiguration_description": "nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí"
"artistConfiguration_description": "nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"trayEnabled": "zobrazit v oznamovací oblasti",
"trayEnabled_description": "zobrazit/skrýt ikonu/nabídku v oznamovací oblasti. pokud je zakázáno, vypne také minimalizaci/ukončení do oznamovací oblasti"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@@ -572,7 +575,8 @@
"showDetails": "získat informace",
"shareItem": "sdílet položku",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"download": "stáhnout"
"download": "stáhnout",
"playShuffled": "$t(player.shuffle)"
},
"home": {
"mostPlayed": "nejpřehrávanější",
+18 -5
View File
@@ -66,7 +66,7 @@
"cancel": "Abbrechen",
"forceRestartRequired": "Neustarten um die Änderungen zu übernehmen... Schließe die Benachrichtigung zum Neustarten",
"setting": "Einstellungen",
"setting_one": "",
"setting_one": "Einstellung",
"setting_other": "Einstellungen",
"version": "Version",
"title": "Titel",
@@ -106,7 +106,8 @@
"preview": "Vorschau",
"reload": "Neu Laden",
"mbid": "MusicBrainz ID",
"close": "schliessen"
"close": "schliessen",
"share": "Teilen"
},
"error": {
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
@@ -218,7 +219,8 @@
"input_optionMatchAny": "Treffer Einige"
},
"editPlaylist": {
"title": "Bearbeite $t(entity.playlist_one)"
"title": "Bearbeite $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) erfolgreich aktualisiert"
},
"lyricSearch": {
"title": "Songtext Suche",
@@ -226,7 +228,10 @@
"input_artist": "$t(entity.artist_one)"
},
"shareItem": {
"description": "Beschreibung"
"description": "Beschreibung",
"setExpiration": "Ablaufdatum setzen",
"expireInvalid": "Ablaufdatum muss in der Zukunft liegen",
"allowDownloading": "Herunterladen zulassen"
}
},
"entity": {
@@ -260,7 +265,9 @@
"genreWithCount_other": "{{count}} Genres",
"trackWithCount_one": "{{count}} Track",
"trackWithCount_other": "{{count}} Tracks",
"smartPlaylist": "Smart $t(entity.playlist_one)"
"smartPlaylist": "Smart $t(entity.playlist_one)",
"play_one": "{{count}} Wiedergabe",
"play_other": "{{count}} Wiedergaben"
},
"table": {
"config": {
@@ -429,6 +436,12 @@
},
"albumList": {
"title": "$t(entity.album_other)"
},
"albumArtistDetail": {
"about": "Über {{artist}}",
"appearsOn": "erscheint auf",
"recentReleases": "Kürzliche Veröffentlichungen",
"viewDiscography": "Diskographie ansehen"
}
},
"player": {
+5 -1
View File
@@ -333,6 +333,7 @@
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "share item",
"showDetails": "get info"
},
@@ -438,7 +439,7 @@
"repeat_off": "repeat disabled",
"repeat_one": "repeat one",
"repeat_other": "",
"shuffle": "shuffle",
"shuffle": "play shuffled",
"shuffle_off": "shuffle disabled",
"skip": "skip",
"skip_back": "skip backwards",
@@ -591,6 +592,7 @@
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "player album art resolution",
"playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto",
"playerbarOpenDrawer": "playerbar fullscreen toggle",
@@ -651,6 +653,8 @@
"transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick",
"transcodeFormat": "format to transcode",
"transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide",
"trayEnabled": "show tray",
"trayEnabled_description": "show/hide tray icon/menu. if disabled, also disables minimize/exit to tray",
"useSystemTheme": "use system theme",
"useSystemTheme_description": "follow the system-defined light or dark preference",
"volumeWheelStep": "volume wheel step",
+7 -3
View File
@@ -11,7 +11,7 @@
"skip_back": "retroceder",
"favorite": "favorito",
"next": "siguiente",
"shuffle": "mezclar",
"shuffle": "Reproducir aleatoriamente",
"playbackFetchNoResults": "ninguna canción encontrada",
"playbackFetchInProgress": "cargando canciones…",
"addNext": "añadir siguiente",
@@ -245,7 +245,10 @@
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
"playerbarOpenDrawer_description": "Permitir hacer clic en la barra del reproductor para abrir el reproductor en pantalla completa",
"artistConfiguration": "Configuración de la página del artista del álbum",
"artistConfiguration_description": "Configurar qué elementos se muestran y en qué orden en la página del artista del álbum"
"artistConfiguration_description": "Configurar qué elementos se muestran y en qué orden en la página del artista del álbum",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"trayEnabled": "Mostrar en el área de notificación",
"trayEnabled_description": "mostrar/ocultar el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@@ -478,7 +481,8 @@
"shareItem": "Compartir elemento",
"showDetails": "Obtener información",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"download": "descargar"
"download": "descargar",
"playShuffled": "$t(player.shuffle)"
},
"home": {
"mostPlayed": "más reproducidos",
+21 -4
View File
@@ -277,7 +277,8 @@
"generalTab": "général",
"hotkeysTab": "raccourcis",
"windowTab": "fenêtre",
"playbackTab": "lecteur"
"playbackTab": "lecteur",
"advanced": "avancé"
},
"globalSearch": {
"commands": {
@@ -306,7 +307,8 @@
"removeFromQueue": "$t(action.removeFromQueue)",
"shareItem": "partager un élément",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"showDetails": "obtenir des informations"
"showDetails": "obtenir des informations",
"download": "télécharger"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -530,7 +532,21 @@
"playerAlbumArtResolution_description": "la résolution pour l'aperçu de la pochette d'album agrandie du lecteur. plus grand le rend plus net, mais peut ralentir le chargement. la valeur par défaut est 0 (automatique)",
"homeConfiguration_description": "configurer quels éléments sont affichés sur la page d'accueil, et dans quel ordre",
"startMinimized": "démarrer l'application en mode réduit",
"genreBehavior_description": "détermine si cliquer sur un genre ouvre par défaut la liste des pistes ou des albums"
"genreBehavior_description": "détermine si cliquer sur un genre ouvre par défaut la liste des pistes ou des albums",
"transcode": "activer le transcodage",
"transcode_description": "permet le transcodage vers différents formats",
"transcodeBitrate_description": "sélectionne le débit du transcodage. 0 signifie que le serveur choisit",
"transcodeFormat_description": "sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
"volumeWidth": "largeur de la barre de volume",
"volumeWidth_description": "la largeur de la barre de volume",
"customCssEnable": "activer le css personnalisé",
"customCssEnable_description": "permet d'écrire du css personnalisé.",
"customCssNotice": "Attention: bien qu'il y ait un certain assainissement (blocage de url() et de content:), l'utilisation de CSS personnalisé peut toujours présenter des risques en modifiant l'interface.",
"customCss": "css personnalisé",
"webAudio": "utiliser l'audio web",
"transcodeBitrate": "débit binaire du transcodage",
"transcodeFormat": "format de transcodage",
"webAudio_description": "utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si vous rencontrez d'autres problèmes"
},
"form": {
"deletePlaylist": {
@@ -574,7 +590,8 @@
"input_optionMatchAny": "correspondre à n'importe quel"
},
"editPlaylist": {
"title": "modifier $t(entity.playlist_one)"
"title": "modifier $t(entity.playlist_one)",
"publicJellyfinNote": "Jellyfin n'indique pas si une playlist est publique ou non. Si vous souhaitez que cette playlist reste publique, veuillez sélectionner l'entrée suivante"
},
"lyricSearch": {
"title": "rechercher parole",
+8 -4
View File
@@ -140,7 +140,7 @@
"skip_back": "向后跳过",
"favorite": "收藏",
"next": "下一首",
"shuffle": "随机",
"shuffle": "随机播放",
"playbackFetchNoResults": "未找到歌曲",
"playbackFetchInProgress": "正在加载歌曲…",
"addNext": "添加为播放列表下一首",
@@ -152,7 +152,7 @@
"unfavorite": "取消收藏",
"queue_moveToTop": "将所选项移至底部",
"queue_moveToBottom": "将所选项移至顶部",
"shuffle_off": "随机关闭",
"shuffle_off": "禁用随机播放",
"addLast": "添加至播放列表末尾",
"mute": "静音",
"skip_forward": "向前跳过",
@@ -374,7 +374,10 @@
"webAudio_description": "使用 web 音频。这将启用重播增益等高级功能。如果您遇到其他情况,请禁用",
"artistConfiguration_description": "配置专辑艺术家页面上显示的项目及其显示顺序",
"webAudio": "使用 web 音频",
"artistConfiguration": "专辑艺术家页面配置"
"artistConfiguration": "专辑艺术家页面配置",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"trayEnabled_description": "显示/隐藏托盘图标/菜单。如果禁用,也会禁用最小化/退出到托盘",
"trayEnabled": "显示托盘"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -538,7 +541,8 @@
"showDetails": "获取信息",
"shareItem": "分享项目",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"download": "下载"
"download": "下载",
"playShuffled": "$t(player.shuffle)"
},
"trackList": {
"title": "$t(entity.track_other)",
+3 -1
View File
@@ -647,7 +647,9 @@ if (!singleInstance) {
});
createWindow();
createTray();
if (store.get('window_enable_tray', true)) {
createTray();
}
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.
+16 -11
View File
@@ -53,7 +53,7 @@ const getAlbumArtistCoverArtUrl = (args: {
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&` +
`?width=${size}` +
'&quality=96'
);
};
@@ -69,7 +69,7 @@ const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: numbe
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
};
@@ -86,7 +86,7 @@ const getSongCoverArtUrl = (args: {
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
}
@@ -97,7 +97,7 @@ const getSongCoverArtUrl = (args: {
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
}
@@ -116,7 +116,7 @@ const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size:
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
};
@@ -153,11 +153,16 @@ const normalizeSong = (
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
discSubtitle: null,
duration: item.RunTimeTicks / 10000,
gain: item.LUFS
? {
track: -18 - item.LUFS,
}
: null,
gain:
item.NormalizationGain !== undefined
? {
track: item.NormalizationGain,
}
: item.LUFS
? {
track: -18 - item.LUFS,
}
: null,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
@@ -388,7 +393,7 @@ const getGenreCoverArtUrl = (args: {
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
};
@@ -413,6 +413,7 @@ const song = z.object({
MediaSources: z.array(mediaSources),
MediaType: z.string(),
Name: z.string(),
NormalizationGain: z.number().optional(),
ParentIndexNumber: z.number(),
PlaylistItemId: z.string().optional(),
PremiereDate: z.string().optional(),
+12 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useState, useRef } from 'react';
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
@@ -21,8 +21,9 @@ import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-han
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { PlayerState, useCssSettings, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
import { FontType, PlaybackType, PlayerStatus, WebAudio } from '/@/renderer/types';
import '@ag-grid-community/styles/ag-grid.css';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import i18n from '/@/i18n/i18n';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
@@ -91,6 +92,8 @@ export const App = () => {
}
}, [builtIn, custom, system, type]);
const [webAudio, setWebAudio] = useState<WebAudio>();
useEffect(() => {
if (enabled && content) {
// Yes, CSS is sanitized here as well. Prevent a suer from changing the
@@ -125,6 +128,10 @@ export const App = () => {
return { handlePlayQueueAdd };
}, [handlePlayQueueAdd]);
const webAudioProvider = useMemo(() => {
return { setWebAudio, webAudio };
}, [webAudio]);
// Start the mpv instance on startup
useEffect(() => {
const initializeMpv = async () => {
@@ -278,7 +285,9 @@ export const App = () => {
>
<PlayQueueHandlerContext.Provider value={providerValue}>
<ContextMenuProvider>
<AppRouter />
<WebAudioContext.Provider value={webAudioProvider}>
<AppRouter />
</WebAudioContext.Provider>{' '}
</ContextMenuProvider>
</PlayQueueHandlerContext.Provider>
<IsUpdatedDialog />
@@ -18,6 +18,7 @@ import {
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { getServerById, TranscodingConfig, usePlaybackSettings, useSpeed } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast';
import { api } from '/@/renderer/api';
@@ -44,11 +45,6 @@ const getDuration = (ref: any) => {
return ref.current?.player?.player?.player?.duration;
};
type WebAudio = {
context: AudioContext;
gain: GainNode;
};
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
// This is used so that the player will always have an <audio> element. This means that
// player1Source and player2Source are connected BEFORE the user presses play for
@@ -116,7 +112,7 @@ export const AudioPlayer = forwardRef(
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
const playback = useSettingsStore((state) => state.playback.mpvProperties);
const useWebAudio = useSettingsStore((state) => state.playback.webAudio);
const shouldUseWebAudio = useSettingsStore((state) => state.playback.webAudio);
const { resetSampleRate } = useSettingsStoreActions();
const playbackSpeed = useSpeed();
const { transcode } = usePlaybackSettings();
@@ -124,7 +120,7 @@ export const AudioPlayer = forwardRef(
const stream1 = useSongUrl(transcode, currentPlayer === 1, player1);
const stream2 = useSongUrl(transcode, currentPlayer === 2, player2);
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
const { webAudio, setWebAudio } = useWebAudio();
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
null,
);
@@ -181,7 +177,7 @@ export const AudioPlayer = forwardRef(
);
useEffect(() => {
if (useWebAudio && 'AudioContext' in window) {
if (shouldUseWebAudio && 'AudioContext' in window) {
let context: AudioContext;
try {
@@ -200,7 +196,7 @@ export const AudioPlayer = forwardRef(
const gain = context.createGain();
gain.connect(context.destination);
setWebAudio({ context, gain });
setWebAudio!({ context, gain });
return () => {
return context.close();
@@ -568,7 +568,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
suppressRowDrag
columnDefs={topSongsColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.id}
getRowId={(data) => data.data.uniqueId}
rowData={topSongs}
rowHeight={60}
rowSelection="multiple"
@@ -66,7 +66,7 @@ export const AlbumArtistDetailTopSongsListContent = ({
ref={tableRef}
shouldUpdateSong
{...tableProps}
getRowId={(data) => data.data.id}
getRowId={(data) => data.data.uniqueId}
rowClassRules={rowClassRules}
rowData={data}
rowModelType="clientSide"
@@ -18,6 +18,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ id: 'playNext' },
{ id: 'playShuffled' },
{ divider: true, id: 'playSimilarSongs' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
@@ -31,7 +32,8 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
export const SONG_ALBUM_PAGE: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'playNext' },
{ divider: true, id: 'playShuffled' },
{ divider: true, id: 'addToPlaylist' },
];
@@ -39,6 +41,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ id: 'playNext' },
{ id: 'playShuffled' },
{ divider: true, id: 'playSimilarSongs' },
{ id: 'addToPlaylist' },
{ divider: true, id: 'removeFromPlaylist' },
@@ -54,6 +57,7 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ id: 'playNext' },
{ divider: true, id: 'playShuffled' },
{ divider: true, id: 'playSimilarSongs' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
@@ -67,7 +71,8 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'playNext' },
{ divider: true, id: 'playShuffled' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
@@ -79,14 +84,16 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'playNext' },
{ divider: true, id: 'playShuffled' },
{ divider: true, id: 'addToPlaylist' },
];
export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'playNext' },
{ divider: true, id: 'playShuffled' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
@@ -98,7 +105,8 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'playNext' },
{ divider: true, id: 'playShuffled' },
{ divider: true, id: 'shareItem' },
{ id: 'deletePlaylist' },
];
@@ -31,6 +31,7 @@ import {
RiInformationFill,
RiRadio2Fill,
RiDownload2Line,
RiShuffleFill,
} from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
import {
@@ -774,6 +775,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
leftIcon: <RiAddCircleFill size="1.1rem" />,
onClick: () => handlePlay(Play.NEXT),
},
playShuffled: {
id: 'playShuffled',
label: t('page.contextMenu.playShuffled', { postProcess: 'sentenceCase' }),
leftIcon: <RiShuffleFill size="1.1rem" />,
onClick: () => handlePlay(Play.SHUFFLE),
},
playSimilarSongs: {
id: 'playSimilarSongs',
label: t('page.contextMenu.playSimilarSongs', { postProcess: 'sentenceCase' }),
@@ -23,6 +23,7 @@ export type ContextMenuItemType =
| 'play'
| 'playLast'
| 'playNext'
| 'playShuffled'
| 'addToPlaylist'
| 'removeFromPlaylist'
| 'addToFavorites'
@@ -45,6 +46,7 @@ export const CONFIGURABLE_CONTEXT_MENU_ITEMS: ContextMenuItemType[] = [
'play',
'playLast',
'playNext',
'playShuffled',
'playSimilarSongs',
'addToPlaylist',
'removeFromPlaylist',
@@ -11,8 +11,17 @@ import {
useFullScreenPlayerStoreActions,
} from '/@/renderer/store/full-screen-player.store';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
import { lazy, Suspense, useMemo } from 'react';
import { usePlaybackSettings } from '/@/renderer/store';
import { PlaybackType } from '/@/renderer/types';
import { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs';
const Visualizer = lazy(() =>
import('/@/renderer/features/player/components/visualizer').then((module) => ({
default: module.Visualizer,
})),
);
const QueueContainer = styled.div`
position: relative;
display: flex;
@@ -61,27 +70,41 @@ export const FullScreenPlayerQueue = () => {
const { t } = useTranslation();
const { activeTab, opacity } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { type, webAudio } = usePlaybackSettings();
const headerItems = [
{
active: activeTab === 'queue',
icon: <RiFileMusicLine size="1.5rem" />,
label: t('page.fullscreenPlayer.upNext'),
onClick: () => setStore({ activeTab: 'queue' }),
},
{
active: activeTab === 'related',
icon: <HiOutlineQueueList size="1.5rem" />,
label: t('page.fullscreenPlayer.related'),
onClick: () => setStore({ activeTab: 'related' }),
},
{
active: activeTab === 'lyrics',
icon: <RiFileTextLine size="1.5rem" />,
label: t('page.fullscreenPlayer.lyrics'),
onClick: () => setStore({ activeTab: 'lyrics' }),
},
];
const headerItems = useMemo(() => {
const items = [
{
active: activeTab === 'queue',
icon: <RiFileMusicLine size="1.5rem" />,
label: t('page.fullscreenPlayer.upNext'),
onClick: () => setStore({ activeTab: 'queue' }),
},
{
active: activeTab === 'related',
icon: <HiOutlineQueueList size="1.5rem" />,
label: t('page.fullscreenPlayer.related'),
onClick: () => setStore({ activeTab: 'related' }),
},
{
active: activeTab === 'lyrics',
icon: <RiFileTextLine size="1.5rem" />,
label: t('page.fullscreenPlayer.lyrics'),
onClick: () => setStore({ activeTab: 'lyrics' }),
},
];
if (type === PlaybackType.WEB && webAudio) {
items.push({
active: activeTab === 'visualizer',
icon: <RiFileTextLine size="1.5rem" />,
label: 'Visualizer',
onClick: () => setStore({ activeTab: 'visualizer' }),
});
}
return items;
}, [activeTab, setStore, t, type, webAudio]);
return (
<GridContainer
@@ -91,6 +114,7 @@ export const FullScreenPlayerQueue = () => {
<Group
grow
align="center"
className="full-screen-player-queue-header"
position="center"
>
{headerItems.map((item) => (
@@ -127,6 +151,10 @@ export const FullScreenPlayerQueue = () => {
</QueueContainer>
) : activeTab === 'lyrics' ? (
<Lyrics />
) : activeTab === 'visualizer' && type === PlaybackType.WEB && webAudio ? (
<Suspense fallback={<></>}>
<Visualizer />
</Suspense>
) : null}
</GridContainer>
);
@@ -244,8 +244,8 @@ export const LeftControls = () => {
<React.Fragment key={`bar-${artist.id}`}>
{index > 0 && <Separator />}
<Text
$link
component={Link}
$link={artist.id !== ''}
component={artist.id ? Link : undefined}
overflow="hidden"
size="md"
to={
@@ -253,7 +253,7 @@ export const LeftControls = () => {
? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})
: ''
: undefined
}
weight={500}
>
@@ -0,0 +1,72 @@
import { createRef, useCallback, useEffect, useState } from 'react';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import AudioMotionAnalyzer from 'audiomotion-analyzer';
import styled from 'styled-components';
import { useSettingsStore } from '/@/renderer/store';
const StyledContainer = styled.div`
margin: auto;
max-width: 100%;
canvas {
margin: auto;
width: 100%;
}
`;
export const Visualizer = () => {
const { webAudio } = useWebAudio();
const canvasRef = createRef<HTMLDivElement>();
const accent = useSettingsStore((store) => store.general.accent);
const [motion, setMotion] = useState<AudioMotionAnalyzer>();
const [length, setLength] = useState(500);
useEffect(() => {
const { context, gain } = webAudio || {};
if (gain && context && canvasRef.current && !motion) {
const audioMotion = new AudioMotionAnalyzer(canvasRef.current, {
ansiBands: true,
audioCtx: context,
connectSpeakers: false,
gradient: 'prism',
mode: 4,
showPeaks: false,
smoothing: 0.8,
});
setMotion(audioMotion);
audioMotion.connectInput(gain);
}
return () => {};
}, [accent, canvasRef, motion, webAudio]);
const resize = useCallback(() => {
const body = document.querySelector('.full-screen-player-queue-container');
const header = document.querySelector('.full-screen-player-queue-header');
if (body && header) {
const width = body.clientWidth - 30;
const height = body.clientHeight - header.clientHeight - 30;
setLength(Math.min(width, height));
}
}, []);
useEffect(() => {
resize();
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
};
}, [resize]);
return (
<StyledContainer
ref={canvasRef}
style={{ height: length, width: length }}
/>
);
};
@@ -0,0 +1,7 @@
import { createContext } from 'react';
import { WebAudio } from '/@/renderer/types';
export const WebAudioContext = createContext<{
setWebAudio?: (audio: WebAudio) => void;
webAudio?: WebAudio;
}>({});
@@ -0,0 +1,7 @@
import { useContext } from 'react';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
export const useWebAudio = () => {
const { webAudio, setWebAudio } = useContext(WebAudioContext);
return { setWebAudio, webAudio };
};
@@ -222,7 +222,7 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
getRowId={(data) => `${data.data.id}-${data.data.pageIndex}`}
getRowId={(data) => `${data.data.uniqueId}-${data.data.pageIndex}`}
rowClassRules={rowClassRules}
rowData={playlistSongData}
rowHeight={60}
@@ -215,6 +215,13 @@ export const ControlSettings = () => {
}),
value: Play.LAST,
},
{
label: t('setting.playButtonBehavior', {
context: 'optionPlayShuffled',
postProcess: 'titleCase',
}),
value: Play.SHUFFLE,
},
]}
defaultValue={settings.playButtonBehavior}
onChange={(e) =>
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { SelectItem, Switch } from '@mantine/core';
import { SelectItem } from '@mantine/core';
import isElectron from 'is-electron';
import { Select, Slider, toast } from '/@/renderer/components';
import { Select, Slider, Switch, toast } from '/@/renderer/components';
import {
SettingsSection,
SettingOption,
@@ -36,7 +36,6 @@ export const TranscodeSettings = () => {
aria-label="Transcode bitrate"
defaultValue={transcode.bitrate}
min={0}
placeholder="mp3, opus"
w={100}
onBlur={(e) => {
setTranscodingConfig({
@@ -61,6 +60,7 @@ export const TranscodeSettings = () => {
<TextInput
aria-label="transcoding format"
defaultValue={transcode.format}
placeholder="mp3, opus"
width={100}
onBlur={(e) => {
setTranscodingConfig({
@@ -81,11 +81,55 @@ export const WindowSettings = () => {
isHidden: !isElectron(),
title: t('setting.windowBarStyle', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label="toggle hiding tray"
defaultChecked={settings.tray}
disabled={!isElectron()}
onChange={(e) => {
if (!e) return;
localSettings?.set('window_enable_tray', e.currentTarget.checked);
if (e.currentTarget.checked) {
setSettings({
window: {
...settings,
tray: true,
},
});
} else {
localSettings?.set('window_start_minimized', false);
localSettings?.set('window_exit_to_tray', false);
localSettings?.set('window_minimize_to_tray', false);
setSettings({
window: {
...settings,
exitToTray: false,
minimizeToTray: false,
startMinimized: false,
tray: false,
},
});
}
}}
/>
),
description: t('setting.trayEnabled', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
note: t('common.restartRequired', {
postProcess: 'sentenceCase',
}),
title: t('setting.trayEnabled', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label="Toggle minimize to tray"
defaultChecked={settings.minimizeToTray}
defaultChecked={settings.tray}
disabled={!isElectron()}
onChange={(e) => {
if (!e) return;
@@ -103,7 +147,7 @@ export const WindowSettings = () => {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
isHidden: !isElectron() || !settings.tray,
title: t('setting.minimizeToTray', { postProcess: 'sentenceCase' }),
},
{
@@ -128,7 +172,7 @@ export const WindowSettings = () => {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
isHidden: !isElectron() || !settings.tray,
title: t('setting.exitToTray', { postProcess: 'sentenceCase' }),
},
{
@@ -153,7 +197,7 @@ export const WindowSettings = () => {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
isHidden: !isElectron() || !settings.tray,
title: t('setting.startMinimized', { postProcess: 'sentenceCase' }),
},
];
@@ -70,7 +70,7 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr
song,
}}
deselectOnClickOutside={fullScreen}
getRowId={(data) => data.data.id}
getRowId={(data) => data.data.uniqueId}
rowBuffer={50}
rowData={songQuery.data ?? []}
rowHeight={tableConfig.rowHeight || 40}
+40 -6
View File
@@ -107,18 +107,53 @@ export const usePlayerStore = create<PlayerSlice>()(
actions: {
addToQueue: (args) => {
const { initialIndex, playType, songs } = args;
const { shuffledIndex } = get().current;
const shuffledQueue = get().queue.shuffled;
const songsToAddToQueue = map(songs, (song) => ({
...song,
uniqueId: nanoid(),
}));
const queue = get().queue.default;
// If the queue is empty, next/last should behave the same as now
if (playType === Play.NOW || queue.length === 0) {
if (playType === Play.SHUFFLE) {
const songs = shuffle(songsToAddToQueue);
const initialSong = songs[0];
if (get().shuffle === PlayerShuffle.TRACK) {
const shuffledIds = [
initialSong.uniqueId,
...shuffle(songs.slice(1).map((song) => song.uniqueId)),
];
set((state) => {
state.queue.default = songs;
state.queue.shuffled = shuffledIds;
state.current.time = 0;
state.current.player = 1;
state.current.index = 0;
state.current.shuffledIndex = 0;
state.current.song = initialSong;
});
} else {
set((state) => {
state.queue.default = songs;
state.queue.shuffled = [];
state.current.time = 0;
state.current.player = 1;
state.current.index = 0;
state.current.shuffledIndex = 0;
state.current.song = initialSong;
});
}
return get().actions.getPlayerData();
}
const shuffledQueue = get().queue.shuffled;
const queue = get().queue.default;
const { shuffledIndex } = get().current;
if (playType === Play.NOW || queue.length === 0) {
const index = initialIndex || 0;
if (get().shuffle === PlayerShuffle.TRACK) {
const index = initialIndex || 0;
const initialSong = songsToAddToQueue[index];
const queueCopy = [...songsToAddToQueue];
@@ -145,7 +180,6 @@ export const usePlayerStore = create<PlayerSlice>()(
state.current.song = initialSong;
});
} else {
const index = initialIndex || 0;
set((state) => {
state.queue.default = songsToAddToQueue;
state.current.time = 0;
+2
View File
@@ -313,6 +313,7 @@ export interface SettingsState {
exitToTray: boolean;
minimizeToTray: boolean;
startMinimized: boolean;
tray: boolean;
windowBarStyle: Platform;
};
}
@@ -647,6 +648,7 @@ const initialState: SettingsState = {
exitToTray: false,
minimizeToTray: false,
startMinimized: false,
tray: true,
windowBarStyle: platformDefaultWindowBarStyle,
},
};
+6
View File
@@ -109,6 +109,7 @@ export enum Play {
LAST = 'last',
NEXT = 'next',
NOW = 'now',
SHUFFLE = 'shuffle',
}
export enum CrossfadeStyle {
@@ -234,3 +235,8 @@ export enum AuthState {
LOADING = 'loading',
VALID = 'valid',
}
export type WebAudio = {
context: AudioContext;
gain: GainNode;
};