mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-29 07:17:44 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da445b815d | |||
| c875146779 | |||
| 9806d2f553 | |||
| 18a7fd0731 | |||
| 062617bb40 | |||
| f8ca8861fc | |||
| 26eea7422d | |||
| 21d788226c | |||
| 9a1bf8f4a9 | |||
| 0fab3ba318 | |||
| 5ddbfcbfee |
@@ -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",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -864,7 +864,9 @@
|
||||
"compressorReset_description": "將所有壓縮器參數恢復為預設值",
|
||||
"compressorSavePreset_description": "將目前壓縮器設定儲存為具名預設",
|
||||
"compressorThreshold_description": "開始進行壓縮的訊號電平。",
|
||||
"compressorThreshold": "閥值"
|
||||
"compressorThreshold": "閥值",
|
||||
"enableRomaji_description": "在日文歌詞下方顯示羅馬拼音。",
|
||||
"enableRomaji": "啟用羅馬拼音顯示"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+10
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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(() => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user