Compare commits

...

23 Commits

Author SHA1 Message Date
jeffvli b9312d86fd update to v1.13.0 2026-06-03 01:01:04 -07:00
jeffvli 30a1bda93d add new i18n locales 2026-06-03 00:28:12 -07:00
jeffvli 0e24eeeb1c only show queue save toast on explicit request (#2090) 2026-06-03 00:28:12 -07:00
Hosted Weblate 58d4dea09a Translated using Weblate
Currently translated at 22.2% (277 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 21.9% (273 of 1244 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-03 09:09:27 +02:00
jeffvli c4da44a443 add playlist/artist image upload for jellyfin (#2105) 2026-06-03 00:07:31 -07:00
jeffvli be3f959354 fetch local lyrics first if preferred (#2100) 2026-06-02 23:38:31 -07:00
Overdrive deb69ef8ea Feature: Add sleep timer for end of current album (#2081)
* Add logic for stopping playback at end of current album, unless in shuffle mode.
2026-06-02 23:22:56 -07:00
fiso64 5ac0aaeec0 fix(player): step correctly when seeking rapidly (#2084)
* fix(player): step correctly when seeking rapidly

mediaSkipBackward/mediaSkipForward compute the new target relative to
the timestamp store, which is only refreshed by a ~500ms engine poll.
Rapid presses within that window all read the same stale position and
recompute the same target, so the time appears stuck until the poll
catches up.

Update the timestamp store immediately when a seek is issued so
subsequent presses compute from the new position; the poll then just
reconciles to the same value. Also applied to mediaSeekToTimestamp so
absolute seeks reflect in the UI without the poll lag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(player): unify seek comments per review

Use one canonical explanation in mediaSkipBackward and have
mediaSeekToTimestamp/mediaSkipForward reference it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:11:21 +00:00
Kendall Garner 515cadb916 Better cross platform font handling (#2104)
* fix: better handling of custom font

Practically speaking, custom font seems to have only worked on Linux, because
`net.fetch` would include the mime type in the response headers which could validate the payload.
This doesn't appear to be the case on windows/macOS. Instead:

1. On Linux (or if some other system supports it), check the content type. If good, serve as normal
2. Otherwise, fetch the payload. Read the first four to five bytes and check for a valid magic number.

Additionally, to prevent arbitrary requests fetching other paths via injected content, sync the custom font path
to the main process, and then make _every_ request to `feishin:/` point to the same renderer path.
When setting the font, first send the path to the main process. This will register `feishin:/` to point
to the path provided. This is done via a promise-based set.

Finally, provide a default value for the file input (a best effort approximation for the last part of the file path)
on the file input component.

* make the linter happy
2026-06-02 19:34:16 +00:00
Hosted Weblate 4b4d64c7fc Translated using Weblate
Currently translated at 100.0% (1244 of 1244 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Translated using Weblate

Currently translated at 36.9% (460 of 1244 strings) (Ukrainian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/

Translated using Weblate

Currently translated at 20.5% (256 of 1244 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: albatrays <weblate.duct925@passmail.net>
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-02 16:01:13 +02:00
Hosted Weblate f7e1198482 Added translation using Weblate (Estonian)
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-01 15:15:06 +02:00
jeffvli 7243ed7f15 fix timestamp restore when song switched before playback start (#2094) 2026-05-31 23:09:33 -07:00
York 7e9a78898f fix: punctuation hotkeys not captured (#2091) 2026-05-31 22:43:58 -07:00
Kendall Garner 6aab8d4121 fix(scrobble): use proper pause/unpause for seek, use ms for subsonic 2026-05-31 21:13:11 -07:00
Kendall Garner 70594a696b chore(context menu): show go to only for albums/tracks, prefer artist over album artist where possible 2026-05-31 19:44:58 -07:00
York 08b4c620f2 Merge pull request #2082 from york9675/fix/spacebar
Fix playback hotkeys by preventing browser default
2026-05-31 14:36:53 +00:00
Hosted Weblate 7a20cf3853 Translated using Weblate
Currently translated at 100.0% (1244 of 1244 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: York <goog10216922@gmail.com>
2026-05-31 10:51:08 +02:00
Hosted Weblate dd186c570f Translated using Weblate
Currently translated at 0.8% (10 of 1244 strings) (Tagalog)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/tl/

Co-authored-by: Redd <reddtheeditor@gmail.com>
2026-05-31 08:04:07 +02:00
Hosted Weblate d5e9d491b6 Added translation using Weblate (Tagalog)
Co-authored-by: Redd <reddtheeditor@gmail.com>
2026-05-31 06:08:43 +02:00
Hosted Weblate 28dc822e4f Translated using Weblate
Currently translated at 100.0% (1244 of 1244 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

Currently translated at 100.0% (1244 of 1244 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 100.0% (1244 of 1244 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 84.1% (1047 of 1244 strings) (English)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/en/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Lucas Winther <lucasw89@live.dk>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-05-30 19:01:12 +00:00
Kendall Garner def1b1e710 fix(butterchurn): allow unsafe wasm for butterchurn 2026-05-30 07:01:53 -07:00
Kendall Garner 2ff9e4b0a2 fix(subsonic): fix favoriting/unfavoriting on playlist songs 2026-05-29 09:19:39 -07:00
jeffvli 49bfc907cd bump lock-threads to v6 2026-05-28 23:07:47 -07:00
37 changed files with 990 additions and 152 deletions
+5 -4
View File
@@ -12,7 +12,8 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v6
with:
process-only: 'issues, prs'
issue-inactive-days: 120
@@ -29,15 +30,15 @@ jobs:
days-before-pr-close: 30
stale-issue-message: >
This issue has been automatically marked as stale because it has not had recent activity. The resources of the Feishin team are limited, and so we are asking for your help.
If this is a **bug** and you can still reproduce this error on the <code>development</code> branch, please reply with all of the information you have about it in order to keep the issue open.
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
stale-pr-message: >
This PR has been automatically marked as stale because it has not had recent activity. The resources of the Feishin team are limited, and so we are asking for your help.
This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.12.1",
"version": "1.13.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
+18
View File
@@ -8,6 +8,7 @@ import cs from './locales/cs.json';
import de from './locales/de.json';
import en from './locales/en.json';
import es from './locales/es.json';
import et from './locales/et.json';
import eu from './locales/eu.json';
import fa from './locales/fa.json';
import fi from './locales/fi.json';
@@ -27,6 +28,8 @@ import sl from './locales/sl.json';
import sr from './locales/sr.json';
import sv from './locales/sv.json';
import ta from './locales/ta.json';
import th from './locales/th.json';
import tl from './locales/tl.json';
import tr from './locales/tr.json';
import zhHans from './locales/zh-Hans.json';
import zhHant from './locales/zh-Hant.json';
@@ -38,6 +41,7 @@ const resources = {
de: { translation: de },
en: { translation: en },
es: { translation: es },
et: { translation: et },
eu: { translation: eu },
fa: { translation: fa },
fi: { translation: fi },
@@ -57,6 +61,8 @@ const resources = {
sr: { translation: sr },
sv: { translation: sv },
ta: { translation: ta },
th: { translation: th },
tl: { translation: tl },
tr: { translation: tr },
'zh-Hans': { translation: zhHans },
'zh-Hant': { translation: zhHant },
@@ -87,6 +93,10 @@ export const languages = [
label: 'Español',
value: 'es',
},
{
label: 'Eesti',
value: 'et',
},
{
label: 'Basque',
value: 'eu',
@@ -163,6 +173,14 @@ export const languages = [
label: 'Tamil',
value: 'ta',
},
{
label: 'Thai',
value: 'th',
},
{
label: 'Tagalog',
value: 'tl',
},
{
label: 'Türkçe',
value: 'tr',
+22 -7
View File
@@ -341,7 +341,8 @@
"rename": "Reanomena",
"newVersionAvailable": "Hi ha una nova versió disponible",
"numberOfResults": "{{numberOfResults}} resultats",
"back": "Enrere"
"back": "Enrere",
"openFolder": "Obre la carpeta"
},
"entity": {
"album_one": "Àlbum",
@@ -462,7 +463,7 @@
"expireInvalid": "La data d'expiració ha de ser al futur",
"createFailed": "No s'ha pogut crear el recurs compartit (està habilitat, l'ús compartit?)",
"copyToClipboard": "Copiar al porta-retalls: Ctrl+C, enter",
"successMustClick": "Compartició creada correctament. Feu clic aquí per obrir-la."
"successMustClick": "Compartició creada correctament. Feu clic aquí per obrir-la"
},
"updateServer": {
"success": "S'ha actualitzat el servidor amb èxit",
@@ -495,7 +496,12 @@
"input_played": "Reprodueix el filtre",
"input_played_optionAll": "Totes les pistes",
"input_played_optionUnplayed": "Només les pistes sense reproduir",
"input_played_optionPlayed": "Només les pistes reproduïdes"
"input_played_optionPlayed": "Només les pistes reproduïdes",
"input_kind_albums": "Àlbums",
"input_kind_songs": "Cançons",
"input_kind": "Seleccions a l'atzar",
"input_limit_albums": "Quants àlbums?",
"input_limit_songs": "Quantes cançons?"
},
"createRadioStation": {
"success": "Emissora de ràdio creada amb èxit",
@@ -626,7 +632,7 @@
"customCssEnable_description": "Permet escriure CSS personalitzat",
"customCssNotice": "Atenció: tot i que hi ha un filtre (no es permet ni URL() ni content:), l'ús de CSS personalitzat pot presentar riscs si canvieu la interfície",
"customCss": "Css personalitzat",
"customCss_description": "Contingut del CSS personalitzat. Nota: la propietat \"content\" i els urls remots no es permeten. A sota hi teniu una previsualització. Els camps addicionals que no establiu hi apareixin pel filtre",
"customCss_description": "Contingut del CSS personalitzat. Nota: la propietat \"content\" i els urls remots no es permeten. A sota hi teniu una previsualització. Els camps addicionals que no establiu hi apareixen a causa de la sanitització. Escriptori: Feishin llegeix i escriu custom.css al directori de configuració de l'aplicació i el recarrega quan el fitxer canvia",
"customFontPath": "Ruta de font personalitzada",
"customFontPath_description": "Estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació",
"discordApplicationId": "ID d'aplicació de {{discord}}",
@@ -807,7 +813,7 @@
"releaseChannel": "Canal de versions",
"releaseChannel_description": "Trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques",
"mediaSession": "Activa media session",
"mediaSession_description": "Activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig",
"mediaSession_description": "Activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig. Requereix el Reproductor Web d'Àudio.",
"crossfadeStyle": "Estil de fosa encadenada",
"discordRichPresence": "Estat d'activitat de {{discord}}",
"enableAutoTranslation_description": "Activa la traducció automàtica en carregar la lletra",
@@ -828,7 +834,7 @@
"transcode": "Activa la transcodificació",
"autoDJ": "DJ automàtic",
"autoDJ_itemCount": "Número d'elements",
"autoDJ_itemCount_description": "El nombre d'elements que s'intenten afegir a la cua quan el DJ automàtic està activat",
"autoDJ_itemCount_description": "El nombre d'elements que s'intenten afegir a la cua",
"autoDJ_timing": "Temps",
"autoDJ_timing_description": "El nombre de cançons que han de quedar a la cua per activar el DJ automàtic",
"analyticsDisable": "Desactiva les analítiques basades en l'ús",
@@ -958,7 +964,16 @@
"sidebarPlaylistMode_description": "Com es mostra cada llista de reproducció a la llista de la barra lateral",
"sidebarPlaylistMode": "Mode de llista de reproducció a la barra lateral",
"sidebarPlaylistMode_optionCompact": "Compacte",
"sidebarPlaylistMode_optionExpanded": "Expandit"
"sidebarPlaylistMode_optionExpanded": "Expandit",
"autoDJ_mode": "Mode",
"autoDJ_mode_albums": "Àlbums",
"autoDJ_mode_description": "Trieu si voleu afegir cançons o àlbums sencers a la cua",
"autoDJ_mode_songs": "Cançons",
"autoDJ_enabled": "Activa el DJ automàtic",
"autoDJ_albumStrategy": "Mode de selecció d'àlbum",
"autoDJ_songStrategy": "Mode de selecció de cançó",
"autoDJ_strategy_option_library_random": "A l'atzar",
"autoDJ_strategy_option_similar": "Similar"
},
"table": {
"column": {
+1 -1
View File
@@ -296,7 +296,7 @@
"releaseChannel": "Kanál vydání",
"releaseChannel_description": "Vyberte si mezi stabilními, beta nebo alpha (nočními) vydáními pro automatické aktualizace",
"mediaSession": "Povolit relaci médií",
"mediaSession_description": "Povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce",
"mediaSession_description": "Povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce. Vyžaduje webový přehrávač zvuku.",
"exportImportSettings_control_description": "Exportovat a importovat nastavení pomocí souboru JSON",
"exportImportSettings_control_exportText": "Exportovat nastavení",
"exportImportSettings_control_importText": "Importovat nastavení",
+2 -1
View File
@@ -699,6 +699,7 @@
"viewQueue": "View queue",
"sleepTimer": "Sleep timer",
"sleepTimer_endOfSong": "End of current song",
"sleepTimer_endOfAlbum": "End of current album",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} hr",
"sleepTimer_custom": "Custom",
@@ -1093,7 +1094,7 @@
"sidePlayQueueLayout_description": "Sets the layout of the attached side play queue",
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
"sidePlayQueueLayout_optionVertical": "Vertical",
"mediaSession_description": "Enables media session integration, displaying media controls and metadata in the system volume overlay and lock screen",
"mediaSession_description": "Enables media session integration, displaying media controls and metadata in the system volume overlay and lock screen. Requires the Web Audio Player.",
"mediaSession": "Enable media session",
"sidePlayQueueStyle": "Side play queue style",
"skipDuration_description": "Sets the duration to skip when using the skip buttons on the player bar",
+1 -1
View File
@@ -296,7 +296,7 @@
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
"artistBackground_description": "Añade una imagen de fondo para las páginas de artistas que contienen el arte de los artistas",
"mediaSession": "Activar sesión de medios",
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo. Requiere el Reproductor Web de Audio.",
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
"exportImportSettings_control_exportText": "Exportar configuración",
"exportImportSettings_control_importText": "Importar configuración",
+316
View File
@@ -0,0 +1,316 @@
{
"action": {
"addToFavorites": "Lisa üksusesse $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "Lisa üksusesse $t(entity.playlist, {\"count\": 1})",
"addOrRemoveFromSelection": "Lisa või eemalda valikust",
"selectRangeOfItems": "Vali mitu üksust korraga",
"clearQueue": "Tühjenda järjekord",
"goToCurrent": "Mine praeguse üksuse juurde",
"collapseAllFolders": "Ahenda kõik kaustad",
"expandAllFolders": "Laienda kõik kaustad",
"createPlaylist": "Loo $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "Loo $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "Kustuta $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "Kustuta $t(entity.radioStation, {\"count\": 1})",
"selectAll": "Vali kõik",
"deselectAll": "Tühista kõigi valik",
"downloadStarted": "{{count}} üksuse allalaadimine algas",
"editPlaylist": "Muuda $t(entity.playlist, {\"count\": 1})",
"goToPage": "Mine lehele",
"moveToNext": "Järgmine",
"moveToBottom": "Liiguta lõppu",
"moveToTop": "Liiguta algusesse",
"moveUp": "Liigu üles",
"moveDown": "Liigu alla",
"holdToMoveToTop": "Hoia algusesse liigutamiseks",
"holdToMoveToBottom": "Hoia lõppu liigutamiseks",
"moveItems": "Liiguta üksusi",
"shuffle": "Sega",
"shuffleAll": "Sega kõik",
"shuffleSelected": "Sega valitud",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "Eemalda üksusest $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "Eemalda üksusest $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "Eemalda järjekorrast",
"setRating": "Hinda",
"toggleSmartPlaylistEditor": "Lülita $t(entity.smartPlaylist) redaktor sisse/välja",
"viewPlaylists": "Vaata $t(entity.playlist, {\"count\": 2})",
"viewMore": "Vaata rohkem",
"openApplicationDirectory": "Ava rakenduste kataloog",
"openIn": {
"lastfm": "Ava Last.fm-is",
"listenbrainz": "Ava ListenBrainzis",
"musicbrainz": "Ava MusicBrainzis",
"qobuz": "Ava Qobuzis",
"spotify": "Ava Spotifys"
}
},
"common": {
"countSelected": "{{count}} valitud",
"explicitStatus": "Ebasündsa sisu olek",
"action_one": "Toiming",
"action_other": "Toimingud",
"add": "Lisa",
"additionalParticipants": "Teised osalejad",
"newVersion": "Uus versioon on paigaldatud ({{version}})",
"viewReleaseNotes": "Kuva väljalaskemärkmed",
"albumGain": "Albumi helitugevus (gain)",
"albumPeak": "Albumi tippnivoo",
"areYouSure": "Kas oled kindel?",
"ascending": "Kasvav",
"back": "Tagasi",
"backward": "Tagasi",
"biography": "Biograafia",
"bitDepth": "Bititihedus",
"bitrate": "Bitikiirus",
"bpm": "BPM",
"cancel": "Tühista",
"center": "Keskel",
"channel_one": "Kanal",
"channel_other": "Kanalid",
"clear": "Tühjenda",
"close": "Sulge",
"codec": "Koodek",
"collapse": "Ahenda",
"comingSoon": "Tulekul…",
"configure": "Seadista",
"confirm": "Kinnita",
"create": "Loo",
"currentSong": "Praegune $t(entity.track, {\"count\": 1})",
"decrease": "Vähenda",
"delete": "Kustuta",
"descending": "Kahanev",
"description": "Kirjeldus",
"disable": "Keela",
"disc": "Plaat",
"dismiss": "Peida",
"doNotShowAgain": "Ära seda enam näita",
"duration": "Kestus",
"view": "Vaata",
"edit": "Muuda",
"enable": "Luba",
"expand": "Laienda",
"example": "Näide",
"externalLinks": "Välised lingid",
"openFolder": "Ava kaust",
"faster": "Kiiremini",
"favorite": "Lemmik",
"filter_one": "Filter",
"filter_other": "Filtrid",
"filters": "Filtrid",
"filter_multiple": "Mitu",
"filter_single": "Üksik",
"forceRestartRequired": "Muudatuste rakendamiseks taaskäivita… taaskäivitamiseks sule teavitus",
"forward": "Edasi",
"gap": "Vahe",
"home": "Avaleht",
"increase": "Suurenda",
"left": "Vasak",
"limit": "Limiit",
"manage": "Halda",
"maximize": "Maksimeeri",
"menu": "Menüü",
"minimize": "Minimeeri",
"modified": "Muudetud",
"mbid": "MusicBrainz ID",
"grouping": "Rühmitamine",
"mood": "Meeleolu",
"name": "Nimi",
"no": "Ei",
"none": "Puudub",
"noResultsFromQuery": "Päring ei andnud vasteid",
"numberOfResults": "{{numberOfResults}} vastet",
"noFilters": "Seadistatud filtreid pole",
"note": "Märkus",
"ok": "Ok",
"owner": "Omanik",
"playerMustBePaused": "Mängija peab olema pausil",
"preview": "Eelvaade",
"previousSong": "Eelmine $t(entity.track, {\"count\": 1})",
"private": "Privaatne",
"public": "Avalik",
"quit": "Välju",
"random": "Juhuslik",
"rating": "Hinne",
"retry": "Proovi uuesti",
"recordLabel": "Plaadifirma",
"releaseType": "Väljaande tüüp",
"refresh": "Värskenda",
"reload": "Laadi uuesti",
"rename": "Nimeta ümber",
"reset": "Lähtesta",
"resetToDefault": "Taasta vaikeväärtused",
"restartRequired": "Vajalik on taaskäivitamine",
"right": "Parem",
"sampleRate": "Diskreetimissagedus",
"save": "Salvesta",
"saveAndReplace": "Salvesta ja asenda",
"saveAs": "Salvesta nimega",
"search": "Otsi",
"setting_one": "Säte",
"setting_other": "Sätted",
"slower": "Aeglasemalt",
"share": "Jaga",
"size": "Suurus",
"sort": "Järjesta",
"sortOrder": "Järjestus",
"tags": "Sildid",
"title": "Pealkiri",
"trackNumber": "Pala",
"trackGain": "Pala võimendus",
"trackPeak": "Pala tippväärtus",
"translation": "Tõlge",
"unknown": "Tundmatu",
"version": "Versioon",
"year": "Aasta",
"yes": "Jah",
"explicit": "Ebatsensuurne",
"clean": "Puhas",
"gridRows": "Ruudustiku ridu",
"tableColumns": "Tabeli veerge",
"itemsMore": "{{count}} veel",
"newVersionAvailable": "Saadaval on uus versioon",
"path": "Asukoht"
},
"entity": {
"album_one": "Album",
"album_other": "Albumid",
"albumArtist_one": "Albumi esitaja",
"albumArtist_other": "Albumi esitajad",
"albumArtistCount_one": "{{count}} albumi esitaja",
"albumArtistCount_other": "{{count}} albumi esitajat",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albumit",
"radioStation_one": "Raadiojaam",
"radioStation_other": "Raadiojaamad",
"radioStationWithCount_one": "{{count}} raadiojaam",
"radioStationWithCount_other": "{{count}} raadiojaama",
"artist_one": "Esitaja",
"artist_other": "Esitajad",
"artistWithCount_one": "{{count}} esitaja",
"artistWithCount_other": "{{count}} esitajat",
"favorite_one": "Lemmik",
"favorite_other": "Lemmikud",
"folder_one": "Kaust",
"folder_other": "Kaustad",
"folderWithCount_one": "{{count}} kaust",
"folderWithCount_other": "{{count}} kausta",
"genre_one": "Žanr",
"genre_other": "Žanrid",
"genreWithCount_one": "{{count}} žanr",
"genreWithCount_other": "{{count}} žanrit",
"playlist_one": "Esitusloend",
"playlist_other": "Esitusloendid",
"play_one": "{{count}} esitus",
"play_other": "{{count}} esitust",
"playlistWithCount_one": "{{count}} esitusloend",
"playlistWithCount_other": "{{count}} esitusloendit",
"smartPlaylist": "Nutikas $t(entity.playlist, {\"count\": 1})",
"track_one": "Rada",
"track_other": "Rajad",
"song_one": "Lugu",
"song_other": "Lood",
"trackWithCount_one": "{{count}} rada",
"trackWithCount_other": "{{count}} rada"
},
"error": {
"apiRouteError": "Päringut ei saanud edastada",
"audioDeviceFetchError": "Heliseadmete hankimisel tekkis viga",
"authenticationFailed": "Autentimine nurjus",
"badAlbum": "Näed seda lehte, kuna see lugu ei kuulu ühegi albumi alla. Kõige sagedamini juhtub see siis, kui lugu asub sinu muusikakausta juurkataloogis (otse muusikakaustas). Jellyfin rühmitab lood albumiteks ainult siis, kui need asuvad eraldi kaustas",
"badValue": "Kehtetu valik \"{{value}}\". Seda väärtust enam ei eksisteeri",
"credentialsRequired": "Nõutav on autentimine",
"endpointNotImplementedError": "Lõpp-punkt {{endpoint}} pole serveri {{serverType}} puhul toetatud",
"genericError": "Tekkis viga",
"invalidJson": "Vigane JSON",
"invalidServer": "Vigane server",
"localFontAccessDenied": "Juurdepääs kohalikele fontidele on keelatud",
"loginRateError": "Liiga palju sisselogimiskatseid, proovi mõne sekundi pärast uuesti",
"mpvRequired": "Vajalik on MPV",
"multipleServerSaveQueueError": "Esitusjärjekorras on lugusi, mis ei pärine praegusest serverist. See pole toetatud",
"networkError": "Tekkis võrguviga",
"noNetwork": "Server pole saadaval",
"noNetworkDescription": "Selle serveriga ei õnnestunud ühendust luua",
"notificationDenied": "Märguanded on keelatud. Sellel seadel pole mõju",
"openError": "Faili avamine nurjus",
"playbackError": "Meedia esitamisel tekkis viga",
"playbackPausedDueToError": "Taasesitus peatati vea tõttu",
"remoteDisableError": "Kaugserveri toimingu $t(common.disable) käigus tekkis viga",
"remoteEnableError": "Tõrge kaugserveri toimingul: $t(common.enable)",
"remotePortError": "Kaugserveri pordi määramisel tekkis viga",
"remotePortWarning": "Uue pordi rakendamiseks taaskäivita server",
"saveQueueFailed": "Järjekorra salvestamine nurjus",
"serverLockSingleServer": "Lukustatud serveri korral on lubatud ainult üks server",
"serverNotSelectedError": "Serverit pole valitud",
"serverRequired": "Vajalik on server",
"sessionExpiredError": "Su seanss on aegunud",
"systemFontError": "Süsteemifontide hankimisel ilmnes viga",
"settingsSyncError": "Esitaja (renderer) ja peaprotsessi seadete vahel leiti lahknevusi. Muudatuste rakendamiseks taaskäivita rakendus"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"matchAnd": "ja",
"matchOr": "või",
"albumCount": "$t(entity.album, {\"count\": 2}) arv",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "Biograafia",
"bitrate": "Bitikiirus",
"bpm": "BPM",
"channels": "$t(common.channel, {\"count\": 2})",
"comment": "Kommentaar",
"communityRating": "Kogukonna hinne",
"criticRating": "Kriitikute hinne",
"dateAdded": "Lisatud",
"disc": "Plaat",
"duration": "Kestus",
"favorited": "Lemmikuks lisatud",
"fromYear": "Aastast",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "ID",
"isCompilation": "on kogumik",
"isFavorited": "on lemmik",
"isPublic": "on avalik",
"isRated": "on hinnatud",
"isRecentlyPlayed": "on hiljuti esitatud",
"lastPlayed": "Viimati esitatud",
"mostPlayed": "Enim esitatud",
"name": "Nimi",
"note": "Märkus",
"owner": "$t(common.owner)",
"path": "Asukoht",
"playCount": "Esituskordi",
"random": "Juhuslik",
"rating": "Hinne",
"recentlyAdded": "Viimati lisatud",
"recentlyPlayed": "Hiljuti esitatud",
"recentlyUpdated": "Hiljuti uuendatud",
"releaseDate": "Ilmumiskuupäev",
"releaseYear": "Ilmumisaasta",
"search": "Otsing",
"songCount": "Lugude arv",
"explicitStatus": "$t(common.explicitStatus)",
"title": "Pealkiri",
"sortName": "Sortimisnimi",
"trackNumber": "Lugu"
},
"filterOperator": {
"contains": "Sisaldab",
"endsWith": "Lõpeb",
"is": "On",
"isNot": "Ei ole",
"isGreaterThan": "On suurem kui",
"isLessThan": "On väiksem kui",
"matchesRegex": "Vastab regulaaravaldisele",
"notContains": "Ei sisalda",
"inTheRangeDate": "Jääb vahemikku (kuupäev)",
"inTheRange": "Jääb vahemikku",
"inTheLast": "Viimase",
"inPlaylist": "On",
"beforeDate": "On enne (kuupäeva)",
"before": "On enne",
"afterDate": "On pärast (kuupäeva)",
"after": "On pärast",
"notInPlaylist": "Ei ole"
}
}
+1 -1
View File
@@ -979,7 +979,7 @@
"preservePitch": "Utrzymuj ton",
"preventSleepOnPlayback_description": "Powstrzymuje ekran przed uśpieniem, gdy muzyka jest odtwarzana",
"preventSleepOnPlayback": "Powstrzymuj uśpienie podczas odtwarzania",
"mediaSession_description": "Włącza integrację z Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokady",
"mediaSession_description": "Włącza integrację z Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokad. Wymaga odtwarzacza web audio.",
"mediaSession": "Włącz media session",
"transcode": "Włącz transkodowanie",
"queryBuilder": "Kreator zaptań",
+14
View File
@@ -0,0 +1,14 @@
{
"action": {
"addToFavorites": "Idagdag sa $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "Idagdag sa $t(entity.playlist, {\"count\": 1})",
"addOrRemoveFromSelection": "Idagdag o alisin sa pinili",
"collapseAllFolders": "Isara lahat ng mga folder",
"expandAllFolders": "Buksan lahat ng mga folder",
"createPlaylist": "Gumawa $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "Gumawa $t(entity.radioStation, {\"count\": 1})",
"selectAll": "Piliin lahat",
"deselectAll": "Huwag piliin lahat",
"downloadStarted": "Nagsimulang mag-dowload ng {{count}} (mga) aytem"
}
}
+2 -1
View File
@@ -169,7 +169,8 @@
"tableColumns": "Стовпці таблиці",
"itemsMore": "{{count}} більше",
"numberOfResults": "{{numberOfResults}} результатів",
"newVersionAvailable": "Доступна нова версія"
"newVersionAvailable": "Доступна нова версія",
"back": "Повернутися"
},
"entity": {
"album_one": "Альбом",
+3 -2
View File
@@ -420,7 +420,8 @@
"sleepTimer_setCustom": "設定定時器",
"sleepTimer_cancel": "取消定時器",
"albumRadio": "專輯電台",
"scrobbleForceSubmit": "強制紀錄"
"scrobbleForceSubmit": "強制紀錄",
"sleepTimer_endOfAlbum": "專輯播完時"
},
"setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -666,7 +667,7 @@
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
"mediaSession": "啟用 Media Session",
"mediaSession_description": "啟用 Media Session 整合功能,系統音量 Overlay 和鎖定畫面顯示媒體資料與控制面板",
"mediaSession_description": "啟用 Media Session 整合功能,系統音量疊加層和鎖定畫面顯示媒體控制項與中繼資料。此功能需要使用網頁播放器。",
"releaseChannel": "發佈通道",
"analyticsDisable": "選擇退出使用情況分析",
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
+8
View File
@@ -141,6 +141,14 @@ ipcMain.on('settings-set', (__event, data: { property: string; value: any }) =>
}
});
ipcMain.handle('settings-set-sync', (__event, data: { property: string; value: any }) => {
if (data.value === null) {
store.delete(data.property);
} else {
store.set(data.property, data.value);
}
});
ipcMain.handle('password-get', (_event, server: string): null | string => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
+60 -11
View File
@@ -252,7 +252,9 @@ function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdate
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
}
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
protocol.registerSchemesAsPrivileged([
{ privileges: { bypassCSP: true, corsEnabled: true }, scheme: 'feishin' },
]);
process.on('uncaughtException', (error: any) => {
console.error('Error in main process', error);
@@ -989,14 +991,33 @@ app.on('window-all-closed', () => {
}
});
const FONT_HEADERS = [
const FONT_HEADERS = new Set([
'font/collection',
'font/otf',
'font/sfnt',
'font/ttf',
'font/woff',
'font/woff2',
];
]);
const bytesToInt = (array: Uint8Array, length: number): number => {
let value = 0;
for (let i = 0; i < length; i++) {
value = (value << 8) + array[i];
}
return value;
};
const FONT_FOUR_BYTE_MAGIC_NUMBERS = new Set([
0x4f54544f, // font/otf
0x774f4632, // font/woff2
0x774f4646, // font/woff
]);
const FONT_FIVE_BYTE_MAGIC_NUMBERS = new Set([
0x0001000000, // ttf, collection, sfnt
]);
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
@@ -1017,12 +1038,9 @@ if (!singleInstance) {
app.whenReady()
.then(() => {
protocol.handle('feishin', async (request) => {
const filePath = `file:${request.url.slice('feishin:'.length)}`;
const response = await net.fetch(filePath);
const contentType = response.headers.get('content-type');
if (!contentType || !FONT_HEADERS.includes(contentType)) {
protocol.handle('feishin', async () => {
const filePath = store.get('local_font_path');
if (typeof filePath !== 'string') {
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
@@ -1031,7 +1049,38 @@ if (!singleInstance) {
});
}
return response;
const response = await net.fetch('file:' + filePath);
const contentType = response.headers.get('content-type');
// On Linux, the mime type is included in the response header
// In this case, we can forward the response with no further processing
if (contentType && FONT_HEADERS.has(contentType)) {
return response;
}
// Otherwise, let's check the magic number to see if
// the file is a font type. This is either four or five bytes
const payload = await response.arrayBuffer();
const magicNumber = new Uint8Array(payload.slice(0, 5));
const fiveHex = bytesToInt(magicNumber, 5);
const fourHex = bytesToInt(magicNumber, 4);
if (
FONT_FIVE_BYTE_MAGIC_NUMBERS.has(fiveHex) ||
FONT_FOUR_BYTE_MAGIC_NUMBERS.has(fourHex)
) {
// We have to create a new response with the payload, since it has been read now
return new Response(payload, {
headers: response.headers,
});
}
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
status: 403,
statusText: 'Forbidden',
});
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
@@ -1039,7 +1088,7 @@ if (!singleInstance) {
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"script-src 'self' 'unsafe-inline' https://umami.jeffvli.org; style-src 'self' 'unsafe-inline'; media-src 'self' http: https: data: blob:; img-src 'self' http: https: data: blob:; connect-src 'self' http: https: ws: wss:; default-src 'self';",
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://umami.jeffvli.org; style-src 'self' 'unsafe-inline'; media-src 'self' http: https: data: blob:; img-src 'self' http: https: data: blob:; connect-src 'self' http: https: ws: wss:; default-src 'self';",
],
},
});
+8
View File
@@ -9,6 +9,13 @@ const set = (
ipcRenderer.send('settings-set', { property, value });
};
const setSync = async (
property: string,
value: boolean | null | Record<string, unknown> | string | string[],
) => {
return ipcRenderer.invoke('settings-set-sync', { property, value });
};
const get = async (property: string) => {
return ipcRenderer.invoke('settings-get', { property });
};
@@ -99,6 +106,7 @@ export const localSettings = {
passwordSet,
restart,
set,
setSync,
setZoomFactor,
themeSet,
};
+1
View File
@@ -139,6 +139,7 @@ export const utils = {
rendererToggleSidebar,
rendererUpdateAvailable,
saveCustomCss,
separator: isWindows() ? '\\' : '/',
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
+36
View File
@@ -54,6 +54,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.deleteArtistImage,
400: jfType._response.error,
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
@@ -63,6 +72,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
deletePlaylistImage: {
body: null,
method: 'DELETE',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.deletePlaylistImage,
400: jfType._response.error,
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
@@ -356,6 +374,24 @@ export const contract = c.router({
400: jfType._response.error,
},
},
uploadArtistImage: {
body: z.string(),
method: 'POST',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.uploadArtistImage,
400: jfType._response.error,
},
},
uploadPlaylistImage: {
body: z.string(),
method: 'POST',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.uploadPlaylistImage,
400: jfType._response.error,
},
},
});
const axiosClient = axios.create({});
@@ -1,3 +1,4 @@
import axios from 'axios';
import { set } from 'idb-keyval';
import chunk from 'lodash/chunk';
import filter from 'lodash/filter';
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
import {
albumArtistListSortMap,
albumListSortMap,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
Folder,
genreListSortMap,
ImageArgs,
@@ -29,6 +34,10 @@ import {
SortOrder,
sortOrderMap,
Tag,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadPlaylistImageArgs,
UploadPlaylistImageResponse,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
};
const getImageContentType = (bytes: Uint8Array): string => {
if (bytes[0] === 0x89 && bytes[1] === 0x50) {
return 'image/png';
}
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
return 'image/jpeg';
}
if (bytes[0] === 0x47 && bytes[1] === 0x49) {
return 'image/gif';
}
if (bytes[0] === 0x52 && bytes[1] === 0x49) {
return 'image/webp';
}
return 'image/jpeg';
};
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
type JellyfinApiClientProps = DeletePlaylistImageArgs['apiClientProps'];
const deleteItemPrimaryImage = async (
apiClientProps: JellyfinApiClientProps,
id: string,
errorMessage: string,
): Promise<boolean> => {
const res = await jfApiClient({
...apiClientProps,
server: apiClientProps.server ?? null,
}).deleteArtistImage({
params: {
id,
},
});
if (res.status !== 204) {
throw new Error(errorMessage);
}
return true;
};
const uploadItemPrimaryImage = async (
apiClientProps: JellyfinApiClientProps,
id: string,
image: Uint8Array,
errorMessage: string,
): Promise<boolean> => {
const server = apiClientProps.server;
const serverUrl = getServerUrl(server);
if (!serverUrl) {
throw new Error('Server is required');
}
const contentType = getImageContentType(image);
const base64 = uint8ArrayToBase64(image);
const authHeader = createAuthHeader();
const authorization = server?.credential
? authHeader.concat(`, Token="${server.credential}"`)
: authHeader;
const res = await axios.post(`${serverUrl}/Items/${id}/Images/Primary`, base64, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
},
signal: apiClientProps.signal,
});
if (res.status !== 204) {
throw new Error(errorMessage);
}
return true;
};
// Limit the query to 50 at a time to be *extremely* conservative on the
// length of the full URL, since the ids are part of the query string and
// not the POST body
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
[ServerFeature.PUBLIC_PLAYLIST]: [1],
},
],
['10.0.0', { [ServerFeature.TAGS]: [1] }],
[
'10.0.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
[ServerFeature.TAGS]: [1],
},
],
];
const JF_FIELDS = {
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
id: res.body.Id,
};
},
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete artist image');
},
deleteFavorite: async (args) => {
const { apiClientProps, query } = args;
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
deletePlaylistImage: async (
args: DeletePlaylistImageArgs,
): Promise<DeletePlaylistImageResponse> => {
const { apiClientProps, query } = args;
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete playlist image');
},
getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args;
@@ -1847,6 +1963,28 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
const { apiClientProps, body, query } = args;
return uploadItemPrimaryImage(
apiClientProps,
query.id,
body.image,
'Failed to upload artist image',
);
},
uploadPlaylistImage: async (
args: UploadPlaylistImageArgs,
): Promise<UploadPlaylistImageResponse> => {
const { apiClientProps, body, query } = args;
return uploadItemPrimaryImage(
apiClientProps,
query.id,
body.image,
'Failed to upload playlist image',
);
},
};
function getLibraryId(musicFolderId?: string | string[]) {
@@ -366,7 +366,10 @@ export const SubsonicController: InternalControllerEndpoint = {
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
? query.id
: undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
id:
query.type === LibraryItem.SONG || query.type === LibraryItem.PLAYLIST_SONG
? query.id
: undefined,
},
});
@@ -419,7 +422,10 @@ export const SubsonicController: InternalControllerEndpoint = {
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
? query.id
: undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
id:
query.type === LibraryItem.SONG || query.type === LibraryItem.PLAYLIST_SONG
? query.id
: undefined,
},
});
@@ -2327,7 +2333,6 @@ export const SubsonicController: InternalControllerEndpoint = {
case 'start':
state = 'starting';
break;
case 'timeupdate':
case 'unpause':
state = 'playing';
break;
@@ -4,50 +4,38 @@ import { generatePath, useNavigate } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
QueueSong,
Song,
} from '/@/shared/types/domain-types';
import { Album, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
interface GoToActionProps {
items: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[];
items: Album[] | QueueSong[] | Song[];
}
export const GoToAction = ({ items }: GoToActionProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { albumArtists, albumId } = useMemo(() => {
const { albumId, artists } = useMemo(() => {
const firstItem = items[0];
if (firstItem._itemType === LibraryItem.ALBUM) {
return {
albumArtists: firstItem.albumArtists || [],
albumId: firstItem.id,
};
} else if (firstItem._itemType === LibraryItem.SONG) {
return {
albumArtists: firstItem.albumArtists || [],
albumId: firstItem.albumId,
};
} else if (
firstItem._itemType === LibraryItem.ARTIST ||
firstItem._itemType === LibraryItem.ALBUM_ARTIST
) {
return {
albumArtists: [{ id: firstItem.id, name: firstItem.name }],
albumId: null,
};
switch (firstItem._itemType) {
case LibraryItem.ALBUM:
return {
albumId: firstItem.id,
artists: firstItem.albumArtists || [],
};
case LibraryItem.SONG:
return {
albumId: firstItem.albumId,
artists:
(firstItem.artists?.length ? firstItem.artists : firstItem.albumArtists) ||
[],
};
default:
return {
albumId: null,
artists: [],
};
}
return {
albumArtists: [],
albumId: null,
};
}, [items]);
const handleGoToAlbum = useCallback(() => {
@@ -55,7 +43,7 @@ export const GoToAction = ({ items }: GoToActionProps) => {
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId }));
}, [albumId, navigate]);
const handleGoToAlbumArtist = useCallback(
const handleGoToArtist = useCallback(
(albumArtistId: string) => {
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId }));
},
@@ -81,13 +69,13 @@ export const GoToAction = ({ items }: GoToActionProps) => {
{t('page.contextMenu.goToAlbum')}
</ContextMenu.Item>
)}
{albumArtists.map((albumArtist) => (
{artists.map((artist) => (
<ContextMenu.Item
key={albumArtist.id}
key={artist.id}
leftIcon="artist"
onSelect={() => handleGoToAlbumArtist(albumArtist.id)}
onSelect={() => handleGoToArtist(artist.id)}
>
{`${t('page.contextMenu.goTo')} ${albumArtist.name}`}
{`${t('page.contextMenu.goTo')} ${artist.name}`}
</ContextMenu.Item>
))}
</ContextMenu.SubmenuContent>
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';
@@ -39,8 +38,6 @@ export const AlbumArtistContextMenu = ({ items, type }: AlbumArtistContextMenuPr
<DownloadAction ids={ids} />
<ShareAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />
<ContextMenu.Divider />
<GoToAction items={items} />
<ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content>
);
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';
@@ -39,8 +38,6 @@ export const ArtistContextMenu = ({ items, type }: ArtistContextMenuProps) => {
<DownloadAction ids={ids} />
<ShareAction ids={ids} itemType={LibraryItem.ARTIST} />
<ContextMenu.Divider />
<GoToAction items={items} />
<ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content>
);
+43 -17
View File
@@ -109,12 +109,8 @@ export function computeSelectedFromResult(
};
}
const hasLocalLocal =
(Array.isArray(local) && local.length > 0) ||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics));
// If setting is set to prefer local lyrics, return the local lyrics if available
if (preferLocalLyrics && hasLocalLocal) {
if (preferLocalLyrics && hasLocalLyrics(local)) {
if (Array.isArray(local) && local.length > 0) {
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
return { selected: item, selectedSynced: item.synced };
@@ -236,6 +232,13 @@ export function getDisplayOffset(
return storedOffsetMs;
}
export function hasLocalLyrics(local: FullLyricsMetadata | null | StructuredLyric[]): boolean {
return (
(Array.isArray(local) && local.length > 0) ||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics))
);
}
const emptyResult = (): LyricsQueryResult => ({
local: null,
overrideData: null,
@@ -277,16 +280,11 @@ export const lyricsQueries = {
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
// Fetch local lyrics
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
// Fetch remote auto lyrics
const remoteAutoPromise =
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
? null
: fetchRemoteLyricsAuto(song);
// Fetch override data
const overrideDataPromise = overrideSelection
? fetchRemoteLyricsById({
remoteSongId: overrideSelection.id,
@@ -295,11 +293,40 @@ export const lyricsQueries = {
})
: null;
const [local, remoteAuto, overrideData] = await Promise.all([
localPromise,
remoteAutoPromise,
overrideDataPromise,
]);
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
let local: FullLyricsMetadata | null | StructuredLyric[];
let remoteAuto: FullLyricsMetadata | null;
let overrideData: LyricsResponse | null;
if (preferLocalLyrics) {
local = await localPromise;
if (hasLocalLyrics(local)) {
overrideData = overrideDataPromise ? await overrideDataPromise : null;
remoteAuto = null;
if (remoteAutoPromise) {
void remoteAutoPromise.then((fetchedRemoteAuto) => {
if (signal.aborted || !fetchedRemoteAuto) return;
queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) =>
prev ? { ...prev, remoteAuto: fetchedRemoteAuto } : prev,
);
});
}
} else {
[remoteAuto, overrideData] = await Promise.all([
remoteAutoPromise,
overrideDataPromise,
]);
}
} else {
[local, remoteAuto, overrideData] = await Promise.all([
localPromise,
remoteAutoPromise,
overrideDataPromise,
]);
}
const partial: Pick<
LyricsQueryResult,
@@ -320,13 +347,12 @@ export const lyricsQueries = {
preferLocalLyrics,
selectedStructuredIndex,
);
const displayOffset = getDisplayOffset(
const resultSelectedOffsetMs = getDisplayOffset(
selected,
selectedOffsetMs,
selectedStructuredIndex,
local,
);
const resultSelectedOffsetMs = displayOffset;
return {
...emptyResult(),
@@ -1,6 +1,6 @@
import { useIsFetching } from '@tanstack/react-query';
import { t } from 'i18next';
import { RefObject } from 'react';
import { RefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './play-queue-list-controls.module.css';
@@ -21,6 +21,7 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { toast } from '/@/shared/components/toast/toast';
import { ServerFeature } from '/@/shared/types/features-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
@@ -135,7 +136,17 @@ const QueueRestoreActions = () => {
const isFetching = useIsFetching({ queryKey: queryKeys.player.fetch({ type: 'queue' }) });
const { isPending: isSavingQueue, mutate: handleSaveQueue } = useSaveQueue();
const { isPending: isSavingQueue, mutate: saveQueue } = useSaveQueue();
const handleSaveQueue = useCallback(() => {
saveQueue(undefined, {
onSuccess: () => {
toast.success({
message: t('form.saveQueue.success'),
});
},
});
}, [saveQueue]);
const handleRestoreQueue = useRestoreQueue();
@@ -3,7 +3,11 @@ import { useTranslation } from 'react-i18next';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store';
import {
usePlayerShuffle,
usePlayerStatus,
usePlayerStoreBase,
} from '/@/renderer/store/player.store';
import {
useSleepTimerActions,
useSleepTimerActive,
@@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Popover } from '/@/shared/components/popover/popover';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { PlayerStatus } from '/@/shared/types/types';
import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
const PRESET_OPTIONS = [
{ minutes: 0, mode: 'endOfSong' as const },
{ minutes: 0, mode: 'endOfAlbum' as const },
{ minutes: 5, mode: 'timed' as const },
{ minutes: 10, mode: 'timed' as const },
{ minutes: 15, mode: 'timed' as const },
@@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string {
const useSleepTimer = () => {
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const { cancelTimer, setRemaining } = useSleepTimerActions();
const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions();
const { mediaPause } = usePlayer();
const mediaPauseRef = useRef(mediaPause);
mediaPauseRef.current = mediaPause;
// End of album mode. Set the pauseOnNextSongEnd flag whenever the current track
// is the last one of the target album.
const evaluateEndOfAlbum = useCallback(() => {
const { currentSong, nextSong } = usePlayerStoreBase.getState().getPlayerData();
if (!currentSong) {
return;
}
let target = useSleepTimerStore.getState().targetAlbumId;
if (target === null) {
target = currentSong.albumId;
setTargetAlbumId(target);
}
if (currentSong.albumId !== target) {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
cancelTimer();
return;
}
const isLastOfAlbum = !nextSong || nextSong.albumId !== currentSong.albumId;
usePlayerStoreBase.getState().setPauseOnNextSongEnd(isLastOfAlbum);
}, [cancelTimer, setTargetAlbumId]);
const handleOnCurrentSongChange = useCallback(() => {
if (!active) {
return;
@@ -65,8 +96,14 @@ const useSleepTimer = () => {
if (mode === 'endOfSong') {
cancelTimer();
mediaPauseRef.current();
return;
}
}, [active, mode, cancelTimer, mediaPauseRef]);
// Cancel and pause song change in end-of-album mode
if (mode === 'endOfAlbum') {
evaluateEndOfAlbum();
}
}, [active, mode, cancelTimer, evaluateEndOfAlbum, mediaPauseRef]);
const status = usePlayerStatus();
@@ -104,15 +141,32 @@ const useSleepTimer = () => {
// mediaAutoNext returns PAUSED status when the current song ends.
// This is a generic player mechanism — the web player handles it
// without needing to know about the sleep timer.
// End-of-album mode: set the same flag conditionally, here we run
// the intial evaluation in case the timer was started while already
// on the last track of the album
useEffect(() => {
if (!active || mode !== 'endOfSong') return;
if (!active) {
return;
}
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
if (mode === 'endOfSong') {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}, [active, mode]);
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}
if (mode === 'endOfAlbum') {
evaluateEndOfAlbum();
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}
return undefined;
}, [active, mode, evaluateEndOfAlbum]);
};
export const SleepTimerHookInner = () => {
@@ -135,8 +189,14 @@ export const SleepTimerButton = () => {
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const remaining = useSleepTimerRemaining();
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } =
useSleepTimerActions();
const { mediaPause } = usePlayer();
const shuffle = usePlayerShuffle();
// Track level shuffle scatters and album across a play queue making 'end-of-album'
// meaningless. Album shuffle keeps each album intact, so keep 'end-of-'album
// enabled there
const isTrackShuffle = shuffle === PlayerShuffle.TRACK;
const [showCustom, setShowCustom] = useState(false);
const [customHours, setCustomHours] = useState<number>(0);
@@ -151,13 +211,15 @@ export const SleepTimerButton = () => {
(option: (typeof PRESET_OPTIONS)[number]) => {
if (option.mode === 'endOfSong') {
startEndOfSongTimer();
} else if (option.mode === 'endOfAlbum') {
startEndOfAlbumTimer();
} else {
startTimedTimer(option.minutes * 60);
}
setShowCustom(false);
setOpened(false);
},
[startEndOfSongTimer, startTimedTimer],
[startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer],
);
const handleCustomStart = useCallback(() => {
@@ -178,6 +240,9 @@ export const SleepTimerButton = () => {
if (option.mode === 'endOfSong') {
return t('player.sleepTimer_endOfSong');
}
if (option.mode === 'endOfAlbum') {
return t('player.sleepTimer_endOfAlbum');
}
if (option.minutes >= 60) {
return t('player.sleepTimer_hours', {
count: option.minutes / 60,
@@ -231,6 +296,10 @@ export const SleepTimerButton = () => {
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfSong')}
</Text>
) : mode === 'endOfAlbum' ? (
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfAlbum')}
</Text>
) : (
<Text c="primary" fw="600" size="lg">
{formatRemaining(remaining)}
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
</Flex>
)}
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
(option, index) => (
{PRESET_OPTIONS.filter(
(option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum',
).map((option) => {
const disabled = option.mode === 'endOfAlbum' && isTrackShuffle;
return (
<Button
disabled={disabled}
fullWidth
justify="flex-start"
key={index}
key={option.mode}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
>
{getPresetLabel(option)}
</Button>
),
)}
);
})}
<Divider my="md" />
@@ -19,6 +19,8 @@ import {
import { toast } from '/@/shared/components/toast/toast';
import { PlayerStatus } from '/@/shared/types/types';
let startupRestoreSessionHandled = false;
export const useQueueRestoreTimestamp = () => {
const { mediaSeekToTimestamp } = usePlayerActions();
@@ -51,28 +53,65 @@ export const useInitialTimestampRestore = () => {
const startupRestoreInitializedRef = useRef(false);
const startupSeekArmedRef = useRef<null | number>(null);
const startupSeekTargetUniqueIdRef = useRef<null | string>(null);
const startupSeekAppliedRef = useRef(false);
const applyStartupSeek = useCallback(() => {
const cancelStartupSeek = useCallback(() => {
if (startupSeekAppliedRef.current) {
return;
}
const seekTimestamp = startupSeekArmedRef.current;
if (!seekTimestamp || seekTimestamp <= 0) {
return;
}
startupSeekAppliedRef.current = true;
startupSeekArmedRef.current = null;
startupSeekTargetUniqueIdRef.current = null;
}, []);
const applyStartupSeek = useCallback(() => {
const seekTimestamp = startupSeekArmedRef.current;
if (startupSeekAppliedRef.current) {
return;
}
if (!seekTimestamp || seekTimestamp <= 0) {
return;
}
const targetUniqueId = startupSeekTargetUniqueIdRef.current;
const currentUniqueId = usePlayerStore.getState().getQueue().items[
usePlayerStore.getState().player.index
]?._uniqueId;
if (targetUniqueId && currentUniqueId !== targetUniqueId) {
cancelStartupSeek();
return;
}
startupSeekAppliedRef.current = true;
startupSeekArmedRef.current = null;
startupSeekTargetUniqueIdRef.current = null;
setTimeout(() => {
mediaSeekToTimestamp(seekTimestamp);
}, 100);
}, [mediaSeekToTimestamp]);
}, [cancelStartupSeek, mediaSeekToTimestamp]);
useEffect(() => {
if (startupRestoreInitializedRef.current) {
const targetUniqueId = startupSeekTargetUniqueIdRef.current;
if (
!targetUniqueId ||
startupSeekAppliedRef.current ||
!currentSong ||
currentSong._uniqueId === targetUniqueId
) {
return;
}
cancelStartupSeek();
}, [cancelStartupSeek, currentSong]);
useEffect(() => {
if (startupRestoreInitializedRef.current || startupRestoreSessionHandled) {
return;
}
@@ -81,9 +120,11 @@ export const useInitialTimestampRestore = () => {
}
startupRestoreInitializedRef.current = true;
startupRestoreSessionHandled = true;
if (timestamp > 0) {
startupSeekArmedRef.current = timestamp;
startupSeekTargetUniqueIdRef.current = currentSong._uniqueId;
}
if (playerStatus === PlayerStatus.PLAYING) {
@@ -129,26 +170,20 @@ export const useSaveQueue = () => {
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
}
try {
await api.controller.savePlayQueue({
apiClientProps: { serverId },
query: {
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
songs: queue.items.map((item) => item.id),
},
});
toast.success({
message: t('form.saveQueue.success'),
});
} catch (error) {
toast.error({
message: (error as Error).message,
title: t('error.saveQueueFailed'),
});
throw error;
}
return api.controller.savePlayQueue({
apiClientProps: { serverId },
query: {
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
songs: queue.items.map((item) => item.id),
},
});
},
onError: (error) => {
toast.error({
message: (error as Error).message,
title: t('error.saveQueueFailed'),
});
},
});
@@ -45,7 +45,7 @@ const getPositionValue = (seconds: number, useTicks: boolean) => {
return Math.round(seconds * 1e7);
}
return seconds;
return seconds * 1000;
};
/*
@@ -459,12 +459,14 @@ export const useScrobble = () => {
lastProgressEventRef.current = properties.timestamp;
lastSeekEventRef.current = now;
const currentStatus = usePlayerStore.getState().player.status;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'timeupdate',
event: currentStatus === PlayerStatus.PLAYING ? 'unpause' : 'pause',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
@@ -36,6 +36,7 @@ import { FontType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
@@ -289,21 +290,29 @@ export const ApplicationSettings = memo(() => {
control: (
<FileInput
accept=".ttc,.ttf,.otf,.woff,.woff2"
onChange={(e) =>
clearable
defaultValue={
fontSettings.custom
? new File([], fontSettings.custom.split(utils?.separator || '').pop()!)
: null
}
onChange={async (e) => {
const custom = e ? getPathForFile?.(e) || null : null;
await localSettings?.setSync('local_font_path', custom);
setSettings({
font: {
...fontSettings,
custom: e ? getPathForFile?.(e) || null : null,
custom,
},
})
}
});
}}
w={300}
/>
),
description: t('setting.customFontPath', {
context: 'description',
}),
isHidden: fontSettings.type !== FontType.CUSTOM,
isHidden: !isElectron() || fontSettings.type !== FontType.CUSTOM,
title: t('setting.customFontPath'),
},
{
@@ -32,6 +32,7 @@ export const useSyncSettingsToMain = () => {
const settingsFromStore = useSettingsStore.getState();
const settings = {
font: settingsFromStore.font,
general: settingsFromStore.general,
hotkeys: settingsFromStore.hotkeys,
lyrics: settingsFromStore.lyrics,
@@ -101,6 +102,10 @@ export const useSyncSettingsToMain = () => {
mainStoreKey: 'enableNeteaseTranslation',
rendererValue: settings.lyrics.enableNeteaseTranslation,
},
{
mainStoreKey: 'local_font_path',
rendererValue: settings.font.custom,
},
];
// Compare and sync each setting
+11
View File
@@ -1185,6 +1185,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
});
},
mediaSeekToTimestamp: (timestamp: number) => {
// See mediaSkipBackward: update the timestamp store right away to
// avoid the stale-read left by the ~500ms engine poll.
setTimestampStore(timestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);
});
@@ -1196,6 +1199,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const newTimestamp = Math.max(0, currentTimestamp - timeToSkip);
// Update the timestamp store right away so the UI and any
// subsequent seek compute from the new position instead of the
// stale value left by the ~500ms engine poll (otherwise mashing
// the seek keys repeatedly lands on the same time).
setTimestampStore(newTimestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
@@ -1217,6 +1225,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);
// See mediaSkipBackward: update the timestamp store right away to
// avoid the stale-read left by the ~500ms engine poll.
setTimestampStore(newTimestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
+25 -1
View File
@@ -1,11 +1,13 @@
import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
export type SleepTimerMode = 'endOfSong' | 'timed';
export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed';
interface SleepTimerActions {
cancelTimer: () => void;
setRemaining: (remaining: number) => void;
setTargetAlbumId: (albumId: null | string) => void;
startEndOfAlbumTimer: () => void;
startEndOfSongTimer: () => void;
startTimedTimer: (durationSeconds: number) => void;
}
@@ -17,6 +19,8 @@ interface SleepTimerState {
mode: SleepTimerMode;
/** Remaining seconds (only ticks while playing) */
remaining: number;
/** Album Id for song when mode activated */
targetAlbumId: null | string;
}
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: false,
mode: 'timed',
remaining: 0,
targetAlbumId: null,
});
},
mode: 'timed',
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
set({ remaining });
},
setTargetAlbumId: (albumId: null | string) => {
set({ targetAlbumId: albumId });
},
startEndOfAlbumTimer: () => {
set({
active: true,
mode: 'endOfAlbum',
remaining: 0,
targetAlbumId: null,
});
},
startEndOfSongTimer: () => {
set({
active: true,
mode: 'endOfSong',
remaining: 0,
targetAlbumId: null,
});
},
@@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: true,
mode: 'timed',
remaining: durationSeconds,
targetAlbumId: null,
});
},
targetAlbumId: null,
}),
);
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
useShallow((s) => ({
cancelTimer: s.cancelTimer,
setRemaining: s.setRemaining,
setTargetAlbumId: s.setTargetAlbumId,
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
startEndOfSongTimer: s.startEndOfSongTimer,
startTimedTimer: s.startTimedTimer,
})),
+5
View File
@@ -134,6 +134,11 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
document.body.appendChild(textStyleRef.current);
}
// Note: we change the url to bust caches when changing the path
// The url provided here does NOT matter, validation is done
// on the main process. Any feishin:/ url will fetch the same
// item, which the renderer will check via magic number to be
// some font item
textStyleRef.current.textContent = `
@font-face {
font-family: "dynamic-font";
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
playCount: item.UserData?.PlayCount || 0,
similarArtists,
songCount: item.SongCount ?? null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
@@ -434,6 +435,7 @@ const normalizePlaylist = (
size: null,
songCount: item?.ChildCount || null,
sync: null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
};
};
+12
View File
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
const deletePlaylist = z.null();
const deletePlaylistImage = z.null();
const deleteArtistImage = deletePlaylistImage;
const uploadPlaylistImage = z.null();
const uploadArtistImage = uploadPlaylistImage;
const deletePlaylistParameters = z.object({
Id: z.string(),
});
@@ -886,7 +894,9 @@ export const jfType = {
albumList,
authenticate,
createPlaylist,
deleteArtistImage,
deletePlaylist,
deletePlaylistImage,
error,
favorite,
filters,
@@ -912,6 +922,8 @@ export const jfType = {
studioList,
topSongsList,
updatePlaylist,
uploadArtistImage,
uploadPlaylistImage,
user,
},
};
+1 -1
View File
@@ -1363,7 +1363,7 @@ export type ScrobbleArgs = BaseEndpointArgs & {
export type ScrobbleQuery = {
albumId?: string;
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
event?: 'pause' | 'start' | 'unpause';
id: string;
mediaType: 'podcast' | 'song';
playbackRate: number;
+20 -1
View File
@@ -2,6 +2,20 @@ import type { HotkeyItem } from '@mantine/hooks';
const RESERVED_KEYS = new Set(['alt', 'ctrl', 'meta', 'mod', 'shift']);
const PUNCTUATION_KEY_TO_PHYSICAL: Record<string, string> = {
"'": 'Quote',
',': 'Comma',
'-': 'Minus',
'.': 'Period',
'/': 'Slash',
';': 'Semicolon',
'=': 'Equal',
'[': 'BracketLeft',
'\\': 'Backslash',
']': 'BracketRight',
'`': 'Backquote',
};
/**
* Converts stored hotkey strings to Mantine's physical-key format.
* Mantine matches KeyboardEvent.code via normalizeKey, which turns Digit1 into
@@ -25,6 +39,11 @@ export const toPhysicalHotkey = (hotkey: string): string =>
return `Digit${part}`;
}
const punctuationPhysical = PUNCTUATION_KEY_TO_PHYSICAL[part];
if (punctuationPhysical) {
return punctuationPhysical;
}
return part;
})
.join('+');
@@ -33,5 +52,5 @@ export const withPhysicalKeys = (hotkeys: HotkeyItem[]): HotkeyItem[] =>
hotkeys.map(([hotkey, handler, options]) => [
toPhysicalHotkey(hotkey),
handler,
{ ...options, usePhysicalKeys: true },
{ ...options, preventDefault: true, usePhysicalKeys: true },
]);
@@ -3,7 +3,12 @@ const CODE_TO_HOTKEY_KEY: Record<string, string> = {
ArrowLeft: 'arrowleft',
ArrowRight: 'arrowright',
ArrowUp: 'arrowup',
Backquote: '`',
Backslash: '\\',
Backspace: 'backspace',
BracketLeft: '[',
BracketRight: ']',
Comma: ',',
Delete: 'delete',
End: 'end',
Enter: 'enter',
@@ -14,6 +19,10 @@ const CODE_TO_HOTKEY_KEY: Record<string, string> = {
Minus: 'minus',
PageDown: 'pagedown',
PageUp: 'pageup',
Period: '.',
Quote: "'",
Semicolon: ';',
Slash: '/',
Space: 'space',
Tab: 'tab',
};