mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-11 14:53:47 +02:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9312d86fd | |||
| 30a1bda93d | |||
| 0e24eeeb1c | |||
| 58d4dea09a | |||
| c4da44a443 | |||
| be3f959354 | |||
| deb69ef8ea | |||
| 5ac0aaeec0 | |||
| 515cadb916 | |||
| 4b4d64c7fc | |||
| f7e1198482 | |||
| 7243ed7f15 | |||
| 7e9a78898f | |||
| 6aab8d4121 | |||
| 70594a696b | |||
| 08b4c620f2 | |||
| 7a20cf3853 | |||
| dd186c570f | |||
| d5e9d491b6 | |||
| 28dc822e4f | |||
| def1b1e710 | |||
| 2ff9e4b0a2 | |||
| 49bfc907cd | |||
| 34314bdf46 | |||
| 9d53c53c54 | |||
| 8acd585630 | |||
| 1f5907716f | |||
| 99ae0c99c6 | |||
| a56253cd3a | |||
| a2cdce66bc | |||
| 7454832663 | |||
| 1ed185606d | |||
| d9da588c7c | |||
| e206136156 | |||
| 57b11e0dae | |||
| 2fc130d709 | |||
| 1aa6b88cfa | |||
| 329d028edd | |||
| 4955f30081 | |||
| bf7ca937ff |
@@ -6,7 +6,7 @@ body:
|
||||
- type: checkboxes
|
||||
id: check-duplicate
|
||||
attributes:
|
||||
label: I have already checked through the existing bug reports and found no duplicates
|
||||
label: I have already checked through the existing (both open AND closed) bug reports and found no duplicates
|
||||
options:
|
||||
- label: 'Yes'
|
||||
required: true
|
||||
|
||||
@@ -4,6 +4,11 @@ permissions: write-all
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Docker image tag (e.g. 1.12.0 or latest)'
|
||||
required: true
|
||||
type: string
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
@@ -33,11 +38,10 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }}
|
||||
type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Setup Docker buildx
|
||||
|
||||
@@ -12,7 +12,8 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v6
|
||||
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
issue-inactive-days: 120
|
||||
@@ -29,15 +30,15 @@ jobs:
|
||||
days-before-pr-close: 30
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had recent activity. The resources of the Feishin team are limited, and so we are asking for your help.
|
||||
|
||||
|
||||
If this is a **bug** and you can still reproduce this error on the <code>development</code> branch, please reply with all of the information you have about it in order to keep the issue open.
|
||||
|
||||
|
||||
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
|
||||
|
||||
|
||||
stale-pr-message: >
|
||||
This PR has been automatically marked as stale because it has not had recent activity. The resources of the Feishin team are limited, and so we are asking for your help.
|
||||
|
||||
|
||||
This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ WORKDIR /app
|
||||
# Copy package.json first to cache node_modules
|
||||
COPY package.json pnpm-lock.yaml .
|
||||
|
||||
RUN npm install -g pnpm
|
||||
# Match CI (pnpm/action-setup version: 10). Latest pnpm 11 fails install without approve-builds.
|
||||
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
@@ -189,6 +189,7 @@
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import cs from './locales/cs.json';
|
||||
import de from './locales/de.json';
|
||||
import en from './locales/en.json';
|
||||
import es from './locales/es.json';
|
||||
import et from './locales/et.json';
|
||||
import eu from './locales/eu.json';
|
||||
import fa from './locales/fa.json';
|
||||
import fi from './locales/fi.json';
|
||||
@@ -27,6 +28,8 @@ import sl from './locales/sl.json';
|
||||
import sr from './locales/sr.json';
|
||||
import sv from './locales/sv.json';
|
||||
import ta from './locales/ta.json';
|
||||
import th from './locales/th.json';
|
||||
import tl from './locales/tl.json';
|
||||
import tr from './locales/tr.json';
|
||||
import zhHans from './locales/zh-Hans.json';
|
||||
import zhHant from './locales/zh-Hant.json';
|
||||
@@ -38,6 +41,7 @@ const resources = {
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
et: { translation: et },
|
||||
eu: { translation: eu },
|
||||
fa: { translation: fa },
|
||||
fi: { translation: fi },
|
||||
@@ -57,6 +61,8 @@ const resources = {
|
||||
sr: { translation: sr },
|
||||
sv: { translation: sv },
|
||||
ta: { translation: ta },
|
||||
th: { translation: th },
|
||||
tl: { translation: tl },
|
||||
tr: { translation: tr },
|
||||
'zh-Hans': { translation: zhHans },
|
||||
'zh-Hant': { translation: zhHant },
|
||||
@@ -87,6 +93,10 @@ export const languages = [
|
||||
label: 'Español',
|
||||
value: 'es',
|
||||
},
|
||||
{
|
||||
label: 'Eesti',
|
||||
value: 'et',
|
||||
},
|
||||
{
|
||||
label: 'Basque',
|
||||
value: 'eu',
|
||||
@@ -163,6 +173,14 @@ export const languages = [
|
||||
label: 'Tamil',
|
||||
value: 'ta',
|
||||
},
|
||||
{
|
||||
label: 'Thai',
|
||||
value: 'th',
|
||||
},
|
||||
{
|
||||
label: 'Tagalog',
|
||||
value: 'tl',
|
||||
},
|
||||
{
|
||||
label: 'Türkçe',
|
||||
value: 'tr',
|
||||
|
||||
@@ -331,7 +331,10 @@
|
||||
"serverRequired": "يتطلب خادم",
|
||||
"sessionExpiredError": "انتهت صلاحية جلستك",
|
||||
"systemFontError": "حدث خطأ أثناء محاولة الحصول على خطوط النظام",
|
||||
"settingsSyncError": "تم اكتشاف تعارضات بين إعدادات العارض والعملية الرئيسية. أعد تشغيل التطبيق لتطبيق التغييرات"
|
||||
"settingsSyncError": "تم اكتشاف تعارضات بين إعدادات العارض والعملية الرئيسية. أعد تشغيل التطبيق لتطبيق التغييرات",
|
||||
"invalidJson": "JSON غير صالح",
|
||||
"invalidServer": "خادم غير صالح",
|
||||
"localFontAccessDenied": "تم رفض الوصول إلى الخطوط المحلية"
|
||||
},
|
||||
"filter": {
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
@@ -372,7 +375,8 @@
|
||||
"sortName": "أسم الفرز",
|
||||
"title": "العنوان",
|
||||
"toYear": "إلى سنة",
|
||||
"trackNumber": "مقطع"
|
||||
"trackNumber": "مقطع",
|
||||
"isCompilation": "تجميعة"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "د",
|
||||
@@ -413,14 +417,19 @@
|
||||
"input_url": "الرابط",
|
||||
"input_username": "أسم المستخدم",
|
||||
"success": "تمت إضافة الخادم بنجاح",
|
||||
"title": "إضافة خادم"
|
||||
"title": "إضافة خادم",
|
||||
"input_preferInstantMix": "تفضيل الميكس الفوري",
|
||||
"input_preferInstantMixDescription": "استخدم الميكس الفوري فقط للحصول على أغاني مشابهة. مفيد إذا كان لديك إضافات تعدّل هذا السلوك",
|
||||
"input_remoteUrlPlaceholder": "اختياري: عنوان URL عام للميزات الخارجية"
|
||||
},
|
||||
"largeFetchConfirmation": {
|
||||
"title": "أضف العناصر إلى قائمة التشغيل"
|
||||
"title": "أضف العناصر إلى قائمة التشغيل",
|
||||
"description": "سيقوم هذا الإجراء بإضافة جميع العناصر في العرض المفلتر الحالي"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"input_skipDuplicates": "تخطي العناصر المكررة",
|
||||
"title": "أضف إلى $t(entity.playlist, {\"count\": 1})"
|
||||
"title": "أضف إلى $t(entity.playlist, {\"count\": 1})",
|
||||
"create": "إنشاء $t(entity.playlist, {\"count\": 1}) {{playlist}}"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_public": "عام"
|
||||
@@ -428,7 +437,9 @@
|
||||
"createRadioStation": {
|
||||
"input_homepageUrl": "رابط الرئيسية",
|
||||
"input_name": "الأسم",
|
||||
"input_streamUrl": "رابط البث"
|
||||
"input_streamUrl": "رابط البث",
|
||||
"success": "تم إنشاء محطة راديو جديدة بنجاح",
|
||||
"title": "إنشاء محطة راديو"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "تم تحديث محطة الراديو بنجاح"
|
||||
@@ -440,7 +451,8 @@
|
||||
},
|
||||
"editPlaylist": {
|
||||
"success": "تم تحديث $t(entity.playlist, {\"count\": 1}) بنجاح",
|
||||
"title": "تعديل $t(entity.playlist, {\"count\": 1})"
|
||||
"title": "تعديل $t(entity.playlist, {\"count\": 1})",
|
||||
"publicJellyfinNote": "لسبب ما، لا يكشف Jellyfin عما إذا كانت قائمة التشغيل عامة أم لا. إذا كنت ترغب في إبقائها عامة، يرجى التأكد من تحديد الخيار التالي"
|
||||
},
|
||||
"lyricsExport": {
|
||||
"export": "تصدير الكلمات",
|
||||
@@ -451,7 +463,32 @@
|
||||
},
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "تطابق الجميع",
|
||||
"input_optionMatchAny": "تطابق أي"
|
||||
"input_optionMatchAny": "تطابق أي",
|
||||
"title": "محرر الاستعلامات",
|
||||
"addRuleGroup": "إضافة مجموعة قواعد",
|
||||
"removeRuleGroup": "إزالة مجموعة قواعد",
|
||||
"resetToDefault": "استعادة الإعدادات الافتراضية"
|
||||
},
|
||||
"shareItem": {
|
||||
"allowDownloading": "السماح بالتحميل",
|
||||
"description": "الوصف"
|
||||
},
|
||||
"shuffleAll": {
|
||||
"title": "تشغيل عشوائي",
|
||||
"input_kind_albums": "ألبومات",
|
||||
"input_kind_songs": "أغاني",
|
||||
"input_kind": "إختيارات عشوائية",
|
||||
"input_minYear": "من سنة",
|
||||
"input_maxYear": "إلى سنة"
|
||||
},
|
||||
"updateServer": {
|
||||
"success": "تم تحديث الخادم بنجاح",
|
||||
"title": "تحديث الخادم"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"albumArtistDetail": {
|
||||
"favoriteSongs": "الأغاني المفضلة"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,8 @@
|
||||
"rename": "Reanomena",
|
||||
"newVersionAvailable": "Hi ha una nova versió disponible",
|
||||
"numberOfResults": "{{numberOfResults}} resultats",
|
||||
"back": "Enrere"
|
||||
"back": "Enrere",
|
||||
"openFolder": "Obre la carpeta"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Àlbum",
|
||||
@@ -462,7 +463,7 @@
|
||||
"expireInvalid": "La data d'expiració ha de ser al futur",
|
||||
"createFailed": "No s'ha pogut crear el recurs compartit (està habilitat, l'ús compartit?)",
|
||||
"copyToClipboard": "Copiar al porta-retalls: Ctrl+C, enter",
|
||||
"successMustClick": "Compartició creada correctament. Feu clic aquí per obrir-la."
|
||||
"successMustClick": "Compartició creada correctament. Feu clic aquí per obrir-la"
|
||||
},
|
||||
"updateServer": {
|
||||
"success": "S'ha actualitzat el servidor amb èxit",
|
||||
@@ -495,7 +496,12 @@
|
||||
"input_played": "Reprodueix el filtre",
|
||||
"input_played_optionAll": "Totes les pistes",
|
||||
"input_played_optionUnplayed": "Només les pistes sense reproduir",
|
||||
"input_played_optionPlayed": "Només les pistes reproduïdes"
|
||||
"input_played_optionPlayed": "Només les pistes reproduïdes",
|
||||
"input_kind_albums": "Àlbums",
|
||||
"input_kind_songs": "Cançons",
|
||||
"input_kind": "Seleccions a l'atzar",
|
||||
"input_limit_albums": "Quants àlbums?",
|
||||
"input_limit_songs": "Quantes cançons?"
|
||||
},
|
||||
"createRadioStation": {
|
||||
"success": "Emissora de ràdio creada amb èxit",
|
||||
@@ -626,7 +632,7 @@
|
||||
"customCssEnable_description": "Permet escriure CSS personalitzat",
|
||||
"customCssNotice": "Atenció: tot i que hi ha un filtre (no es permet ni URL() ni content:), l'ús de CSS personalitzat pot presentar riscs si canvieu la interfície",
|
||||
"customCss": "Css personalitzat",
|
||||
"customCss_description": "Contingut del CSS personalitzat. Nota: la propietat \"content\" i els urls remots no es permeten. A sota hi teniu una previsualització. Els camps addicionals que no establiu hi apareixin pel filtre",
|
||||
"customCss_description": "Contingut del CSS personalitzat. Nota: la propietat \"content\" i els urls remots no es permeten. A sota hi teniu una previsualització. Els camps addicionals que no establiu hi apareixen a causa de la sanitització. Escriptori: Feishin llegeix i escriu custom.css al directori de configuració de l'aplicació i el recarrega quan el fitxer canvia",
|
||||
"customFontPath": "Ruta de font personalitzada",
|
||||
"customFontPath_description": "Estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació",
|
||||
"discordApplicationId": "ID d'aplicació de {{discord}}",
|
||||
@@ -807,7 +813,7 @@
|
||||
"releaseChannel": "Canal de versions",
|
||||
"releaseChannel_description": "Trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques",
|
||||
"mediaSession": "Activa media session",
|
||||
"mediaSession_description": "Activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig",
|
||||
"mediaSession_description": "Activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig. Requereix el Reproductor Web d'Àudio.",
|
||||
"crossfadeStyle": "Estil de fosa encadenada",
|
||||
"discordRichPresence": "Estat d'activitat de {{discord}}",
|
||||
"enableAutoTranslation_description": "Activa la traducció automàtica en carregar la lletra",
|
||||
@@ -828,7 +834,7 @@
|
||||
"transcode": "Activa la transcodificació",
|
||||
"autoDJ": "DJ automàtic",
|
||||
"autoDJ_itemCount": "Número d'elements",
|
||||
"autoDJ_itemCount_description": "El nombre d'elements que s'intenten afegir a la cua quan el DJ automàtic està activat",
|
||||
"autoDJ_itemCount_description": "El nombre d'elements que s'intenten afegir a la cua",
|
||||
"autoDJ_timing": "Temps",
|
||||
"autoDJ_timing_description": "El nombre de cançons que han de quedar a la cua per activar el DJ automàtic",
|
||||
"analyticsDisable": "Desactiva les analítiques basades en l'ús",
|
||||
@@ -958,7 +964,16 @@
|
||||
"sidebarPlaylistMode_description": "Com es mostra cada llista de reproducció a la llista de la barra lateral",
|
||||
"sidebarPlaylistMode": "Mode de llista de reproducció a la barra lateral",
|
||||
"sidebarPlaylistMode_optionCompact": "Compacte",
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandit"
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandit",
|
||||
"autoDJ_mode": "Mode",
|
||||
"autoDJ_mode_albums": "Àlbums",
|
||||
"autoDJ_mode_description": "Trieu si voleu afegir cançons o àlbums sencers a la cua",
|
||||
"autoDJ_mode_songs": "Cançons",
|
||||
"autoDJ_enabled": "Activa el DJ automàtic",
|
||||
"autoDJ_albumStrategy": "Mode de selecció d'àlbum",
|
||||
"autoDJ_songStrategy": "Mode de selecció de cançó",
|
||||
"autoDJ_strategy_option_library_random": "A l'atzar",
|
||||
"autoDJ_strategy_option_similar": "Similar"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
"customCssEnable": "Povolit vlastní CSS",
|
||||
"customCssEnable_description": "Umožnit psaní vlastního CSS",
|
||||
"customCssNotice": "Varování: i když provádíme určitou sanitizaci (zakázáním URL() a content:), může používání CSS stále představovat riziko změnami rozhraní",
|
||||
"customCss_description": "Vlastní CSS obsah. Upozornění: vlastnosti content a vzdálené URL jsou zakázané. Níže je zobrazen náhled vašeho obsahu. Další pole, která jste nenastavili, jsou přítomna z důvodu sanitizace",
|
||||
"customCss_description": "Vlastní CSS obsah. Upozornění: vlastnosti content a vzdálené URL jsou zakázané. Níže je zobrazen náhled vašeho obsahu. Další pole, která jste nenastavili, jsou přítomna z důvodu sanitizace. Počítačový Feishin čte a zapisuje soubor custom.css do konfiguračního adresáře aplikace a znovu jej načte po jeho změně",
|
||||
"customCss": "Vlastní css",
|
||||
"webAudio": "Použít webový zvuk",
|
||||
"webAudio_description": "Použít webový zvuk. Tím povolíte pokročilé funkce jako ReplayGain. Zakažte, pokud se objeví problémy",
|
||||
@@ -296,7 +296,7 @@
|
||||
"releaseChannel": "Kanál vydání",
|
||||
"releaseChannel_description": "Vyberte si mezi stabilními, beta nebo alpha (nočními) vydáními pro automatické aktualizace",
|
||||
"mediaSession": "Povolit relaci médií",
|
||||
"mediaSession_description": "Povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce",
|
||||
"mediaSession_description": "Povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce. Vyžaduje webový přehrávač zvuku.",
|
||||
"exportImportSettings_control_description": "Exportovat a importovat nastavení pomocí souboru JSON",
|
||||
"exportImportSettings_control_exportText": "Exportovat nastavení",
|
||||
"exportImportSettings_control_importText": "Importovat nastavení",
|
||||
@@ -345,7 +345,7 @@
|
||||
"playerbarSlider_description": "Vlnová křivka není doporučena, pokud se nacházíte na pomalém nebo měřeném internetovém připojení",
|
||||
"autoDJ": "Automatický DJ",
|
||||
"autoDJ_itemCount": "Počet položek",
|
||||
"autoDJ_itemCount_description": "Počet položek, které se pokusíme přidat do fronty po povolení automatického DJ",
|
||||
"autoDJ_itemCount_description": "Počet položek, které se pokusíme přidat do fronty",
|
||||
"autoDJ_timing": "Časování",
|
||||
"autoDJ_timing_description": "Počet skladeb zbývajících ve frontě před spuštěním automatického DJ",
|
||||
"logLevel": "Úroveň protokolu",
|
||||
@@ -447,7 +447,16 @@
|
||||
"sidebarPlaylistMode_description": "Jak je každý seznam skladeb zobrazen v seznamu v postranní liště",
|
||||
"sidebarPlaylistMode": "Režim seznamů skladeb v postranní liště",
|
||||
"sidebarPlaylistMode_optionCompact": "Kompaktní",
|
||||
"sidebarPlaylistMode_optionExpanded": "Rozšířený"
|
||||
"sidebarPlaylistMode_optionExpanded": "Rozšířený",
|
||||
"autoDJ_mode": "Režim",
|
||||
"autoDJ_mode_albums": "Alba",
|
||||
"autoDJ_mode_description": "Vyberte, zda do fronty přidávat skladby nebo celá alba",
|
||||
"autoDJ_mode_songs": "Skladby",
|
||||
"autoDJ_enabled": "Povolit automatického DJ",
|
||||
"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é"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -623,7 +632,8 @@
|
||||
"newVersionAvailable": "Je dostupná nová verze",
|
||||
"numberOfResults": "{{numberOfResults}} výsledků",
|
||||
"grouping": "Seskupování",
|
||||
"back": "Zpět"
|
||||
"back": "Zpět",
|
||||
"openFolder": "Otevřít složku"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -1122,7 +1132,12 @@
|
||||
"input_played": "Přehrát filtr",
|
||||
"input_played_optionAll": "Všechny skladby",
|
||||
"input_played_optionUnplayed": "Pouze nepřehrané skladby",
|
||||
"input_played_optionPlayed": "Pouze přehrané skladby"
|
||||
"input_played_optionPlayed": "Pouze přehrané skladby",
|
||||
"input_kind_albums": "Alba",
|
||||
"input_kind_songs": "Skladby",
|
||||
"input_kind": "Náhodný výběr",
|
||||
"input_limit_albums": "Kolik alb?",
|
||||
"input_limit_songs": "Kolik skladeb?"
|
||||
},
|
||||
"saveQueue": {
|
||||
"success": "Fronta přehrávání uložena na server"
|
||||
|
||||
+42
-33
@@ -13,7 +13,7 @@
|
||||
"removeFromPlaylist": "Aus $t(entity.playlist, {\"count\": 1}) entfernen",
|
||||
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) anzeigen",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromQueue": "Aus wiedergabeliste entfernen",
|
||||
"removeFromQueue": "Aus Wiedergabeliste entfernen",
|
||||
"setRating": "Bewertung setzen",
|
||||
"toggleSmartPlaylistEditor": "Editor für $t(entity.smartPlaylist) ein-/ausblenden",
|
||||
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
|
||||
@@ -169,7 +169,8 @@
|
||||
"filter_multiple": "Mehrfach",
|
||||
"retry": "Erneut versuchen",
|
||||
"newVersionAvailable": "Eine neue version ist verfügbar",
|
||||
"numberOfResults": "{{numberOfResults}} ergebnisse"
|
||||
"numberOfResults": "{{numberOfResults}} ergebnisse",
|
||||
"openFolder": "Verzeichnis öffnen"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
||||
@@ -191,13 +192,13 @@
|
||||
"audioDeviceFetchError": "Beim Versuch, Audiogeräte abzurufen, ist ein Fehler aufgetreten",
|
||||
"invalidServer": "Ungültiger Server",
|
||||
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut",
|
||||
"badAlbum": "Sie sehen diese Seite, weil dieses Lied nicht Teil eines Albums ist. Dieses Problem tritt meist auf, wenn sich ein Lied im Überordner befindet. Jellyfin gruppiert Tracks nur, wenn diese sich innerhalb eines Ordners befinden",
|
||||
"badAlbum": "Sie sehen diese Seite, weil dieses Lied nicht Teil eines Albums ist. Dieses Problem tritt meist auf, wenn sich ein Lied im Überordner befindet. Jellyfin gruppiert Tracks nur, wenn diese sich innerhalb eines Verzeichnisses befinden",
|
||||
"networkError": "Ein Netzwerkfehler ist aufgetreten",
|
||||
"openError": "Datei kann nicht geöffnet werden",
|
||||
"badValue": "Ungültige option \"{{value}}\". Dieser Wert existiert nicht mehr",
|
||||
"notificationDenied": "Berechtigungen über Benachrichtigungen wurden verweigert. Diese Einstellung hat keinen Effekt",
|
||||
"saveQueueFailed": "Wiedergabeliste konnte nicht gespeichert werden",
|
||||
"multipleServerSaveQueueError": "Die Wiedergabeliste enthält einen oder mehrere Titel, die nicht vom aktuellen Server stammen. dies wird nicht unterstützt",
|
||||
"multipleServerSaveQueueError": "Die Wiedergabeliste enthält einen oder mehrere Titel, die nicht vom aktuellen Server stammen. Dies wird nicht unterstützt",
|
||||
"noNetwork": "Server nicht verfügbar",
|
||||
"noNetworkDescription": "Verbindung zum Server konnte nicht hergestellt werden",
|
||||
"invalidJson": "JSON ungültig",
|
||||
@@ -309,7 +310,7 @@
|
||||
"editPlaylist": {
|
||||
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
|
||||
"success": "$t(entity.playlist, {\"count\": 1}) erfolgreich aktualisiert",
|
||||
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus"
|
||||
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen, ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"title": "Songtext suche",
|
||||
@@ -332,7 +333,7 @@
|
||||
"title": "Privater Modus"
|
||||
},
|
||||
"largeFetchConfirmation": {
|
||||
"title": "Elemente der wiedergabeliste hinzufügen",
|
||||
"title": "Elemente der Wiedergabeliste hinzufügen",
|
||||
"description": "Diese Aktion fügt alle Elemente in der aktuell gefilterten Ansicht hinzu"
|
||||
},
|
||||
"shuffleAll": {
|
||||
@@ -347,7 +348,7 @@
|
||||
"input_played": "Wiedergabefilter"
|
||||
},
|
||||
"saveQueue": {
|
||||
"success": "Wiedergabeliste auf server gespeichert"
|
||||
"success": "Wiedergabeliste auf Server gespeichert"
|
||||
},
|
||||
"createRadioStation": {
|
||||
"success": "Radiosender erfolgreich erstellt",
|
||||
@@ -368,14 +369,14 @@
|
||||
"entity": {
|
||||
"genre_one": "Genre",
|
||||
"genre_other": "Genres",
|
||||
"playlistWithCount_one": "{{count}} wiedergabeliste",
|
||||
"playlistWithCount_other": "{{count}} wiedergabelisten",
|
||||
"playlistWithCount_one": "{{count}} Wiedergabeliste",
|
||||
"playlistWithCount_other": "{{count}} Wiedergabelisten",
|
||||
"playlist_one": "Wiedergabeliste",
|
||||
"playlist_other": "Wiedergabelisten",
|
||||
"artist_one": "Interpret",
|
||||
"artist_other": "Interpreten",
|
||||
"folderWithCount_one": "{{count}} verzeichnis",
|
||||
"folderWithCount_other": "{{count}} verzeichnisse",
|
||||
"folderWithCount_one": "{{count}} Verzeichnis",
|
||||
"folderWithCount_other": "{{count}} Verzeichnisse",
|
||||
"albumArtist_one": "Albuminterpret",
|
||||
"albumArtist_other": "Albuminterpreten",
|
||||
"track_one": "Track",
|
||||
@@ -552,9 +553,9 @@
|
||||
"privateModeOff": "Privaten Modus deaktivieren",
|
||||
"privateModeOn": "Privaten Modus aktivieren",
|
||||
"commandPalette": "Kommandopalette öffnen",
|
||||
"selectMusicFolder": "Musikordner wählen",
|
||||
"noMusicFolder": "Kein musikordner gewählt",
|
||||
"multipleMusicFolders": "{{count}} musikordner ausgewählt"
|
||||
"selectMusicFolder": "Musikverzeichnis wählen",
|
||||
"noMusicFolder": "Kein Musikverzeichnis gewählt",
|
||||
"multipleMusicFolders": "{{count}} Musikverzeichnis ausgewählt"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "Meistgespielt",
|
||||
@@ -681,7 +682,7 @@
|
||||
"topSongs": "Toplieder",
|
||||
"relatedArtists": "Ähnliche $t(entity.artist, {\"count\": 2})",
|
||||
"groupingTypeAll": "Alle veröffentlichungsformate",
|
||||
"groupingTypePrimary": "Primäre veröffentlichungsformate",
|
||||
"groupingTypePrimary": "Primäre Veröffentlichungsformate",
|
||||
"favoriteSongs": "Lieblingslieder",
|
||||
"favoriteSongsFrom": "Liebslingslieder von {{title}}",
|
||||
"topSongsCommunity": "Community",
|
||||
@@ -761,8 +762,8 @@
|
||||
"addLastShuffled": "Als Letztes (zufällige Wiedergabe)",
|
||||
"addNextShuffled": "Als Nächstes (zufällige Wiedergabe)",
|
||||
"holdToShuffle": "Halten für zufallswiedergabe",
|
||||
"restoreQueueFromServer": "Wiedergabeliste von server wiederherstellen",
|
||||
"saveQueueToServer": "Wiedergabeliste auf server speichern",
|
||||
"restoreQueueFromServer": "Wiedergabeliste von Server wiederherstellen",
|
||||
"saveQueueToServer": "Wiedergabeliste auf Server speichern",
|
||||
"lyrics": "Songtexte",
|
||||
"artistRadio": "Künstler radio",
|
||||
"sleepTimer_endOfSong": "Ende des aktuellen liedes",
|
||||
@@ -900,13 +901,13 @@
|
||||
"sidebarPlaylistSorting": "Wiedergabelisten-sortierung in der seitenleiste",
|
||||
"minimizeToTray": "Zur taskleiste minimieren",
|
||||
"skipPlaylistPage": "Wiedergabeliste-seite überspringen",
|
||||
"themeDark": "Erscheinungsbild (dunkel)",
|
||||
"themeDark": "Design (dunkel)",
|
||||
"sidebarCollapsedNavigation": "Navigation in der seitenleiste (komprimiert)",
|
||||
"gaplessAudio_optionWeak": "Schwach (empfohlen)",
|
||||
"minimumScrobbleSeconds": "Minimum scrobble-dauer (sekunden)",
|
||||
"hotkey_playbackStop": "Stoppen",
|
||||
"savePlayQueue_description": "Speichert die Wiedergabeliste beim Schließen der Anwendung, und stellt diese wieder her, wenn die Anwendung geöffnet wird",
|
||||
"useSystemTheme": "Nach erscheinungsbild des systems richten",
|
||||
"useSystemTheme": "Nach Erscheinungsbild des Systems richten",
|
||||
"enableRemote_description": "Aktiviert den Server für die Fernsteuerung, damit andere Geräte die Anwendung steuern können",
|
||||
"fontType_optionSystem": "System schriftart",
|
||||
"discordUpdateInterval": "{{discord}} rich presence aktualisierungsintervall",
|
||||
@@ -922,7 +923,7 @@
|
||||
"fontType": "Schriftartenquelle",
|
||||
"followLyric": "Aktuellen songtext synchronisieren",
|
||||
"font_description": "Wähle die Schriftart für die Anwendung",
|
||||
"themeLight": "Erscheinungsbild (hell)",
|
||||
"themeLight": "Design (hell)",
|
||||
"sidePlayQueueStyle_optionDetached": "Lösgelöst",
|
||||
"windowBarStyle_description": "Legt das Erscheinungsbild des Fensterrahmens fest",
|
||||
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) zu favoriten hinzufügen",
|
||||
@@ -950,7 +951,7 @@
|
||||
"albumBackgroundBlur_description": "Passt die Stärke der Unschärfe an, welche auf das Hintergrundbild des Albums angewandt wird",
|
||||
"clearCacheSuccess": "Cache erfolgreich geleert",
|
||||
"contextMenu": "Kontextmenü-einstellungen (rechtsklick)",
|
||||
"customCssEnable_description": "Erlaubt das hinzufügen von benutzerdefiniertem CSS",
|
||||
"customCssEnable_description": "Erlaubt das Hinzufügen von benutzerdefiniertem CSS",
|
||||
"artistBackground": "Künstler hintergrundbild",
|
||||
"artistBackground_description": "Fügt ein Hintergrundbild für die Künstlerseite hinzu",
|
||||
"artistConfiguration": "Künstler albumseite konfiguration",
|
||||
@@ -980,8 +981,8 @@
|
||||
"logLevel_optionWarn": "Warnung",
|
||||
"autoDJ": "Auto DJ",
|
||||
"autoDJ_itemCount": "Anzahl",
|
||||
"autoDJ_itemCount_description": "Die anzahl der lieder, die bei aktiviertem auto DJ zur wiedergabeliste hinzugefügt werden sollen",
|
||||
"autoDJ_timing_description": "Die anzahl der lieder, die sich noch in der wiedergabeliste befinden, bevor auto DJ ausgelöst wird",
|
||||
"autoDJ_itemCount_description": "Die Anzahl der Lieder, die zur Wiedergabeliste hinzugefügt werden soll",
|
||||
"autoDJ_timing_description": "Die Anzahl der Lieder, die sich noch in der Wiedergabeliste befinden, bevor Auto-DJ ausgelöst wird",
|
||||
"autoDJ_timing": "Timing",
|
||||
"discordDisplayType": "{{discord}} presence darstellungsart",
|
||||
"discordLinkType_mbz_lastfm": "{{musicbrainz}} mit {{lastfm}} als ersatz",
|
||||
@@ -1022,7 +1023,7 @@
|
||||
"artistConfiguration_description": "Legt fest, welche Elemente auf der Albumkünstlerseite angezeigt werden und in welcher Reihenfolge",
|
||||
"contextMenu_description": "Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet",
|
||||
"crossfadeStyle": "Art der überblende",
|
||||
"customCss_description": "Benutzerdefinierter CSS-inhalt. Hinweis: inhalte und remote-urls sind nicht zulässige eigenschaften. Unten siehst du eine vorschau deines inhalts. Aufgrund von bereinigung werden womöglich zusätzliche, nicht von dir definierte felder angezeigt",
|
||||
"customCss_description": "Benutzerdefinierter CSS-Inhalt. Hinweis: Content und Remote URLs sind nicht zulässige Eigenschaften. Eine Vorschau deines Inhalts wird unten angezeigt. Aufgrund von Bereinigung werden womöglich zusätzliche, nicht von dir definierte Felder angezeigt. Desktop: Feishin liest und schreibt in eine custom.css Datei im App-Konfigurationsverzeichnis, und lädt diese neu, wenn sich die Datei ändert.",
|
||||
"customCssNotice": "Warnung: obwohl eine gewisse bereinigung erfolgt (nicht zulässig sind z. B. \"URL()\" und \"content:\"), kann ein benutzerdefiniertes CSS risiken mit sich bringen, da die benutzeroberfläche dadurch verändert wird",
|
||||
"releaseChannel_optionBeta": "Beta",
|
||||
"releaseChannel_optionLatest": "Stabil",
|
||||
@@ -1064,7 +1065,7 @@
|
||||
"automaticUpdates": "Automatische updates",
|
||||
"automaticUpdates_description": "Updates automatisch suchen und installieren",
|
||||
"releaseChannel_optionAlpha": "Alpha (nightly)",
|
||||
"useThemeAccentColor": "Akzentfarbe des themas nutzen",
|
||||
"useThemeAccentColor": "Standard Akzentfarbe übernehmen",
|
||||
"analyticsEnable_description": "Anonymisierte Nutzungsdaten werden an den Entwickler gesendet, um die Anwendung zu verbessern",
|
||||
"artistReleaseTypeConfiguration_description": "Konfigurieren, welche Release-Typen und in welcher Reihenfolge diese auf der Album-Künstlerseite angezeigt werden",
|
||||
"homeConfiguration_description": "Konfigurieren, welche Elemente und in welcher Reihenfolge diese auf der Startseite angezeigt werden",
|
||||
@@ -1111,14 +1112,14 @@
|
||||
"queryBuilder": "Abfrage-editor",
|
||||
"queryBuilderCustomFields_inputLabel": "Label",
|
||||
"queryBuilderCustomFields_description": "Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu",
|
||||
"autosave": "Automatisch aktuelle wiedergabeliste speichern",
|
||||
"autosave_description": "Aktiviere die automatische speicherung der aktuellen wiedergabe auf dem server. Diese funktion ist nur bei Navidrome/Subsonic servern verfügbar und es darf sich nicht um eine gemischte wiedergabeliste handeln.",
|
||||
"autosaveCount": "Häufigkeit der automatischen speicherung bei wiedergabelisten",
|
||||
"autosave": "Automatisch aktuelle Wiedergabeliste speichern",
|
||||
"autosave_description": "Aktiviere die automatische Speicherung der aktuellen Wiedergabe auf dem Server. Diese Funktion ist nur bei Navidrome/Subsonic Servern verfügbar und es darf sich nicht um eine gemischte Wiedergabeliste handeln.",
|
||||
"autosaveCount": "Häufigkeit der automatischen Speicherung bei Wiedergabelisten",
|
||||
"autosaveCount_description": "Wieviele Lieder gespielt werden, bevor die Wiedergabeliste gespeichert wird. 1 (Minimum) bedeutet die Speicherung nach jedem gespielten Lied",
|
||||
"useThemeAccentColor_description": "Verwendet die Primärfarbe des gewählten Themas anstatt einer ausgewählten Akzentfarbe",
|
||||
"useThemePrimaryShade": "Primärschatten des themas nutzen",
|
||||
"useThemePrimaryShade_description": "Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten",
|
||||
"primaryShade": "Primärschatten",
|
||||
"useThemeAccentColor_description": "Verwendet die primäre Farbe des gewählten Designs",
|
||||
"useThemePrimaryShade": "Standard Farbton übernehmen",
|
||||
"useThemePrimaryShade_description": "Verwendet den primären Farbton des ausgewählten Designs für die Primärfarbvarianten",
|
||||
"primaryShade": "Primärer Farbton",
|
||||
"listenbrainz": "ListenBrainz Links anzeigen",
|
||||
"listenbrainz_description": "Zeige Links zu ListenBrainz auf den Interpreten/Alben Seiten",
|
||||
"mpvExtraParameters": "Zusätzliche mpv parameter",
|
||||
@@ -1133,7 +1134,15 @@
|
||||
"nativeSpotify_description": "In der Spotify app statt im browser öffnen",
|
||||
"imageResolution_optionFullScreenPlayer": "Wiedergabe im vollbildmodus",
|
||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||
"sidePlayQueueLayout_optionVertical": "Vertikal"
|
||||
"sidePlayQueueLayout_optionVertical": "Vertikal",
|
||||
"sidebarPlaylistFolders": "Verzeichnisse aktivieren",
|
||||
"sidebarPlaylistFolderSeparator": "Verzeichnistrennzeichen",
|
||||
"sidebarPlaylistFolderView_description": "Wie Verzeichnisse in der Seitenleiste angezeigt werden",
|
||||
"sidebarPlaylistFolderView": "Verzeichnisansicht",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Einzelne Ordner",
|
||||
"sidebarPlaylistFolderView_optionTree": "Baumstruktur",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Navigationsansicht",
|
||||
"sidebarPlaylistFolderSeparator_description": "Zeichen (oder Zeichenfolge), das die Verzeichnisebenen im Wiedergabelistentitel trennt"
|
||||
},
|
||||
"dragDropZone": {
|
||||
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
|
||||
|
||||
@@ -699,6 +699,7 @@
|
||||
"viewQueue": "View queue",
|
||||
"sleepTimer": "Sleep timer",
|
||||
"sleepTimer_endOfSong": "End of current song",
|
||||
"sleepTimer_endOfAlbum": "End of current album",
|
||||
"sleepTimer_minutes": "{{count}} min",
|
||||
"sleepTimer_hours": "{{count}} hr",
|
||||
"sleepTimer_custom": "Custom",
|
||||
@@ -1093,7 +1094,7 @@
|
||||
"sidePlayQueueLayout_description": "Sets the layout of the attached side play queue",
|
||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||
"mediaSession_description": "Enables media session integration, displaying media controls and metadata in the system volume overlay and lock screen",
|
||||
"mediaSession_description": "Enables media session integration, displaying media controls and metadata in the system volume overlay and lock screen. Requires the Web Audio Player.",
|
||||
"mediaSession": "Enable media session",
|
||||
"sidePlayQueueStyle": "Side play queue style",
|
||||
"skipDuration_description": "Sets the duration to skip when using the skip buttons on the player bar",
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
"customCssEnable_description": "Permite escribir CSS personalizado",
|
||||
"customCss": "CSS personalizado",
|
||||
"customCssNotice": "Aviso: mientras hay alguna sanitización (rechazar URL() y content:), usar CSS personalizado puede aún entrañar riesgos cambiando la interfaz",
|
||||
"customCss_description": "Content CSS personalizado. Nota: content y urls remotas son propiedades rechazadas. Una vista previa de tu content se muestra debajo. Las entradas adicionales que no estableciste están presentes debido a la sanitización",
|
||||
"customCss_description": "Content CSS personalizado. Nota: content y remote urls son propiedades rechazadas. Una vista previa de tu content se muestra debajo. Las entradas adicionales que no estableciste están presentes debido a la sanitización. Escritorio: Feishin lee y escribe custom.css en el directorio de configuración de la aplicación y lo recarga cuando cambia el archivo",
|
||||
"webAudio": "Usar audio web",
|
||||
"webAudio_description": "Utilizar audio web. Esto habilita funciones avanzadas como ReplayGain. Desactiva esta opción si tienes problemas",
|
||||
"transcode_description": "Permite la transcodificación a distintos formatos",
|
||||
@@ -296,7 +296,7 @@
|
||||
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
|
||||
"artistBackground_description": "Añade una imagen de fondo para las páginas de artistas que contienen el arte de los artistas",
|
||||
"mediaSession": "Activar sesión de medios",
|
||||
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
|
||||
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo. Requiere el Reproductor Web de Audio.",
|
||||
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
|
||||
"exportImportSettings_control_exportText": "Exportar configuración",
|
||||
"exportImportSettings_control_importText": "Importar configuración",
|
||||
@@ -345,7 +345,7 @@
|
||||
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
|
||||
"autoDJ": "DJ Automático",
|
||||
"autoDJ_itemCount": "Recuento de elementos",
|
||||
"autoDJ_itemCount_description": "El número de elementos que se ha intentado añadir a la cola cuando DJ automático está activado",
|
||||
"autoDJ_itemCount_description": "El número de elementos que se ha intentado añadir a la cola",
|
||||
"autoDJ_timing_description": "El número de canciones restantes en la cola antes de que DJ automático se dispare",
|
||||
"autoDJ_timing": "Tiempo",
|
||||
"logLevel": "Nivel de registro",
|
||||
@@ -447,7 +447,16 @@
|
||||
"sidebarPlaylistMode_optionCompact": "Compacto",
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandido",
|
||||
"sidebarPlaylistMode_description": "Cómo se muestra cada lista de reproducción en la lista de la barra lateral",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Píxeles que está sangrado cada nivel del árbol"
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Píxeles que está sangrado cada nivel del árbol",
|
||||
"autoDJ_mode": "Modo",
|
||||
"autoDJ_mode_albums": "Álbumes",
|
||||
"autoDJ_mode_songs": "Canciones",
|
||||
"autoDJ_enabled": "Activar DJ automático",
|
||||
"autoDJ_albumStrategy": "Modo de selección de álbum",
|
||||
"autoDJ_songStrategy": "Modo de selección de canción",
|
||||
"autoDJ_strategy_option_library_random": "Aleatorio",
|
||||
"autoDJ_strategy_option_similar": "Similar",
|
||||
"autoDJ_mode_description": "Elegir para añadir canciones o álbumes enteros a la cola"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -623,7 +632,8 @@
|
||||
"newVersionAvailable": "Una nueva versión está disponible",
|
||||
"numberOfResults": "{{numberOfResults}} resultados",
|
||||
"grouping": "Agrupar",
|
||||
"back": "Atrás"
|
||||
"back": "Atrás",
|
||||
"openFolder": "Abrir carpeta"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "Reiniciar el servidor para aplicar el nuevo puerto",
|
||||
@@ -1013,7 +1023,12 @@
|
||||
"input_played": "Reproducir filtro",
|
||||
"input_played_optionAll": "Todas las pistas",
|
||||
"input_played_optionUnplayed": "Solo las pistas sin reproducir",
|
||||
"input_played_optionPlayed": "Solo las pistas reproducidas"
|
||||
"input_played_optionPlayed": "Solo las pistas reproducidas",
|
||||
"input_kind_albums": "Álbumes",
|
||||
"input_kind_songs": "Canciones",
|
||||
"input_limit_albums": "¿Cuántos álbumes?",
|
||||
"input_limit_songs": "¿Cuántas canciones?",
|
||||
"input_kind": "Selecciones aleatorias"
|
||||
},
|
||||
"saveQueue": {
|
||||
"success": "Cola de reproducción guardada en el servidor"
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
{
|
||||
"action": {
|
||||
"addToFavorites": "Lisa üksusesse $t(entity.favorite, {\"count\": 2})",
|
||||
"addToPlaylist": "Lisa üksusesse $t(entity.playlist, {\"count\": 1})",
|
||||
"addOrRemoveFromSelection": "Lisa või eemalda valikust",
|
||||
"selectRangeOfItems": "Vali mitu üksust korraga",
|
||||
"clearQueue": "Tühjenda järjekord",
|
||||
"goToCurrent": "Mine praeguse üksuse juurde",
|
||||
"collapseAllFolders": "Ahenda kõik kaustad",
|
||||
"expandAllFolders": "Laienda kõik kaustad",
|
||||
"createPlaylist": "Loo $t(entity.playlist, {\"count\": 1})",
|
||||
"createRadioStation": "Loo $t(entity.radioStation, {\"count\": 1})",
|
||||
"deletePlaylist": "Kustuta $t(entity.playlist, {\"count\": 1})",
|
||||
"deleteRadioStation": "Kustuta $t(entity.radioStation, {\"count\": 1})",
|
||||
"selectAll": "Vali kõik",
|
||||
"deselectAll": "Tühista kõigi valik",
|
||||
"downloadStarted": "{{count}} üksuse allalaadimine algas",
|
||||
"editPlaylist": "Muuda $t(entity.playlist, {\"count\": 1})",
|
||||
"goToPage": "Mine lehele",
|
||||
"moveToNext": "Järgmine",
|
||||
"moveToBottom": "Liiguta lõppu",
|
||||
"moveToTop": "Liiguta algusesse",
|
||||
"moveUp": "Liigu üles",
|
||||
"moveDown": "Liigu alla",
|
||||
"holdToMoveToTop": "Hoia algusesse liigutamiseks",
|
||||
"holdToMoveToBottom": "Hoia lõppu liigutamiseks",
|
||||
"moveItems": "Liiguta üksusi",
|
||||
"shuffle": "Sega",
|
||||
"shuffleAll": "Sega kõik",
|
||||
"shuffleSelected": "Sega valitud",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromFavorites": "Eemalda üksusest $t(entity.favorite, {\"count\": 2})",
|
||||
"removeFromPlaylist": "Eemalda üksusest $t(entity.playlist, {\"count\": 1})",
|
||||
"removeFromQueue": "Eemalda järjekorrast",
|
||||
"setRating": "Hinda",
|
||||
"toggleSmartPlaylistEditor": "Lülita $t(entity.smartPlaylist) redaktor sisse/välja",
|
||||
"viewPlaylists": "Vaata $t(entity.playlist, {\"count\": 2})",
|
||||
"viewMore": "Vaata rohkem",
|
||||
"openApplicationDirectory": "Ava rakenduste kataloog",
|
||||
"openIn": {
|
||||
"lastfm": "Ava Last.fm-is",
|
||||
"listenbrainz": "Ava ListenBrainzis",
|
||||
"musicbrainz": "Ava MusicBrainzis",
|
||||
"qobuz": "Ava Qobuzis",
|
||||
"spotify": "Ava Spotifys"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"countSelected": "{{count}} valitud",
|
||||
"explicitStatus": "Ebasündsa sisu olek",
|
||||
"action_one": "Toiming",
|
||||
"action_other": "Toimingud",
|
||||
"add": "Lisa",
|
||||
"additionalParticipants": "Teised osalejad",
|
||||
"newVersion": "Uus versioon on paigaldatud ({{version}})",
|
||||
"viewReleaseNotes": "Kuva väljalaskemärkmed",
|
||||
"albumGain": "Albumi helitugevus (gain)",
|
||||
"albumPeak": "Albumi tippnivoo",
|
||||
"areYouSure": "Kas oled kindel?",
|
||||
"ascending": "Kasvav",
|
||||
"back": "Tagasi",
|
||||
"backward": "Tagasi",
|
||||
"biography": "Biograafia",
|
||||
"bitDepth": "Bititihedus",
|
||||
"bitrate": "Bitikiirus",
|
||||
"bpm": "BPM",
|
||||
"cancel": "Tühista",
|
||||
"center": "Keskel",
|
||||
"channel_one": "Kanal",
|
||||
"channel_other": "Kanalid",
|
||||
"clear": "Tühjenda",
|
||||
"close": "Sulge",
|
||||
"codec": "Koodek",
|
||||
"collapse": "Ahenda",
|
||||
"comingSoon": "Tulekul…",
|
||||
"configure": "Seadista",
|
||||
"confirm": "Kinnita",
|
||||
"create": "Loo",
|
||||
"currentSong": "Praegune $t(entity.track, {\"count\": 1})",
|
||||
"decrease": "Vähenda",
|
||||
"delete": "Kustuta",
|
||||
"descending": "Kahanev",
|
||||
"description": "Kirjeldus",
|
||||
"disable": "Keela",
|
||||
"disc": "Plaat",
|
||||
"dismiss": "Peida",
|
||||
"doNotShowAgain": "Ära seda enam näita",
|
||||
"duration": "Kestus",
|
||||
"view": "Vaata",
|
||||
"edit": "Muuda",
|
||||
"enable": "Luba",
|
||||
"expand": "Laienda",
|
||||
"example": "Näide",
|
||||
"externalLinks": "Välised lingid",
|
||||
"openFolder": "Ava kaust",
|
||||
"faster": "Kiiremini",
|
||||
"favorite": "Lemmik",
|
||||
"filter_one": "Filter",
|
||||
"filter_other": "Filtrid",
|
||||
"filters": "Filtrid",
|
||||
"filter_multiple": "Mitu",
|
||||
"filter_single": "Üksik",
|
||||
"forceRestartRequired": "Muudatuste rakendamiseks taaskäivita… taaskäivitamiseks sule teavitus",
|
||||
"forward": "Edasi",
|
||||
"gap": "Vahe",
|
||||
"home": "Avaleht",
|
||||
"increase": "Suurenda",
|
||||
"left": "Vasak",
|
||||
"limit": "Limiit",
|
||||
"manage": "Halda",
|
||||
"maximize": "Maksimeeri",
|
||||
"menu": "Menüü",
|
||||
"minimize": "Minimeeri",
|
||||
"modified": "Muudetud",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"grouping": "Rühmitamine",
|
||||
"mood": "Meeleolu",
|
||||
"name": "Nimi",
|
||||
"no": "Ei",
|
||||
"none": "Puudub",
|
||||
"noResultsFromQuery": "Päring ei andnud vasteid",
|
||||
"numberOfResults": "{{numberOfResults}} vastet",
|
||||
"noFilters": "Seadistatud filtreid pole",
|
||||
"note": "Märkus",
|
||||
"ok": "Ok",
|
||||
"owner": "Omanik",
|
||||
"playerMustBePaused": "Mängija peab olema pausil",
|
||||
"preview": "Eelvaade",
|
||||
"previousSong": "Eelmine $t(entity.track, {\"count\": 1})",
|
||||
"private": "Privaatne",
|
||||
"public": "Avalik",
|
||||
"quit": "Välju",
|
||||
"random": "Juhuslik",
|
||||
"rating": "Hinne",
|
||||
"retry": "Proovi uuesti",
|
||||
"recordLabel": "Plaadifirma",
|
||||
"releaseType": "Väljaande tüüp",
|
||||
"refresh": "Värskenda",
|
||||
"reload": "Laadi uuesti",
|
||||
"rename": "Nimeta ümber",
|
||||
"reset": "Lähtesta",
|
||||
"resetToDefault": "Taasta vaikeväärtused",
|
||||
"restartRequired": "Vajalik on taaskäivitamine",
|
||||
"right": "Parem",
|
||||
"sampleRate": "Diskreetimissagedus",
|
||||
"save": "Salvesta",
|
||||
"saveAndReplace": "Salvesta ja asenda",
|
||||
"saveAs": "Salvesta nimega",
|
||||
"search": "Otsi",
|
||||
"setting_one": "Säte",
|
||||
"setting_other": "Sätted",
|
||||
"slower": "Aeglasemalt",
|
||||
"share": "Jaga",
|
||||
"size": "Suurus",
|
||||
"sort": "Järjesta",
|
||||
"sortOrder": "Järjestus",
|
||||
"tags": "Sildid",
|
||||
"title": "Pealkiri",
|
||||
"trackNumber": "Pala",
|
||||
"trackGain": "Pala võimendus",
|
||||
"trackPeak": "Pala tippväärtus",
|
||||
"translation": "Tõlge",
|
||||
"unknown": "Tundmatu",
|
||||
"version": "Versioon",
|
||||
"year": "Aasta",
|
||||
"yes": "Jah",
|
||||
"explicit": "Ebatsensuurne",
|
||||
"clean": "Puhas",
|
||||
"gridRows": "Ruudustiku ridu",
|
||||
"tableColumns": "Tabeli veerge",
|
||||
"itemsMore": "{{count}} veel",
|
||||
"newVersionAvailable": "Saadaval on uus versioon",
|
||||
"path": "Asukoht"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Album",
|
||||
"album_other": "Albumid",
|
||||
"albumArtist_one": "Albumi esitaja",
|
||||
"albumArtist_other": "Albumi esitajad",
|
||||
"albumArtistCount_one": "{{count}} albumi esitaja",
|
||||
"albumArtistCount_other": "{{count}} albumi esitajat",
|
||||
"albumWithCount_one": "{{count}} album",
|
||||
"albumWithCount_other": "{{count}} albumit",
|
||||
"radioStation_one": "Raadiojaam",
|
||||
"radioStation_other": "Raadiojaamad",
|
||||
"radioStationWithCount_one": "{{count}} raadiojaam",
|
||||
"radioStationWithCount_other": "{{count}} raadiojaama",
|
||||
"artist_one": "Esitaja",
|
||||
"artist_other": "Esitajad",
|
||||
"artistWithCount_one": "{{count}} esitaja",
|
||||
"artistWithCount_other": "{{count}} esitajat",
|
||||
"favorite_one": "Lemmik",
|
||||
"favorite_other": "Lemmikud",
|
||||
"folder_one": "Kaust",
|
||||
"folder_other": "Kaustad",
|
||||
"folderWithCount_one": "{{count}} kaust",
|
||||
"folderWithCount_other": "{{count}} kausta",
|
||||
"genre_one": "Žanr",
|
||||
"genre_other": "Žanrid",
|
||||
"genreWithCount_one": "{{count}} žanr",
|
||||
"genreWithCount_other": "{{count}} žanrit",
|
||||
"playlist_one": "Esitusloend",
|
||||
"playlist_other": "Esitusloendid",
|
||||
"play_one": "{{count}} esitus",
|
||||
"play_other": "{{count}} esitust",
|
||||
"playlistWithCount_one": "{{count}} esitusloend",
|
||||
"playlistWithCount_other": "{{count}} esitusloendit",
|
||||
"smartPlaylist": "Nutikas $t(entity.playlist, {\"count\": 1})",
|
||||
"track_one": "Rada",
|
||||
"track_other": "Rajad",
|
||||
"song_one": "Lugu",
|
||||
"song_other": "Lood",
|
||||
"trackWithCount_one": "{{count}} rada",
|
||||
"trackWithCount_other": "{{count}} rada"
|
||||
},
|
||||
"error": {
|
||||
"apiRouteError": "Päringut ei saanud edastada",
|
||||
"audioDeviceFetchError": "Heliseadmete hankimisel tekkis viga",
|
||||
"authenticationFailed": "Autentimine nurjus",
|
||||
"badAlbum": "Näed seda lehte, kuna see lugu ei kuulu ühegi albumi alla. Kõige sagedamini juhtub see siis, kui lugu asub sinu muusikakausta juurkataloogis (otse muusikakaustas). Jellyfin rühmitab lood albumiteks ainult siis, kui need asuvad eraldi kaustas",
|
||||
"badValue": "Kehtetu valik \"{{value}}\". Seda väärtust enam ei eksisteeri",
|
||||
"credentialsRequired": "Nõutav on autentimine",
|
||||
"endpointNotImplementedError": "Lõpp-punkt {{endpoint}} pole serveri {{serverType}} puhul toetatud",
|
||||
"genericError": "Tekkis viga",
|
||||
"invalidJson": "Vigane JSON",
|
||||
"invalidServer": "Vigane server",
|
||||
"localFontAccessDenied": "Juurdepääs kohalikele fontidele on keelatud",
|
||||
"loginRateError": "Liiga palju sisselogimiskatseid, proovi mõne sekundi pärast uuesti",
|
||||
"mpvRequired": "Vajalik on MPV",
|
||||
"multipleServerSaveQueueError": "Esitusjärjekorras on lugusi, mis ei pärine praegusest serverist. See pole toetatud",
|
||||
"networkError": "Tekkis võrguviga",
|
||||
"noNetwork": "Server pole saadaval",
|
||||
"noNetworkDescription": "Selle serveriga ei õnnestunud ühendust luua",
|
||||
"notificationDenied": "Märguanded on keelatud. Sellel seadel pole mõju",
|
||||
"openError": "Faili avamine nurjus",
|
||||
"playbackError": "Meedia esitamisel tekkis viga",
|
||||
"playbackPausedDueToError": "Taasesitus peatati vea tõttu",
|
||||
"remoteDisableError": "Kaugserveri toimingu $t(common.disable) käigus tekkis viga",
|
||||
"remoteEnableError": "Tõrge kaugserveri toimingul: $t(common.enable)",
|
||||
"remotePortError": "Kaugserveri pordi määramisel tekkis viga",
|
||||
"remotePortWarning": "Uue pordi rakendamiseks taaskäivita server",
|
||||
"saveQueueFailed": "Järjekorra salvestamine nurjus",
|
||||
"serverLockSingleServer": "Lukustatud serveri korral on lubatud ainult üks server",
|
||||
"serverNotSelectedError": "Serverit pole valitud",
|
||||
"serverRequired": "Vajalik on server",
|
||||
"sessionExpiredError": "Su seanss on aegunud",
|
||||
"systemFontError": "Süsteemifontide hankimisel ilmnes viga",
|
||||
"settingsSyncError": "Esitaja (renderer) ja peaprotsessi seadete vahel leiti lahknevusi. Muudatuste rakendamiseks taaskäivita rakendus"
|
||||
},
|
||||
"filter": {
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"matchAnd": "ja",
|
||||
"matchOr": "või",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2}) arv",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"biography": "Biograafia",
|
||||
"bitrate": "Bitikiirus",
|
||||
"bpm": "BPM",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"comment": "Kommentaar",
|
||||
"communityRating": "Kogukonna hinne",
|
||||
"criticRating": "Kriitikute hinne",
|
||||
"dateAdded": "Lisatud",
|
||||
"disc": "Plaat",
|
||||
"duration": "Kestus",
|
||||
"favorited": "Lemmikuks lisatud",
|
||||
"fromYear": "Aastast",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"id": "ID",
|
||||
"isCompilation": "on kogumik",
|
||||
"isFavorited": "on lemmik",
|
||||
"isPublic": "on avalik",
|
||||
"isRated": "on hinnatud",
|
||||
"isRecentlyPlayed": "on hiljuti esitatud",
|
||||
"lastPlayed": "Viimati esitatud",
|
||||
"mostPlayed": "Enim esitatud",
|
||||
"name": "Nimi",
|
||||
"note": "Märkus",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "Asukoht",
|
||||
"playCount": "Esituskordi",
|
||||
"random": "Juhuslik",
|
||||
"rating": "Hinne",
|
||||
"recentlyAdded": "Viimati lisatud",
|
||||
"recentlyPlayed": "Hiljuti esitatud",
|
||||
"recentlyUpdated": "Hiljuti uuendatud",
|
||||
"releaseDate": "Ilmumiskuupäev",
|
||||
"releaseYear": "Ilmumisaasta",
|
||||
"search": "Otsing",
|
||||
"songCount": "Lugude arv",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"title": "Pealkiri",
|
||||
"sortName": "Sortimisnimi",
|
||||
"trackNumber": "Lugu"
|
||||
},
|
||||
"filterOperator": {
|
||||
"contains": "Sisaldab",
|
||||
"endsWith": "Lõpeb",
|
||||
"is": "On",
|
||||
"isNot": "Ei ole",
|
||||
"isGreaterThan": "On suurem kui",
|
||||
"isLessThan": "On väiksem kui",
|
||||
"matchesRegex": "Vastab regulaaravaldisele",
|
||||
"notContains": "Ei sisalda",
|
||||
"inTheRangeDate": "Jääb vahemikku (kuupäev)",
|
||||
"inTheRange": "Jääb vahemikku",
|
||||
"inTheLast": "Viimase",
|
||||
"inPlaylist": "On",
|
||||
"beforeDate": "On enne (kuupäeva)",
|
||||
"before": "On enne",
|
||||
"afterDate": "On pärast (kuupäeva)",
|
||||
"after": "On pärast",
|
||||
"notInPlaylist": "Ei ole"
|
||||
}
|
||||
}
|
||||
@@ -410,7 +410,12 @@
|
||||
"input_played": "Filtr odtwarzania",
|
||||
"input_played_optionAll": "Wszystkie utwory",
|
||||
"input_played_optionUnplayed": "Tylko nieodtworzone utwory",
|
||||
"input_played_optionPlayed": "Tylko odtworzone utwory"
|
||||
"input_played_optionPlayed": "Tylko odtworzone utwory",
|
||||
"input_kind_albums": "Albumy",
|
||||
"input_kind_songs": "Piosenki",
|
||||
"input_kind": "Losowy wybór",
|
||||
"input_limit_albums": "Ile albumów?",
|
||||
"input_limit_songs": "Ile piosenek?"
|
||||
},
|
||||
"saveQueue": {
|
||||
"success": "Zapisano kolejkę odtwarzania na serwerze"
|
||||
@@ -974,7 +979,7 @@
|
||||
"preservePitch": "Utrzymuj ton",
|
||||
"preventSleepOnPlayback_description": "Powstrzymuje ekran przed uśpieniem, gdy muzyka jest odtwarzana",
|
||||
"preventSleepOnPlayback": "Powstrzymuj uśpienie podczas odtwarzania",
|
||||
"mediaSession_description": "Włącza integrację z Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokady",
|
||||
"mediaSession_description": "Włącza integrację z Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokad. Wymaga odtwarzacza web audio.",
|
||||
"mediaSession": "Włącz media session",
|
||||
"transcode": "Włącz transkodowanie",
|
||||
"queryBuilder": "Kreator zaptań",
|
||||
@@ -991,7 +996,7 @@
|
||||
"audioFadeOnStatusChange_description": "Umożliwia zanikanie lub pojawianie się dźwięku gdy zmieni się status play/pauza",
|
||||
"autoDJ": "Automatyczny DJ",
|
||||
"autoDJ_itemCount": "Liczba elementów",
|
||||
"autoDJ_itemCount_description": "Liczba elementów, które będzie próbować dodać do kolejki kiedy automatyczny DJ jest włączony",
|
||||
"autoDJ_itemCount_description": "Liczba elementów, które będzie próbować dodać do kolejki",
|
||||
"autoDJ_timing": "Czas dodawania",
|
||||
"autoDJ_timing_description": "Ilość piosenek pozostałych w kolejce przed tym gdy zostanie włączony automatyczny DJ",
|
||||
"logLevel": "Poziom logów",
|
||||
@@ -1093,7 +1098,16 @@
|
||||
"sidebarPlaylistMode_description": "Jak każda z playlist jest wyświetlana w liście w pasku bocznym",
|
||||
"sidebarPlaylistMode": "Tryb playlist bocznego paska",
|
||||
"sidebarPlaylistMode_optionCompact": "Kompaktowy",
|
||||
"sidebarPlaylistMode_optionExpanded": "Rozszerzony"
|
||||
"sidebarPlaylistMode_optionExpanded": "Rozszerzony",
|
||||
"autoDJ_mode": "Tryb",
|
||||
"autoDJ_mode_albums": "Albumy",
|
||||
"autoDJ_mode_description": "Wybierz dodawanie piosenek lub całych albumów do kolejki",
|
||||
"autoDJ_mode_songs": "Piosenki",
|
||||
"autoDJ_enabled": "Włącz Auto DJ",
|
||||
"autoDJ_albumStrategy": "Tryb wyboru albumów",
|
||||
"autoDJ_songStrategy": "Tryb wyboru piosenek",
|
||||
"autoDJ_strategy_option_library_random": "Losowo",
|
||||
"autoDJ_strategy_option_similar": "Podobne"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"action": {
|
||||
"addToFavorites": "Idagdag sa $t(entity.favorite, {\"count\": 2})",
|
||||
"addToPlaylist": "Idagdag sa $t(entity.playlist, {\"count\": 1})",
|
||||
"addOrRemoveFromSelection": "Idagdag o alisin sa pinili",
|
||||
"collapseAllFolders": "Isara lahat ng mga folder",
|
||||
"expandAllFolders": "Buksan lahat ng mga folder",
|
||||
"createPlaylist": "Gumawa $t(entity.playlist, {\"count\": 1})",
|
||||
"createRadioStation": "Gumawa $t(entity.radioStation, {\"count\": 1})",
|
||||
"selectAll": "Piliin lahat",
|
||||
"deselectAll": "Huwag piliin lahat",
|
||||
"downloadStarted": "Nagsimulang mag-dowload ng {{count}} (mga) aytem"
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,8 @@
|
||||
"tableColumns": "Стовпці таблиці",
|
||||
"itemsMore": "{{count}} більше",
|
||||
"numberOfResults": "{{numberOfResults}} результатів",
|
||||
"newVersionAvailable": "Доступна нова версія"
|
||||
"newVersionAvailable": "Доступна нова версія",
|
||||
"back": "Повернутися"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Альбом",
|
||||
|
||||
@@ -420,7 +420,8 @@
|
||||
"sleepTimer_setCustom": "設定定時器",
|
||||
"sleepTimer_cancel": "取消定時器",
|
||||
"albumRadio": "專輯電台",
|
||||
"scrobbleForceSubmit": "強制紀錄"
|
||||
"scrobbleForceSubmit": "強制紀錄",
|
||||
"sleepTimer_endOfAlbum": "專輯播完時"
|
||||
},
|
||||
"setting": {
|
||||
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
||||
@@ -666,7 +667,7 @@
|
||||
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
|
||||
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
|
||||
"mediaSession": "啟用 Media Session",
|
||||
"mediaSession_description": "啟用 Media Session 整合功能,於系統音量 Overlay 和鎖定畫面中顯示媒體資料與控制面板",
|
||||
"mediaSession_description": "啟用 Media Session 整合功能,在系統音量疊加層和鎖定畫面上顯示媒體控制項與中繼資料。此功能需要使用網頁播放器。",
|
||||
"releaseChannel": "發佈通道",
|
||||
"analyticsDisable": "選擇退出使用情況分析",
|
||||
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
|
||||
@@ -715,8 +716,8 @@
|
||||
"playerFilters": "從佇列中過濾歌曲",
|
||||
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
|
||||
"autoDJ": "Auto DJ",
|
||||
"autoDJ_itemCount": "歌曲數量",
|
||||
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
|
||||
"autoDJ_itemCount": "項目數量",
|
||||
"autoDJ_itemCount_description": "嘗試加入佇列的項目數量",
|
||||
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
|
||||
"autoDJ_timing": "觸發時機",
|
||||
"logLevel": "Log等級",
|
||||
@@ -818,7 +819,16 @@
|
||||
"sidebarPlaylistMode_description": "各播放清單在側邊欄列表中的顯示方式",
|
||||
"sidebarPlaylistMode": "側邊欄播放清單模式",
|
||||
"sidebarPlaylistMode_optionCompact": "緊湊",
|
||||
"sidebarPlaylistMode_optionExpanded": "展開"
|
||||
"sidebarPlaylistMode_optionExpanded": "展開",
|
||||
"autoDJ_mode": "模式",
|
||||
"autoDJ_mode_albums": "專輯",
|
||||
"autoDJ_mode_description": "選擇將歌曲或整張專輯加入佇列",
|
||||
"autoDJ_mode_songs": "歌曲",
|
||||
"autoDJ_enabled": "啟用Auto DJ",
|
||||
"autoDJ_albumStrategy": "專輯選擇模式",
|
||||
"autoDJ_songStrategy": "歌曲選擇模式",
|
||||
"autoDJ_strategy_option_library_random": "隨機",
|
||||
"autoDJ_strategy_option_similar": "相似"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -1137,7 +1147,12 @@
|
||||
"input_played": "播放過濾器",
|
||||
"input_played_optionAll": "所有曲目",
|
||||
"input_played_optionUnplayed": "僅未播放的曲目",
|
||||
"input_played_optionPlayed": "僅播放過的曲目"
|
||||
"input_played_optionPlayed": "僅播放過的曲目",
|
||||
"input_kind_albums": "專輯",
|
||||
"input_kind_songs": "歌曲",
|
||||
"input_kind": "隨機選取",
|
||||
"input_limit_albums": "專輯數量?",
|
||||
"input_limit_songs": "歌曲數量?"
|
||||
},
|
||||
"createRadioStation": {
|
||||
"success": "電台建立成功",
|
||||
|
||||
@@ -120,8 +120,14 @@ const createMpv = async (data: {
|
||||
}): Promise<MpvAPI> => {
|
||||
const { binaryPath, extraParameters, properties } = data;
|
||||
const resolvedBinaryPath = await resolveMpvBinaryPath(binaryPath);
|
||||
const normalizedExtraParameters = (extraParameters ?? [])
|
||||
.map((param) => param.trim())
|
||||
.filter((param) => param.length > 0);
|
||||
|
||||
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
|
||||
const params = uniq([
|
||||
...DEFAULT_MPV_PARAMETERS(normalizedExtraParameters),
|
||||
...normalizedExtraParameters,
|
||||
]);
|
||||
|
||||
const mpv = new MpvAPI(
|
||||
{
|
||||
|
||||
@@ -141,6 +141,14 @@ ipcMain.on('settings-set', (__event, data: { property: string; value: any }) =>
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('settings-set-sync', (__event, data: { property: string; value: any }) => {
|
||||
if (data.value === null) {
|
||||
store.delete(data.property);
|
||||
} else {
|
||||
store.set(data.property, data.value);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('password-get', (_event, server: string): null | string => {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const servers = store.get('server') as Record<string, string> | undefined;
|
||||
|
||||
+61
-12
@@ -252,7 +252,9 @@ function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdate
|
||||
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ privileges: { bypassCSP: true, corsEnabled: true }, scheme: 'feishin' },
|
||||
]);
|
||||
|
||||
process.on('uncaughtException', (error: any) => {
|
||||
console.error('Error in main process', error);
|
||||
@@ -335,7 +337,7 @@ if (isDevelopment) {
|
||||
}
|
||||
|
||||
const RESOURCES_PATH = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'assets')
|
||||
? path.join(path.dirname(app.getAppPath()), 'assets')
|
||||
: path.join(__dirname, '../../assets');
|
||||
|
||||
const getAssetPath = (...paths: string[]): string => {
|
||||
@@ -989,14 +991,33 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const FONT_HEADERS = [
|
||||
const FONT_HEADERS = new Set([
|
||||
'font/collection',
|
||||
'font/otf',
|
||||
'font/sfnt',
|
||||
'font/ttf',
|
||||
'font/woff',
|
||||
'font/woff2',
|
||||
];
|
||||
]);
|
||||
|
||||
const bytesToInt = (array: Uint8Array, length: number): number => {
|
||||
let value = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
value = (value << 8) + array[i];
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const FONT_FOUR_BYTE_MAGIC_NUMBERS = new Set([
|
||||
0x4f54544f, // font/otf
|
||||
0x774f4632, // font/woff2
|
||||
0x774f4646, // font/woff
|
||||
]);
|
||||
|
||||
const FONT_FIVE_BYTE_MAGIC_NUMBERS = new Set([
|
||||
0x0001000000, // ttf, collection, sfnt
|
||||
]);
|
||||
|
||||
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
|
||||
|
||||
@@ -1017,12 +1038,9 @@ if (!singleInstance) {
|
||||
|
||||
app.whenReady()
|
||||
.then(() => {
|
||||
protocol.handle('feishin', async (request) => {
|
||||
const filePath = `file:${request.url.slice('feishin:'.length)}`;
|
||||
const response = await net.fetch(filePath);
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (!contentType || !FONT_HEADERS.includes(contentType)) {
|
||||
protocol.handle('feishin', async () => {
|
||||
const filePath = store.get('local_font_path');
|
||||
if (typeof filePath !== 'string') {
|
||||
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
||||
|
||||
return new Response(null, {
|
||||
@@ -1031,7 +1049,38 @@ if (!singleInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
const response = await net.fetch('file:' + filePath);
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
// On Linux, the mime type is included in the response header
|
||||
// In this case, we can forward the response with no further processing
|
||||
if (contentType && FONT_HEADERS.has(contentType)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Otherwise, let's check the magic number to see if
|
||||
// the file is a font type. This is either four or five bytes
|
||||
const payload = await response.arrayBuffer();
|
||||
const magicNumber = new Uint8Array(payload.slice(0, 5));
|
||||
const fiveHex = bytesToInt(magicNumber, 5);
|
||||
const fourHex = bytesToInt(magicNumber, 4);
|
||||
|
||||
if (
|
||||
FONT_FIVE_BYTE_MAGIC_NUMBERS.has(fiveHex) ||
|
||||
FONT_FOUR_BYTE_MAGIC_NUMBERS.has(fourHex)
|
||||
) {
|
||||
// We have to create a new response with the payload, since it has been read now
|
||||
return new Response(payload, {
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
||||
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
@@ -1039,7 +1088,7 @@ if (!singleInstance) {
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [
|
||||
"script-src 'self' 'unsafe-inline' https://umami.jeffvli.org; style-src 'self' 'unsafe-inline'; media-src 'self' http: https: data: blob:; img-src 'self' http: https: data: blob:; connect-src 'self' http: https: ws: wss:; default-src 'self';",
|
||||
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://umami.jeffvli.org; style-src 'self' 'unsafe-inline'; media-src 'self' http: https: data: blob:; img-src 'self' http: https: data: blob:; connect-src 'self' http: https: ws: wss:; default-src 'self';",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,13 @@ const set = (
|
||||
ipcRenderer.send('settings-set', { property, value });
|
||||
};
|
||||
|
||||
const setSync = async (
|
||||
property: string,
|
||||
value: boolean | null | Record<string, unknown> | string | string[],
|
||||
) => {
|
||||
return ipcRenderer.invoke('settings-set-sync', { property, value });
|
||||
};
|
||||
|
||||
const get = async (property: string) => {
|
||||
return ipcRenderer.invoke('settings-get', { property });
|
||||
};
|
||||
@@ -99,6 +106,7 @@ export const localSettings = {
|
||||
passwordSet,
|
||||
restart,
|
||||
set,
|
||||
setSync,
|
||||
setZoomFactor,
|
||||
themeSet,
|
||||
};
|
||||
|
||||
@@ -139,6 +139,7 @@ export const utils = {
|
||||
rendererToggleSidebar,
|
||||
rendererUpdateAvailable,
|
||||
saveCustomCss,
|
||||
separator: isWindows() ? '\\' : '/',
|
||||
setInputFocused,
|
||||
startPowerSaveBlocker,
|
||||
stopPowerSaveBlocker,
|
||||
|
||||
@@ -54,6 +54,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deleteArtistImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.deleteArtistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
@@ -63,6 +72,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deletePlaylistImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.deletePlaylistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
@@ -356,6 +374,24 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
uploadArtistImage: {
|
||||
body: z.string(),
|
||||
method: 'POST',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.uploadArtistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
uploadPlaylistImage: {
|
||||
body: z.string(),
|
||||
method: 'POST',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.uploadPlaylistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { set } from 'idb-keyval';
|
||||
import chunk from 'lodash/chunk';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
|
||||
import {
|
||||
albumArtistListSortMap,
|
||||
albumListSortMap,
|
||||
DeleteArtistImageArgs,
|
||||
DeleteArtistImageResponse,
|
||||
DeletePlaylistImageArgs,
|
||||
DeletePlaylistImageResponse,
|
||||
Folder,
|
||||
genreListSortMap,
|
||||
ImageArgs,
|
||||
@@ -29,6 +34,10 @@ import {
|
||||
SortOrder,
|
||||
sortOrderMap,
|
||||
Tag,
|
||||
UploadArtistImageArgs,
|
||||
UploadArtistImageResponse,
|
||||
UploadPlaylistImageArgs,
|
||||
UploadPlaylistImageResponse,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
|
||||
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
};
|
||||
|
||||
const getImageContentType = (bytes: Uint8Array): string => {
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (bytes[0] === 0x52 && bytes[1] === 0x49) {
|
||||
return 'image/webp';
|
||||
}
|
||||
|
||||
return 'image/jpeg';
|
||||
};
|
||||
|
||||
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
type JellyfinApiClientProps = DeletePlaylistImageArgs['apiClientProps'];
|
||||
|
||||
const deleteItemPrimaryImage = async (
|
||||
apiClientProps: JellyfinApiClientProps,
|
||||
id: string,
|
||||
errorMessage: string,
|
||||
): Promise<boolean> => {
|
||||
const res = await jfApiClient({
|
||||
...apiClientProps,
|
||||
server: apiClientProps.server ?? null,
|
||||
}).deleteArtistImage({
|
||||
params: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const uploadItemPrimaryImage = async (
|
||||
apiClientProps: JellyfinApiClientProps,
|
||||
id: string,
|
||||
image: Uint8Array,
|
||||
errorMessage: string,
|
||||
): Promise<boolean> => {
|
||||
const server = apiClientProps.server;
|
||||
const serverUrl = getServerUrl(server);
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error('Server is required');
|
||||
}
|
||||
|
||||
const contentType = getImageContentType(image);
|
||||
const base64 = uint8ArrayToBase64(image);
|
||||
|
||||
const authHeader = createAuthHeader();
|
||||
const authorization = server?.credential
|
||||
? authHeader.concat(`, Token="${server.credential}"`)
|
||||
: authHeader;
|
||||
|
||||
const res = await axios.post(`${serverUrl}/Items/${id}/Images/Primary`, base64, {
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
signal: apiClientProps.signal,
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Limit the query to 50 at a time to be *extremely* conservative on the
|
||||
// length of the full URL, since the ids are part of the query string and
|
||||
// not the POST body
|
||||
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
|
||||
[ServerFeature.PUBLIC_PLAYLIST]: [1],
|
||||
},
|
||||
],
|
||||
['10.0.0', { [ServerFeature.TAGS]: [1] }],
|
||||
[
|
||||
'10.0.0',
|
||||
{
|
||||
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
|
||||
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
|
||||
[ServerFeature.TAGS]: [1],
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const JF_FIELDS = {
|
||||
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
id: res.body.Id,
|
||||
};
|
||||
},
|
||||
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete artist image');
|
||||
},
|
||||
deleteFavorite: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
deletePlaylistImage: async (
|
||||
args: DeletePlaylistImageArgs,
|
||||
): Promise<DeletePlaylistImageResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete playlist image');
|
||||
},
|
||||
getAlbumArtistDetail: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -411,8 +527,12 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get album detail');
|
||||
}
|
||||
|
||||
// Workaround for Jellyfin bug that returns items that share the same album name
|
||||
const albumIdSet = new Set([query.id]);
|
||||
const songs = songsRes.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
|
||||
|
||||
return jfNormalize.album(
|
||||
{ ...res.body, Songs: songsRes.body.Items },
|
||||
{ ...res.body, Songs: songs },
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
@@ -1843,6 +1963,28 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
return uploadItemPrimaryImage(
|
||||
apiClientProps,
|
||||
query.id,
|
||||
body.image,
|
||||
'Failed to upload artist image',
|
||||
);
|
||||
},
|
||||
uploadPlaylistImage: async (
|
||||
args: UploadPlaylistImageArgs,
|
||||
): Promise<UploadPlaylistImageResponse> => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
return uploadItemPrimaryImage(
|
||||
apiClientProps,
|
||||
query.id,
|
||||
body.image,
|
||||
'Failed to upload playlist image',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function getLibraryId(musicFolderId?: string | string[]) {
|
||||
|
||||
@@ -366,7 +366,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
|
||||
? query.id
|
||||
: undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
id:
|
||||
query.type === LibraryItem.SONG || query.type === LibraryItem.PLAYLIST_SONG
|
||||
? query.id
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -419,7 +422,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
|
||||
? query.id
|
||||
: undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
id:
|
||||
query.type === LibraryItem.SONG || query.type === LibraryItem.PLAYLIST_SONG
|
||||
? query.id
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2327,7 +2333,6 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
case 'start':
|
||||
state = 'starting';
|
||||
break;
|
||||
case 'timeupdate':
|
||||
case 'unpause':
|
||||
state = 'playing';
|
||||
break;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list
|
||||
import { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column';
|
||||
import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column';
|
||||
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
|
||||
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
|
||||
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
|
||||
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
|
||||
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
|
||||
@@ -239,10 +240,7 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
|
||||
case TableColumn.CHANNELS:
|
||||
case TableColumn.DISC_NUMBER:
|
||||
case TableColumn.SAMPLE_RATE:
|
||||
case TableColumn.TRACK_NUMBER:
|
||||
return (
|
||||
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||
);
|
||||
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.COMPOSER:
|
||||
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
@@ -304,6 +302,11 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
|
||||
/>
|
||||
);
|
||||
|
||||
case TableColumn.TRACK_NUMBER:
|
||||
return (
|
||||
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||
);
|
||||
|
||||
case TableColumn.USER_FAVORITE:
|
||||
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
|
||||
@@ -4,50 +4,38 @@ import { generatePath, useNavigate } from 'react-router';
|
||||
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
Song,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Album, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
interface GoToActionProps {
|
||||
items: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[];
|
||||
items: Album[] | QueueSong[] | Song[];
|
||||
}
|
||||
|
||||
export const GoToAction = ({ items }: GoToActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { albumArtists, albumId } = useMemo(() => {
|
||||
const { albumId, artists } = useMemo(() => {
|
||||
const firstItem = items[0];
|
||||
|
||||
if (firstItem._itemType === LibraryItem.ALBUM) {
|
||||
return {
|
||||
albumArtists: firstItem.albumArtists || [],
|
||||
albumId: firstItem.id,
|
||||
};
|
||||
} else if (firstItem._itemType === LibraryItem.SONG) {
|
||||
return {
|
||||
albumArtists: firstItem.albumArtists || [],
|
||||
albumId: firstItem.albumId,
|
||||
};
|
||||
} else if (
|
||||
firstItem._itemType === LibraryItem.ARTIST ||
|
||||
firstItem._itemType === LibraryItem.ALBUM_ARTIST
|
||||
) {
|
||||
return {
|
||||
albumArtists: [{ id: firstItem.id, name: firstItem.name }],
|
||||
albumId: null,
|
||||
};
|
||||
switch (firstItem._itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
return {
|
||||
albumId: firstItem.id,
|
||||
artists: firstItem.albumArtists || [],
|
||||
};
|
||||
case LibraryItem.SONG:
|
||||
return {
|
||||
albumId: firstItem.albumId,
|
||||
artists:
|
||||
(firstItem.artists?.length ? firstItem.artists : firstItem.albumArtists) ||
|
||||
[],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
albumId: null,
|
||||
artists: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
albumArtists: [],
|
||||
albumId: null,
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const handleGoToAlbum = useCallback(() => {
|
||||
@@ -55,7 +43,7 @@ export const GoToAction = ({ items }: GoToActionProps) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId }));
|
||||
}, [albumId, navigate]);
|
||||
|
||||
const handleGoToAlbumArtist = useCallback(
|
||||
const handleGoToArtist = useCallback(
|
||||
(albumArtistId: string) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId }));
|
||||
},
|
||||
@@ -81,13 +69,13 @@ export const GoToAction = ({ items }: GoToActionProps) => {
|
||||
{t('page.contextMenu.goToAlbum')}
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{albumArtists.map((albumArtist) => (
|
||||
{artists.map((artist) => (
|
||||
<ContextMenu.Item
|
||||
key={albumArtist.id}
|
||||
key={artist.id}
|
||||
leftIcon="artist"
|
||||
onSelect={() => handleGoToAlbumArtist(albumArtist.id)}
|
||||
onSelect={() => handleGoToArtist(artist.id)}
|
||||
>
|
||||
{`${t('page.contextMenu.goTo')} ${albumArtist.name}`}
|
||||
{`${t('page.contextMenu.goTo')} ${artist.name}`}
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</ContextMenu.SubmenuContent>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
|
||||
import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';
|
||||
@@ -39,8 +38,6 @@ export const AlbumArtistContextMenu = ({ items, type }: AlbumArtistContextMenuPr
|
||||
<DownloadAction ids={ids} />
|
||||
<ShareAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />
|
||||
<ContextMenu.Divider />
|
||||
<GoToAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
|
||||
import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';
|
||||
@@ -39,8 +38,6 @@ export const ArtistContextMenu = ({ items, type }: ArtistContextMenuProps) => {
|
||||
<DownloadAction ids={ids} />
|
||||
<ShareAction ids={ids} itemType={LibraryItem.ARTIST} />
|
||||
<ContextMenu.Divider />
|
||||
<GoToAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import {
|
||||
useIsRadioActive,
|
||||
useRadioPlayer,
|
||||
@@ -36,6 +37,7 @@ const DiscordStatusDisplayType = {
|
||||
} as const;
|
||||
|
||||
type ActivityState = [QueueSong | undefined, number, PlayerStatus];
|
||||
type ActivityTrigger = 'initial' | 'interval' | 'seek' | 'status_change' | 'track_change';
|
||||
|
||||
const MAX_FIELD_LENGTH = 127;
|
||||
const MAX_URL_LENGTH = 256;
|
||||
@@ -64,22 +66,24 @@ export const useDiscordRpc = () => {
|
||||
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
||||
const previousEnabledRef = useRef<boolean>(discordSettings.enabled);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const previousActivityStateRef = useRef<ActivityState | null>(null);
|
||||
const discordEnabledRef = useRef<boolean>(discordSettings.enabled);
|
||||
const privateModeRef = useRef<boolean>(privateMode);
|
||||
|
||||
// Update imageUrl ref when it changes
|
||||
useEffect(() => {
|
||||
imageUrlRef.current = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
discordEnabledRef.current = discordSettings.enabled;
|
||||
}, [discordSettings.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
privateModeRef.current = privateMode;
|
||||
}, [privateMode]);
|
||||
|
||||
const setActivity = useCallback(
|
||||
async (current: ActivityState, previous: ActivityState) => {
|
||||
// Check if track changed by comparing with previous state
|
||||
async (current: ActivityState, trigger: ActivityTrigger) => {
|
||||
const song = current[0];
|
||||
const previousSong = previous[0];
|
||||
const trackChangedByState =
|
||||
song && previousSong
|
||||
? song._uniqueId !== previousSong._uniqueId
|
||||
: song !== previousSong;
|
||||
const trackChanged = song ? lastUniqueId !== song._uniqueId : false;
|
||||
|
||||
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
||||
@@ -103,6 +107,7 @@ export const useDiscordRpc = () => {
|
||||
meta: {
|
||||
reason,
|
||||
status: current[2],
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
return discordRpc?.clearActivity();
|
||||
@@ -152,6 +157,7 @@ export const useDiscordRpc = () => {
|
||||
showAsListening: discordSettings.showAsListening,
|
||||
stationName: stationName || 'Radio',
|
||||
title,
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
discordRpc?.setActivity(activity);
|
||||
@@ -162,214 +168,177 @@ export const useDiscordRpc = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
1. If the song has just started, update status
|
||||
2. If we jump more then 1.2 seconds from last state, update status to match
|
||||
3. If the current song id is completely different, update status
|
||||
4. If the player state changed, update status
|
||||
*/
|
||||
if (trackChanged) {
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: {
|
||||
artistName: song.artists?.[0]?.name,
|
||||
songId: song._uniqueId,
|
||||
songName: song.name,
|
||||
},
|
||||
});
|
||||
setlastUniqueId(song._uniqueId);
|
||||
}
|
||||
|
||||
const reason = trigger;
|
||||
const start = Math.round(Date.now() - current[1] * 1000);
|
||||
const end = Math.round(start + song.duration);
|
||||
|
||||
const artists = song?.artists.map((artist) => artist.name).join(', ');
|
||||
|
||||
const statusDisplayMap = {
|
||||
[DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE,
|
||||
[DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME,
|
||||
[DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS,
|
||||
};
|
||||
|
||||
const activity: SetActivity = {
|
||||
details: truncate((song?.name && song.name.padEnd(2, ' ')) || 'Idle'),
|
||||
instance: false,
|
||||
largeImageKey: undefined,
|
||||
largeImageText: truncate(
|
||||
(song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',
|
||||
),
|
||||
smallImageKey: undefined,
|
||||
smallImageText: undefined,
|
||||
state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'),
|
||||
statusDisplayType: statusDisplayMap[discordSettings.displayType],
|
||||
// I would love to use the actual type as opposed to hardcoding to 2,
|
||||
// but manually installing the discord-types package appears to break things
|
||||
type: discordSettings.showAsListening ? 2 : 0,
|
||||
};
|
||||
|
||||
if (
|
||||
previous[1] === 0 ||
|
||||
Math.abs(current[1] - previous[1]) > 1.2 ||
|
||||
trackChangedByState ||
|
||||
trackChanged ||
|
||||
current[2] !== previous[2]
|
||||
(discordSettings.linkType == DiscordLinkType.LAST_FM ||
|
||||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&
|
||||
song?.artistName
|
||||
) {
|
||||
if (trackChangedByState || trackChanged) {
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: {
|
||||
artistName: song.artists?.[0]?.name,
|
||||
songId: song._uniqueId,
|
||||
songName: song.name,
|
||||
},
|
||||
});
|
||||
setlastUniqueId(song._uniqueId);
|
||||
activity.stateUrl =
|
||||
'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);
|
||||
|
||||
const detailsUrl =
|
||||
'https://www.last.fm/music/' +
|
||||
encodeURIComponent(song.albumArtists[0].name) +
|
||||
'/' +
|
||||
encodeURIComponent(song.album || '_') +
|
||||
'/' +
|
||||
encodeURIComponent(song.name);
|
||||
|
||||
// The details URL has a max length, only set it if it doesn't exceed it
|
||||
if (detailsUrl.length <= MAX_URL_LENGTH) {
|
||||
activity.detailsUrl = detailsUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
discordSettings.linkType == DiscordLinkType.MBZ ||
|
||||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM
|
||||
) {
|
||||
if (song?.mbzTrackId) {
|
||||
activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId;
|
||||
} else if (song?.mbzRecordingId) {
|
||||
activity.detailsUrl =
|
||||
'https://musicbrainz.org/recording/' + song.mbzRecordingId;
|
||||
}
|
||||
}
|
||||
|
||||
if (current[2] === PlayerStatus.PLAYING) {
|
||||
if (start && end) {
|
||||
activity.startTimestamp = start;
|
||||
activity.endTimestamp = end;
|
||||
}
|
||||
|
||||
let reason: string;
|
||||
if (trackChangedByState || trackChanged) {
|
||||
reason = 'track_changed';
|
||||
} else if (previous[1] === 0) {
|
||||
reason = 'song_started';
|
||||
} else if (Math.abs(current[1] - previous[1]) > 1.2) {
|
||||
reason = 'time_jump';
|
||||
} else {
|
||||
reason = 'player_state_changed';
|
||||
}
|
||||
|
||||
const start = Math.round(Date.now() - current[1] * 1000);
|
||||
const end = Math.round(start + song.duration);
|
||||
|
||||
const artists = song?.artists.map((artist) => artist.name).join(', ');
|
||||
|
||||
const statusDisplayMap = {
|
||||
[DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE,
|
||||
[DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME,
|
||||
[DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS,
|
||||
};
|
||||
|
||||
const activity: SetActivity = {
|
||||
details: truncate((song?.name && song.name.padEnd(2, ' ')) || 'Idle'),
|
||||
instance: false,
|
||||
largeImageKey: undefined,
|
||||
largeImageText: truncate(
|
||||
(song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',
|
||||
),
|
||||
smallImageKey: undefined,
|
||||
smallImageText: undefined,
|
||||
state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'),
|
||||
statusDisplayType: statusDisplayMap[discordSettings.displayType],
|
||||
// I would love to use the actual type as opposed to hardcoding to 2,
|
||||
// but manually installing the discord-types package appears to break things
|
||||
type: discordSettings.showAsListening ? 2 : 0,
|
||||
};
|
||||
|
||||
if (
|
||||
(discordSettings.linkType == DiscordLinkType.LAST_FM ||
|
||||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&
|
||||
song?.artistName
|
||||
) {
|
||||
activity.stateUrl =
|
||||
'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);
|
||||
|
||||
const detailsUrl =
|
||||
'https://www.last.fm/music/' +
|
||||
encodeURIComponent(song.albumArtists[0].name) +
|
||||
'/' +
|
||||
encodeURIComponent(song.album || '_') +
|
||||
'/' +
|
||||
encodeURIComponent(song.name);
|
||||
|
||||
// The details URL has a max length, only set it if it doesn't exceed it
|
||||
if (detailsUrl.length <= MAX_URL_LENGTH) {
|
||||
activity.detailsUrl = detailsUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
discordSettings.linkType == DiscordLinkType.MBZ ||
|
||||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM
|
||||
) {
|
||||
if (song?.mbzTrackId) {
|
||||
activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId;
|
||||
} else if (song?.mbzRecordingId) {
|
||||
activity.detailsUrl =
|
||||
'https://musicbrainz.org/recording/' + song.mbzRecordingId;
|
||||
}
|
||||
}
|
||||
|
||||
if (current[2] === PlayerStatus.PLAYING) {
|
||||
if (start && end) {
|
||||
activity.startTimestamp = start;
|
||||
activity.endTimestamp = end;
|
||||
}
|
||||
|
||||
if (discordSettings.showStateIcon) {
|
||||
activity.smallImageKey = 'playing';
|
||||
activity.smallImageText = sentenceCase(current[2]);
|
||||
}
|
||||
} else {
|
||||
activity.smallImageKey = 'paused';
|
||||
if (discordSettings.showStateIcon) {
|
||||
activity.smallImageKey = 'playing';
|
||||
activity.smallImageText = sentenceCase(current[2]);
|
||||
}
|
||||
} else {
|
||||
activity.smallImageKey = 'paused';
|
||||
activity.smallImageText = sentenceCase(current[2]);
|
||||
}
|
||||
|
||||
if (discordSettings.showServerImage && song) {
|
||||
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
|
||||
if (song._serverType === ServerType.JELLYFIN) {
|
||||
activity.largeImageKey = imageUrlRef.current;
|
||||
} else if (
|
||||
song._serverType === ServerType.NAVIDROME ||
|
||||
song._serverType === ServerType.SUBSONIC
|
||||
) {
|
||||
try {
|
||||
const info = await api.controller.getAlbumInfo({
|
||||
apiClientProps: {
|
||||
forceRemoteUrl: true,
|
||||
serverId: song._serverId,
|
||||
},
|
||||
query: { id: song.albumId },
|
||||
});
|
||||
if (discordSettings.showServerImage && song) {
|
||||
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
|
||||
if (song._serverType === ServerType.JELLYFIN) {
|
||||
activity.largeImageKey = imageUrlRef.current;
|
||||
} else if (
|
||||
song._serverType === ServerType.NAVIDROME ||
|
||||
song._serverType === ServerType.SUBSONIC
|
||||
) {
|
||||
try {
|
||||
const info = await api.controller.getAlbumInfo({
|
||||
apiClientProps: {
|
||||
forceRemoteUrl: true,
|
||||
serverId: song._serverId,
|
||||
},
|
||||
query: { id: song.albumId },
|
||||
});
|
||||
|
||||
if (info.imageUrl) {
|
||||
activity.largeImageKey = info.imageUrl;
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
if (info.imageUrl) {
|
||||
activity.largeImageKey = info.imageUrl;
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
activity.largeImageKey === undefined &&
|
||||
lastfmApiKey &&
|
||||
song?.album &&
|
||||
song?.albumArtists.length
|
||||
) {
|
||||
const albumInfo = await fetch(
|
||||
`https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${lastfmApiKey}&artist=${encodeURIComponent(song.albumArtists[0].name)}&album=${encodeURIComponent(song.album)}&format=json`,
|
||||
);
|
||||
|
||||
const albumInfoJson = await albumInfo.json();
|
||||
|
||||
if (albumInfoJson.album?.image?.[3]['#text']) {
|
||||
activity.largeImageKey = albumInfoJson.album.image[3]['#text'];
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default icon if not set
|
||||
if (!activity.largeImageKey) {
|
||||
activity.largeImageKey = 'icon';
|
||||
}
|
||||
|
||||
// Initialize if needed
|
||||
const isConnected = await discordRpc?.isConnected();
|
||||
if (!isConnected) {
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: {
|
||||
clientId: discordSettings.clientId,
|
||||
},
|
||||
});
|
||||
|
||||
previousEnabledRef.current = true;
|
||||
|
||||
await discordRpc?.initialize(discordSettings.clientId);
|
||||
}
|
||||
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: {
|
||||
albumName: song.album,
|
||||
artistName: song.artists?.[0]?.name,
|
||||
currentStatus: current[2],
|
||||
currentTime: current[1],
|
||||
displayType: discordSettings.displayType,
|
||||
hasLargeImage: !!activity.largeImageKey,
|
||||
hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp),
|
||||
previousStatus: previous[2],
|
||||
previousTime: previous[1],
|
||||
reason,
|
||||
showAsListening: discordSettings.showAsListening,
|
||||
songName: song.name,
|
||||
trackChanged: trackChangedByState || trackChanged,
|
||||
},
|
||||
});
|
||||
discordRpc?.setActivity(activity);
|
||||
} else {
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcUpdateSkipped, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: {
|
||||
currentStatus: current[2],
|
||||
currentTime: current[1],
|
||||
previousStatus: previous[2],
|
||||
previousTime: previous[1],
|
||||
timeDiff: Math.abs(current[1] - previous[1]),
|
||||
trackChanged: trackChangedByState || trackChanged,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
activity.largeImageKey === undefined &&
|
||||
lastfmApiKey &&
|
||||
song?.album &&
|
||||
song?.albumArtists.length
|
||||
) {
|
||||
const albumInfo = await fetch(
|
||||
`https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${lastfmApiKey}&artist=${encodeURIComponent(song.albumArtists[0].name)}&album=${encodeURIComponent(song.album)}&format=json`,
|
||||
);
|
||||
|
||||
const albumInfoJson = await albumInfo.json();
|
||||
|
||||
if (albumInfoJson.album?.image?.[3]['#text']) {
|
||||
activity.largeImageKey = albumInfoJson.album.image[3]['#text'];
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default icon if not set
|
||||
if (!activity.largeImageKey) {
|
||||
activity.largeImageKey = 'icon';
|
||||
}
|
||||
|
||||
// Initialize if needed
|
||||
const isConnected = await discordRpc?.isConnected();
|
||||
if (!isConnected) {
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: {
|
||||
clientId: discordSettings.clientId,
|
||||
},
|
||||
});
|
||||
|
||||
previousEnabledRef.current = true;
|
||||
|
||||
await discordRpc?.initialize(discordSettings.clientId);
|
||||
}
|
||||
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: {
|
||||
albumName: song.album,
|
||||
artistName: song.artists?.[0]?.name,
|
||||
currentStatus: current[2],
|
||||
currentTime: current[1],
|
||||
displayType: discordSettings.displayType,
|
||||
hasLargeImage: !!activity.largeImageKey,
|
||||
hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp),
|
||||
reason,
|
||||
showAsListening: discordSettings.showAsListening,
|
||||
songName: song.name,
|
||||
trackChanged,
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
discordRpc?.setActivity(activity);
|
||||
},
|
||||
[
|
||||
discordSettings.showAsListening,
|
||||
@@ -390,7 +359,7 @@ export const useDiscordRpc = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const debouncedSetActivity = useDebouncedCallback(setActivity, 500);
|
||||
const debouncedSetActivity = useDebouncedCallback(setActivity, 1000);
|
||||
|
||||
// Quit Discord RPC if it was enabled and is now disabled
|
||||
useEffect(() => {
|
||||
@@ -409,95 +378,110 @@ export const useDiscordRpc = () => {
|
||||
}
|
||||
}, [discordSettings.clientId, privateMode, discordSettings.enabled]);
|
||||
|
||||
const getCurrentActivityState = useCallback((): ActivityState => {
|
||||
const state = usePlayerStore.getState();
|
||||
return [
|
||||
state.getCurrentSong(),
|
||||
useTimestampStoreBase.getState().timestamp,
|
||||
state.player.status,
|
||||
];
|
||||
}, []);
|
||||
|
||||
const clearRefreshInterval = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const emitActivityUpdateRef = useRef<(next: ActivityState, trigger: ActivityTrigger) => void>(
|
||||
() => {},
|
||||
);
|
||||
|
||||
const resetRefreshInterval = useCallback(() => {
|
||||
clearRefreshInterval();
|
||||
intervalRef.current = setInterval(() => {
|
||||
const current = getCurrentActivityState();
|
||||
emitActivityUpdateRef.current(current, 'interval');
|
||||
}, 15000);
|
||||
}, [clearRefreshInterval, getCurrentActivityState]);
|
||||
|
||||
const emitActivityUpdate = useCallback(
|
||||
(next: ActivityState, trigger: ActivityTrigger) => {
|
||||
debouncedSetActivity(next, trigger);
|
||||
resetRefreshInterval();
|
||||
},
|
||||
[debouncedSetActivity, resetRefreshInterval],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
emitActivityUpdateRef.current = emitActivityUpdate;
|
||||
}, [emitActivityUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!discordSettings.enabled || privateMode) {
|
||||
clearRefreshInterval();
|
||||
return;
|
||||
}
|
||||
|
||||
const getCurrentActivityState = (): ActivityState => {
|
||||
const state = usePlayerStore.getState();
|
||||
const currentSong = state.getCurrentSong();
|
||||
const currentTime = useTimestampStoreBase.getState().timestamp;
|
||||
const status = state.player.status;
|
||||
return [currentSong, currentTime, status];
|
||||
};
|
||||
|
||||
const resetInterval = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
intervalRef.current = setInterval(() => {
|
||||
const current = getCurrentActivityState();
|
||||
const previous = previousActivityStateRef.current || current;
|
||||
debouncedSetActivity(current, previous);
|
||||
previousActivityStateRef.current = current;
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
resetInterval();
|
||||
|
||||
const initialState = getCurrentActivityState();
|
||||
let previousUniqueId = initialState[0]?._uniqueId || '';
|
||||
|
||||
previousActivityStateRef.current = initialState;
|
||||
|
||||
// Set activity immediately when Discord RPC is enabled
|
||||
debouncedSetActivity(initialState, initialState);
|
||||
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state): ActivityState => {
|
||||
const currentSong = state.getCurrentSong();
|
||||
const currentTime = useTimestampStoreBase.getState().timestamp;
|
||||
const status = state.player.status;
|
||||
|
||||
return [currentSong, currentTime, status];
|
||||
},
|
||||
(current, previous) => {
|
||||
const currentUniqueId = current[0]?._uniqueId || '';
|
||||
const trackChanged = previousUniqueId !== currentUniqueId;
|
||||
|
||||
if (trackChanged && current[0]) {
|
||||
resetInterval();
|
||||
previousUniqueId = currentUniqueId;
|
||||
}
|
||||
|
||||
const activity: ActivityState = [
|
||||
current[0] as QueueSong,
|
||||
current[1] as number,
|
||||
current[2] as PlayerStatus,
|
||||
];
|
||||
|
||||
// Use the ref as the source of truth for previous state
|
||||
const previousActivity: ActivityState =
|
||||
previousActivityStateRef.current ||
|
||||
(previous
|
||||
? [
|
||||
previous[0] as QueueSong,
|
||||
previous[1] as number,
|
||||
previous[2] as PlayerStatus,
|
||||
]
|
||||
: activity);
|
||||
|
||||
debouncedSetActivity(activity, previousActivity);
|
||||
|
||||
previousActivityStateRef.current = activity;
|
||||
},
|
||||
);
|
||||
emitActivityUpdate(initialState, 'initial');
|
||||
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
clearRefreshInterval();
|
||||
};
|
||||
}, [
|
||||
debouncedSetActivity,
|
||||
discordSettings.clientId,
|
||||
clearRefreshInterval,
|
||||
discordSettings.enabled,
|
||||
emitActivityUpdate,
|
||||
getCurrentActivityState,
|
||||
privateMode,
|
||||
setActivity,
|
||||
]);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
onCurrentSongChange: ({ song }) => {
|
||||
if (!discordEnabledRef.current || privateModeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerState = usePlayerStore.getState();
|
||||
const activityState: ActivityState = [
|
||||
song,
|
||||
useTimestampStoreBase.getState().timestamp,
|
||||
playerState.player.status,
|
||||
];
|
||||
emitActivityUpdateRef.current(activityState, 'track_change');
|
||||
},
|
||||
onPlayerSeekToTimestamp: ({ timestamp }) => {
|
||||
if (!discordEnabledRef.current || privateModeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerState = usePlayerStore.getState();
|
||||
const activityState: ActivityState = [
|
||||
playerState.getCurrentSong(),
|
||||
timestamp,
|
||||
playerState.player.status,
|
||||
];
|
||||
emitActivityUpdateRef.current(activityState, 'seek');
|
||||
},
|
||||
onPlayerStatus: ({ status }) => {
|
||||
if (!discordEnabledRef.current || privateModeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerState = usePlayerStore.getState();
|
||||
const activityState: ActivityState = [
|
||||
playerState.getCurrentSong(),
|
||||
useTimestampStoreBase.getState().timestamp,
|
||||
status,
|
||||
];
|
||||
emitActivityUpdateRef.current(activityState, 'status_change');
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
const DiscordRpcHookInner = () => {
|
||||
|
||||
@@ -109,12 +109,8 @@ export function computeSelectedFromResult(
|
||||
};
|
||||
}
|
||||
|
||||
const hasLocalLocal =
|
||||
(Array.isArray(local) && local.length > 0) ||
|
||||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics));
|
||||
|
||||
// If setting is set to prefer local lyrics, return the local lyrics if available
|
||||
if (preferLocalLyrics && hasLocalLocal) {
|
||||
if (preferLocalLyrics && hasLocalLyrics(local)) {
|
||||
if (Array.isArray(local) && local.length > 0) {
|
||||
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
||||
return { selected: item, selectedSynced: item.synced };
|
||||
@@ -236,6 +232,13 @@ export function getDisplayOffset(
|
||||
return storedOffsetMs;
|
||||
}
|
||||
|
||||
export function hasLocalLyrics(local: FullLyricsMetadata | null | StructuredLyric[]): boolean {
|
||||
return (
|
||||
(Array.isArray(local) && local.length > 0) ||
|
||||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics))
|
||||
);
|
||||
}
|
||||
|
||||
const emptyResult = (): LyricsQueryResult => ({
|
||||
local: null,
|
||||
overrideData: null,
|
||||
@@ -277,16 +280,11 @@ export const lyricsQueries = {
|
||||
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
|
||||
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
|
||||
|
||||
// Fetch local lyrics
|
||||
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
||||
|
||||
// Fetch remote auto lyrics
|
||||
const remoteAutoPromise =
|
||||
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
|
||||
? null
|
||||
: fetchRemoteLyricsAuto(song);
|
||||
|
||||
// Fetch override data
|
||||
const overrideDataPromise = overrideSelection
|
||||
? fetchRemoteLyricsById({
|
||||
remoteSongId: overrideSelection.id,
|
||||
@@ -295,11 +293,40 @@ export const lyricsQueries = {
|
||||
})
|
||||
: null;
|
||||
|
||||
const [local, remoteAuto, overrideData] = await Promise.all([
|
||||
localPromise,
|
||||
remoteAutoPromise,
|
||||
overrideDataPromise,
|
||||
]);
|
||||
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
||||
|
||||
let local: FullLyricsMetadata | null | StructuredLyric[];
|
||||
let remoteAuto: FullLyricsMetadata | null;
|
||||
let overrideData: LyricsResponse | null;
|
||||
|
||||
if (preferLocalLyrics) {
|
||||
local = await localPromise;
|
||||
|
||||
if (hasLocalLyrics(local)) {
|
||||
overrideData = overrideDataPromise ? await overrideDataPromise : null;
|
||||
remoteAuto = null;
|
||||
|
||||
if (remoteAutoPromise) {
|
||||
void remoteAutoPromise.then((fetchedRemoteAuto) => {
|
||||
if (signal.aborted || !fetchedRemoteAuto) return;
|
||||
queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) =>
|
||||
prev ? { ...prev, remoteAuto: fetchedRemoteAuto } : prev,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
[remoteAuto, overrideData] = await Promise.all([
|
||||
remoteAutoPromise,
|
||||
overrideDataPromise,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
[local, remoteAuto, overrideData] = await Promise.all([
|
||||
localPromise,
|
||||
remoteAutoPromise,
|
||||
overrideDataPromise,
|
||||
]);
|
||||
}
|
||||
|
||||
const partial: Pick<
|
||||
LyricsQueryResult,
|
||||
@@ -320,13 +347,12 @@ export const lyricsQueries = {
|
||||
preferLocalLyrics,
|
||||
selectedStructuredIndex,
|
||||
);
|
||||
const displayOffset = getDisplayOffset(
|
||||
const resultSelectedOffsetMs = getDisplayOffset(
|
||||
selected,
|
||||
selectedOffsetMs,
|
||||
selectedStructuredIndex,
|
||||
local,
|
||||
);
|
||||
const resultSelectedOffsetMs = displayOffset;
|
||||
|
||||
return {
|
||||
...emptyResult(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useIsFetching } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { RefObject } from 'react';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './play-queue-list-controls.module.css';
|
||||
@@ -21,6 +21,7 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
@@ -135,7 +136,17 @@ const QueueRestoreActions = () => {
|
||||
|
||||
const isFetching = useIsFetching({ queryKey: queryKeys.player.fetch({ type: 'queue' }) });
|
||||
|
||||
const { isPending: isSavingQueue, mutate: handleSaveQueue } = useSaveQueue();
|
||||
const { isPending: isSavingQueue, mutate: saveQueue } = useSaveQueue();
|
||||
|
||||
const handleSaveQueue = useCallback(() => {
|
||||
saveQueue(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: t('form.saveQueue.success'),
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [saveQueue]);
|
||||
|
||||
const handleRestoreQueue = useRestoreQueue();
|
||||
|
||||
|
||||
@@ -214,7 +214,14 @@ export const SidebarPlayQueue = () => {
|
||||
))}
|
||||
</SplitPane>
|
||||
) : (
|
||||
<Stack gap={0} h="100%" w="100%">
|
||||
<Stack
|
||||
gap={0}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
w="100%"
|
||||
>
|
||||
<PlayQueueListControls
|
||||
handleSearch={setSearch}
|
||||
searchTerm={search}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { QueueSong } from '/@/shared/types/domain-types';
|
||||
export function useSongUrl(
|
||||
song: QueueSong | undefined,
|
||||
current: boolean,
|
||||
transcode: TranscodingConfig,
|
||||
transcode: Partial<TranscodingConfig>,
|
||||
): string | undefined {
|
||||
const prior = useRef(['', '']);
|
||||
const shouldReusePrior = Boolean(
|
||||
@@ -24,7 +24,7 @@ export function useSongUrl(
|
||||
bitrate: transcode.bitrate,
|
||||
format: transcode.format,
|
||||
id: song!.id,
|
||||
transcode: transcode.enabled,
|
||||
transcode: transcode.enabled ?? false,
|
||||
},
|
||||
}),
|
||||
queryKey: [
|
||||
@@ -63,7 +63,7 @@ export function useSongUrl(
|
||||
|
||||
export const getSongUrl = async (
|
||||
song: QueueSong,
|
||||
transcode: TranscodingConfig,
|
||||
transcode: Partial<TranscodingConfig>,
|
||||
skipAutoTranscode?: boolean,
|
||||
) => {
|
||||
const url = await api.controller.getStreamUrl({
|
||||
@@ -73,7 +73,7 @@ export const getSongUrl = async (
|
||||
format: transcode.format,
|
||||
id: song.id,
|
||||
skipAutoTranscode,
|
||||
transcode: transcode.enabled,
|
||||
transcode: transcode.enabled ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@ export function WebPlayer() {
|
||||
gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(playerRef.current.player1().ref),
|
||||
hasNextSong: Boolean(player2),
|
||||
isFlac: false,
|
||||
isTransitioning,
|
||||
nextPlayer: playerRef.current.player2(),
|
||||
@@ -206,6 +207,7 @@ export function WebPlayer() {
|
||||
gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(playerRef.current.player2().ref),
|
||||
hasNextSong: Boolean(player1),
|
||||
isFlac: false,
|
||||
isTransitioning,
|
||||
nextPlayer: playerRef.current.player1(),
|
||||
@@ -680,6 +682,7 @@ function exponentialEaseOut(t: number): number {
|
||||
function gaplessHandler(args: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
hasNextSong: boolean;
|
||||
isFlac: boolean;
|
||||
isTransitioning: boolean | string;
|
||||
nextPlayer: {
|
||||
@@ -688,7 +691,19 @@ function gaplessHandler(args: {
|
||||
};
|
||||
setIsTransitioning: Dispatch<boolean | string>;
|
||||
}) {
|
||||
const { currentTime, duration, isFlac, isTransitioning, nextPlayer, setIsTransitioning } = args;
|
||||
const {
|
||||
currentTime,
|
||||
duration,
|
||||
hasNextSong,
|
||||
isFlac,
|
||||
isTransitioning,
|
||||
nextPlayer,
|
||||
setIsTransitioning,
|
||||
} = args;
|
||||
|
||||
if (!hasNextSong) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isTransitioning) {
|
||||
if (currentTime > duration - 2) {
|
||||
|
||||
@@ -9,7 +9,13 @@ import styles from './playerbar-waveform.module.css';
|
||||
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
|
||||
import { PlayerbarSeekSlider } from '/@/renderer/features/player/components/playerbar-seek-slider';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { BarAlign, usePlayerbarSlider, usePlayerSong, usePlayerTimestamp } from '/@/renderer/store';
|
||||
import {
|
||||
BarAlign,
|
||||
usePlaybackSettings,
|
||||
usePlayerbarSlider,
|
||||
usePlayerSong,
|
||||
usePlayerTimestamp,
|
||||
} from '/@/renderer/store';
|
||||
import { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
@@ -30,7 +36,12 @@ export const PlayerbarWaveform = () => {
|
||||
|
||||
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
||||
|
||||
const streamUrl = useSongUrl(currentSong, true, { bitrate: 64, enabled: false, format: 'mp3' });
|
||||
const { transcode } = usePlaybackSettings();
|
||||
const streamUrl = useSongUrl(currentSong, true, {
|
||||
bitrate: 64,
|
||||
enabled: transcode.enabled,
|
||||
format: 'mp3',
|
||||
});
|
||||
|
||||
const { color } = useAppThemeColors();
|
||||
const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard position="top" width={280}>
|
||||
<HoverCard openDelay={500} position="top" width={280}>
|
||||
<HoverCard.Target>
|
||||
<Group
|
||||
align="center"
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store';
|
||||
import {
|
||||
usePlayerShuffle,
|
||||
usePlayerStatus,
|
||||
usePlayerStoreBase,
|
||||
} from '/@/renderer/store/player.store';
|
||||
import {
|
||||
useSleepTimerActions,
|
||||
useSleepTimerActive,
|
||||
@@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
const PRESET_OPTIONS = [
|
||||
{ minutes: 0, mode: 'endOfSong' as const },
|
||||
{ minutes: 0, mode: 'endOfAlbum' as const },
|
||||
{ minutes: 5, mode: 'timed' as const },
|
||||
{ minutes: 10, mode: 'timed' as const },
|
||||
{ minutes: 15, mode: 'timed' as const },
|
||||
@@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string {
|
||||
const useSleepTimer = () => {
|
||||
const active = useSleepTimerActive();
|
||||
const mode = useSleepTimerMode();
|
||||
const { cancelTimer, setRemaining } = useSleepTimerActions();
|
||||
const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions();
|
||||
const { mediaPause } = usePlayer();
|
||||
|
||||
const mediaPauseRef = useRef(mediaPause);
|
||||
mediaPauseRef.current = mediaPause;
|
||||
|
||||
// End of album mode. Set the pauseOnNextSongEnd flag whenever the current track
|
||||
// is the last one of the target album.
|
||||
const evaluateEndOfAlbum = useCallback(() => {
|
||||
const { currentSong, nextSong } = usePlayerStoreBase.getState().getPlayerData();
|
||||
|
||||
if (!currentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
let target = useSleepTimerStore.getState().targetAlbumId;
|
||||
|
||||
if (target === null) {
|
||||
target = currentSong.albumId;
|
||||
setTargetAlbumId(target);
|
||||
}
|
||||
|
||||
if (currentSong.albumId !== target) {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
cancelTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const isLastOfAlbum = !nextSong || nextSong.albumId !== currentSong.albumId;
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(isLastOfAlbum);
|
||||
}, [cancelTimer, setTargetAlbumId]);
|
||||
|
||||
const handleOnCurrentSongChange = useCallback(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
@@ -65,8 +96,14 @@ const useSleepTimer = () => {
|
||||
if (mode === 'endOfSong') {
|
||||
cancelTimer();
|
||||
mediaPauseRef.current();
|
||||
return;
|
||||
}
|
||||
}, [active, mode, cancelTimer, mediaPauseRef]);
|
||||
|
||||
// Cancel and pause song change in end-of-album mode
|
||||
if (mode === 'endOfAlbum') {
|
||||
evaluateEndOfAlbum();
|
||||
}
|
||||
}, [active, mode, cancelTimer, evaluateEndOfAlbum, mediaPauseRef]);
|
||||
|
||||
const status = usePlayerStatus();
|
||||
|
||||
@@ -104,15 +141,32 @@ const useSleepTimer = () => {
|
||||
// mediaAutoNext returns PAUSED status when the current song ends.
|
||||
// This is a generic player mechanism — the web player handles it
|
||||
// without needing to know about the sleep timer.
|
||||
// End-of-album mode: set the same flag conditionally, here we run
|
||||
// the intial evaluation in case the timer was started while already
|
||||
// on the last track of the album
|
||||
useEffect(() => {
|
||||
if (!active || mode !== 'endOfSong') return;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
||||
if (mode === 'endOfSong') {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
||||
|
||||
return () => {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
};
|
||||
}, [active, mode]);
|
||||
return () => {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'endOfAlbum') {
|
||||
evaluateEndOfAlbum();
|
||||
|
||||
return () => {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [active, mode, evaluateEndOfAlbum]);
|
||||
};
|
||||
|
||||
export const SleepTimerHookInner = () => {
|
||||
@@ -135,8 +189,14 @@ export const SleepTimerButton = () => {
|
||||
const active = useSleepTimerActive();
|
||||
const mode = useSleepTimerMode();
|
||||
const remaining = useSleepTimerRemaining();
|
||||
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
|
||||
const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } =
|
||||
useSleepTimerActions();
|
||||
const { mediaPause } = usePlayer();
|
||||
const shuffle = usePlayerShuffle();
|
||||
// Track level shuffle scatters and album across a play queue making 'end-of-album'
|
||||
// meaningless. Album shuffle keeps each album intact, so keep 'end-of-'album
|
||||
// enabled there
|
||||
const isTrackShuffle = shuffle === PlayerShuffle.TRACK;
|
||||
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [customHours, setCustomHours] = useState<number>(0);
|
||||
@@ -151,13 +211,15 @@ export const SleepTimerButton = () => {
|
||||
(option: (typeof PRESET_OPTIONS)[number]) => {
|
||||
if (option.mode === 'endOfSong') {
|
||||
startEndOfSongTimer();
|
||||
} else if (option.mode === 'endOfAlbum') {
|
||||
startEndOfAlbumTimer();
|
||||
} else {
|
||||
startTimedTimer(option.minutes * 60);
|
||||
}
|
||||
setShowCustom(false);
|
||||
setOpened(false);
|
||||
},
|
||||
[startEndOfSongTimer, startTimedTimer],
|
||||
[startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer],
|
||||
);
|
||||
|
||||
const handleCustomStart = useCallback(() => {
|
||||
@@ -178,6 +240,9 @@ export const SleepTimerButton = () => {
|
||||
if (option.mode === 'endOfSong') {
|
||||
return t('player.sleepTimer_endOfSong');
|
||||
}
|
||||
if (option.mode === 'endOfAlbum') {
|
||||
return t('player.sleepTimer_endOfAlbum');
|
||||
}
|
||||
if (option.minutes >= 60) {
|
||||
return t('player.sleepTimer_hours', {
|
||||
count: option.minutes / 60,
|
||||
@@ -231,6 +296,10 @@ export const SleepTimerButton = () => {
|
||||
<Text c="primary" size="sm">
|
||||
{t('player.sleepTimer_endOfSong')}
|
||||
</Text>
|
||||
) : mode === 'endOfAlbum' ? (
|
||||
<Text c="primary" size="sm">
|
||||
{t('player.sleepTimer_endOfAlbum')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="primary" fw="600" size="lg">
|
||||
{formatRemaining(remaining)}
|
||||
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
|
||||
(option, index) => (
|
||||
{PRESET_OPTIONS.filter(
|
||||
(option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum',
|
||||
).map((option) => {
|
||||
const disabled = option.mode === 'endOfAlbum' && isTrackShuffle;
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
key={index}
|
||||
key={option.mode}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreset(option);
|
||||
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
|
||||
>
|
||||
{getPresetLabel(option)}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
let startupRestoreSessionHandled = false;
|
||||
|
||||
export const useQueueRestoreTimestamp = () => {
|
||||
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||
|
||||
@@ -51,28 +53,65 @@ export const useInitialTimestampRestore = () => {
|
||||
|
||||
const startupRestoreInitializedRef = useRef(false);
|
||||
const startupSeekArmedRef = useRef<null | number>(null);
|
||||
const startupSeekTargetUniqueIdRef = useRef<null | string>(null);
|
||||
const startupSeekAppliedRef = useRef(false);
|
||||
|
||||
const applyStartupSeek = useCallback(() => {
|
||||
const cancelStartupSeek = useCallback(() => {
|
||||
if (startupSeekAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTimestamp = startupSeekArmedRef.current;
|
||||
if (!seekTimestamp || seekTimestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
startupSeekAppliedRef.current = true;
|
||||
startupSeekArmedRef.current = null;
|
||||
startupSeekTargetUniqueIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const applyStartupSeek = useCallback(() => {
|
||||
const seekTimestamp = startupSeekArmedRef.current;
|
||||
|
||||
if (startupSeekAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!seekTimestamp || seekTimestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUniqueId = startupSeekTargetUniqueIdRef.current;
|
||||
const currentUniqueId = usePlayerStore.getState().getQueue().items[
|
||||
usePlayerStore.getState().player.index
|
||||
]?._uniqueId;
|
||||
|
||||
if (targetUniqueId && currentUniqueId !== targetUniqueId) {
|
||||
cancelStartupSeek();
|
||||
return;
|
||||
}
|
||||
|
||||
startupSeekAppliedRef.current = true;
|
||||
startupSeekArmedRef.current = null;
|
||||
startupSeekTargetUniqueIdRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
mediaSeekToTimestamp(seekTimestamp);
|
||||
}, 100);
|
||||
}, [mediaSeekToTimestamp]);
|
||||
}, [cancelStartupSeek, mediaSeekToTimestamp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupRestoreInitializedRef.current) {
|
||||
const targetUniqueId = startupSeekTargetUniqueIdRef.current;
|
||||
if (
|
||||
!targetUniqueId ||
|
||||
startupSeekAppliedRef.current ||
|
||||
!currentSong ||
|
||||
currentSong._uniqueId === targetUniqueId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelStartupSeek();
|
||||
}, [cancelStartupSeek, currentSong]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupRestoreInitializedRef.current || startupRestoreSessionHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,9 +120,11 @@ export const useInitialTimestampRestore = () => {
|
||||
}
|
||||
|
||||
startupRestoreInitializedRef.current = true;
|
||||
startupRestoreSessionHandled = true;
|
||||
|
||||
if (timestamp > 0) {
|
||||
startupSeekArmedRef.current = timestamp;
|
||||
startupSeekTargetUniqueIdRef.current = currentSong._uniqueId;
|
||||
}
|
||||
|
||||
if (playerStatus === PlayerStatus.PLAYING) {
|
||||
@@ -129,26 +170,20 @@ export const useSaveQueue = () => {
|
||||
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await api.controller.savePlayQueue({
|
||||
apiClientProps: { serverId },
|
||||
query: {
|
||||
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
||||
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
||||
songs: queue.items.map((item) => item.id),
|
||||
},
|
||||
});
|
||||
|
||||
toast.success({
|
||||
message: t('form.saveQueue.success'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.saveQueueFailed'),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return api.controller.savePlayQueue({
|
||||
apiClientProps: { serverId },
|
||||
query: {
|
||||
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
||||
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
||||
songs: queue.items.map((item) => item.id),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.saveQueueFailed'),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const getPositionValue = (seconds: number, useTicks: boolean) => {
|
||||
return Math.round(seconds * 1e7);
|
||||
}
|
||||
|
||||
return seconds;
|
||||
return seconds * 1000;
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -180,9 +180,6 @@ export const useScrobble = () => {
|
||||
|
||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
const serverId = currentSong?._serverId;
|
||||
const server = getServerById(serverId);
|
||||
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||
const currentStatus = usePlayerStore.getState().player.status;
|
||||
const currentTime = properties.timestamp;
|
||||
@@ -239,36 +236,36 @@ export const useScrobble = () => {
|
||||
}
|
||||
|
||||
// Send progress events every 10 seconds
|
||||
if (hasPlaybackReport) {
|
||||
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||
if (timeSinceLastProgress >= 10) {
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: serverId || '' },
|
||||
query: {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'timeupdate',
|
||||
id: currentSong.id,
|
||||
mediaType: mediaType,
|
||||
playbackRate,
|
||||
position: getPositionValue(currentTime, useTicks),
|
||||
submission: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
|
||||
category: LogCategory.SCROBBLE,
|
||||
meta: {
|
||||
id: currentSong.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
lastProgressEventRef.current = currentTime;
|
||||
}
|
||||
}
|
||||
// if (hasPlaybackReport) {
|
||||
// const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||
// if (timeSinceLastProgress >= 10) {
|
||||
// sendScrobble.mutate(
|
||||
// {
|
||||
// apiClientProps: { serverId: serverId || '' },
|
||||
// query: {
|
||||
// albumId: currentSong.albumId,
|
||||
// event: 'timeupdate',
|
||||
// id: currentSong.id,
|
||||
// mediaType: mediaType,
|
||||
// playbackRate,
|
||||
// position: getPositionValue(currentTime, useTicks),
|
||||
// submission: false,
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// onSuccess: () => {
|
||||
// logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
|
||||
// category: LogCategory.SCROBBLE,
|
||||
// meta: {
|
||||
// id: currentSong.id,
|
||||
// },
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
// lastProgressEventRef.current = currentTime;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Check if we should submit scrobble based on listened time
|
||||
if (!isCurrentSongScrobbledRef.current) {
|
||||
@@ -462,12 +459,14 @@ export const useScrobble = () => {
|
||||
lastProgressEventRef.current = properties.timestamp;
|
||||
lastSeekEventRef.current = now;
|
||||
|
||||
const currentStatus = usePlayerStore.getState().player.status;
|
||||
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
||||
query: {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'timeupdate',
|
||||
event: currentStatus === PlayerStatus.PLAYING ? 'unpause' : 'pause',
|
||||
id: currentSong.id,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
|
||||
@@ -74,6 +74,7 @@ export const PlaylistListInfiniteTable = ({
|
||||
columns={columns}
|
||||
data={loadedItems}
|
||||
enableAlternateRowColors={enableAlternateRowColors}
|
||||
enableExpansion={false}
|
||||
enableHeader={enableHeader}
|
||||
enableHorizontalBorders={enableHorizontalBorders}
|
||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||
|
||||
@@ -87,6 +87,7 @@ export const PlaylistListPaginatedTable = ({
|
||||
columns={columns}
|
||||
data={data || []}
|
||||
enableAlternateRowColors={enableAlternateRowColors}
|
||||
enableExpansion={false}
|
||||
enableHeader={enableHeader}
|
||||
enableHorizontalBorders={enableHorizontalBorders}
|
||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { FontType } from '/@/shared/types/types';
|
||||
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
|
||||
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
|
||||
|
||||
@@ -289,21 +290,29 @@ export const ApplicationSettings = memo(() => {
|
||||
control: (
|
||||
<FileInput
|
||||
accept=".ttc,.ttf,.otf,.woff,.woff2"
|
||||
onChange={(e) =>
|
||||
clearable
|
||||
defaultValue={
|
||||
fontSettings.custom
|
||||
? new File([], fontSettings.custom.split(utils?.separator || '').pop()!)
|
||||
: null
|
||||
}
|
||||
onChange={async (e) => {
|
||||
const custom = e ? getPathForFile?.(e) || null : null;
|
||||
await localSettings?.setSync('local_font_path', custom);
|
||||
setSettings({
|
||||
font: {
|
||||
...fontSettings,
|
||||
custom: e ? getPathForFile?.(e) || null : null,
|
||||
custom,
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
w={300}
|
||||
/>
|
||||
),
|
||||
description: t('setting.customFontPath', {
|
||||
context: 'description',
|
||||
}),
|
||||
isHidden: fontSettings.type !== FontType.CUSTOM,
|
||||
isHidden: !isElectron() || fontSettings.type !== FontType.CUSTOM,
|
||||
title: t('setting.customFontPath'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,10 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import {
|
||||
keyboardCodeToHotkeyKey,
|
||||
MODIFIER_KEY_CODES,
|
||||
} from '/@/shared/utils/keyboard-code-to-hotkey';
|
||||
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
|
||||
@@ -112,25 +116,16 @@ export const HotkeyManagerSettings = memo(() => {
|
||||
const debouncedSetHotkey = debounce(
|
||||
(binding: BindingActions, e: KeyboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const IGNORED_KEYS = ['Control', 'Alt', 'Shift', 'Meta', ' ', 'Escape'];
|
||||
const keys: string[] = [];
|
||||
if (e.ctrlKey) keys.push('mod');
|
||||
if (e.altKey) keys.push('alt');
|
||||
if (e.shiftKey) keys.push('shift');
|
||||
if (e.metaKey) keys.push('meta');
|
||||
if (e.key === ' ') keys.push('space');
|
||||
if (!IGNORED_KEYS.includes(e.key)) {
|
||||
if (e.code.includes('Numpad')) {
|
||||
if (e.key === '+') keys.push('numpadadd');
|
||||
else if (e.key === '-') keys.push('numpadsubtract');
|
||||
else if (e.key === '*') keys.push('numpadmultiply');
|
||||
else if (e.key === '/') keys.push('numpaddivide');
|
||||
else if (e.key === '.') keys.push('numpaddecimal');
|
||||
else keys.push(`numpad${e.key}`.toLowerCase());
|
||||
} else if (e.key === '+') {
|
||||
keys.push('equal');
|
||||
} else {
|
||||
keys.push(e.key?.toLowerCase());
|
||||
|
||||
if (!MODIFIER_KEY_CODES.has(e.code) && e.code !== 'Escape') {
|
||||
const hotkeyKey = keyboardCodeToHotkeyKey(e.code);
|
||||
if (hotkeyKey) {
|
||||
keys.push(hotkeyKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,13 +36,12 @@ export const WindowSettings = memo(() => {
|
||||
if (!e) return;
|
||||
|
||||
// Platform.LINUX is used as the native frame option regardless of the actual platform
|
||||
const hasFrame = localSettings?.get('window_has_frame') as
|
||||
| boolean
|
||||
| undefined;
|
||||
const isSwitchingToFrame = !hasFrame && e === Platform.LINUX;
|
||||
const isSwitchingToNoFrame = hasFrame && e !== Platform.LINUX;
|
||||
|
||||
const requireRestart = isSwitchingToFrame || isSwitchingToNoFrame;
|
||||
const previousWindowBarStyle = settings.windowBarStyle;
|
||||
const isSwitchingToNative =
|
||||
previousWindowBarStyle !== Platform.LINUX && e === Platform.LINUX;
|
||||
const isSwitchingFromNative =
|
||||
previousWindowBarStyle === Platform.LINUX && e !== Platform.LINUX;
|
||||
const requireRestart = isSwitchingToNative || isSwitchingFromNative;
|
||||
|
||||
if (requireRestart) {
|
||||
openRestartRequiredToast();
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type HotkeyItem as MantineHotkeyItem,
|
||||
useHotkeys as useMantineHotkeys,
|
||||
} from '@mantine/hooks';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppStore } from '/@/renderer/store';
|
||||
import { withPhysicalKeys } from '/@/shared/utils/hotkeys';
|
||||
|
||||
const EMPTY_HOTKEYS: MantineHotkeyItem[] = [];
|
||||
|
||||
@@ -13,8 +15,10 @@ export const useHotkeys = (
|
||||
triggerOnContentEditable?: boolean,
|
||||
) => {
|
||||
const commandPaletteOpened = useAppStore((state) => state.commandPalette.opened);
|
||||
const physicalHotkeys = useMemo(() => withPhysicalKeys(hotkeys), [hotkeys]);
|
||||
|
||||
useMantineHotkeys(
|
||||
commandPaletteOpened ? EMPTY_HOTKEYS : hotkeys,
|
||||
commandPaletteOpened ? EMPTY_HOTKEYS : physicalHotkeys,
|
||||
tagsToIgnore,
|
||||
triggerOnContentEditable,
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ export const useSyncSettingsToMain = () => {
|
||||
const settingsFromStore = useSettingsStore.getState();
|
||||
|
||||
const settings = {
|
||||
font: settingsFromStore.font,
|
||||
general: settingsFromStore.general,
|
||||
hotkeys: settingsFromStore.hotkeys,
|
||||
lyrics: settingsFromStore.lyrics,
|
||||
@@ -101,6 +102,10 @@ export const useSyncSettingsToMain = () => {
|
||||
mainStoreKey: 'enableNeteaseTranslation',
|
||||
rendererValue: settings.lyrics.enableNeteaseTranslation,
|
||||
},
|
||||
{
|
||||
mainStoreKey: 'local_font_path',
|
||||
rendererValue: settings.font.custom,
|
||||
},
|
||||
];
|
||||
|
||||
// Compare and sync each setting
|
||||
|
||||
@@ -223,7 +223,7 @@ function calculateNextIndex(
|
||||
} else {
|
||||
// Repeat none: move to next track, or pause if at the end
|
||||
if (isLastTrack) {
|
||||
return { nextIndex: 0, shouldPause: true };
|
||||
return { nextIndex: currentIndex, shouldPause: true };
|
||||
} else {
|
||||
return { nextIndex: currentIndex + 1, shouldPause: false };
|
||||
}
|
||||
@@ -939,10 +939,12 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
const pauseOnNext = player.pauseOnNextSongEnd;
|
||||
const newStatus =
|
||||
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
|
||||
const shouldKeepCurrentPlayer = newStatus === PlayerStatus.PAUSED;
|
||||
const shouldSwapPlayer = !isRepeatOneSameTrack && !shouldKeepCurrentPlayer;
|
||||
|
||||
set((state) => {
|
||||
state.player.index = nextPlaybackIndex;
|
||||
state.player.playerNum = newPlayerNum;
|
||||
state.player.playerNum = shouldSwapPlayer ? newPlayerNum : player.playerNum;
|
||||
setTimestampStore(0);
|
||||
state.player.status = newStatus;
|
||||
|
||||
@@ -999,7 +1001,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
}
|
||||
|
||||
const { player1, player2 } = getDualPlayerSongs(
|
||||
newPlayerNum,
|
||||
shouldSwapPlayer ? newPlayerNum : player.playerNum,
|
||||
currentSong,
|
||||
nextSong,
|
||||
repeat,
|
||||
@@ -1009,7 +1011,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
currentSong,
|
||||
index: currentQueueIndex,
|
||||
nextSong,
|
||||
num: newPlayerNum,
|
||||
num: shouldSwapPlayer ? newPlayerNum : player.playerNum,
|
||||
player1,
|
||||
player2,
|
||||
previousSong,
|
||||
@@ -1183,6 +1185,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
});
|
||||
},
|
||||
mediaSeekToTimestamp: (timestamp: number) => {
|
||||
// See mediaSkipBackward: update the timestamp store right away to
|
||||
// avoid the stale-read left by the ~500ms engine poll.
|
||||
setTimestampStore(timestamp);
|
||||
set((state) => {
|
||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);
|
||||
});
|
||||
@@ -1194,6 +1199,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||
const newTimestamp = Math.max(0, currentTimestamp - timeToSkip);
|
||||
|
||||
// Update the timestamp store right away so the UI and any
|
||||
// subsequent seek compute from the new position instead of the
|
||||
// stale value left by the ~500ms engine poll (otherwise mashing
|
||||
// the seek keys repeatedly lands on the same time).
|
||||
setTimestampStore(newTimestamp);
|
||||
set((state) => {
|
||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
||||
});
|
||||
@@ -1215,6 +1225,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||
const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);
|
||||
|
||||
// See mediaSkipBackward: update the timestamp store right away to
|
||||
// avoid the stale-read left by the ~500ms engine poll.
|
||||
setTimestampStore(newTimestamp);
|
||||
set((state) => {
|
||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export type SleepTimerMode = 'endOfSong' | 'timed';
|
||||
export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed';
|
||||
|
||||
interface SleepTimerActions {
|
||||
cancelTimer: () => void;
|
||||
setRemaining: (remaining: number) => void;
|
||||
setTargetAlbumId: (albumId: null | string) => void;
|
||||
startEndOfAlbumTimer: () => void;
|
||||
startEndOfSongTimer: () => void;
|
||||
startTimedTimer: (durationSeconds: number) => void;
|
||||
}
|
||||
@@ -17,6 +19,8 @@ interface SleepTimerState {
|
||||
mode: SleepTimerMode;
|
||||
/** Remaining seconds (only ticks while playing) */
|
||||
remaining: number;
|
||||
/** Album Id for song when mode activated */
|
||||
targetAlbumId: null | string;
|
||||
}
|
||||
|
||||
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
|
||||
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
||||
active: false,
|
||||
mode: 'timed',
|
||||
remaining: 0,
|
||||
targetAlbumId: null,
|
||||
});
|
||||
},
|
||||
mode: 'timed',
|
||||
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
||||
set({ remaining });
|
||||
},
|
||||
|
||||
setTargetAlbumId: (albumId: null | string) => {
|
||||
set({ targetAlbumId: albumId });
|
||||
},
|
||||
|
||||
startEndOfAlbumTimer: () => {
|
||||
set({
|
||||
active: true,
|
||||
mode: 'endOfAlbum',
|
||||
remaining: 0,
|
||||
targetAlbumId: null,
|
||||
});
|
||||
},
|
||||
|
||||
startEndOfSongTimer: () => {
|
||||
set({
|
||||
active: true,
|
||||
mode: 'endOfSong',
|
||||
remaining: 0,
|
||||
targetAlbumId: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
||||
active: true,
|
||||
mode: 'timed',
|
||||
remaining: durationSeconds,
|
||||
targetAlbumId: null,
|
||||
});
|
||||
},
|
||||
|
||||
targetAlbumId: null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
|
||||
useShallow((s) => ({
|
||||
cancelTimer: s.cancelTimer,
|
||||
setRemaining: s.setRemaining,
|
||||
setTargetAlbumId: s.setTargetAlbumId,
|
||||
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
|
||||
startEndOfSongTimer: s.startEndOfSongTimer,
|
||||
startTimedTimer: s.startTimedTimer,
|
||||
})),
|
||||
|
||||
@@ -134,6 +134,11 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
||||
document.body.appendChild(textStyleRef.current);
|
||||
}
|
||||
|
||||
// Note: we change the url to bust caches when changing the path
|
||||
// The url provided here does NOT matter, validation is done
|
||||
// on the main process. Any feishin:/ url will fetch the same
|
||||
// item, which the renderer will check via magic number to be
|
||||
// some font item
|
||||
textStyleRef.current.textContent = `
|
||||
@font-face {
|
||||
font-family: "dynamic-font";
|
||||
|
||||
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
similarArtists,
|
||||
songCount: item.SongCount ?? null,
|
||||
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
@@ -434,6 +435,7 @@ const normalizePlaylist = (
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
sync: null,
|
||||
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
|
||||
|
||||
const deletePlaylist = z.null();
|
||||
|
||||
const deletePlaylistImage = z.null();
|
||||
|
||||
const deleteArtistImage = deletePlaylistImage;
|
||||
|
||||
const uploadPlaylistImage = z.null();
|
||||
|
||||
const uploadArtistImage = uploadPlaylistImage;
|
||||
|
||||
const deletePlaylistParameters = z.object({
|
||||
Id: z.string(),
|
||||
});
|
||||
@@ -886,7 +894,9 @@ export const jfType = {
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deleteArtistImage,
|
||||
deletePlaylist,
|
||||
deletePlaylistImage,
|
||||
error,
|
||||
favorite,
|
||||
filters,
|
||||
@@ -912,6 +922,8 @@ export const jfType = {
|
||||
studioList,
|
||||
topSongsList,
|
||||
updatePlaylist,
|
||||
uploadArtistImage,
|
||||
uploadPlaylistImage,
|
||||
user,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,17 @@ import {
|
||||
type HotkeyItem as MantineHotkeyItem,
|
||||
useHotkeys as useMantineHotkeys,
|
||||
} from '@mantine/hooks';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useHotkeys = useMantineHotkeys;
|
||||
import { withPhysicalKeys } from '/@/shared/utils/hotkeys';
|
||||
|
||||
export const useHotkeys = (
|
||||
hotkeys: MantineHotkeyItem[],
|
||||
tagsToIgnore?: string[],
|
||||
triggerOnContentEditable?: boolean,
|
||||
) => {
|
||||
const physicalHotkeys = useMemo(() => withPhysicalKeys(hotkeys), [hotkeys]);
|
||||
useMantineHotkeys(physicalHotkeys, tagsToIgnore, triggerOnContentEditable);
|
||||
};
|
||||
|
||||
export type HotkeyItem = MantineHotkeyItem;
|
||||
|
||||
@@ -1363,7 +1363,7 @@ export type ScrobbleArgs = BaseEndpointArgs & {
|
||||
|
||||
export type ScrobbleQuery = {
|
||||
albumId?: string;
|
||||
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
|
||||
event?: 'pause' | 'start' | 'unpause';
|
||||
id: string;
|
||||
mediaType: 'podcast' | 'song';
|
||||
playbackRate: number;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { HotkeyItem } from '@mantine/hooks';
|
||||
|
||||
const RESERVED_KEYS = new Set(['alt', 'ctrl', 'meta', 'mod', 'shift']);
|
||||
|
||||
const PUNCTUATION_KEY_TO_PHYSICAL: Record<string, string> = {
|
||||
"'": 'Quote',
|
||||
',': 'Comma',
|
||||
'-': 'Minus',
|
||||
'.': 'Period',
|
||||
'/': 'Slash',
|
||||
';': 'Semicolon',
|
||||
'=': 'Equal',
|
||||
'[': 'BracketLeft',
|
||||
'\\': 'Backslash',
|
||||
']': 'BracketRight',
|
||||
'`': 'Backquote',
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts stored hotkey strings to Mantine's physical-key format.
|
||||
* Mantine matches KeyboardEvent.code via normalizeKey, which turns Digit1 into
|
||||
* "digit1" but leaves "1" as "1" — so mod+1 must become mod+Digit1.
|
||||
*/
|
||||
export const toPhysicalHotkey = (hotkey: string): string =>
|
||||
hotkey
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.map((part) => {
|
||||
if (part === '[plus]') {
|
||||
return part;
|
||||
}
|
||||
|
||||
const lower = part.toLowerCase();
|
||||
if (RESERVED_KEYS.has(lower)) {
|
||||
return lower;
|
||||
}
|
||||
|
||||
if (/^\d$/.test(part)) {
|
||||
return `Digit${part}`;
|
||||
}
|
||||
|
||||
const punctuationPhysical = PUNCTUATION_KEY_TO_PHYSICAL[part];
|
||||
if (punctuationPhysical) {
|
||||
return punctuationPhysical;
|
||||
}
|
||||
|
||||
return part;
|
||||
})
|
||||
.join('+');
|
||||
|
||||
export const withPhysicalKeys = (hotkeys: HotkeyItem[]): HotkeyItem[] =>
|
||||
hotkeys.map(([hotkey, handler, options]) => [
|
||||
toPhysicalHotkey(hotkey),
|
||||
handler,
|
||||
{ ...options, preventDefault: true, usePhysicalKeys: true },
|
||||
]);
|
||||
@@ -0,0 +1,77 @@
|
||||
const CODE_TO_HOTKEY_KEY: Record<string, string> = {
|
||||
ArrowDown: 'arrowdown',
|
||||
ArrowLeft: 'arrowleft',
|
||||
ArrowRight: 'arrowright',
|
||||
ArrowUp: 'arrowup',
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
Backspace: 'backspace',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Comma: ',',
|
||||
Delete: 'delete',
|
||||
End: 'end',
|
||||
Enter: 'enter',
|
||||
Equal: 'equal',
|
||||
Escape: 'escape',
|
||||
Home: 'home',
|
||||
Insert: 'insert',
|
||||
Minus: 'minus',
|
||||
PageDown: 'pagedown',
|
||||
PageUp: 'pageup',
|
||||
Period: '.',
|
||||
Quote: "'",
|
||||
Semicolon: ';',
|
||||
Slash: '/',
|
||||
Space: 'space',
|
||||
Tab: 'tab',
|
||||
};
|
||||
|
||||
const NUMPAD_CODE_TO_HOTKEY_KEY: Record<string, string> = {
|
||||
Add: 'numpadadd',
|
||||
Decimal: 'numpaddecimal',
|
||||
Divide: 'numpaddivide',
|
||||
Enter: 'numpadenter',
|
||||
Multiply: 'numpadmultiply',
|
||||
Subtract: 'numpadsubtract',
|
||||
};
|
||||
|
||||
export const MODIFIER_KEY_CODES = new Set([
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
]);
|
||||
|
||||
export const keyboardCodeToHotkeyKey = (code: string): null | string => {
|
||||
const mapped = CODE_TO_HOTKEY_KEY[code];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3).toLowerCase();
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5);
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const suffix = code.slice(6);
|
||||
const numpadMapped = NUMPAD_CODE_TO_HOTKEY_KEY[suffix];
|
||||
if (numpadMapped) {
|
||||
return numpadMapped;
|
||||
}
|
||||
|
||||
if (/^\d$/.test(suffix)) {
|
||||
return `numpad${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user