Compare commits

..

11 Commits

Author SHA1 Message Date
Kendall Garner da445b815d feat(genre): support sorting by track/album count 2026-06-28 19:39:32 -07:00
Tarulia c875146779 feat: add setting for static window title (#2183) 2026-06-29 01:35:42 +00:00
Kendall Garner 9806d2f553 fix: require all sorts to have default value 2026-06-28 17:36:14 -07:00
Kendall Garner 18a7fd0731 disconnect rpc on discord unmount 2026-06-28 15:31:15 -07:00
Hosted Weblate 062617bb40 Translated using Weblate
Currently translated at 100.0% (1285 of 1285 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1285 of 1285 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 100.0% (1285 of 1285 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
2026-06-28 05:01:23 +00:00
York f8ca8861fc feat: add romaji lyrics display (#2180) 2026-06-26 21:07:58 -07:00
Ryan Kupka 26eea7422d fix: recover mpv playback after the OS resumes from sleep (#2172)
mpv/ffmpeg had no network-level timeout or reconnect options, so a
network stream left open across a system sleep would block forever on
the now-dead TCP connection instead of failing or reconnecting. Since
Node-MPV's IPC commands only resolve when mpv replies, a wedged mpv
process also made quit()/restart hang indefinitely, so the only way
out was to kill the whole app.

- Add --network-timeout and ffmpeg reconnect options to mpv's default
  parameters so a stalled stream fails fast instead of hanging.
- Make the quit() helper resilient to an unresponsive mpv process by
  racing it against a timeout and force-killing as a fallback.
- Listen for Electron's powerMonitor 'resume' event and tell the
  renderer to reload mpv, so playback recovers automatically instead
  of requiring a manual app restart.
2026-06-26 19:18:27 -07:00
Hosted Weblate 21d788226c Translated using Weblate
Currently translated at 100.0% (1283 of 1283 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
2026-06-26 22:01:23 +02:00
Hosted Weblate 9a1bf8f4a9 Translated using Weblate
Currently translated at 48.0% (616 of 1283 strings) (Ukrainian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/

Co-authored-by: albatrays <weblate.duct925@passmail.net>
2026-06-24 15:27:33 +02:00
Hosted Weblate 0fab3ba318 Translated using Weblate
Currently translated at 100.0% (1283 of 1283 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Translated using Weblate

Currently translated at 100.0% (1283 of 1283 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Translated using Weblate

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

Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-06-24 10:01:26 +00:00
Norman 5ddbfcbfee Highlight the playlist in the left panel on play (#2025)
* Fixed bad smart playlist field s

* first try to add playlist highlight

* Simplified calls

* Now works for grids too.

* Derive the playlist highlight from the currently-playing track's origin instead of a stale global field.

* addressed comments
2026-06-24 03:18:02 +00:00
41 changed files with 588 additions and 67 deletions
+40 -2
View File
@@ -973,7 +973,43 @@
"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"
"autoDJ_strategy_option_similar": "Similar",
"enableFurigana_description": "Mostra guies de pronunciació (furigana) per les lletres en japonès.",
"enableFurigana": "Activa la generació de furigana",
"equalizer_descriptionMpv": "Equalitzador paramètric amb FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Equalitzador paramètric amb l'API de Web Audio",
"equalizer": "Equalitzador",
"equalizerBands_description": "Guany per banda. Arrossegueu-lo amunt o avall, o introduïu-hi un valor. Rang: -12 a +12 dB.",
"equalizerBands": "Bandes",
"equalizerPreamp_description": "Guany d'entrada previ a les bandes de l'equalitzador. Poseu-lo en negatiu quan realceu les bandes per evitar el clipping (MPV).",
"equalizerPreamp": "Preamplificador",
"equalizerPreset_description": "Aplica una corba d'equalitzador personalitzada integrada o desada",
"equalizerPreset": "Preajustament",
"equalizerPresetDeletePlaceholder": "Elimina la personalització...",
"equalizerPresetGroupBuiltIn": "Integrat",
"equalizerPresetGroupCustom": "Personalitzat",
"equalizerPresetNamePlaceholder": "Nom de l'ajust predefinit...",
"equalizerPresetSelectPlaceholder": "Seleccioneu un ajust predefinit",
"equalizerSavePreset_description": "Desa la configuració actual de l'equalitzador com a ajust predefinit amb nom",
"equalizerSavePreset": "Desa l'ajust",
"compressor_descriptionMpv": "Compressor de rang dinàmic amb el compressor de FFmpeg (MPV)",
"compressor_descriptionWebAudio": "Compressor de rang dinàmic amb l'API de Web Audio",
"compressor": "Compressor",
"compressorAttack_description": "La rapidesa amb què el compressor s'activa quan el senyal excedeix el llindar.",
"compressorAttack": "Atac",
"compressorKnee_description": "Amplada de la zona de resposta suau. Com més alt sigui, més gradual serà la transició cap a la compressió.",
"compressorKnee": "Zona de resposta",
"compressorMakeupGain_description": "Guany de sortida aplicat després de la compressió per recuperar volum.",
"compressorMakeupGain": "Guany de compensació",
"compressorPreset_description": "Aplica una configuració personalitzada del compressor integrada o desada",
"compressorRatio_description": "Ràtio de compressio, p. ex. 4 = 4:1.",
"compressorRatio": "Ràtio",
"compressorRelease_description": "Com de ràpid el compressor es desactiva un cop el senyal sigui inferior al llindar.",
"compressorRelease": "Desactivació",
"compressorReset_description": "Restaura tots els paràmetres del compressor als seus valors per defecte",
"compressorSavePreset_description": "Desa la configuració actual del compressor com un ajust predefinit amb nom",
"compressorThreshold_description": "Nivell de senyal a partir del qual comença la compressió.",
"compressorThreshold": "Llindar"
},
"table": {
"column": {
@@ -1266,7 +1302,9 @@
"notContains": "No conté",
"notInPlaylist": "No és a",
"notInTheLast": "No és a l'últim",
"startsWith": "Comença amb"
"startsWith": "Comença amb",
"isMissing": "Falta",
"isPresent": "Està present"
},
"queryBuilder": {
"standardTags": "Etiquetes estàndard",
+39 -1
View File
@@ -457,7 +457,45 @@
"autoDJ_albumStrategy": "Režim výběru alb",
"autoDJ_songStrategy": "Režim výběru skladeb",
"autoDJ_strategy_option_library_random": "Náhodně",
"autoDJ_strategy_option_similar": "Podobné"
"autoDJ_strategy_option_similar": "Podobné",
"enableFurigana_description": "Zobrazit návody na výslovnost (furigana) u japonských kandži textů.",
"enableFurigana": "Povolit generování furigana",
"equalizer_descriptionMpv": "Parametrický ekvalizér skrze FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Parametrický ekvalizér skrze Web Audio API",
"equalizer": "Ekvalizér",
"equalizerBands_description": "Zisk na pásmo. Posuňte nahoru/dolů nebo zadejte hodnotu. Rozsah: -12 do +12 dB.",
"equalizerBands": "Pásma",
"equalizerPreamp_description": "Vstupní zisk před pásmy ekvalizéru. Při zvýšení pásem nastavte na negativní hodnotu pro zabránění clippingu (MPV).",
"equalizerPreamp": "Předzesilovač",
"equalizerPreset_description": "Použít vestavěnou nebo uloženou vlastní křivku ekvalizéru",
"equalizerPreset": "Předvolba",
"equalizerPresetDeletePlaceholder": "Odstranit vlastní…",
"equalizerPresetGroupBuiltIn": "Vestavěná",
"equalizerPresetGroupCustom": "Vlastní",
"equalizerPresetNamePlaceholder": "Název předvolby…",
"equalizerPresetSelectPlaceholder": "Vybrat předvolbu",
"equalizerSavePreset_description": "Uložit aktuální nastavení ekvalizéru jako pojmenovanou předvolbu",
"equalizerSavePreset": "Uložit předvolbu",
"compressor_descriptionMpv": "Kompresor dynamického rozsahu skrze FFmpeg acompressor (MPV)",
"compressor_descriptionWebAudio": "Kompresor dynamického rozsahu skrze Web Audio API",
"compressor": "Kompresor",
"compressorAttack_description": "Jak rychle se kompresor spustí, když signál překročí hranici.",
"compressorAttack": "Útok",
"compressorKnee_description": "Měkká šířka. Čím vyšší jsou hodnoty, tím pozvolnější je přechod do komprese.",
"compressorKnee": "Koleno",
"compressorMakeupGain_description": "Výstupní zesílení aplikované po kompresi za účelem obnovení hlasitosti.",
"compressorMakeupGain": "Následný zisk",
"compressorPreset_description": "Použít vestavěné nebo uložené vlastní nastavení kompresoru",
"compressorRatio_description": "Poměr komprese, např. 4 = 4:1.",
"compressorRatio": "Poměr",
"compressorRelease_description": "Jak rychle se kompresor uvolní, když signál spadne pod nastavenou hranici.",
"compressorRelease": "Uvolnění",
"compressorReset_description": "Obnovit všechny parametry kompresoru na jejich výchozí hodnoty",
"compressorSavePreset_description": "Uložit aktuální nastavení kompresoru jako pojmenovanou předvolbu",
"compressorThreshold_description": "Úroveň signálu, nad kterou začne komprese.",
"compressorThreshold": "Hranice",
"enableRomaji_description": "Zobrazit rómadži výslovnost pod japonskými texty.",
"enableRomaji": "Povolit generování rómadži"
},
"action": {
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
+4
View File
@@ -845,6 +845,8 @@
"enableAutoTranslation": "Enable auto translation",
"enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.",
"enableFurigana": "Enable furigana generation",
"enableRomaji_description": "Display a romaji pronunciation line under Japanese lyrics.",
"enableRomaji": "Enable romaji generation",
"equalizer_descriptionMpv": "Parametric equalizer via FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Parametric equalizer via Web Audio API",
"equalizer": "Equalizer",
@@ -1173,6 +1175,8 @@
"webAudio": "Use web audio",
"windowBarStyle_description": "Select the style of the window bar",
"windowBarStyle": "Window bar style",
"windowBarTrackinfo": "Track info in Window Title",
"windowBarTrackinfo_description": "Show current track's title and artist, queue position, and Playing/Paused state in the Window's Title.",
"zoom_description": "Sets the zoom percentage for the application",
"zoom": "Zoom percentage",
"queryBuilder": "Query builder",
+3 -1
View File
@@ -493,7 +493,9 @@
"compressorMakeupGain_description": "Ganancia de salida aplicada tras la compresión para recuperar el volumen.",
"compressorMakeupGain": "Ganancia de compensación",
"compressorAttack_description": "La rapidez con la que el compresor entra en acción una vez que la señal supera el umbral.",
"compressorAttack": "Ataque"
"compressorAttack": "Ataque",
"enableRomaji_description": "Muestra una línea de pronunciación en romaji debajo de las letras japonesas.",
"enableRomaji": "Activar generación de romaji"
},
"action": {
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
+37 -1
View File
@@ -1135,7 +1135,43 @@
"queryBuilderCustomFields_inputLabel": "Nimetus",
"queryBuilderCustomFields_inputTag": "Silt",
"queryBuilderCustomFields": "Kohandatud väljad",
"queryBuilderCustomFields_description": "Lisa kohandatud välju, mida päringukoosturis kasutada"
"queryBuilderCustomFields_description": "Lisa kohandatud välju, mida päringukoosturis kasutada",
"equalizer_descriptionMpv": "Parametriline ekvalaiser FFmpeg lavfi (MPV) kaudu",
"equalizer_descriptionWebAudio": "Parametriline ekvalaiser Web Audio API kaudu",
"equalizer": "Ekvalaiser",
"equalizerBands_description": "Riba põhivõimendus. Lohista üles/alla või sisesta väärtus. Vahemik: -12 kuni +12 dB.",
"equalizerBands": "Ribad",
"equalizerPreamp_description": "Sisendvõimendus enne ekvalaiseri ribasid. Moonutuste vältimiseks määra ribade võimendamisel negatiivne väärtus (MPV).",
"equalizerPreamp": "Eelvõimendus",
"equalizerPreset_description": "Rakenda sisseehitatud või salvestatud kohandatud EQ-häälestus",
"equalizerPreset": "Eelseadistus",
"equalizerPresetDeletePlaceholder": "Kustuta kohandatud...",
"equalizerPresetGroupBuiltIn": "Sisseehitatud",
"equalizerPresetGroupCustom": "Kohandatud",
"equalizerPresetNamePlaceholder": "Eelseadistuse nimi...",
"equalizerPresetSelectPlaceholder": "Vali eelseadistus",
"equalizerSavePreset_description": "Salvesta praegused EQ-seaded nimetatud eelseadistusena",
"equalizerSavePreset": "Salvesta eelseadistus",
"compressor_descriptionMpv": "Dünaamilise vahemiku kompressor FFmpeg acompressori kaudu (MPV)",
"compressor_descriptionWebAudio": "Dünaamilise vahemiku kompressor Web Audio API kaudu",
"enableFurigana_description": "Kuva jaapani kanji-märkide kohal hääldusjuhiseid (furigana).",
"enableFurigana": "Luba furigana kuvamine",
"compressor": "Kompressor",
"compressorAttack_description": "Kui kiiresti kompressor pärast läve ületamist rakendub.",
"compressorAttack": "Rakendumisaeg",
"compressorKnee_description": "Sujuva ülemineku (soft-knee) ulatus. Suuremad väärtused muudavad kompressiooni rakendumise astmelisemaks.",
"compressorKnee": "Üleminek",
"compressorMakeupGain_description": "Väljundvõimendus helitugevuse taastamiseks pärast kompressiooni.",
"compressorMakeupGain": "Väljundvõimendus",
"compressorPreset_description": "Rakenda sisseehitatud või salvestatud kohandatud kompressoriseadistus",
"compressorRatio_description": "Kompressiooniaste, nt 4 = 4:1.",
"compressorRatio": "Kompressiooniaste",
"compressorRelease_description": "Kui kiiresti kompressiooni mõju pärast lävest allapoole langemist lakkab.",
"compressorRelease": "Vabastusaeg",
"compressorReset_description": "Taasta kõigi kompressori parameetrite vaikeväärtused",
"compressorSavePreset_description": "Salvesta praegused kompressori seaded nimetatud eelseadistusena",
"compressorThreshold_description": "Signaali tase, mida ületades kompressioon algab.",
"compressorThreshold": "Lävi"
},
"datetime": {
"minuteShort": "m",
+37 -1
View File
@@ -1108,7 +1108,43 @@
"autoDJ_albumStrategy": "Tryb wyboru albumów",
"autoDJ_songStrategy": "Tryb wyboru piosenek",
"autoDJ_strategy_option_library_random": "Losowo",
"autoDJ_strategy_option_similar": "Podobne"
"autoDJ_strategy_option_similar": "Podobne",
"enableFurigana_description": "Wyświetlaj pomoce wymowy (furigana) nad tekstami Japońskimi kanji.",
"enableFurigana": "Włącz generowanie furigana",
"equalizer_descriptionMpv": "Equalizer parametryczny przez FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Parametryczny equalizer przez API Web Audio",
"equalizer": "Equalizer",
"equalizerBands_description": "Wzmocnienie dla poszczególnych pasm. Przesuń w górę/dół lub wpisz wartość. Zakres: -12 do +12 dB.",
"equalizerBands": "Pasma",
"equalizerPreamp_description": "Wzmocnienie sygnału przed pasmami EQ. Ustaw na wartość ujemną podczas wzmacniania pasm, aby zapobiec przesterowaniu (MPV).",
"equalizerPreamp": "Przedwzmacnianie",
"equalizerPreset_description": "Zastosuj wbudowaną lub niestandardową zapisaną krzywą EQ",
"equalizerPreset": "Ustawienia wstępne",
"equalizerPresetDeletePlaceholder": "Usuń niestandardowe...",
"equalizerPresetGroupBuiltIn": "Wbudowane",
"equalizerPresetGroupCustom": "Niestandardowe",
"equalizerPresetNamePlaceholder": "Nazwa ustawień wstępnych...",
"equalizerPresetSelectPlaceholder": "Wybierz ustawienia wstępne",
"equalizerSavePreset_description": "Zapisz aktualne ustawienia EQ jako nazwany zestaw ustawień wstępnych",
"equalizerSavePreset": "Zapisz ustawienia wstępne",
"compressor_descriptionMpv": "Kompresor zakresu dynamicznego przez FFmpeg acompressor (MPV)",
"compressor_descriptionWebAudio": "Kompresor zakresu dynamicznego poprzez API Web Audio",
"compressor": "Kompresor",
"compressorAttack_description": "Jak szybko załączany jest kompresor po przekroczeniu progu przez sygnał.",
"compressorAttack": "Attack",
"compressorKnee_description": "Szerokośc soft-knee. Większe wartości powodują przejście do kompresji bardziej stopniowym.",
"compressorKnee": "Knee",
"compressorMakeupGain_description": "Zwiększenie wyjściowe dodawane po kompresji aby, przywrócić głośność.",
"compressorMakeupGain": "Makeup Gain",
"compressorPreset_description": "Zastosuj wbudowane lub niestandardowe zapisane ustawienie kompresora",
"compressorRatio_description": "Proporcje kompresji, np. 4 = 4:1.",
"compressorRatio": "Proporcje",
"compressorRelease_description": "Jak szybko kompresor odpuszcza po spadnięciu sygnału poniżej progu.",
"compressorRelease": "Odpuszczenie",
"compressorReset_description": "Przywróć wszystkie parametry kompresora do wartości domyślnych",
"compressorSavePreset_description": "Zapisz aktualne ustawienia kompresora jako nazwany zestaw ustawień wstępnych",
"compressorThreshold_description": "Poziom sygnału nad którym rozpoczyna się kompresja.",
"compressorThreshold": "Próg"
},
"table": {
"config": {
+20 -1
View File
@@ -683,7 +683,19 @@
"transcoding": "Транскодування",
"discord": "Діскорд",
"logger": "Логгер",
"playerFilters": "Фільтри плеєра"
"playerFilters": "Фільтри плеєра",
"advanced": "Розширені",
"analytics": "Аналітика",
"generalTab": "Загальні",
"hotkeysTab": "Гарячі клавіші",
"playbackTab": "Відтворення",
"windowTab": "Вікно",
"updates": "Оновлення",
"cache": "Кеш",
"application": "Застосунок",
"queryBuilder": "Конструктор черги",
"theme": "Тема",
"controls": "Керування"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
@@ -707,6 +719,13 @@
"artistTracks": "Треки {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
},
"collections": {
"overrideExisting": "Перевизначити існуючі",
"saveAsCollection": "Зберегти як колекцію"
}
},
"queryBuilder": {
+3 -1
View File
@@ -864,7 +864,9 @@
"compressorReset_description": "將所有壓縮器參數恢復為預設值",
"compressorSavePreset_description": "將目前壓縮器設定儲存為具名預設",
"compressorThreshold_description": "開始進行壓縮的訊號電平。",
"compressorThreshold": "閥值"
"compressorThreshold": "閥值",
"enableRomaji_description": "在日文歌詞下方顯示羅馬拼音。",
"enableRomaji": "啟用羅馬拼音顯示"
},
"table": {
"config": {
+18 -1
View File
@@ -7,16 +7,19 @@ let kuroshiroInstance: any = null;
let initPromise: null | Promise<void> = null;
const getKuroshiro = async () => {
if (kuroshiroInstance) return kuroshiroInstance;
if (initPromise) {
await initPromise;
return kuroshiroInstance;
}
if (kuroshiroInstance) return kuroshiroInstance;
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
kuroshiroInstance = new KuroshiroClass();
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
await initPromise;
initPromise = null;
return kuroshiroInstance;
};
@@ -35,3 +38,17 @@ export const convertFurigana = async (text: string): Promise<string> => {
return text;
}
};
export const convertRomaji = async (text: string): Promise<string> => {
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
if (!KuroshiroClass.Util.hasKana(text)) return text;
try {
const kuroshiro = await getKuroshiro();
return await kuroshiro.convert(text, { mode: 'spaced', to: 'romaji' });
} catch (e) {
console.error('Romaji conversion error: ', e);
return text;
}
};
+5 -1
View File
@@ -1,7 +1,7 @@
import { ipcMain } from 'electron';
import { store } from '../settings';
import { convertFurigana } from './furigana';
import { convertFurigana, convertRomaji } from './furigana';
import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';
@@ -236,3 +236,7 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => {
return await convertFurigana(text);
});
ipcMain.handle('lyric-convert-romaji', async (_event, text: string) => {
return await convertRomaji(text);
});
+57 -10
View File
@@ -1,5 +1,5 @@
import console from 'console';
import { app, ipcMain } from 'electron';
import { app, ipcMain, powerMonitor } from 'electron';
import { access, rm } from 'fs/promises';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
@@ -85,6 +85,19 @@ const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
parameters.push('--prefetch-playlist=yes');
}
// Without these, mpv/ffmpeg will block indefinitely on a dead TCP connection
// instead of failing or reconnecting. This commonly happens when the OS network
// adapter resets after the system wakes from sleep while a stream is open.
if (!extraParameters?.some((param) => param.startsWith('--network-timeout'))) {
parameters.push('--network-timeout=10');
}
if (!extraParameters?.some((param) => param.startsWith('--stream-lavf-o'))) {
parameters.push(
'--stream-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_at_eof=1,reconnect_delay_max=5',
);
}
return parameters;
};
@@ -191,21 +204,44 @@ export const getMpvInstance = () => {
return mpvInstance;
};
const QUIT_TIMEOUT_MS = 3000;
const killMpvProcess = (mpv: MpvAPI) => {
const mpvProcess = (mpv as any).process || (mpv as any).mpvProcess;
if (mpvProcess && typeof mpvProcess.kill === 'function') {
try {
mpvProcess.kill('SIGTERM');
} catch (killErr) {
mpvLog({ action: 'Failed to kill mpv process' }, killErr as NodeMpvError);
}
}
};
const quit = async (instance?: MpvAPI | null) => {
const mpv = instance || getMpvInstance();
if (mpv) {
try {
await mpv.quit();
// mpv.quit() resolves only when mpv replies over IPC. If mpv's command queue
// is wedged (e.g. blocked on a dead network stream after the system resumes
// from sleep), that reply never arrives, so this must not be allowed to hang
// forever - fall back to killing the process directly.
let timedOut = false;
await Promise.race([
mpv.quit(),
new Promise((resolve) => {
setTimeout(() => {
timedOut = true;
resolve(undefined);
}, QUIT_TIMEOUT_MS);
}),
]);
if (timedOut) {
killMpvProcess(mpv);
}
} catch {
// If quit() fails, try to kill the process directly
const mpvProcess = (mpv as any).process || (mpv as any).mpvProcess;
if (mpvProcess && typeof mpvProcess.kill === 'function') {
try {
mpvProcess.kill('SIGTERM');
} catch (killErr) {
mpvLog({ action: 'Failed to kill mpv process' }, killErr as NodeMpvError);
}
}
killMpvProcess(mpv);
}
if (!isWindows()) {
try {
@@ -666,6 +702,17 @@ const cleanupMpv = async (force = false) => {
}
};
// When the OS resumes from sleep, any network stream mpv had open is likely dead
// (the connection silently dropped while the network adapter was suspended). Tell
// the renderer to reload mpv so it reconnects with a fresh stream instead of staying
// stuck on the old, now-dead connection until the app is manually restarted.
powerMonitor.on('resume', () => {
if (getMpvInstance()) {
mpvLog({ action: 'System resumed from sleep, reloading mpv' });
getMainWindow()?.webContents.send('renderer-mpv-reconnect');
}
});
app.on('before-quit', async (event) => {
switch (mpvState) {
case MpvState.DONE:
+5
View File
@@ -30,8 +30,13 @@ const convertFurigana = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-furigana', text);
};
const convertRomaji = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-romaji', text);
};
export const lyrics = {
convertFurigana,
convertRomaji,
getRemoteLyricsByRemoteId,
getRemoteLyricsBySong,
searchRemoteLyrics,
+5
View File
@@ -174,6 +174,10 @@ const rendererPlayerFallback = (cb: (data: boolean) => void) => {
ipcRenderer.on('renderer-player-fallback', (_, data) => cb(data));
};
const rendererMpvReconnect = (cb: () => void) => {
ipcRenderer.on('renderer-mpv-reconnect', () => cb());
};
export const mpvPlayer = {
autoNext,
cleanup,
@@ -205,6 +209,7 @@ export const mpvPlayerListener = {
rendererAutoNext,
rendererCurrentTime,
rendererError,
rendererMpvReconnect,
rendererNext,
rendererPause,
rendererPlay,
@@ -19,7 +19,6 @@ import {
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
genreListSortMap,
InternalControllerEndpoint,
playlistListSortMap,
PlaylistSongListArgs,
@@ -596,26 +595,7 @@ export const NavidromeController: InternalControllerEndpoint = {
};
}
const res = await ndApiClient(apiClientProps).getGenreList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: genreListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
library_id: getLibraryId(query.musicFolderId),
name: query.searchTerm,
},
});
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
return {
items: res.body.data.map((genre) => ndNormalize.genre(genre, apiClientProps.server)),
startIndex: query.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return SubsonicController.getGenreList(args);
},
getImageRequest: SubsonicController.getImageRequest,
getImageUrl: SubsonicController.getImageUrl,
@@ -1090,9 +1090,15 @@ export const SubsonicController: InternalControllerEndpoint = {
}
switch (query.sortBy) {
case GenreListSort.ALBUM_COUNT:
results = orderBy(results, [(v) => v.albumCount], [sortOrder]);
break;
case GenreListSort.NAME:
results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]);
break;
case GenreListSort.SONG_COUNT:
results = orderBy(results, [(v) => v.songCount], [sortOrder]);
break;
default:
break;
}
@@ -64,6 +64,7 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
return draggedItems;
},
itemType,
metadata: { playlistId },
onDragStart: () => {
if (!item || !isDataRow) {
return;
@@ -248,10 +249,15 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
case DragTarget.SONG: {
const sourceItems = (args.source.item || []) as Song[];
if (sourceItems.length > 0) {
playerContext.addToQueueByData(sourceItems, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
const sourcePlaylistId = args.source.metadata?.playlistId as
| string
| undefined;
playerContext.addToQueueByData(
sourceItems,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
undefined,
sourcePlaylistId ?? null,
);
}
break;
}
@@ -7,11 +7,14 @@ import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-o
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { setMultipleSearchParams } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumArtistListSort } from '/@/shared/types/domain-types';
import { AlbumArtistListSort, ArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useAlbumArtistListFilters = () => {
const { sortBy } = useSortByFilter<AlbumArtistListSort>(null, ItemListKey.ALBUM_ARTIST);
const { sortBy } = useSortByFilter<AlbumArtistListSort>(
ArtistListSort.NAME,
ItemListKey.ALBUM_ARTIST,
);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST);
@@ -7,7 +7,7 @@ import { ArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useArtistListFilters = () => {
const { sortBy } = useSortByFilter<ArtistListSort>(null, ItemListKey.ARTIST);
const { sortBy } = useSortByFilter<ArtistListSort>(ArtistListSort.NAME, ItemListKey.ARTIST);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ARTIST);
@@ -81,6 +81,15 @@ export const useDiscordRpc = () => {
privateModeRef.current = privateMode;
}, [privateMode]);
// If the component is unmounted while RPC is enabled, quit RPC
useEffect(() => {
return () => {
if (previousEnabledRef.current) {
discordRpc?.quit();
}
};
}, []);
const setActivity = useCallback(
async (current: ActivityState, trigger: ActivityTrigger) => {
const song = current[0];
@@ -6,7 +6,7 @@ import { GenreListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useGenreListFilters = () => {
const { sortBy } = useSortByFilter<GenreListSort>(null, ItemListKey.GENRE);
const { sortBy } = useSortByFilter<GenreListSort>(GenreListSort.NAME, ItemListKey.GENRE);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE);
@@ -307,6 +307,20 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
isHidden: !isElectron(),
title: t('setting.enableFurigana'),
},
{
control: (
<Switch
aria-label="Enable romaji"
defaultChecked={lyricsSettings.enableRomaji}
onChange={(e) => updateLyricsSetting({ enableRomaji: e.currentTarget.checked })}
/>
),
description: t('setting.enableRomaji', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableRomaji'),
},
{
control: (
<Switch
@@ -28,3 +28,27 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
staleTime: Infinity,
});
};
export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => {
return useQuery({
enabled: enabled && !!lyrics && !!lyricsApi,
queryFn: async () => {
if (!lyrics || !lyricsApi || !enabled) return lyrics;
if (typeof lyrics === 'string') {
return await lyricsApi.convertRomaji(lyrics);
} else if (Array.isArray(lyrics)) {
const text = lyrics.map(([, line]) => line).join('\n');
const converted = await lyricsApi.convertRomaji(text);
const convertedLines = converted.split('\n');
return lyrics.map(([time], i) => [
time,
convertedLines[i] ?? lyrics[i][1],
]) as SynchronizedLyricsArray;
}
return lyrics;
},
queryKey: ['romaji', lyrics],
staleTime: Infinity,
});
};
@@ -25,3 +25,8 @@
.lyric-line:global(.synchronized) {
cursor: pointer;
}
.romaji-line {
font-size: 0.8em;
font-weight: 600;
}
+20 -1
View File
@@ -10,11 +10,21 @@ import { Stack } from '/@/shared/components/stack/stack';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
alignment: 'center' | 'left' | 'right';
fontSize: number;
romajiText?: null | string;
text: string;
translatedText?: null | string;
}
export const LyricLine = memo(
({ alignment, className, fontSize, text, ...props }: LyricLineProps) => {
({
alignment,
className,
fontSize,
romajiText,
text,
translatedText,
...props
}: LyricLineProps) => {
const lines = useMemo(() => text.split('_BREAK_'), [text]);
const style = useMemo(
@@ -31,6 +41,15 @@ export const LyricLine = memo(
{lines.map((line, index) => (
<span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
))}
{romajiText && (
<span
className={styles.romajiLine}
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
/>
)}
{translatedText && (
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
)}
</Stack>
</Box>
);
+16 -1
View File
@@ -14,7 +14,10 @@ import {
type LyricsQueryResult,
} from '/@/renderer/features/lyrics/api/lyrics-api';
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
import { useFuriganaLyrics } from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics';
import {
useFuriganaLyrics,
useRomajiLyrics,
} from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import {
SynchronizedLyrics,
@@ -51,6 +54,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const {
enableAutoTranslation,
enableFurigana,
enableRomaji,
preferLocalLyrics,
translationApiKey,
translationApiProvider,
@@ -119,6 +123,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
}, [data, indexToUse, preferLocalLyrics]);
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
const { data: romajiConvertedLyrics } = useRomajiLyrics(lyrics?.lyrics, !!enableRomaji);
const displayLyrics = useMemo(() => {
if (isLyricsDisabled || !lyrics) return null;
@@ -344,12 +349,22 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
<SynchronizedLyrics
{...(displayLyrics as SynchronizedLyricsProps)}
offsetMs={displayOffsetMs}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
) : (
<UnsynchronizedLyrics
{...(displayLyrics as UnsynchronizedLyricsProps)}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as UnsynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
@@ -23,6 +23,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray;
offsetMs?: number;
romajiLyrics?: null | SynchronizedLyricsArray;
settingsKey?: string;
style?: React.CSSProperties;
translatedLyrics?: null | string;
@@ -34,6 +35,7 @@ export const SynchronizedLyrics = ({
name,
offsetMs,
remote,
romajiLyrics,
settingsKey = 'default',
source,
style,
@@ -368,10 +370,9 @@ export const SynchronizedLyrics = ({
handleSeek(time / 1000);
}
}}
text={
text +
(translatedLyrics ? `_BREAK_${translatedLyrics.split('\n')[idx]}` : '')
}
romajiText={romajiLyrics?.[idx]?.[1]}
text={text}
translatedText={translatedLyrics?.split('\n')[idx]}
/>
))}
</div>
@@ -8,6 +8,7 @@ import { FullLyricsMetadata } from '/@/shared/types/domain-types';
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string;
romajiLyrics?: null | string;
settingsKey?: string;
translatedLyrics?: null | string;
}
@@ -17,6 +18,7 @@ export const UnsynchronizedLyrics = ({
lyrics,
name,
remote,
romajiLyrics,
settingsKey = 'default',
source,
translatedLyrics,
@@ -42,6 +44,10 @@ export const UnsynchronizedLyrics = ({
return translatedLyrics ? translatedLyrics.split('\n') : [];
}, [translatedLyrics]);
const romajiLines = useMemo(() => {
return romajiLyrics ? romajiLyrics.split('\n') : [];
}, [romajiLyrics]);
return (
<div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>
{settings.showProvider && source && (
@@ -67,7 +73,9 @@ export const UnsynchronizedLyrics = ({
fontSize={settings.fontSizeUnsync}
id={`lyric-${idx}`}
key={idx}
text={text + (translatedLines[idx] ? `_BREAK_${translatedLines[idx]}` : '')}
romajiText={romajiLines[idx]}
text={text}
translatedText={translatedLines[idx]}
/>
))}
</div>
@@ -68,9 +68,13 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
};
eventEmitter.on('MPV_RELOAD', handleMpvReload);
// The main process notifies us after the OS resumes from sleep, since the
// stream mpv had open is likely on a now-dead connection.
mpvPlayerListener?.rendererMpvReconnect(handleMpvReload);
return () => {
eventEmitter.off('MPV_RELOAD', handleMpvReload);
ipc?.removeAllListeners('renderer-mpv-reconnect');
};
}, []);
@@ -39,7 +39,12 @@ import {
import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
export interface PlayerContext {
addToQueueByData: (data: Song[], type: AddToQueueType, playSongId?: string) => void;
addToQueueByData: (
data: Song[],
type: AddToQueueType,
playSongId?: string,
contextPlaylistId?: null | string,
) => void;
addToQueueByFetch: (
serverId: string,
id: string[],
@@ -137,6 +142,23 @@ const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
}
};
const isReplaceQueueType = (type: AddToQueueType): boolean => {
if (typeof type === 'object') return false;
return type === Play.NOW || type === Play.SHUFFLE;
};
// HashRouter puts the route in location.hash, not pathname.
const inferPlaylistContextFromUrl = (): null | string => {
const route = window.location.hash.replace(/^#/, '');
const match = route.match(/^\/playlists\/([^/]+)/);
return match ? match[1] : null;
};
// Stamps each song with the playlist it was queued from, so the sidebar highlight
// can be derived from whichever song is currently playing (see useCurrentPlaylistContextId).
const tagPlaylistContext = (songs: Song[], contextPlaylistId: string): Song[] =>
songs.map((song) => ({ ...song, _contextPlaylistId: contextPlaylistId }));
export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -187,9 +209,20 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [doNotShowAgain, setDoNotShowAgain, t]);
const addToQueueByData = useCallback(
(data: Song[], type: AddToQueueType, playSongId?: string) => {
(
data: Song[],
type: AddToQueueType,
playSongId?: string,
contextPlaylistId?: null | string,
) => {
const filters = useSettingsStore.getState().playback.filters;
const filteredData = filterSongsByPlayerFilters(data, filters);
let filteredData = filterSongsByPlayerFilters(data, filters);
const resolvedContextId =
contextPlaylistId ??
(isReplaceQueueType(type) ? inferPlaylistContextFromUrl() : null);
if (resolvedContextId) {
filteredData = tagPlaylistContext(filteredData, resolvedContextId);
}
if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom';
@@ -279,7 +312,21 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}
const filters = useSettingsStore.getState().playback.filters;
const filteredSongs = filterSongsByPlayerFilters(sortedSongs, filters);
let filteredSongs = filterSongsByPlayerFilters(sortedSongs, filters);
// Songs from multiple playlists are merged together, so there is no single
// playlist to attribute them to: skip tagging (and URL inference) entirely.
const isMultiPlaylist = itemType === LibraryItem.PLAYLIST && id.length > 1;
const explicitId =
itemType === LibraryItem.PLAYLIST && id.length === 1 ? id[0] : null;
const resolvedContextId =
explicitId ??
(!isMultiPlaylist && isReplaceQueueType(type)
? inferPlaylistContextFromUrl()
: null);
if (resolvedContextId) {
filteredSongs = tagPlaylistContext(filteredSongs, resolvedContextId);
}
if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom';
@@ -11,7 +11,10 @@ import { PlaylistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const usePlaylistListFilters = () => {
const sortByFilter = useSortByFilter<PlaylistListSort>(null, ItemListKey.PLAYLIST);
const sortByFilter = useSortByFilter<PlaylistListSort>(
PlaylistListSort.NAME,
ItemListKey.PLAYLIST,
);
const sortOrderFilter = useSortOrderFilter(null, ItemListKey.PLAYLIST);
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
@@ -107,6 +107,20 @@ export const LyricSettings = memo(() => {
isHidden: !isElectron(),
title: t('setting.enableFurigana'),
},
{
control: (
<Switch
aria-label="Enable romaji generation"
defaultChecked={settings.enableRomaji}
onChange={(e) => updateSetting({ enableRomaji: e.currentTarget.checked })}
/>
),
description: t('setting.enableRomaji', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableRomaji'),
},
{
control: (
<Switch
@@ -63,6 +63,30 @@ export const WindowSettings = memo(() => {
isHidden: !isElectron(),
title: t('setting.windowBarStyle'),
},
{
control: (
<Switch
aria-label="Toggle track info in Window Bar"
defaultChecked={settings.windowBarTrackinfo}
onChange={(e) => {
if (!e) return;
setSettings({
window: {
windowBarTrackinfo: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.windowBarTrackinfo', {
context: 'description',
}),
// tab is hidden entirely right now
// but if it was shown we would want to show this option
// as it also controls the tab title in web
isHidden: false,
title: t('setting.windowBarTrackinfo'),
},
{
control: (
<Switch
@@ -823,18 +823,38 @@ const GENRE_LIST_FILTERS: Partial<
},
],
[ServerType.NAVIDROME]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name'),
value: GenreListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.songCount'),
value: GenreListSort.SONG_COUNT,
},
],
[ServerType.SUBSONIC]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name'),
value: GenreListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.SONG_COUNT,
},
],
};
@@ -8,7 +8,7 @@ import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { ItemListKey } from '/@/shared/types/types';
export const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: ItemListKey) => {
export const useSortByFilter = <TSortBy>(defaultValue: string, listKey: ItemListKey) => {
const server = useCurrentServer();
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
const [searchParams, setSearchParams] = useSearchParams();
@@ -136,6 +136,10 @@
white-space: nowrap;
}
.name-active {
color: var(--theme-colors-primary);
}
.image-container {
flex-shrink: 0;
width: 3rem;
@@ -28,6 +28,7 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor';
import { AppRoute } from '/@/renderer/router/routes';
import {
useCurrentPlaylistContextId,
useCurrentServer,
useCurrentServerId,
usePermissions,
@@ -116,6 +117,8 @@ export const PlaylistRowButton = memo(
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
const sidebarPlaylistMode = useSidebarPlaylistMode();
const isCompact = sidebarPlaylistMode === 'compact';
const activePlaylistId = useCurrentPlaylistContextId();
const isActive = activePlaylistId === item.id;
const [isHovered, setIsHovered] = useState(false);
const isSmartPlaylist = Boolean(item.rules);
@@ -292,7 +295,13 @@ export const PlaylistRowButton = memo(
>
{isCompact ? (
<>
<Text className={styles.compactName} fw={500} size="md">
<Text
className={clsx(styles.compactName, {
[styles.nameActive]: isActive,
})}
fw={500}
size="md"
>
{name}
</Text>
{isHovered && (
@@ -307,7 +316,13 @@ export const PlaylistRowButton = memo(
<div className={styles.rowGroup}>
<Image containerClassName={styles.imageContainer} src={imageUrl} />
<div className={styles.metadata}>
<Text className={styles.name} fw={500} size="md">
<Text
className={clsx(styles.name, {
[styles.nameActive]: isActive,
})}
fw={500}
size="md"
>
{name}
</Text>
<div className={styles.metadataGroup}>
+14 -1
View File
@@ -14,7 +14,13 @@ import macMin from './assets/min-mac.png';
import styles from './window-bar.module.css';
import { useRadioPlayer } from '/@/renderer/features/radio/hooks/use-radio-player';
import { useAppStore, usePlayerData, usePlayerStatus, useWindowSettings } from '/@/renderer/store';
import {
useAppStore,
usePlayerData,
usePlayerStatus,
useWindowBarTrackinfo,
useWindowSettings,
} from '/@/renderer/store';
import { Text } from '/@/shared/components/text/text';
import { Platform, PlayerStatus } from '/@/shared/types/types';
@@ -130,6 +136,8 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => {
export const WindowBar = () => {
const { t } = useTranslation();
const { windowBarStyle } = useWindowSettings();
const windowBarTrackinfo = useWindowBarTrackinfo();
const playerStatus = usePlayerStatus();
const privateMode = useAppStore((state) => state.privateMode);
const handleMinimize = () => minimize();
@@ -153,6 +161,10 @@ export const WindowBar = () => {
const title = useMemo(() => {
const privateModeString = privateMode ? t('page.windowBar.privateMode') : '';
if (!windowBarTrackinfo) {
return `Feishin${privateMode ? ` ${privateModeString}` : ''}`;
}
// Show radio information if radio is active
if (isRadioActive) {
const radioStatusString = !isRadioPlaying ? t('page.windowBar.paused') : '';
@@ -194,6 +206,7 @@ export const WindowBar = () => {
queueLength,
stationName,
t,
windowBarTrackinfo,
]);
useEffect(() => {
+6
View File
@@ -1640,6 +1640,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const excludedPlayerKeys = ['playerNum', 'seekToTimestamp', 'status'];
// If we're not restoring the play queue, we don't need the index property
// (it is meaningless without the queue)
if (!shouldRestorePlayQueue) {
excludedPlayerKeys.push('index');
}
@@ -2076,6 +2077,7 @@ export const updateQueueSong = (songId: string, updatedSong: Song) => {
const uniqueId = song._uniqueId;
state.queue.songs[song._uniqueId] = {
...updatedSong,
_contextPlaylistId: song._contextPlaylistId,
_uniqueId: uniqueId,
};
}
@@ -2083,6 +2085,10 @@ export const updateQueueSong = (songId: string, updatedSong: Song) => {
});
};
export const useCurrentPlaylistContextId = () => {
return usePlayerStoreBase((state) => state.getCurrentSong()?._contextPlaylistId ?? null);
};
export const usePlayerMuted = () => {
return usePlayerStoreBase((state) => state.player.muted);
};
+7
View File
@@ -578,6 +578,7 @@ const LyricsSettingsSchema = z.object({
enableAutoTranslation: z.boolean(),
enableFurigana: z.boolean().optional(),
enableNeteaseTranslation: z.boolean(),
enableRomaji: z.boolean().optional(),
fetch: z.boolean(),
follow: z.boolean(),
preferLocalLyrics: z.boolean(),
@@ -677,6 +678,7 @@ const WindowSettingsSchema = z.object({
startMinimized: z.boolean(),
tray: z.boolean(),
windowBarStyle: z.nativeEnum(Platform),
windowBarTrackinfo: z.boolean(),
});
const QueryValueInputTypeSchema = z.enum([
@@ -1848,6 +1850,7 @@ const initialState: SettingsState = {
enableAutoTranslation: false,
enableFurigana: false,
enableNeteaseTranslation: false,
enableRomaji: false,
fetch: true,
follow: true,
preferLocalLyrics: true,
@@ -2012,6 +2015,7 @@ const initialState: SettingsState = {
startMinimized: false,
tray: true,
windowBarStyle: platformDefaultWindowBarStyle,
windowBarTrackinfo: true,
},
};
@@ -2558,6 +2562,9 @@ export const useWindowSettings = () => useSettingsStore((state) => state.window,
export const useWindowBarStyle = () =>
useSettingsStore((state) => state.window.windowBarStyle, shallow);
export const useWindowBarTrackinfo = () =>
useSettingsStore((state) => state.window.windowBarTrackinfo, shallow);
export const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys, shallow);
export const useHotkeyBindings = () => useSettingsStore((state) => state.hotkeys.bindings, shallow);
@@ -27,7 +27,9 @@ export enum NDAlbumListSort {
}
export enum NDGenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export enum NDPlaylistListSort {
@@ -754,6 +756,8 @@ const tag = z.object({
const tagList = z.array(tag);
export enum NDTagListSort {
ALBUM_COUNT = 'albumCount',
SONG_COUNT = 'songCount',
TAG_VALUE = 'tagValue',
}
+19 -2
View File
@@ -73,6 +73,7 @@ export interface QueueData {
}
export type QueueSong = Song & {
_contextPlaylistId?: null | string;
_uniqueId: string;
};
@@ -154,7 +155,9 @@ export enum ExternalType {
}
export enum GenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export enum ImageType {
@@ -165,7 +168,9 @@ export enum ImageType {
}
export enum TagListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export type Album = {
@@ -429,19 +434,25 @@ type BaseEndpointArgs = {
type GenreListSortMap = {
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
navidrome: Record<GenreListSort, NDGenreListSort | undefined>;
subsonic: Record<UserListSort, undefined>;
navidrome: Record<GenreListSort, NDGenreListSort>;
subsonic: Record<GenreListSort, undefined>;
};
export const genreListSortMap: GenreListSortMap = {
jellyfin: {
albumCount: undefined,
name: JFGenreListSort.NAME,
songCount: undefined,
},
navidrome: {
albumCount: NDGenreListSort.NAME,
name: NDGenreListSort.NAME,
songCount: NDGenreListSort.NAME,
},
subsonic: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
};
@@ -453,13 +464,19 @@ type TagListSortMap = {
export const tagListSortMap: TagListSortMap = {
jellyfin: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
navidrome: {
albumCount: NDTagListSort.ALBUM_COUNT,
name: NDTagListSort.TAG_VALUE,
songCount: NDTagListSort.SONG_COUNT,
},
subsonic: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
};