mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-17 07:56:29 +02:00
Compare commits
50 Commits
v1.12.0
...
f6f25154a1
| Author | SHA1 | Date | |
|---|---|---|---|
| f6f25154a1 | |||
| 880516069d | |||
| 95970183db | |||
| 3a2c952d2a | |||
| 46b94a83f1 | |||
| 40a1d1438d | |||
| 905088cae7 | |||
| 705b375dab | |||
| 0b537b07ee | |||
| dfa6198bdd | |||
| 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
|
- type: checkboxes
|
||||||
id: check-duplicate
|
id: check-duplicate
|
||||||
attributes:
|
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:
|
options:
|
||||||
- label: 'Yes'
|
- label: 'Yes'
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ permissions: write-all
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Docker image tag (e.g. 1.12.0 or latest)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
@@ -33,11 +38,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||||
type=ref,event=pr
|
type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }}
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }}
|
||||||
type=semver,pattern={{major}}
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v5
|
- uses: dessant/lock-threads@v6
|
||||||
|
|
||||||
with:
|
with:
|
||||||
process-only: 'issues, prs'
|
process-only: 'issues, prs'
|
||||||
issue-inactive-days: 120
|
issue-inactive-days: 120
|
||||||
@@ -29,19 +30,19 @@ jobs:
|
|||||||
days-before-pr-close: 30
|
days-before-pr-close: 30
|
||||||
stale-issue-message: >
|
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.
|
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.
|
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.
|
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
|
||||||
|
|
||||||
|
|
||||||
stale-pr-message: >
|
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 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.
|
This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
|
||||||
|
|
||||||
|
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: 'stale'
|
||||||
exempt-issue-labels: 'keep,security'
|
exempt-issue-labels: 'keep,security,enhancement'
|
||||||
stale-pr-label: 'stale'
|
stale-pr-label: 'stale'
|
||||||
exempt-pr-labels: 'keep,security'
|
exempt-pr-labels: 'keep,security'
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@ WORKDIR /app
|
|||||||
# Copy package.json first to cache node_modules
|
# Copy package.json first to cache node_modules
|
||||||
COPY package.json pnpm-lock.yaml .
|
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
|
RUN pnpm install
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "1.12.0",
|
"version": "1.13.0",
|
||||||
"description": "A modern self-hosted music player.",
|
"description": "A modern self-hosted music player.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"subsonic",
|
"subsonic",
|
||||||
@@ -189,6 +189,7 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"electron",
|
"electron",
|
||||||
|
"electron-winstaller",
|
||||||
"esbuild"
|
"esbuild"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import cs from './locales/cs.json';
|
|||||||
import de from './locales/de.json';
|
import de from './locales/de.json';
|
||||||
import en from './locales/en.json';
|
import en from './locales/en.json';
|
||||||
import es from './locales/es.json';
|
import es from './locales/es.json';
|
||||||
|
import et from './locales/et.json';
|
||||||
import eu from './locales/eu.json';
|
import eu from './locales/eu.json';
|
||||||
import fa from './locales/fa.json';
|
import fa from './locales/fa.json';
|
||||||
import fi from './locales/fi.json';
|
import fi from './locales/fi.json';
|
||||||
@@ -27,6 +28,8 @@ import sl from './locales/sl.json';
|
|||||||
import sr from './locales/sr.json';
|
import sr from './locales/sr.json';
|
||||||
import sv from './locales/sv.json';
|
import sv from './locales/sv.json';
|
||||||
import ta from './locales/ta.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 tr from './locales/tr.json';
|
||||||
import zhHans from './locales/zh-Hans.json';
|
import zhHans from './locales/zh-Hans.json';
|
||||||
import zhHant from './locales/zh-Hant.json';
|
import zhHant from './locales/zh-Hant.json';
|
||||||
@@ -38,6 +41,7 @@ const resources = {
|
|||||||
de: { translation: de },
|
de: { translation: de },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
es: { translation: es },
|
es: { translation: es },
|
||||||
|
et: { translation: et },
|
||||||
eu: { translation: eu },
|
eu: { translation: eu },
|
||||||
fa: { translation: fa },
|
fa: { translation: fa },
|
||||||
fi: { translation: fi },
|
fi: { translation: fi },
|
||||||
@@ -57,6 +61,8 @@ const resources = {
|
|||||||
sr: { translation: sr },
|
sr: { translation: sr },
|
||||||
sv: { translation: sv },
|
sv: { translation: sv },
|
||||||
ta: { translation: ta },
|
ta: { translation: ta },
|
||||||
|
th: { translation: th },
|
||||||
|
tl: { translation: tl },
|
||||||
tr: { translation: tr },
|
tr: { translation: tr },
|
||||||
'zh-Hans': { translation: zhHans },
|
'zh-Hans': { translation: zhHans },
|
||||||
'zh-Hant': { translation: zhHant },
|
'zh-Hant': { translation: zhHant },
|
||||||
@@ -87,6 +93,10 @@ export const languages = [
|
|||||||
label: 'Español',
|
label: 'Español',
|
||||||
value: 'es',
|
value: 'es',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Eesti',
|
||||||
|
value: 'et',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Basque',
|
label: 'Basque',
|
||||||
value: 'eu',
|
value: 'eu',
|
||||||
@@ -163,6 +173,14 @@ export const languages = [
|
|||||||
label: 'Tamil',
|
label: 'Tamil',
|
||||||
value: 'ta',
|
value: 'ta',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Thai',
|
||||||
|
value: 'th',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tagalog',
|
||||||
|
value: 'tl',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Türkçe',
|
label: 'Türkçe',
|
||||||
value: 'tr',
|
value: 'tr',
|
||||||
|
|||||||
@@ -331,7 +331,10 @@
|
|||||||
"serverRequired": "يتطلب خادم",
|
"serverRequired": "يتطلب خادم",
|
||||||
"sessionExpiredError": "انتهت صلاحية جلستك",
|
"sessionExpiredError": "انتهت صلاحية جلستك",
|
||||||
"systemFontError": "حدث خطأ أثناء محاولة الحصول على خطوط النظام",
|
"systemFontError": "حدث خطأ أثناء محاولة الحصول على خطوط النظام",
|
||||||
"settingsSyncError": "تم اكتشاف تعارضات بين إعدادات العارض والعملية الرئيسية. أعد تشغيل التطبيق لتطبيق التغييرات"
|
"settingsSyncError": "تم اكتشاف تعارضات بين إعدادات العارض والعملية الرئيسية. أعد تشغيل التطبيق لتطبيق التغييرات",
|
||||||
|
"invalidJson": "JSON غير صالح",
|
||||||
|
"invalidServer": "خادم غير صالح",
|
||||||
|
"localFontAccessDenied": "تم رفض الوصول إلى الخطوط المحلية"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"album": "$t(entity.album, {\"count\": 1})",
|
"album": "$t(entity.album, {\"count\": 1})",
|
||||||
@@ -372,7 +375,8 @@
|
|||||||
"sortName": "أسم الفرز",
|
"sortName": "أسم الفرز",
|
||||||
"title": "العنوان",
|
"title": "العنوان",
|
||||||
"toYear": "إلى سنة",
|
"toYear": "إلى سنة",
|
||||||
"trackNumber": "مقطع"
|
"trackNumber": "مقطع",
|
||||||
|
"isCompilation": "تجميعة"
|
||||||
},
|
},
|
||||||
"datetime": {
|
"datetime": {
|
||||||
"minuteShort": "د",
|
"minuteShort": "د",
|
||||||
@@ -413,14 +417,19 @@
|
|||||||
"input_url": "الرابط",
|
"input_url": "الرابط",
|
||||||
"input_username": "أسم المستخدم",
|
"input_username": "أسم المستخدم",
|
||||||
"success": "تمت إضافة الخادم بنجاح",
|
"success": "تمت إضافة الخادم بنجاح",
|
||||||
"title": "إضافة خادم"
|
"title": "إضافة خادم",
|
||||||
|
"input_preferInstantMix": "تفضيل الميكس الفوري",
|
||||||
|
"input_preferInstantMixDescription": "استخدم الميكس الفوري فقط للحصول على أغاني مشابهة. مفيد إذا كان لديك إضافات تعدّل هذا السلوك",
|
||||||
|
"input_remoteUrlPlaceholder": "اختياري: عنوان URL عام للميزات الخارجية"
|
||||||
},
|
},
|
||||||
"largeFetchConfirmation": {
|
"largeFetchConfirmation": {
|
||||||
"title": "أضف العناصر إلى قائمة التشغيل"
|
"title": "أضف العناصر إلى قائمة التشغيل",
|
||||||
|
"description": "سيقوم هذا الإجراء بإضافة جميع العناصر في العرض المفلتر الحالي"
|
||||||
},
|
},
|
||||||
"addToPlaylist": {
|
"addToPlaylist": {
|
||||||
"input_skipDuplicates": "تخطي العناصر المكررة",
|
"input_skipDuplicates": "تخطي العناصر المكررة",
|
||||||
"title": "أضف إلى $t(entity.playlist, {\"count\": 1})"
|
"title": "أضف إلى $t(entity.playlist, {\"count\": 1})",
|
||||||
|
"create": "إنشاء $t(entity.playlist, {\"count\": 1}) {{playlist}}"
|
||||||
},
|
},
|
||||||
"createPlaylist": {
|
"createPlaylist": {
|
||||||
"input_public": "عام"
|
"input_public": "عام"
|
||||||
@@ -428,7 +437,9 @@
|
|||||||
"createRadioStation": {
|
"createRadioStation": {
|
||||||
"input_homepageUrl": "رابط الرئيسية",
|
"input_homepageUrl": "رابط الرئيسية",
|
||||||
"input_name": "الأسم",
|
"input_name": "الأسم",
|
||||||
"input_streamUrl": "رابط البث"
|
"input_streamUrl": "رابط البث",
|
||||||
|
"success": "تم إنشاء محطة راديو جديدة بنجاح",
|
||||||
|
"title": "إنشاء محطة راديو"
|
||||||
},
|
},
|
||||||
"editRadioStation": {
|
"editRadioStation": {
|
||||||
"success": "تم تحديث محطة الراديو بنجاح"
|
"success": "تم تحديث محطة الراديو بنجاح"
|
||||||
@@ -440,7 +451,8 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "تم تحديث $t(entity.playlist, {\"count\": 1}) بنجاح",
|
"success": "تم تحديث $t(entity.playlist, {\"count\": 1}) بنجاح",
|
||||||
"title": "تعديل $t(entity.playlist, {\"count\": 1})"
|
"title": "تعديل $t(entity.playlist, {\"count\": 1})",
|
||||||
|
"publicJellyfinNote": "لسبب ما، لا يكشف Jellyfin عما إذا كانت قائمة التشغيل عامة أم لا. إذا كنت ترغب في إبقائها عامة، يرجى التأكد من تحديد الخيار التالي"
|
||||||
},
|
},
|
||||||
"lyricsExport": {
|
"lyricsExport": {
|
||||||
"export": "تصدير الكلمات",
|
"export": "تصدير الكلمات",
|
||||||
@@ -451,7 +463,32 @@
|
|||||||
},
|
},
|
||||||
"queryEditor": {
|
"queryEditor": {
|
||||||
"input_optionMatchAll": "تطابق الجميع",
|
"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",
|
"rename": "Reanomena",
|
||||||
"newVersionAvailable": "Hi ha una nova versió disponible",
|
"newVersionAvailable": "Hi ha una nova versió disponible",
|
||||||
"numberOfResults": "{{numberOfResults}} resultats",
|
"numberOfResults": "{{numberOfResults}} resultats",
|
||||||
"back": "Enrere"
|
"back": "Enrere",
|
||||||
|
"openFolder": "Obre la carpeta"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Àlbum",
|
"album_one": "Àlbum",
|
||||||
@@ -462,7 +463,7 @@
|
|||||||
"expireInvalid": "La data d'expiració ha de ser al futur",
|
"expireInvalid": "La data d'expiració ha de ser al futur",
|
||||||
"createFailed": "No s'ha pogut crear el recurs compartit (està habilitat, l'ús compartit?)",
|
"createFailed": "No s'ha pogut crear el recurs compartit (està habilitat, l'ús compartit?)",
|
||||||
"copyToClipboard": "Copiar al porta-retalls: Ctrl+C, enter",
|
"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": {
|
"updateServer": {
|
||||||
"success": "S'ha actualitzat el servidor amb èxit",
|
"success": "S'ha actualitzat el servidor amb èxit",
|
||||||
@@ -495,7 +496,12 @@
|
|||||||
"input_played": "Reprodueix el filtre",
|
"input_played": "Reprodueix el filtre",
|
||||||
"input_played_optionAll": "Totes les pistes",
|
"input_played_optionAll": "Totes les pistes",
|
||||||
"input_played_optionUnplayed": "Només les pistes sense reproduir",
|
"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": {
|
"createRadioStation": {
|
||||||
"success": "Emissora de ràdio creada amb èxit",
|
"success": "Emissora de ràdio creada amb èxit",
|
||||||
@@ -626,7 +632,7 @@
|
|||||||
"customCssEnable_description": "Permet escriure CSS personalitzat",
|
"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",
|
"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": "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": "Ruta de font personalitzada",
|
||||||
"customFontPath_description": "Estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació",
|
"customFontPath_description": "Estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació",
|
||||||
"discordApplicationId": "ID d'aplicació de {{discord}}",
|
"discordApplicationId": "ID d'aplicació de {{discord}}",
|
||||||
@@ -807,7 +813,7 @@
|
|||||||
"releaseChannel": "Canal de versions",
|
"releaseChannel": "Canal de versions",
|
||||||
"releaseChannel_description": "Trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques",
|
"releaseChannel_description": "Trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques",
|
||||||
"mediaSession": "Activa media session",
|
"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",
|
"crossfadeStyle": "Estil de fosa encadenada",
|
||||||
"discordRichPresence": "Estat d'activitat de {{discord}}",
|
"discordRichPresence": "Estat d'activitat de {{discord}}",
|
||||||
"enableAutoTranslation_description": "Activa la traducció automàtica en carregar la lletra",
|
"enableAutoTranslation_description": "Activa la traducció automàtica en carregar la lletra",
|
||||||
@@ -828,7 +834,7 @@
|
|||||||
"transcode": "Activa la transcodificació",
|
"transcode": "Activa la transcodificació",
|
||||||
"autoDJ": "DJ automàtic",
|
"autoDJ": "DJ automàtic",
|
||||||
"autoDJ_itemCount": "Número d'elements",
|
"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": "Temps",
|
||||||
"autoDJ_timing_description": "El nombre de cançons que han de quedar a la cua per activar el DJ automàtic",
|
"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",
|
"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_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": "Mode de llista de reproducció a la barra lateral",
|
||||||
"sidebarPlaylistMode_optionCompact": "Compacte",
|
"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": {
|
"table": {
|
||||||
"column": {
|
"column": {
|
||||||
@@ -1167,7 +1182,8 @@
|
|||||||
"sleepTimer_setCustom": "Configura el temporitzador",
|
"sleepTimer_setCustom": "Configura el temporitzador",
|
||||||
"sleepTimer_cancel": "Cancel·la el temporitzador",
|
"sleepTimer_cancel": "Cancel·la el temporitzador",
|
||||||
"albumRadio": "Ràdio d'àlbums",
|
"albumRadio": "Ràdio d'àlbums",
|
||||||
"scrobbleForceSubmit": "Força l'scrobble"
|
"scrobbleForceSubmit": "Força l'scrobble",
|
||||||
|
"sleepTimer_endOfAlbum": "Final de l'àlbum actual"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"credentialsRequired": "Credencials requerides",
|
"credentialsRequired": "Credencials requerides",
|
||||||
|
|||||||
+26
-10
@@ -49,7 +49,8 @@
|
|||||||
"sleepTimer_setCustom": "Nastavit časovač",
|
"sleepTimer_setCustom": "Nastavit časovač",
|
||||||
"sleepTimer_cancel": "Zrušit časovač",
|
"sleepTimer_cancel": "Zrušit časovač",
|
||||||
"albumRadio": "Rádio alba",
|
"albumRadio": "Rádio alba",
|
||||||
"scrobbleForceSubmit": "Vynutit scrobble"
|
"scrobbleForceSubmit": "Vynutit scrobble",
|
||||||
|
"sleepTimer_endOfAlbum": "Konec aktuálního alba"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
|
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
|
||||||
@@ -234,7 +235,7 @@
|
|||||||
"customCssEnable": "Povolit vlastní CSS",
|
"customCssEnable": "Povolit vlastní CSS",
|
||||||
"customCssEnable_description": "Umožnit psaní vlastního 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í",
|
"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",
|
"customCss": "Vlastní css",
|
||||||
"webAudio": "Použít webový zvuk",
|
"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",
|
"webAudio_description": "Použít webový zvuk. Tím povolíte pokročilé funkce jako ReplayGain. Zakažte, pokud se objeví problémy",
|
||||||
@@ -296,7 +297,7 @@
|
|||||||
"releaseChannel": "Kanál vydání",
|
"releaseChannel": "Kanál vydání",
|
||||||
"releaseChannel_description": "Vyberte si mezi stabilními, beta nebo alpha (nočními) vydáními pro automatické aktualizace",
|
"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": "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_description": "Exportovat a importovat nastavení pomocí souboru JSON",
|
||||||
"exportImportSettings_control_exportText": "Exportovat nastavení",
|
"exportImportSettings_control_exportText": "Exportovat nastavení",
|
||||||
"exportImportSettings_control_importText": "Importovat nastavení",
|
"exportImportSettings_control_importText": "Importovat nastavení",
|
||||||
@@ -345,7 +346,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í",
|
"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": "Automatický DJ",
|
||||||
"autoDJ_itemCount": "Počet položek",
|
"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": "Časování",
|
||||||
"autoDJ_timing_description": "Počet skladeb zbývajících ve frontě před spuštěním automatického DJ",
|
"autoDJ_timing_description": "Počet skladeb zbývajících ve frontě před spuštěním automatického DJ",
|
||||||
"logLevel": "Úroveň protokolu",
|
"logLevel": "Úroveň protokolu",
|
||||||
@@ -447,7 +448,16 @@
|
|||||||
"sidebarPlaylistMode_description": "Jak je každý seznam skladeb zobrazen v seznamu v postranní liště",
|
"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": "Režim seznamů skladeb v postranní liště",
|
||||||
"sidebarPlaylistMode_optionCompact": "Kompaktní",
|
"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": {
|
"action": {
|
||||||
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -623,7 +633,8 @@
|
|||||||
"newVersionAvailable": "Je dostupná nová verze",
|
"newVersionAvailable": "Je dostupná nová verze",
|
||||||
"numberOfResults": "{{numberOfResults}} výsledků",
|
"numberOfResults": "{{numberOfResults}} výsledků",
|
||||||
"grouping": "Seskupování",
|
"grouping": "Seskupování",
|
||||||
"back": "Zpět"
|
"back": "Zpět",
|
||||||
|
"openFolder": "Otevřít složku"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -1122,7 +1133,12 @@
|
|||||||
"input_played": "Přehrát filtr",
|
"input_played": "Přehrát filtr",
|
||||||
"input_played_optionAll": "Všechny skladby",
|
"input_played_optionAll": "Všechny skladby",
|
||||||
"input_played_optionUnplayed": "Pouze nepřehrané 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": {
|
"saveQueue": {
|
||||||
"success": "Fronta přehrávání uložena na server"
|
"success": "Fronta přehrávání uložena na server"
|
||||||
@@ -1154,7 +1170,7 @@
|
|||||||
"playlist_few": "playlisty",
|
"playlist_few": "playlisty",
|
||||||
"playlist_other": "Playlisty",
|
"playlist_other": "Playlisty",
|
||||||
"artist_one": "Umělec",
|
"artist_one": "Umělec",
|
||||||
"artist_few": "umělci",
|
"artist_few": "Umělci",
|
||||||
"artist_other": "Umělci",
|
"artist_other": "Umělci",
|
||||||
"folderWithCount_one": "{{count}} složka",
|
"folderWithCount_one": "{{count}} složka",
|
||||||
"folderWithCount_few": "{{count}} složky",
|
"folderWithCount_few": "{{count}} složky",
|
||||||
@@ -1172,7 +1188,7 @@
|
|||||||
"albumWithCount_few": "{{count}} alba",
|
"albumWithCount_few": "{{count}} alba",
|
||||||
"albumWithCount_other": "{{count}} alb",
|
"albumWithCount_other": "{{count}} alb",
|
||||||
"favorite_one": "Oblíbený",
|
"favorite_one": "Oblíbený",
|
||||||
"favorite_few": "oblíbené",
|
"favorite_few": "Oblíbené",
|
||||||
"favorite_other": "Oblíbené",
|
"favorite_other": "Oblíbené",
|
||||||
"artistWithCount_one": "{{count}} umělec",
|
"artistWithCount_one": "{{count}} umělec",
|
||||||
"artistWithCount_few": "{{count}} umělci",
|
"artistWithCount_few": "{{count}} umělci",
|
||||||
@@ -1182,7 +1198,7 @@
|
|||||||
"folder_other": "Složky",
|
"folder_other": "Složky",
|
||||||
"smartPlaylist": "Chytrý $t(entity.playlist, {\"count\": 1})",
|
"smartPlaylist": "Chytrý $t(entity.playlist, {\"count\": 1})",
|
||||||
"album_one": "Album",
|
"album_one": "Album",
|
||||||
"album_few": "alba",
|
"album_few": "Alba",
|
||||||
"album_other": "Alba",
|
"album_other": "Alba",
|
||||||
"genreWithCount_one": "{{count}} žánr",
|
"genreWithCount_one": "{{count}} žánr",
|
||||||
"genreWithCount_few": "{{count}} žánry",
|
"genreWithCount_few": "{{count}} žánry",
|
||||||
|
|||||||
+42
-33
@@ -13,7 +13,7 @@
|
|||||||
"removeFromPlaylist": "Aus $t(entity.playlist, {\"count\": 1}) entfernen",
|
"removeFromPlaylist": "Aus $t(entity.playlist, {\"count\": 1}) entfernen",
|
||||||
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) anzeigen",
|
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) anzeigen",
|
||||||
"refresh": "$t(common.refresh)",
|
"refresh": "$t(common.refresh)",
|
||||||
"removeFromQueue": "Aus wiedergabeliste entfernen",
|
"removeFromQueue": "Aus Wiedergabeliste entfernen",
|
||||||
"setRating": "Bewertung setzen",
|
"setRating": "Bewertung setzen",
|
||||||
"toggleSmartPlaylistEditor": "Editor für $t(entity.smartPlaylist) ein-/ausblenden",
|
"toggleSmartPlaylistEditor": "Editor für $t(entity.smartPlaylist) ein-/ausblenden",
|
||||||
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
|
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
|
||||||
@@ -169,7 +169,8 @@
|
|||||||
"filter_multiple": "Mehrfach",
|
"filter_multiple": "Mehrfach",
|
||||||
"retry": "Erneut versuchen",
|
"retry": "Erneut versuchen",
|
||||||
"newVersionAvailable": "Eine neue version ist verfügbar",
|
"newVersionAvailable": "Eine neue version ist verfügbar",
|
||||||
"numberOfResults": "{{numberOfResults}} ergebnisse"
|
"numberOfResults": "{{numberOfResults}} ergebnisse",
|
||||||
|
"openFolder": "Verzeichnis öffnen"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
"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",
|
"audioDeviceFetchError": "Beim Versuch, Audiogeräte abzurufen, ist ein Fehler aufgetreten",
|
||||||
"invalidServer": "Ungültiger Server",
|
"invalidServer": "Ungültiger Server",
|
||||||
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut",
|
"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",
|
"networkError": "Ein Netzwerkfehler ist aufgetreten",
|
||||||
"openError": "Datei kann nicht geöffnet werden",
|
"openError": "Datei kann nicht geöffnet werden",
|
||||||
"badValue": "Ungültige option \"{{value}}\". Dieser Wert existiert nicht mehr",
|
"badValue": "Ungültige option \"{{value}}\". Dieser Wert existiert nicht mehr",
|
||||||
"notificationDenied": "Berechtigungen über Benachrichtigungen wurden verweigert. Diese Einstellung hat keinen Effekt",
|
"notificationDenied": "Berechtigungen über Benachrichtigungen wurden verweigert. Diese Einstellung hat keinen Effekt",
|
||||||
"saveQueueFailed": "Wiedergabeliste konnte nicht gespeichert werden",
|
"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",
|
"noNetwork": "Server nicht verfügbar",
|
||||||
"noNetworkDescription": "Verbindung zum Server konnte nicht hergestellt werden",
|
"noNetworkDescription": "Verbindung zum Server konnte nicht hergestellt werden",
|
||||||
"invalidJson": "JSON ungültig",
|
"invalidJson": "JSON ungültig",
|
||||||
@@ -309,7 +310,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
|
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) erfolgreich aktualisiert",
|
"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": {
|
"lyricSearch": {
|
||||||
"title": "Songtext suche",
|
"title": "Songtext suche",
|
||||||
@@ -332,7 +333,7 @@
|
|||||||
"title": "Privater Modus"
|
"title": "Privater Modus"
|
||||||
},
|
},
|
||||||
"largeFetchConfirmation": {
|
"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"
|
"description": "Diese Aktion fügt alle Elemente in der aktuell gefilterten Ansicht hinzu"
|
||||||
},
|
},
|
||||||
"shuffleAll": {
|
"shuffleAll": {
|
||||||
@@ -347,7 +348,7 @@
|
|||||||
"input_played": "Wiedergabefilter"
|
"input_played": "Wiedergabefilter"
|
||||||
},
|
},
|
||||||
"saveQueue": {
|
"saveQueue": {
|
||||||
"success": "Wiedergabeliste auf server gespeichert"
|
"success": "Wiedergabeliste auf Server gespeichert"
|
||||||
},
|
},
|
||||||
"createRadioStation": {
|
"createRadioStation": {
|
||||||
"success": "Radiosender erfolgreich erstellt",
|
"success": "Radiosender erfolgreich erstellt",
|
||||||
@@ -368,14 +369,14 @@
|
|||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "Genre",
|
"genre_one": "Genre",
|
||||||
"genre_other": "Genres",
|
"genre_other": "Genres",
|
||||||
"playlistWithCount_one": "{{count}} wiedergabeliste",
|
"playlistWithCount_one": "{{count}} Wiedergabeliste",
|
||||||
"playlistWithCount_other": "{{count}} wiedergabelisten",
|
"playlistWithCount_other": "{{count}} Wiedergabelisten",
|
||||||
"playlist_one": "Wiedergabeliste",
|
"playlist_one": "Wiedergabeliste",
|
||||||
"playlist_other": "Wiedergabelisten",
|
"playlist_other": "Wiedergabelisten",
|
||||||
"artist_one": "Interpret",
|
"artist_one": "Interpret",
|
||||||
"artist_other": "Interpreten",
|
"artist_other": "Interpreten",
|
||||||
"folderWithCount_one": "{{count}} verzeichnis",
|
"folderWithCount_one": "{{count}} Verzeichnis",
|
||||||
"folderWithCount_other": "{{count}} verzeichnisse",
|
"folderWithCount_other": "{{count}} Verzeichnisse",
|
||||||
"albumArtist_one": "Albuminterpret",
|
"albumArtist_one": "Albuminterpret",
|
||||||
"albumArtist_other": "Albuminterpreten",
|
"albumArtist_other": "Albuminterpreten",
|
||||||
"track_one": "Track",
|
"track_one": "Track",
|
||||||
@@ -552,9 +553,9 @@
|
|||||||
"privateModeOff": "Privaten Modus deaktivieren",
|
"privateModeOff": "Privaten Modus deaktivieren",
|
||||||
"privateModeOn": "Privaten Modus aktivieren",
|
"privateModeOn": "Privaten Modus aktivieren",
|
||||||
"commandPalette": "Kommandopalette öffnen",
|
"commandPalette": "Kommandopalette öffnen",
|
||||||
"selectMusicFolder": "Musikordner wählen",
|
"selectMusicFolder": "Musikverzeichnis wählen",
|
||||||
"noMusicFolder": "Kein musikordner gewählt",
|
"noMusicFolder": "Kein Musikverzeichnis gewählt",
|
||||||
"multipleMusicFolders": "{{count}} musikordner ausgewählt"
|
"multipleMusicFolders": "{{count}} Musikverzeichnis ausgewählt"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"mostPlayed": "Meistgespielt",
|
"mostPlayed": "Meistgespielt",
|
||||||
@@ -681,7 +682,7 @@
|
|||||||
"topSongs": "Toplieder",
|
"topSongs": "Toplieder",
|
||||||
"relatedArtists": "Ähnliche $t(entity.artist, {\"count\": 2})",
|
"relatedArtists": "Ähnliche $t(entity.artist, {\"count\": 2})",
|
||||||
"groupingTypeAll": "Alle veröffentlichungsformate",
|
"groupingTypeAll": "Alle veröffentlichungsformate",
|
||||||
"groupingTypePrimary": "Primäre veröffentlichungsformate",
|
"groupingTypePrimary": "Primäre Veröffentlichungsformate",
|
||||||
"favoriteSongs": "Lieblingslieder",
|
"favoriteSongs": "Lieblingslieder",
|
||||||
"favoriteSongsFrom": "Liebslingslieder von {{title}}",
|
"favoriteSongsFrom": "Liebslingslieder von {{title}}",
|
||||||
"topSongsCommunity": "Community",
|
"topSongsCommunity": "Community",
|
||||||
@@ -761,8 +762,8 @@
|
|||||||
"addLastShuffled": "Als Letztes (zufällige Wiedergabe)",
|
"addLastShuffled": "Als Letztes (zufällige Wiedergabe)",
|
||||||
"addNextShuffled": "Als Nächstes (zufällige Wiedergabe)",
|
"addNextShuffled": "Als Nächstes (zufällige Wiedergabe)",
|
||||||
"holdToShuffle": "Halten für zufallswiedergabe",
|
"holdToShuffle": "Halten für zufallswiedergabe",
|
||||||
"restoreQueueFromServer": "Wiedergabeliste von server wiederherstellen",
|
"restoreQueueFromServer": "Wiedergabeliste von Server wiederherstellen",
|
||||||
"saveQueueToServer": "Wiedergabeliste auf server speichern",
|
"saveQueueToServer": "Wiedergabeliste auf Server speichern",
|
||||||
"lyrics": "Songtexte",
|
"lyrics": "Songtexte",
|
||||||
"artistRadio": "Künstler radio",
|
"artistRadio": "Künstler radio",
|
||||||
"sleepTimer_endOfSong": "Ende des aktuellen liedes",
|
"sleepTimer_endOfSong": "Ende des aktuellen liedes",
|
||||||
@@ -900,13 +901,13 @@
|
|||||||
"sidebarPlaylistSorting": "Wiedergabelisten-sortierung in der seitenleiste",
|
"sidebarPlaylistSorting": "Wiedergabelisten-sortierung in der seitenleiste",
|
||||||
"minimizeToTray": "Zur taskleiste minimieren",
|
"minimizeToTray": "Zur taskleiste minimieren",
|
||||||
"skipPlaylistPage": "Wiedergabeliste-seite überspringen",
|
"skipPlaylistPage": "Wiedergabeliste-seite überspringen",
|
||||||
"themeDark": "Erscheinungsbild (dunkel)",
|
"themeDark": "Design (dunkel)",
|
||||||
"sidebarCollapsedNavigation": "Navigation in der seitenleiste (komprimiert)",
|
"sidebarCollapsedNavigation": "Navigation in der seitenleiste (komprimiert)",
|
||||||
"gaplessAudio_optionWeak": "Schwach (empfohlen)",
|
"gaplessAudio_optionWeak": "Schwach (empfohlen)",
|
||||||
"minimumScrobbleSeconds": "Minimum scrobble-dauer (sekunden)",
|
"minimumScrobbleSeconds": "Minimum scrobble-dauer (sekunden)",
|
||||||
"hotkey_playbackStop": "Stoppen",
|
"hotkey_playbackStop": "Stoppen",
|
||||||
"savePlayQueue_description": "Speichert die Wiedergabeliste beim Schließen der Anwendung, und stellt diese wieder her, wenn die Anwendung geöffnet wird",
|
"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",
|
"enableRemote_description": "Aktiviert den Server für die Fernsteuerung, damit andere Geräte die Anwendung steuern können",
|
||||||
"fontType_optionSystem": "System schriftart",
|
"fontType_optionSystem": "System schriftart",
|
||||||
"discordUpdateInterval": "{{discord}} rich presence aktualisierungsintervall",
|
"discordUpdateInterval": "{{discord}} rich presence aktualisierungsintervall",
|
||||||
@@ -922,7 +923,7 @@
|
|||||||
"fontType": "Schriftartenquelle",
|
"fontType": "Schriftartenquelle",
|
||||||
"followLyric": "Aktuellen songtext synchronisieren",
|
"followLyric": "Aktuellen songtext synchronisieren",
|
||||||
"font_description": "Wähle die Schriftart für die Anwendung",
|
"font_description": "Wähle die Schriftart für die Anwendung",
|
||||||
"themeLight": "Erscheinungsbild (hell)",
|
"themeLight": "Design (hell)",
|
||||||
"sidePlayQueueStyle_optionDetached": "Lösgelöst",
|
"sidePlayQueueStyle_optionDetached": "Lösgelöst",
|
||||||
"windowBarStyle_description": "Legt das Erscheinungsbild des Fensterrahmens fest",
|
"windowBarStyle_description": "Legt das Erscheinungsbild des Fensterrahmens fest",
|
||||||
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) zu favoriten hinzufügen",
|
"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",
|
"albumBackgroundBlur_description": "Passt die Stärke der Unschärfe an, welche auf das Hintergrundbild des Albums angewandt wird",
|
||||||
"clearCacheSuccess": "Cache erfolgreich geleert",
|
"clearCacheSuccess": "Cache erfolgreich geleert",
|
||||||
"contextMenu": "Kontextmenü-einstellungen (rechtsklick)",
|
"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": "Künstler hintergrundbild",
|
||||||
"artistBackground_description": "Fügt ein Hintergrundbild für die Künstlerseite hinzu",
|
"artistBackground_description": "Fügt ein Hintergrundbild für die Künstlerseite hinzu",
|
||||||
"artistConfiguration": "Künstler albumseite konfiguration",
|
"artistConfiguration": "Künstler albumseite konfiguration",
|
||||||
@@ -980,8 +981,8 @@
|
|||||||
"logLevel_optionWarn": "Warnung",
|
"logLevel_optionWarn": "Warnung",
|
||||||
"autoDJ": "Auto DJ",
|
"autoDJ": "Auto DJ",
|
||||||
"autoDJ_itemCount": "Anzahl",
|
"autoDJ_itemCount": "Anzahl",
|
||||||
"autoDJ_itemCount_description": "Die anzahl der lieder, die bei aktiviertem auto DJ zur wiedergabeliste hinzugefügt werden sollen",
|
"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_description": "Die Anzahl der Lieder, die sich noch in der Wiedergabeliste befinden, bevor Auto-DJ ausgelöst wird",
|
||||||
"autoDJ_timing": "Timing",
|
"autoDJ_timing": "Timing",
|
||||||
"discordDisplayType": "{{discord}} presence darstellungsart",
|
"discordDisplayType": "{{discord}} presence darstellungsart",
|
||||||
"discordLinkType_mbz_lastfm": "{{musicbrainz}} mit {{lastfm}} als ersatz",
|
"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",
|
"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",
|
"contextMenu_description": "Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet",
|
||||||
"crossfadeStyle": "Art der überblende",
|
"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",
|
"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_optionBeta": "Beta",
|
||||||
"releaseChannel_optionLatest": "Stabil",
|
"releaseChannel_optionLatest": "Stabil",
|
||||||
@@ -1064,7 +1065,7 @@
|
|||||||
"automaticUpdates": "Automatische updates",
|
"automaticUpdates": "Automatische updates",
|
||||||
"automaticUpdates_description": "Updates automatisch suchen und installieren",
|
"automaticUpdates_description": "Updates automatisch suchen und installieren",
|
||||||
"releaseChannel_optionAlpha": "Alpha (nightly)",
|
"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",
|
"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",
|
"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",
|
"homeConfiguration_description": "Konfigurieren, welche Elemente und in welcher Reihenfolge diese auf der Startseite angezeigt werden",
|
||||||
@@ -1111,14 +1112,14 @@
|
|||||||
"queryBuilder": "Abfrage-editor",
|
"queryBuilder": "Abfrage-editor",
|
||||||
"queryBuilderCustomFields_inputLabel": "Label",
|
"queryBuilderCustomFields_inputLabel": "Label",
|
||||||
"queryBuilderCustomFields_description": "Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu",
|
"queryBuilderCustomFields_description": "Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu",
|
||||||
"autosave": "Automatisch aktuelle wiedergabeliste speichern",
|
"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.",
|
"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": "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",
|
"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",
|
"useThemeAccentColor_description": "Verwendet die primäre Farbe des gewählten Designs",
|
||||||
"useThemePrimaryShade": "Primärschatten des themas nutzen",
|
"useThemePrimaryShade": "Standard Farbton übernehmen",
|
||||||
"useThemePrimaryShade_description": "Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten",
|
"useThemePrimaryShade_description": "Verwendet den primären Farbton des ausgewählten Designs für die Primärfarbvarianten",
|
||||||
"primaryShade": "Primärschatten",
|
"primaryShade": "Primärer Farbton",
|
||||||
"listenbrainz": "ListenBrainz Links anzeigen",
|
"listenbrainz": "ListenBrainz Links anzeigen",
|
||||||
"listenbrainz_description": "Zeige Links zu ListenBrainz auf den Interpreten/Alben Seiten",
|
"listenbrainz_description": "Zeige Links zu ListenBrainz auf den Interpreten/Alben Seiten",
|
||||||
"mpvExtraParameters": "Zusätzliche mpv parameter",
|
"mpvExtraParameters": "Zusätzliche mpv parameter",
|
||||||
@@ -1133,7 +1134,15 @@
|
|||||||
"nativeSpotify_description": "In der Spotify app statt im browser öffnen",
|
"nativeSpotify_description": "In der Spotify app statt im browser öffnen",
|
||||||
"imageResolution_optionFullScreenPlayer": "Wiedergabe im vollbildmodus",
|
"imageResolution_optionFullScreenPlayer": "Wiedergabe im vollbildmodus",
|
||||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
"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": {
|
"dragDropZone": {
|
||||||
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
|
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
|
||||||
|
|||||||
@@ -699,6 +699,7 @@
|
|||||||
"viewQueue": "View queue",
|
"viewQueue": "View queue",
|
||||||
"sleepTimer": "Sleep timer",
|
"sleepTimer": "Sleep timer",
|
||||||
"sleepTimer_endOfSong": "End of current song",
|
"sleepTimer_endOfSong": "End of current song",
|
||||||
|
"sleepTimer_endOfAlbum": "End of current album",
|
||||||
"sleepTimer_minutes": "{{count}} min",
|
"sleepTimer_minutes": "{{count}} min",
|
||||||
"sleepTimer_hours": "{{count}} hr",
|
"sleepTimer_hours": "{{count}} hr",
|
||||||
"sleepTimer_custom": "Custom",
|
"sleepTimer_custom": "Custom",
|
||||||
@@ -1093,7 +1094,7 @@
|
|||||||
"sidePlayQueueLayout_description": "Sets the layout of the attached side play queue",
|
"sidePlayQueueLayout_description": "Sets the layout of the attached side play queue",
|
||||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||||
"sidePlayQueueLayout_optionVertical": "Vertical",
|
"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",
|
"mediaSession": "Enable media session",
|
||||||
"sidePlayQueueStyle": "Side play queue style",
|
"sidePlayQueueStyle": "Side play queue style",
|
||||||
"skipDuration_description": "Sets the duration to skip when using the skip buttons on the player bar",
|
"skipDuration_description": "Sets the duration to skip when using the skip buttons on the player bar",
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
"sleepTimer_endOfSong": "Fin de la canción actual",
|
"sleepTimer_endOfSong": "Fin de la canción actual",
|
||||||
"sleepTimer": "Temporizador de apagado",
|
"sleepTimer": "Temporizador de apagado",
|
||||||
"albumRadio": "Radio del álbum",
|
"albumRadio": "Radio del álbum",
|
||||||
"scrobbleForceSubmit": "Forzar scrobble"
|
"scrobbleForceSubmit": "Forzar scrobble",
|
||||||
|
"sleepTimer_endOfAlbum": "Fin del álbum actual"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
|
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
|
||||||
@@ -235,7 +236,7 @@
|
|||||||
"customCssEnable_description": "Permite escribir CSS personalizado",
|
"customCssEnable_description": "Permite escribir CSS personalizado",
|
||||||
"customCss": "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",
|
"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": "Usar audio web",
|
||||||
"webAudio_description": "Utilizar audio web. Esto habilita funciones avanzadas como ReplayGain. Desactiva esta opción si tienes problemas",
|
"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",
|
"transcode_description": "Permite la transcodificación a distintos formatos",
|
||||||
@@ -296,7 +297,7 @@
|
|||||||
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
|
"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",
|
"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": "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_description": "Exporta e importa la configuración a través de JSON",
|
||||||
"exportImportSettings_control_exportText": "Exportar configuración",
|
"exportImportSettings_control_exportText": "Exportar configuración",
|
||||||
"exportImportSettings_control_importText": "Importar configuración",
|
"exportImportSettings_control_importText": "Importar configuración",
|
||||||
@@ -345,7 +346,7 @@
|
|||||||
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
|
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
|
||||||
"autoDJ": "DJ Automático",
|
"autoDJ": "DJ Automático",
|
||||||
"autoDJ_itemCount": "Recuento de elementos",
|
"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_description": "El número de canciones restantes en la cola antes de que DJ automático se dispare",
|
||||||
"autoDJ_timing": "Tiempo",
|
"autoDJ_timing": "Tiempo",
|
||||||
"logLevel": "Nivel de registro",
|
"logLevel": "Nivel de registro",
|
||||||
@@ -447,7 +448,16 @@
|
|||||||
"sidebarPlaylistMode_optionCompact": "Compacto",
|
"sidebarPlaylistMode_optionCompact": "Compacto",
|
||||||
"sidebarPlaylistMode_optionExpanded": "Expandido",
|
"sidebarPlaylistMode_optionExpanded": "Expandido",
|
||||||
"sidebarPlaylistMode_description": "Cómo se muestra cada lista de reproducción en la lista de la barra lateral",
|
"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": {
|
"action": {
|
||||||
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -623,7 +633,8 @@
|
|||||||
"newVersionAvailable": "Una nueva versión está disponible",
|
"newVersionAvailable": "Una nueva versión está disponible",
|
||||||
"numberOfResults": "{{numberOfResults}} resultados",
|
"numberOfResults": "{{numberOfResults}} resultados",
|
||||||
"grouping": "Agrupar",
|
"grouping": "Agrupar",
|
||||||
"back": "Atrás"
|
"back": "Atrás",
|
||||||
|
"openFolder": "Abrir carpeta"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "Reiniciar el servidor para aplicar el nuevo puerto",
|
"remotePortWarning": "Reiniciar el servidor para aplicar el nuevo puerto",
|
||||||
@@ -1013,7 +1024,12 @@
|
|||||||
"input_played": "Reproducir filtro",
|
"input_played": "Reproducir filtro",
|
||||||
"input_played_optionAll": "Todas las pistas",
|
"input_played_optionAll": "Todas las pistas",
|
||||||
"input_played_optionUnplayed": "Solo las pistas sin reproducir",
|
"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": {
|
"saveQueue": {
|
||||||
"success": "Cola de reproducción guardada en el servidor"
|
"success": "Cola de reproducción guardada en el servidor"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,8 @@
|
|||||||
"sleepTimer_setCustom": "Définir le minuteur",
|
"sleepTimer_setCustom": "Définir le minuteur",
|
||||||
"sleepTimer_cancel": "Annuler le minuteur",
|
"sleepTimer_cancel": "Annuler le minuteur",
|
||||||
"albumRadio": "Radio d'album",
|
"albumRadio": "Radio d'album",
|
||||||
"scrobbleForceSubmit": "Forcer le scrobble"
|
"scrobbleForceSubmit": "Forcer le scrobble",
|
||||||
|
"sleepTimer_endOfAlbum": "Fin de l'album actuel"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -226,7 +227,8 @@
|
|||||||
"rename": "Renommer",
|
"rename": "Renommer",
|
||||||
"newVersionAvailable": "Une nouvelle version est disponible",
|
"newVersionAvailable": "Une nouvelle version est disponible",
|
||||||
"numberOfResults": "{{numberOfResults}} résultats",
|
"numberOfResults": "{{numberOfResults}} résultats",
|
||||||
"back": "Retour"
|
"back": "Retour",
|
||||||
|
"openFolder": "Ouvrir le dossier"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
|
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
|
||||||
@@ -728,7 +730,7 @@
|
|||||||
"translationTargetLanguage": "Langue cible de traduction",
|
"translationTargetLanguage": "Langue cible de traduction",
|
||||||
"trayEnabled": "Afficher la barre d’état système",
|
"trayEnabled": "Afficher la barre d’état système",
|
||||||
"translationApiProvider_description": "Fournisseur d'API pour la traduction",
|
"translationApiProvider_description": "Fournisseur d'API pour la traduction",
|
||||||
"customCss_description": "Contenu CSS personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement",
|
"customCss_description": "Contenu CSS personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement. Application de Bureau uniquement: feishin lit et écrit le fichier custom.css dans le répertoire de configuration de l'application et le recharge lorsque celui-ci est modifié",
|
||||||
"translationApiKey": "Clé API de traduction",
|
"translationApiKey": "Clé API de traduction",
|
||||||
"translationTargetLanguage_description": "Langue cible pour la traduction",
|
"translationTargetLanguage_description": "Langue cible pour la traduction",
|
||||||
"trayEnabled_description": "Afficher/masquer l’icône/le menu dans la barre d’état système. si désactivé, désactive également la réduction/fermeture vers la barre d’état système",
|
"trayEnabled_description": "Afficher/masquer l’icône/le menu dans la barre d’état système. si désactivé, désactive également la réduction/fermeture vers la barre d’état système",
|
||||||
@@ -813,7 +815,7 @@
|
|||||||
"queryBuilderCustomFields_description": "Ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
|
"queryBuilderCustomFields_description": "Ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
|
||||||
"autoDJ": "DJ auto",
|
"autoDJ": "DJ auto",
|
||||||
"autoDJ_itemCount": "Nombre d'entrée",
|
"autoDJ_itemCount": "Nombre d'entrée",
|
||||||
"autoDJ_itemCount_description": "Le nombre d'entrées tentées d'être ajoutées à la file d'attente lorsque le DJ auto est activé",
|
"autoDJ_itemCount_description": "Le nombre d'entrées tentées d'être ajoutées à la file d'attente",
|
||||||
"autoDJ_timing": "Timing",
|
"autoDJ_timing": "Timing",
|
||||||
"autoDJ_timing_description": "Le nombre de titres restant dans la file d'attente avant le déclenchement du DJ auto",
|
"autoDJ_timing_description": "Le nombre de titres restant dans la file d'attente avant le déclenchement du DJ auto",
|
||||||
"followCurrentSong_description": "Défiler automatiquement la file d'attente jusqu'au titre en cours",
|
"followCurrentSong_description": "Défiler automatiquement la file d'attente jusqu'au titre en cours",
|
||||||
@@ -915,7 +917,16 @@
|
|||||||
"sidebarPlaylistFolderTreeIndent": "Indentation de l'arbre",
|
"sidebarPlaylistFolderTreeIndent": "Indentation de l'arbre",
|
||||||
"sidebarPlaylistMode_description": "Comment chaque liste de lecture est affichée dans la barre latérale",
|
"sidebarPlaylistMode_description": "Comment chaque liste de lecture est affichée dans la barre latérale",
|
||||||
"sidebarPlaylistMode": "Mode de liste de lecture de la barre latérale",
|
"sidebarPlaylistMode": "Mode de liste de lecture de la barre latérale",
|
||||||
"sidebarPlaylistMode_optionCompact": "Compacte"
|
"sidebarPlaylistMode_optionCompact": "Compacte",
|
||||||
|
"autoDJ_mode": "Mode",
|
||||||
|
"autoDJ_mode_albums": "Albums",
|
||||||
|
"autoDJ_mode_description": "Choisissez d'ajouter des titres ou des albums entiers à la file d'attente",
|
||||||
|
"autoDJ_mode_songs": "Titres",
|
||||||
|
"autoDJ_enabled": "Activer le DJ auto",
|
||||||
|
"autoDJ_albumStrategy": "Mode de sélection d'album",
|
||||||
|
"autoDJ_songStrategy": "Mode de sélection de titre",
|
||||||
|
"autoDJ_strategy_option_library_random": "Aléatoire",
|
||||||
|
"autoDJ_strategy_option_similar": "Similaire"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
@@ -1009,7 +1020,12 @@
|
|||||||
"input_played": "Filtre de lecture",
|
"input_played": "Filtre de lecture",
|
||||||
"input_played_optionAll": "Toutes les pistes",
|
"input_played_optionAll": "Toutes les pistes",
|
||||||
"input_played_optionUnplayed": "Seulement les pistes non jouées",
|
"input_played_optionUnplayed": "Seulement les pistes non jouées",
|
||||||
"input_played_optionPlayed": "Seulement les pistes jouées"
|
"input_played_optionPlayed": "Seulement les pistes jouées",
|
||||||
|
"input_kind_songs": "Titres",
|
||||||
|
"input_kind_albums": "Albums",
|
||||||
|
"input_kind": "Sélections aléatoires",
|
||||||
|
"input_limit_albums": "Combien d'albums ?",
|
||||||
|
"input_limit_songs": "Combien de titres ?"
|
||||||
},
|
},
|
||||||
"createRadioStation": {
|
"createRadioStation": {
|
||||||
"success": "Station radio créée avec succès",
|
"success": "Station radio créée avec succès",
|
||||||
|
|||||||
+39
-23
@@ -49,7 +49,8 @@
|
|||||||
"albumRadio": "アルバム・ラジオ",
|
"albumRadio": "アルバム・ラジオ",
|
||||||
"artistRadio": "アーティストラジオ",
|
"artistRadio": "アーティストラジオ",
|
||||||
"trackRadio": "ラジオを追跡する",
|
"trackRadio": "ラジオを追跡する",
|
||||||
"scrobbleForceSubmit": "強制 Scrobble"
|
"scrobbleForceSubmit": "強制 Scrobble",
|
||||||
|
"sleepTimer_endOfAlbum": "現在のアルバムの終了"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
||||||
@@ -430,12 +431,21 @@
|
|||||||
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
|
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
|
||||||
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
|
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
|
||||||
"preventSuspendOnPlayback": "再生の中断を防止する",
|
"preventSuspendOnPlayback": "再生の中断を防止する",
|
||||||
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
|
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示",
|
||||||
|
"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": "類似"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||||
"goToPage": "ページへ移動",
|
"goToPage": "ページへ移動",
|
||||||
"moveToTop": "先頭に移動",
|
"moveToTop": "一番上へ移動",
|
||||||
"clearQueue": "キューをクリア",
|
"clearQueue": "キューをクリア",
|
||||||
"addToFavorites": "$t(entity.favorite, {\"count\": 2}) に追加",
|
"addToFavorites": "$t(entity.favorite, {\"count\": 2}) に追加",
|
||||||
"addToPlaylist": "$t(entity.playlist, {\"count\": 1}) に追加",
|
"addToPlaylist": "$t(entity.playlist, {\"count\": 1}) に追加",
|
||||||
@@ -446,9 +456,9 @@
|
|||||||
"deletePlaylist": "$t(entity.playlist, {\"count\": 1}) を削除",
|
"deletePlaylist": "$t(entity.playlist, {\"count\": 1}) を削除",
|
||||||
"removeFromQueue": "キューから削除",
|
"removeFromQueue": "キューから削除",
|
||||||
"deselectAll": "すべて選択解除",
|
"deselectAll": "すべて選択解除",
|
||||||
"moveToBottom": "末尾に移動",
|
"moveToBottom": "一番下へ移動",
|
||||||
"setRating": "評価を設定する",
|
"setRating": "評価を設定",
|
||||||
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディタの切り替え",
|
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディターを切り替え",
|
||||||
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
|
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Last.fm で開く",
|
"lastfm": "Last.fm で開く",
|
||||||
@@ -457,9 +467,9 @@
|
|||||||
"listenbrainz": "ListenBrainz で開く",
|
"listenbrainz": "ListenBrainz で開く",
|
||||||
"qobuz": "Qobuz で開く"
|
"qobuz": "Qobuz で開く"
|
||||||
},
|
},
|
||||||
"moveToNext": "次",
|
"moveToNext": "次へ進む",
|
||||||
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
|
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
|
||||||
"moveItems": "曲を移動",
|
"moveItems": "項目を移動",
|
||||||
"shuffle": "シャッフル",
|
"shuffle": "シャッフル",
|
||||||
"shuffleAll": "すべてシャッフル",
|
"shuffleAll": "すべてシャッフル",
|
||||||
"shuffleSelected": "選択した曲をシャッフル",
|
"shuffleSelected": "選択した曲をシャッフル",
|
||||||
@@ -471,28 +481,28 @@
|
|||||||
"moveDown": "下に移動",
|
"moveDown": "下に移動",
|
||||||
"holdToMoveToTop": "押し続けると一番上に移動します",
|
"holdToMoveToTop": "押し続けると一番上に移動します",
|
||||||
"holdToMoveToBottom": "押し続けると一番下に移動します",
|
"holdToMoveToBottom": "押し続けると一番下に移動します",
|
||||||
"openApplicationDirectory": "アプリケーションディレクトリを開く",
|
"openApplicationDirectory": "アプリディレクトリを開く",
|
||||||
"selectRangeOfItems": "項目の範囲を選択",
|
"selectRangeOfItems": "項目の範囲を選択",
|
||||||
"addOrRemoveFromSelection": "選択に追加または削除",
|
"addOrRemoveFromSelection": "選択に追加または選択から除外",
|
||||||
"goToCurrent": "現在の項目へ移動",
|
"goToCurrent": "現在の項目へ移動",
|
||||||
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
|
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
|
||||||
"expandAllFolders": "すべてのフォルダーを展開する"
|
"expandAllFolders": "すべてのフォルダーを展開する"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "戻る",
|
"backward": "逆行",
|
||||||
"increase": "増加",
|
"increase": "増加",
|
||||||
"rating": "評価",
|
"rating": "評価",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"refresh": "再読み込み",
|
"refresh": "再読み込み",
|
||||||
"unknown": "不明",
|
"unknown": "不明",
|
||||||
"areYouSure": "実行しますか?",
|
"areYouSure": "実行してもよろしいですか?",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"favorite": "お気に入り",
|
"favorite": "お気に入り",
|
||||||
"left": "左側",
|
"left": "左側",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"right": "右側",
|
"right": "右側",
|
||||||
"currentSong": "現在の $t(entity.track, {\"count\": 1})",
|
"currentSong": "現在の $t(entity.track, {\"count\": 1})",
|
||||||
"collapse": "折りたたみ",
|
"collapse": "折りたたむ",
|
||||||
"trackNumber": "トラック",
|
"trackNumber": "トラック",
|
||||||
"descending": "降順",
|
"descending": "降順",
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
@@ -534,7 +544,7 @@
|
|||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"resetToDefault": "デフォルトにリセット",
|
"resetToDefault": "デフォルトにリセット",
|
||||||
"home": "ホーム",
|
"home": "ホーム",
|
||||||
"comingSoon": "近日利用可能になる予定です…",
|
"comingSoon": "近日公開…",
|
||||||
"reset": "リセット",
|
"reset": "リセット",
|
||||||
"channel_other": "チャンネル",
|
"channel_other": "チャンネル",
|
||||||
"disable": "無効",
|
"disable": "無効",
|
||||||
@@ -543,7 +553,7 @@
|
|||||||
"menu": "メニュー",
|
"menu": "メニュー",
|
||||||
"restartRequired": "再起動が必要です",
|
"restartRequired": "再起動が必要です",
|
||||||
"previousSong": "前の $t(entity.track, {\"count\": 1})",
|
"previousSong": "前の $t(entity.track, {\"count\": 1})",
|
||||||
"noResultsFromQuery": "条件にマッチするものがありません",
|
"noResultsFromQuery": "クエリに一致する結果がありません",
|
||||||
"quit": "終了",
|
"quit": "終了",
|
||||||
"expand": "展開",
|
"expand": "展開",
|
||||||
"search": "検索",
|
"search": "検索",
|
||||||
@@ -553,11 +563,11 @@
|
|||||||
"random": "ランダム",
|
"random": "ランダム",
|
||||||
"size": "サイズ",
|
"size": "サイズ",
|
||||||
"biography": "バイオグラフィー",
|
"biography": "バイオグラフィー",
|
||||||
"note": "ノート",
|
"note": "注記",
|
||||||
"explicitStatus": "明示的なステータス",
|
"explicitStatus": "明示的なステータス",
|
||||||
"additionalParticipants": "追加参加者",
|
"additionalParticipants": "追加参加者",
|
||||||
"newVersion": "新しいバージョン ({{version}}) がインストールされました",
|
"newVersion": "新しいバージョン ({{version}}) がインストールされました",
|
||||||
"viewReleaseNotes": "リリースノートを表示する",
|
"viewReleaseNotes": "リリースノートを表示",
|
||||||
"bitDepth": "ビット深度",
|
"bitDepth": "ビット深度",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"codec": "コーデック",
|
"codec": "コーデック",
|
||||||
@@ -565,7 +575,7 @@
|
|||||||
"sampleRate": "サンプルレート",
|
"sampleRate": "サンプルレート",
|
||||||
"preview": "プレビュー",
|
"preview": "プレビュー",
|
||||||
"private": "プライベート",
|
"private": "プライベート",
|
||||||
"public": "パブリック",
|
"public": "公開",
|
||||||
"share": "共有",
|
"share": "共有",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
"trackGain": "トラックゲイン",
|
"trackGain": "トラックゲイン",
|
||||||
@@ -598,7 +608,8 @@
|
|||||||
"newVersionAvailable": "新しいバージョンが利用可能です",
|
"newVersionAvailable": "新しいバージョンが利用可能です",
|
||||||
"numberOfResults": "{{numberOfResults}} 件の結果",
|
"numberOfResults": "{{numberOfResults}} 件の結果",
|
||||||
"grouping": "グループ化",
|
"grouping": "グループ化",
|
||||||
"back": "戻る"
|
"back": "戻る",
|
||||||
|
"openFolder": "フォルダーを開く"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -710,7 +721,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "新たなポート設定を適用するためサーバーを再起動してください",
|
"remotePortWarning": "新しいポートの設定を反映させるには、サーバーを再起動してください",
|
||||||
"systemFontError": "システムフォントを取得する際にエラーが発生しました",
|
"systemFontError": "システムフォントを取得する際にエラーが発生しました",
|
||||||
"playbackError": "メディアの再生開始時にエラーが発生しました",
|
"playbackError": "メディアの再生開始時にエラーが発生しました",
|
||||||
"remotePortError": "リモートサーバーのポート設定時にエラーが発生しました",
|
"remotePortError": "リモートサーバーのポート設定時にエラーが発生しました",
|
||||||
@@ -725,7 +736,7 @@
|
|||||||
"serverNotSelectedError": "サーバーが選択されていません",
|
"serverNotSelectedError": "サーバーが選択されていません",
|
||||||
"remoteDisableError": "リモートサーバーを$t(common.disable)にする際にエラーが発生しました",
|
"remoteDisableError": "リモートサーバーを$t(common.disable)にする際にエラーが発生しました",
|
||||||
"mpvRequired": "MPV が必要です",
|
"mpvRequired": "MPV が必要です",
|
||||||
"audioDeviceFetchError": "オーディオデバイスの取得時にエラーが発生しました",
|
"audioDeviceFetchError": "オーディオデバイスを取得しようとした際にエラーが発生しました",
|
||||||
"invalidServer": "無効なサーバー",
|
"invalidServer": "無効なサーバー",
|
||||||
"loginRateError": "ログイン試行回数が多すぎます。数秒後に再試行してください",
|
"loginRateError": "ログイン試行回数が多すぎます。数秒後に再試行してください",
|
||||||
"endpointNotImplementedError": "{{serverType}} にはエンドポイント {{endpoint}} が実装されていません",
|
"endpointNotImplementedError": "{{serverType}} にはエンドポイント {{endpoint}} が実装されていません",
|
||||||
@@ -733,7 +744,7 @@
|
|||||||
"networkError": "ネットワークエラーが発生しました",
|
"networkError": "ネットワークエラーが発生しました",
|
||||||
"notificationDenied": "通知の許可が拒否されました。この設定は効果がありません",
|
"notificationDenied": "通知の許可が拒否されました。この設定は効果がありません",
|
||||||
"openError": "ファイルを開けませんでした",
|
"openError": "ファイルを開けませんでした",
|
||||||
"badValue": "無効なオプション「{{value}}」。この値は存在しません",
|
"badValue": "無効なオプション「{{value}}」です。この値は存在しません",
|
||||||
"multipleServerSaveQueueError": "再生キューに現在のサーバーに存在しない曲が 1 曲以上あります。これはサポートされていません",
|
"multipleServerSaveQueueError": "再生キューに現在のサーバーに存在しない曲が 1 曲以上あります。これはサポートされていません",
|
||||||
"noNetwork": "サーバーが利用できません",
|
"noNetwork": "サーバーが利用できません",
|
||||||
"noNetworkDescription": "このサーバーに接続できませんでした",
|
"noNetworkDescription": "このサーバーに接続できませんでした",
|
||||||
@@ -1109,7 +1120,12 @@
|
|||||||
"input_played_optionAll": "すべてのトラック",
|
"input_played_optionAll": "すべてのトラック",
|
||||||
"input_played_optionUnplayed": "未再生のトラックのみ",
|
"input_played_optionUnplayed": "未再生のトラックのみ",
|
||||||
"input_played_optionPlayed": "再生されたトラックのみ",
|
"input_played_optionPlayed": "再生されたトラックのみ",
|
||||||
"input_played": "再生フィルター"
|
"input_played": "再生フィルター",
|
||||||
|
"input_kind_albums": "アルバム",
|
||||||
|
"input_kind_songs": "曲",
|
||||||
|
"input_kind": "ランダムピック",
|
||||||
|
"input_limit_albums": "アルバムは何枚ですか?",
|
||||||
|
"input_limit_songs": "何曲ですか?"
|
||||||
},
|
},
|
||||||
"saveQueue": {
|
"saveQueue": {
|
||||||
"success": "プレイキューをサーバーに保存しました"
|
"success": "プレイキューをサーバーに保存しました"
|
||||||
|
|||||||
+244
-24
@@ -17,7 +17,10 @@
|
|||||||
"removeFromPlaylist": "$t(entity.playlist, {\"count\": 1})에서 제거",
|
"removeFromPlaylist": "$t(entity.playlist, {\"count\": 1})에서 제거",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"musicbrainz": "MusicBrainz에서 보기",
|
"musicbrainz": "MusicBrainz에서 보기",
|
||||||
"lastfm": "Last.fm에서 보기"
|
"lastfm": "Last.fm에서 보기",
|
||||||
|
"listenbrainz": "ListenBrainz에서 열기",
|
||||||
|
"qobuz": "Qobuz에서 열기",
|
||||||
|
"spotify": "Spotify에서 열기"
|
||||||
},
|
},
|
||||||
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) 보기",
|
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) 보기",
|
||||||
"setRating": "평점 지정",
|
"setRating": "평점 지정",
|
||||||
@@ -37,7 +40,10 @@
|
|||||||
"shuffleAll": "모두 섞기",
|
"shuffleAll": "모두 섞기",
|
||||||
"shuffleSelected": "선택항목 섞기",
|
"shuffleSelected": "선택항목 섞기",
|
||||||
"viewMore": "더 보기",
|
"viewMore": "더 보기",
|
||||||
"openApplicationDirectory": "앱 디렉토리 열기"
|
"openApplicationDirectory": "앱 디렉토리 열기",
|
||||||
|
"goToCurrent": "현재 항목으로 이동",
|
||||||
|
"collapseAllFolders": "모든 폴더 접기",
|
||||||
|
"expandAllFolders": "모든 폴더 확장"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"translation": "번역",
|
"translation": "번역",
|
||||||
@@ -149,7 +155,18 @@
|
|||||||
"sort": "정렬",
|
"sort": "정렬",
|
||||||
"gridRows": "행 그리드",
|
"gridRows": "행 그리드",
|
||||||
"tableColumns": "테이블 열",
|
"tableColumns": "테이블 열",
|
||||||
"itemsMore": "{{count}}개 더"
|
"itemsMore": "{{count}}개 더",
|
||||||
|
"back": "뒤로",
|
||||||
|
"example": "예",
|
||||||
|
"openFolder": "폴더 열기",
|
||||||
|
"filter_single": "미혼",
|
||||||
|
"filter_multiple": "다중",
|
||||||
|
"grouping": "그룹화",
|
||||||
|
"mood": "기분",
|
||||||
|
"numberOfResults": "결과 {{numberOfResults}}개",
|
||||||
|
"retry": "다시 해 보다",
|
||||||
|
"rename": "이름 변경",
|
||||||
|
"newVersionAvailable": "새로운 버전이 나왔습니다"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"albumWithCount_other": "{{count}} 앨범",
|
"albumWithCount_other": "{{count}} 앨범",
|
||||||
@@ -197,7 +214,15 @@
|
|||||||
"localFontAccessDenied": "로컬 글꼴에 접근 거부되었습니다",
|
"localFontAccessDenied": "로컬 글꼴에 접근 거부되었습니다",
|
||||||
"apiRouteError": "요청 보내기 실패",
|
"apiRouteError": "요청 보내기 실패",
|
||||||
"badValue": "옵션이 없습니다 {{value}}. 이 값은 더이상 존재하지 않습니다",
|
"badValue": "옵션이 없습니다 {{value}}. 이 값은 더이상 존재하지 않습니다",
|
||||||
"notificationDenied": "알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다"
|
"notificationDenied": "알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다",
|
||||||
|
"invalidJson": "유효하지 않은 JSON",
|
||||||
|
"multipleServerSaveQueueError": "재생 대기열에 현재 서버에 속하지 않은 곡이 하나 이상 포함되어 있습니다. 이는 지원되지 않습니다",
|
||||||
|
"noNetwork": "서버를 이용할 수 없음",
|
||||||
|
"noNetworkDescription": "이 서버에 연결할 수 없습니다",
|
||||||
|
"playbackPausedDueToError": "오류로 인해 재생이 일시 중지되었습니다",
|
||||||
|
"saveQueueFailed": "큐 저장 실패",
|
||||||
|
"serverLockSingleServer": "서버가 잠겨 있을 때는 서버를 하나만 허용합니다",
|
||||||
|
"settingsSyncError": "렌더러와 메인 프로세스의 설정 간에 불일치가 발견되었습니다. 변경 사항을 적용하려면 애플리케이션을 다시 시작하십시오"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"title": "곡명",
|
"title": "곡명",
|
||||||
@@ -222,7 +247,7 @@
|
|||||||
"disc": "디스크",
|
"disc": "디스크",
|
||||||
"bitrate": "비트 전송률",
|
"bitrate": "비트 전송률",
|
||||||
"biography": "바이오그래피",
|
"biography": "바이오그래피",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel, {\"count\": 2})",
|
||||||
"duration": "길이",
|
"duration": "길이",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2}) 앨범수",
|
"albumCount": "$t(entity.album, {\"count\": 2}) 앨범수",
|
||||||
@@ -242,7 +267,10 @@
|
|||||||
"songCount": "곡 갯수",
|
"songCount": "곡 갯수",
|
||||||
"toYear": "년도까지",
|
"toYear": "년도까지",
|
||||||
"trackNumber": "트랙",
|
"trackNumber": "트랙",
|
||||||
"explicitStatus": "$t(common.explicitStatus)"
|
"explicitStatus": "$t(common.explicitStatus)",
|
||||||
|
"matchAnd": "그리고",
|
||||||
|
"matchOr": "또는",
|
||||||
|
"sortName": "이름 정렬"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"addServer": {
|
"addServer": {
|
||||||
@@ -258,7 +286,10 @@
|
|||||||
"input_legacyAuthentication": "레거시 인증 사용",
|
"input_legacyAuthentication": "레거시 인증 사용",
|
||||||
"input_username": "유저 이름",
|
"input_username": "유저 이름",
|
||||||
"input_preferInstantMix": "즉석 믹스 선호",
|
"input_preferInstantMix": "즉석 믹스 선호",
|
||||||
"input_preferInstantMixDescription": "비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다"
|
"input_preferInstantMixDescription": "비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다",
|
||||||
|
"input_preferRemoteUrl": "공개 URL 선호",
|
||||||
|
"input_remoteUrl": "공개 URL",
|
||||||
|
"input_remoteUrlPlaceholder": "선택 사항: 외부 기능을 위한 공개 URL"
|
||||||
},
|
},
|
||||||
"addToPlaylist": {
|
"addToPlaylist": {
|
||||||
"input_skipDuplicates": "중복 건너뛰기",
|
"input_skipDuplicates": "중복 건너뛰기",
|
||||||
@@ -266,7 +297,8 @@
|
|||||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||||
"success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })에 $t(entity.trackWithCount, {\"count\": {{message}} })가 추가되었습니다",
|
"success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })에 $t(entity.trackWithCount, {\"count\": {{message}} })가 추가되었습니다",
|
||||||
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} 생성",
|
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} 생성",
|
||||||
"searchOrCreate": "$t(entity.playlist, {\"count\": 2}) 검색 또는 입력하여 새로 만들기"
|
"searchOrCreate": "$t(entity.playlist, {\"count\": 2}) 검색 또는 입력하여 새로 만들기",
|
||||||
|
"noneAdded": "$t(entity.playlist, {\"count\": 1}) '{{playlist}}'에 트랙이 추가되지 않았습니다"
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"title": "가사 검색",
|
"title": "가사 검색",
|
||||||
@@ -276,7 +308,11 @@
|
|||||||
"queryEditor": {
|
"queryEditor": {
|
||||||
"input_optionMatchAll": "모두 일치",
|
"input_optionMatchAll": "모두 일치",
|
||||||
"input_optionMatchAny": "무엇이든 일치",
|
"input_optionMatchAny": "무엇이든 일치",
|
||||||
"title": "쿼리 편집기"
|
"title": "쿼리 편집기",
|
||||||
|
"addRuleGroup": "규칙 그룹 추가",
|
||||||
|
"removeRuleGroup": "규칙 그룹 제거",
|
||||||
|
"resetToDefault": "기본값으로 초기화",
|
||||||
|
"clearFilters": "필터 초기화"
|
||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "$t(entity.playlist, {\"count\": 1}) 편집",
|
"title": "$t(entity.playlist, {\"count\": 1}) 편집",
|
||||||
@@ -289,7 +325,9 @@
|
|||||||
"success": "클립보드에 공유 링크를 복사했습니다 (또는 열어보려면 클릭하세요)",
|
"success": "클립보드에 공유 링크를 복사했습니다 (또는 열어보려면 클릭하세요)",
|
||||||
"expireInvalid": "만료 날짜는 미래 날짜여야만 합니다",
|
"expireInvalid": "만료 날짜는 미래 날짜여야만 합니다",
|
||||||
"createFailed": "공유 링크를 생성하는데 실패하였습니다 (혹시 공유하기 설정되어 있나요?)",
|
"createFailed": "공유 링크를 생성하는데 실패하였습니다 (혹시 공유하기 설정되어 있나요?)",
|
||||||
"setExpiration": "만료 기간 설정하기"
|
"setExpiration": "만료 기간 설정하기",
|
||||||
|
"copyToClipboard": "클립보드로 복사: Ctrl+C, Enter",
|
||||||
|
"successMustClick": "공유가 성공적으로 생성되었습니다. 여기를 클릭하여 여세요"
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"title": "서버 업데이트",
|
"title": "서버 업데이트",
|
||||||
@@ -312,6 +350,44 @@
|
|||||||
"enabled": "프라이빗 모드가 활성화되었습니다. 재생상태가 외부 서비스에 지금부터 노출되지 않습니다",
|
"enabled": "프라이빗 모드가 활성화되었습니다. 재생상태가 외부 서비스에 지금부터 노출되지 않습니다",
|
||||||
"disabled": "프라이빗 모드가 비활성화되었습니다. 재생상태가 외부서비스에서 지금부터 표시됩니다",
|
"disabled": "프라이빗 모드가 비활성화되었습니다. 재생상태가 외부서비스에서 지금부터 표시됩니다",
|
||||||
"title": "프라이빗 모드"
|
"title": "프라이빗 모드"
|
||||||
|
},
|
||||||
|
"largeFetchConfirmation": {
|
||||||
|
"title": "대기열에 항목을 추가하세요",
|
||||||
|
"description": "이 작업은 현재 필터링된 보기의 모든 항목을 추가합니다"
|
||||||
|
},
|
||||||
|
"createRadioStation": {
|
||||||
|
"success": "라디오 방송국이 성공적으로 생성되었습니다",
|
||||||
|
"title": "라디오 방송국 만들기",
|
||||||
|
"input_homepageUrl": "홈페이지 URL",
|
||||||
|
"input_name": "명의",
|
||||||
|
"input_streamUrl": "스트림 URL"
|
||||||
|
},
|
||||||
|
"editRadioStation": {
|
||||||
|
"success": "라디오 방송국이 성공적으로 업데이트되었습니다"
|
||||||
|
},
|
||||||
|
"lyricsExport": {
|
||||||
|
"export": "가사 내보내기",
|
||||||
|
"input_synced": "동기화된 가사 내보내기",
|
||||||
|
"input_offset": "$t(setting.lyricOffset)"
|
||||||
|
},
|
||||||
|
"saveQueue": {
|
||||||
|
"success": "재생 대기열을 서버에 저장했습니다"
|
||||||
|
},
|
||||||
|
"shuffleAll": {
|
||||||
|
"title": "무작위 재생",
|
||||||
|
"input_kind_albums": "앨범",
|
||||||
|
"input_kind_songs": "노래들",
|
||||||
|
"input_kind": "무작위 선택",
|
||||||
|
"input_limit_albums": "앨범이 몇 장인가요?",
|
||||||
|
"input_limit_songs": "몇 곡인가요?",
|
||||||
|
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||||
|
"input_limit": "몇 곡인가요?",
|
||||||
|
"input_minYear": "연도부터",
|
||||||
|
"input_maxYear": "연도까지",
|
||||||
|
"input_played": "재생 필터",
|
||||||
|
"input_played_optionAll": "모든 트랙",
|
||||||
|
"input_played_optionUnplayed": "재생하지 않은 트랙만",
|
||||||
|
"input_played_optionPlayed": "재생된 트랙만"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
@@ -325,7 +401,13 @@
|
|||||||
"collapseSidebar": "사이드바 줄이기",
|
"collapseSidebar": "사이드바 줄이기",
|
||||||
"expandSidebar": "사이드바 확장",
|
"expandSidebar": "사이드바 확장",
|
||||||
"privateModeOff": "프라이빗 모드 끄기",
|
"privateModeOff": "프라이빗 모드 끄기",
|
||||||
"privateModeOn": "프라이빗 모드 켜기"
|
"privateModeOn": "프라이빗 모드 켜기",
|
||||||
|
"commandPalette": "명령 팔레트 열기",
|
||||||
|
"quit": "$t(common.quit)",
|
||||||
|
"selectMusicFolder": "음악 폴더 선택",
|
||||||
|
"noMusicFolder": "음악 폴더가 선택되지 않았습니다",
|
||||||
|
"multipleMusicFolders": "{{count}}개의 음악 폴더가 선택되었습니다",
|
||||||
|
"settings": "$t(common.setting, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"manageServers": {
|
"manageServers": {
|
||||||
"title": "서버 설정하기",
|
"title": "서버 설정하기",
|
||||||
@@ -350,7 +432,9 @@
|
|||||||
"lyricGap": "가사 간격",
|
"lyricGap": "가사 간격",
|
||||||
"lyricSize": "가사 크기",
|
"lyricSize": "가사 크기",
|
||||||
"showLyricMatch": "가사 일치 표시",
|
"showLyricMatch": "가사 일치 표시",
|
||||||
"showLyricProvider": "가사 제공자 표시"
|
"showLyricProvider": "가사 제공자 표시",
|
||||||
|
"lyricOpacityNonActive": "비활성 가사 불투명도",
|
||||||
|
"lyricScaleNonActive": "비활성 서정적 척도"
|
||||||
},
|
},
|
||||||
"lyrics": "가사",
|
"lyrics": "가사",
|
||||||
"related": "관련",
|
"related": "관련",
|
||||||
@@ -364,7 +448,27 @@
|
|||||||
"shareItem": "공유",
|
"shareItem": "공유",
|
||||||
"goToAlbum": "$t(entity.album, {\"count\": 1})으로 이동",
|
"goToAlbum": "$t(entity.album, {\"count\": 1})으로 이동",
|
||||||
"goToAlbumArtist": "$t(entity.albumArtist, {\"count\": 1})으로 이동",
|
"goToAlbumArtist": "$t(entity.albumArtist, {\"count\": 1})으로 이동",
|
||||||
"showDetails": "추가정보"
|
"showDetails": "추가정보",
|
||||||
|
"addFavorite": "$t(action.addToFavorites)",
|
||||||
|
"addLast": "$t(player.addLast)",
|
||||||
|
"addNext": "$t(player.addNext)",
|
||||||
|
"addToFavorites": "$t(action.addToFavorites)",
|
||||||
|
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||||
|
"createPlaylist": "$t(action.createPlaylist)",
|
||||||
|
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||||
|
"deselectAll": "$t(action.deselectAll)",
|
||||||
|
"moveItems": "$t(action.moveItems)",
|
||||||
|
"moveToNext": "$t(action.moveToNext)",
|
||||||
|
"moveToBottom": "$t(action.moveToBottom)",
|
||||||
|
"moveToTop": "$t(action.moveToTop)",
|
||||||
|
"play": "$t(player.play)",
|
||||||
|
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||||
|
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||||
|
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||||
|
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||||
|
"setRating": "$t(action.setRating)",
|
||||||
|
"playShuffled": "$t(player.shuffle)",
|
||||||
|
"goTo": "이동"
|
||||||
},
|
},
|
||||||
"albumArtistDetail": {
|
"albumArtistDetail": {
|
||||||
"about": "{{artist}}에 대해",
|
"about": "{{artist}}에 대해",
|
||||||
@@ -375,7 +479,13 @@
|
|||||||
"topSongs": "최고의 곡들",
|
"topSongs": "최고의 곡들",
|
||||||
"topSongsFrom": "{{title}}이 포함된 최고의 곡들",
|
"topSongsFrom": "{{title}}이 포함된 최고의 곡들",
|
||||||
"viewAll": "전부 보이기",
|
"viewAll": "전부 보이기",
|
||||||
"viewAllTracks": "$t(entity.track, {\"count\": 2}) 전부 보이기"
|
"viewAllTracks": "$t(entity.track, {\"count\": 2}) 전부 보이기",
|
||||||
|
"favoriteSongs": "좋아하는 노래들",
|
||||||
|
"groupingTypeAll": "모든 릴리스 유형",
|
||||||
|
"groupingTypePrimary": "주요 릴리스 유형",
|
||||||
|
"topSongsCommunity": "공동체",
|
||||||
|
"topSongsPersonal": "개인의",
|
||||||
|
"favoriteSongsFrom": "{{title}}에서 가장 좋아하는 곡들"
|
||||||
},
|
},
|
||||||
"albumArtistList": {
|
"albumArtistList": {
|
||||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||||
@@ -386,11 +496,14 @@
|
|||||||
"released": "발매"
|
"released": "발매"
|
||||||
},
|
},
|
||||||
"albumList": {
|
"albumList": {
|
||||||
"artistAlbums": "{{artist}}의 앨범"
|
"artistAlbums": "{{artist}}의 앨범",
|
||||||
|
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
|
||||||
|
"title": "$t(entity.album, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"genreList": {
|
"genreList": {
|
||||||
"showAlbums": "$t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2}) 표시",
|
"showAlbums": "$t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2}) 표시",
|
||||||
"showTracks": "$t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2}) 표시"
|
"showTracks": "$t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2}) 표시",
|
||||||
|
"title": "$t(entity.genre, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"globalSearch": {
|
"globalSearch": {
|
||||||
"commands": {
|
"commands": {
|
||||||
@@ -405,7 +518,9 @@
|
|||||||
"mostPlayed": "자주 플레이된 곡",
|
"mostPlayed": "자주 플레이된 곡",
|
||||||
"newlyAdded": "최근에 추가된 곡",
|
"newlyAdded": "최근에 추가된 곡",
|
||||||
"recentlyPlayed": "최근에 플레이된 곡",
|
"recentlyPlayed": "최근에 플레이된 곡",
|
||||||
"recentlyReleased": "최근에 발매된 곡"
|
"recentlyReleased": "최근에 발매된 곡",
|
||||||
|
"genres": "$t(entity.genre, {\"count\": 2})",
|
||||||
|
"title": "$t(common.home)"
|
||||||
},
|
},
|
||||||
"itemDetail": {
|
"itemDetail": {
|
||||||
"copyPath": "클립보드에 경로를 복사",
|
"copyPath": "클립보드에 경로를 복사",
|
||||||
@@ -420,15 +535,71 @@
|
|||||||
"generalTab": "일반",
|
"generalTab": "일반",
|
||||||
"hotkeysTab": "단축키",
|
"hotkeysTab": "단축키",
|
||||||
"playbackTab": "재생",
|
"playbackTab": "재생",
|
||||||
"windowTab": "윈도우"
|
"windowTab": "윈도우",
|
||||||
|
"analytics": "해석학",
|
||||||
|
"updates": "업데이트",
|
||||||
|
"cache": "은닉처",
|
||||||
|
"application": "애플리케이션",
|
||||||
|
"queryBuilder": "쿼리 빌더",
|
||||||
|
"theme": "테마",
|
||||||
|
"controls": "통제 수단",
|
||||||
|
"sidebar": "사이드바",
|
||||||
|
"exportImport": "가져오기/내보내기",
|
||||||
|
"audio": "오디오",
|
||||||
|
"lyrics": "가사",
|
||||||
|
"lyricsDisplay": "가사 표시",
|
||||||
|
"transcoding": "트랜스코딩",
|
||||||
|
"discord": "Discord",
|
||||||
|
"logger": "로거",
|
||||||
|
"playerFilters": "선수 필터"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"myLibrary": "내 라이브러리",
|
"myLibrary": "내 라이브러리",
|
||||||
"nowPlaying": "재생중",
|
"nowPlaying": "재생중",
|
||||||
"shared": "공유 $t(entity.playlist, {\"count\": 2})"
|
"shared": "공유 $t(entity.playlist, {\"count\": 2})",
|
||||||
|
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||||
|
"albums": "$t(entity.album, {\"count\": 2})",
|
||||||
|
"collections": "컬렉션",
|
||||||
|
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||||
|
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||||
|
"folders": "$t(entity.folder, {\"count\": 2})",
|
||||||
|
"genres": "$t(entity.genre, {\"count\": 2})",
|
||||||
|
"home": "$t(common.home)",
|
||||||
|
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||||
|
"playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||||
|
"search": "$t(common.search)",
|
||||||
|
"settings": "$t(common.setting, {\"count\": 2})",
|
||||||
|
"tracks": "$t(entity.track, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"artistTracks": "{{artist}}의 음악"
|
"artistTracks": "{{artist}}의 음악",
|
||||||
|
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
|
||||||
|
"title": "$t(entity.track, {\"count\": 2})"
|
||||||
|
},
|
||||||
|
"radioList": {
|
||||||
|
"title": "라디오 방송국"
|
||||||
|
},
|
||||||
|
"releasenotes": {
|
||||||
|
"commitsSinceStable": "{{stable}} 이후 커밋",
|
||||||
|
"noNewCommits": "이 범위에 새로운 커밋이 없습니다",
|
||||||
|
"noStableReleaseToCompare": "비교할 수 있는 안정화 릴리스가 없습니다"
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"title": "$t(entity.favorite, {\"count\": 2})"
|
||||||
|
},
|
||||||
|
"windowBar": {
|
||||||
|
"paused": "(일시 정지됨) ",
|
||||||
|
"privateMode": "(비공개 모드)"
|
||||||
|
},
|
||||||
|
"folderList": {
|
||||||
|
"title": "$t(entity.folder, {\"count\": 2})"
|
||||||
|
},
|
||||||
|
"playlistList": {
|
||||||
|
"title": "$t(entity.playlist, {\"count\": 2})"
|
||||||
|
},
|
||||||
|
"collections": {
|
||||||
|
"overrideExisting": "기존 항목 덮어쓰기",
|
||||||
|
"saveAsCollection": "컬렉션으로 저장"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -473,7 +644,25 @@
|
|||||||
"toggleFullscreenPlayer": "전체화면으로 전환",
|
"toggleFullscreenPlayer": "전체화면으로 전환",
|
||||||
"unfavorite": "즐겨찾기 취소",
|
"unfavorite": "즐겨찾기 취소",
|
||||||
"pause": "멈춤",
|
"pause": "멈춤",
|
||||||
"viewQueue": "대기열 보기"
|
"viewQueue": "대기열 보기",
|
||||||
|
"addLastShuffled": "마지막 (섞인)",
|
||||||
|
"addNextShuffled": "다음 (무작위)",
|
||||||
|
"albumRadio": "앨범 라디오",
|
||||||
|
"artistRadio": "아티스트 라디오",
|
||||||
|
"holdToShuffle": "길게 눌러 섞기",
|
||||||
|
"lyrics": "가사",
|
||||||
|
"restoreQueueFromServer": "서버에서 큐 복원",
|
||||||
|
"saveQueueToServer": "대기열을 서버에 저장",
|
||||||
|
"trackRadio": "라디오 추적",
|
||||||
|
"sleepTimer": "취침 타이머",
|
||||||
|
"sleepTimer_endOfSong": "현재 곡 종료",
|
||||||
|
"sleepTimer_endOfAlbum": "현재 앨범의 끝",
|
||||||
|
"sleepTimer_minutes": "{{count}}분",
|
||||||
|
"sleepTimer_hours": "{{count}}시간",
|
||||||
|
"sleepTimer_off": "끄다",
|
||||||
|
"sleepTimer_timeRemaining": "{{time}} 남음",
|
||||||
|
"sleepTimer_setCustom": "타이머 설정",
|
||||||
|
"sleepTimer_cancel": "타이머 취소"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"accentColor_description": "앱의 강조색상 설정",
|
"accentColor_description": "앱의 강조색상 설정",
|
||||||
@@ -482,7 +671,7 @@
|
|||||||
"albumBackground": "앨범 배경이미지",
|
"albumBackground": "앨범 배경이미지",
|
||||||
"albumBackgroundBlur_description": "앨범 배경이미지의 흐려짐 정도 조정",
|
"albumBackgroundBlur_description": "앨범 배경이미지의 흐려짐 정도 조정",
|
||||||
"albumBackgroundBlur": "앨범배경이미지 흐려짐 크기",
|
"albumBackgroundBlur": "앨범배경이미지 흐려짐 크기",
|
||||||
"applicationHotkeys_description": "앱의 단축키 설정. 앱 전체에 적용되는 단축키를 설정하기 위해서는 체크박스에 체크하세요(PC만 가능)",
|
"applicationHotkeys_description": "애플리케이션 단축키를 설정합니다. 체크박스를 전환하여 전역 단축키로 설정하세요(데스크톱 전용)",
|
||||||
"applicationHotkeys": "앱 단축키",
|
"applicationHotkeys": "앱 단축키",
|
||||||
"artistBackground": "아티스트 배경이미지",
|
"artistBackground": "아티스트 배경이미지",
|
||||||
"artistBackground_description": "아티스트 페이지에 아티스트가 포함된 배경이미지를 추가",
|
"artistBackground_description": "아티스트 페이지에 아티스트가 포함된 배경이미지를 추가",
|
||||||
@@ -492,7 +681,7 @@
|
|||||||
"artistConfiguration_description": "앨범아티스트 페이지에 표시할 정보 및 순서 설정",
|
"artistConfiguration_description": "앨범아티스트 페이지에 표시할 정보 및 순서 설정",
|
||||||
"audioDevice_description": "음악재생에 사용할 장치 선택(웹플레이어만 가능)",
|
"audioDevice_description": "음악재생에 사용할 장치 선택(웹플레이어만 가능)",
|
||||||
"audioDevice": "오디오 장치",
|
"audioDevice": "오디오 장치",
|
||||||
"audioExclusiveMode_description": "단독재생모드 켜기. 이 모드에서는 일반적으로 시스템의 재생장치가 고정되며 MPV로만 오디오가 재생됩니다",
|
"audioExclusiveMode_description": "독점 출력 모드를 활성화합니다. 이 모드에서는 일반적으로 시스템의 오디오 출력이 차단되며, 오직 mpv만이 오디오를 출력할 수 있습니다. 이 모드가 활성화된 동안에는 비주얼라이저의 시스템 오디오 캡처 기능이 작동하지 않습니다",
|
||||||
"audioExclusiveMode": "오디오 단독재생모드",
|
"audioExclusiveMode": "오디오 단독재생모드",
|
||||||
"audioPlayer_description": "재생을 위한 오디오 플레이어 선택",
|
"audioPlayer_description": "재생을 위한 오디오 플레이어 선택",
|
||||||
"audioPlayer": "오디오 플레이어",
|
"audioPlayer": "오디오 플레이어",
|
||||||
@@ -505,7 +694,8 @@
|
|||||||
"broadcast": "방송",
|
"broadcast": "방송",
|
||||||
"ep": "ep앨범",
|
"ep": "ep앨범",
|
||||||
"other": "기타",
|
"other": "기타",
|
||||||
"single": "싱글"
|
"single": "싱글",
|
||||||
|
"album": "$t(entity.album, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"audiobook": "오디오북",
|
"audiobook": "오디오북",
|
||||||
@@ -521,5 +711,35 @@
|
|||||||
"soundtrack": "사운드트랙",
|
"soundtrack": "사운드트랙",
|
||||||
"spokenWord": "보컬사운드"
|
"spokenWord": "보컬사운드"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"datetime": {
|
||||||
|
"minuteShort": "분",
|
||||||
|
"secondShort": "초",
|
||||||
|
"hourShort": "시간",
|
||||||
|
"dayShort": "일"
|
||||||
|
},
|
||||||
|
"filterOperator": {
|
||||||
|
"after": "~ 뒤에 있나요",
|
||||||
|
"afterDate": "(날짜) 이후입니까",
|
||||||
|
"before": "~보다 앞서 있다",
|
||||||
|
"beforeDate": "(날짜) 이전인가요",
|
||||||
|
"contains": "포함",
|
||||||
|
"endsWith": "~로 끝남",
|
||||||
|
"inPlaylist": "~ 안에 있다",
|
||||||
|
"inTheLast": "마지막에 있습니다",
|
||||||
|
"inTheRange": "범위 내에 있습니다",
|
||||||
|
"inTheRangeDate": "범위 내에 있음 (날짜)",
|
||||||
|
"is": "~이다",
|
||||||
|
"isNot": "~이 아닙니까",
|
||||||
|
"isGreaterThan": "~보다 크다",
|
||||||
|
"isLessThan": "~보다 작다",
|
||||||
|
"matchesRegex": "정규식과 일치",
|
||||||
|
"notContains": "함유하지 않음",
|
||||||
|
"notInPlaylist": "~ 안에 있지 않다",
|
||||||
|
"notInTheLast": "마지막에 있지 않다",
|
||||||
|
"startsWith": "~로 시작함"
|
||||||
|
},
|
||||||
|
"queryBuilder": {
|
||||||
|
"customTags": "사용자 정의 태그"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Åpne i Last.fm",
|
"lastfm": "Åpne i Last.fm",
|
||||||
"musicbrainz": "Åpne i MusicBrainz",
|
"musicbrainz": "Åpne i MusicBrainz",
|
||||||
"spotify": "Åpne i Spotify"
|
"spotify": "Åpne i Spotify",
|
||||||
|
"listenbrainz": "Åpne i ListenBrainz",
|
||||||
|
"qobuz": "Åpne i Qobuz"
|
||||||
},
|
},
|
||||||
"moveToBottom": "Flytt til bunnen",
|
"moveToBottom": "Flytt til bunnen",
|
||||||
"deletePlaylist": "Slett $t(entity.playlist, {\"count\": 1})",
|
"deletePlaylist": "Slett $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -38,7 +40,10 @@
|
|||||||
"shuffleAll": "Tilfelding avspilling av alt",
|
"shuffleAll": "Tilfelding avspilling av alt",
|
||||||
"shuffleSelected": "Tilfelding avspilling av utvalgte",
|
"shuffleSelected": "Tilfelding avspilling av utvalgte",
|
||||||
"viewMore": "Se mer",
|
"viewMore": "Se mer",
|
||||||
"openApplicationDirectory": "Åpne applikasjonskatalogen"
|
"openApplicationDirectory": "Åpne applikasjonskatalogen",
|
||||||
|
"goToCurrent": "Gå til gjeldende element",
|
||||||
|
"collapseAllFolders": "Skjul alle mapper",
|
||||||
|
"expandAllFolders": "Utvid alle mapper"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"bpm": "Bpm",
|
"bpm": "Bpm",
|
||||||
@@ -161,7 +166,11 @@
|
|||||||
"tableColumns": "Tabellkolonner",
|
"tableColumns": "Tabellkolonner",
|
||||||
"itemsMore": "{{count}} fler",
|
"itemsMore": "{{count}} fler",
|
||||||
"explicitStatus": "Grovhetsstatus",
|
"explicitStatus": "Grovhetsstatus",
|
||||||
"newVersionAvailable": "En ny version er tilgjengelig"
|
"newVersionAvailable": "En ny version er tilgjengelig",
|
||||||
|
"back": "Tilbake",
|
||||||
|
"openFolder": "Åpne mappe",
|
||||||
|
"grouping": "Grupper",
|
||||||
|
"numberOfResults": "{{numberOfResults}} resultater"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"smartPlaylist": "Smart $t(entity.playlist, {\"count\": 1})",
|
"smartPlaylist": "Smart $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -667,7 +676,19 @@
|
|||||||
"b": "B",
|
"b": "B",
|
||||||
"c": "C",
|
"c": "C",
|
||||||
"d": "D",
|
"d": "D",
|
||||||
"z": "Z"
|
"z": "Z",
|
||||||
|
"none": "Ingen"
|
||||||
|
},
|
||||||
|
"frequencyScale": {
|
||||||
|
"linear": "Lineær skala",
|
||||||
|
"log": "Logaritmisk skala"
|
||||||
|
},
|
||||||
|
"channelLayout": {
|
||||||
|
"single": "Enkel"
|
||||||
|
},
|
||||||
|
"gradient": {
|
||||||
|
"rainbow": "Regnbue",
|
||||||
|
"prism": "Prisme"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-17
@@ -178,14 +178,14 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "Gatunek",
|
"genre_one": "Gatunek",
|
||||||
"genre_few": "gatunki",
|
"genre_few": "Gatunki",
|
||||||
"genre_many": "gatunków",
|
"genre_many": "Gatunków",
|
||||||
"playlistWithCount_one": "{{count}} playlista",
|
"playlistWithCount_one": "{{count}} playlista",
|
||||||
"playlistWithCount_few": "{{count}} playlisty",
|
"playlistWithCount_few": "{{count}} playlisty",
|
||||||
"playlistWithCount_many": "{{count}} playlist",
|
"playlistWithCount_many": "{{count}} playlist",
|
||||||
"playlist_one": "Playlista",
|
"playlist_one": "Playlista",
|
||||||
"playlist_few": "playlisty",
|
"playlist_few": "Playlisty",
|
||||||
"playlist_many": "playlist",
|
"playlist_many": "Playlist",
|
||||||
"artist_one": "Wykonawca",
|
"artist_one": "Wykonawca",
|
||||||
"artist_few": "wykonawcy",
|
"artist_few": "wykonawcy",
|
||||||
"artist_many": "wykonawców",
|
"artist_many": "wykonawców",
|
||||||
@@ -196,8 +196,8 @@
|
|||||||
"albumArtist_few": "Wykonawców albumów",
|
"albumArtist_few": "Wykonawców albumów",
|
||||||
"albumArtist_many": "Wykonawców albumów",
|
"albumArtist_many": "Wykonawców albumów",
|
||||||
"track_one": "Utwór",
|
"track_one": "Utwór",
|
||||||
"track_few": "utwory",
|
"track_few": "Utwory",
|
||||||
"track_many": "utworów",
|
"track_many": "Utworów",
|
||||||
"albumArtistCount_one": "{{count}} wykonawca albumu",
|
"albumArtistCount_one": "{{count}} wykonawca albumu",
|
||||||
"albumArtistCount_few": "{{count}} wykonawców albumu",
|
"albumArtistCount_few": "{{count}} wykonawców albumu",
|
||||||
"albumArtistCount_many": "{{count}} wykonawców albumu",
|
"albumArtistCount_many": "{{count}} wykonawców albumu",
|
||||||
@@ -205,18 +205,18 @@
|
|||||||
"albumWithCount_few": "{{count}} albumy",
|
"albumWithCount_few": "{{count}} albumy",
|
||||||
"albumWithCount_many": "{{count}} albumów",
|
"albumWithCount_many": "{{count}} albumów",
|
||||||
"favorite_one": "Ulubiony",
|
"favorite_one": "Ulubiony",
|
||||||
"favorite_few": "ulubione",
|
"favorite_few": "Ulubione",
|
||||||
"favorite_many": "ulubionych",
|
"favorite_many": "Ulubionych",
|
||||||
"artistWithCount_one": "{{count}} wykonawca",
|
"artistWithCount_one": "{{count}} wykonawca",
|
||||||
"artistWithCount_few": "{{count}} wykonawców",
|
"artistWithCount_few": "{{count}} wykonawców",
|
||||||
"artistWithCount_many": "{{count}} wykonawców",
|
"artistWithCount_many": "{{count}} wykonawców",
|
||||||
"folder_one": "Katalog",
|
"folder_one": "Katalog",
|
||||||
"folder_few": "katalogi",
|
"folder_few": "Katalogi",
|
||||||
"folder_many": "katalogów",
|
"folder_many": "Katalogów",
|
||||||
"smartPlaylist": "Inteligentna $t(entity.playlist, {\"count\": 1})",
|
"smartPlaylist": "Inteligentna $t(entity.playlist, {\"count\": 1})",
|
||||||
"album_one": "Album",
|
"album_one": "Album",
|
||||||
"album_few": "albumy",
|
"album_few": "Albumy",
|
||||||
"album_many": "albumów",
|
"album_many": "Albumów",
|
||||||
"genreWithCount_one": "{{count}} gatunek",
|
"genreWithCount_one": "{{count}} gatunek",
|
||||||
"genreWithCount_few": "{{count}} gatunki",
|
"genreWithCount_few": "{{count}} gatunki",
|
||||||
"genreWithCount_many": "{{count}} gatunków",
|
"genreWithCount_many": "{{count}} gatunków",
|
||||||
@@ -410,7 +410,12 @@
|
|||||||
"input_played": "Filtr odtwarzania",
|
"input_played": "Filtr odtwarzania",
|
||||||
"input_played_optionAll": "Wszystkie utwory",
|
"input_played_optionAll": "Wszystkie utwory",
|
||||||
"input_played_optionUnplayed": "Tylko nieodtworzone 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": {
|
"saveQueue": {
|
||||||
"success": "Zapisano kolejkę odtwarzania na serwerze"
|
"success": "Zapisano kolejkę odtwarzania na serwerze"
|
||||||
@@ -695,7 +700,8 @@
|
|||||||
"sleepTimer_setCustom": "Ustaw wyłącznik",
|
"sleepTimer_setCustom": "Ustaw wyłącznik",
|
||||||
"sleepTimer_cancel": "Anuluj wyłączanie",
|
"sleepTimer_cancel": "Anuluj wyłączanie",
|
||||||
"albumRadio": "Radio albumu",
|
"albumRadio": "Radio albumu",
|
||||||
"scrobbleForceSubmit": "Wymuś scrobble"
|
"scrobbleForceSubmit": "Wymuś scrobble",
|
||||||
|
"sleepTimer_endOfAlbum": "Koniec aktualnego albumu"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "Wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
|
"crossfadeStyle_description": "Wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
|
||||||
@@ -974,7 +980,7 @@
|
|||||||
"preservePitch": "Utrzymuj ton",
|
"preservePitch": "Utrzymuj ton",
|
||||||
"preventSleepOnPlayback_description": "Powstrzymuje ekran przed uśpieniem, gdy muzyka jest odtwarzana",
|
"preventSleepOnPlayback_description": "Powstrzymuje ekran przed uśpieniem, gdy muzyka jest odtwarzana",
|
||||||
"preventSleepOnPlayback": "Powstrzymuj uśpienie podczas odtwarzania",
|
"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",
|
"mediaSession": "Włącz media session",
|
||||||
"transcode": "Włącz transkodowanie",
|
"transcode": "Włącz transkodowanie",
|
||||||
"queryBuilder": "Kreator zaptań",
|
"queryBuilder": "Kreator zaptań",
|
||||||
@@ -991,7 +997,7 @@
|
|||||||
"audioFadeOnStatusChange_description": "Umożliwia zanikanie lub pojawianie się dźwięku gdy zmieni się status play/pauza",
|
"audioFadeOnStatusChange_description": "Umożliwia zanikanie lub pojawianie się dźwięku gdy zmieni się status play/pauza",
|
||||||
"autoDJ": "Automatyczny DJ",
|
"autoDJ": "Automatyczny DJ",
|
||||||
"autoDJ_itemCount": "Liczba elementów",
|
"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": "Czas dodawania",
|
||||||
"autoDJ_timing_description": "Ilość piosenek pozostałych w kolejce przed tym gdy zostanie włączony automatyczny DJ",
|
"autoDJ_timing_description": "Ilość piosenek pozostałych w kolejce przed tym gdy zostanie włączony automatyczny DJ",
|
||||||
"logLevel": "Poziom logów",
|
"logLevel": "Poziom logów",
|
||||||
@@ -1093,7 +1099,16 @@
|
|||||||
"sidebarPlaylistMode_description": "Jak każda z playlist jest wyświetlana w liście w pasku bocznym",
|
"sidebarPlaylistMode_description": "Jak każda z playlist jest wyświetlana w liście w pasku bocznym",
|
||||||
"sidebarPlaylistMode": "Tryb playlist bocznego paska",
|
"sidebarPlaylistMode": "Tryb playlist bocznego paska",
|
||||||
"sidebarPlaylistMode_optionCompact": "Kompaktowy",
|
"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": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
+172
-9
@@ -174,7 +174,8 @@
|
|||||||
"explicitStatus": "Признак нецензурного контента",
|
"explicitStatus": "Признак нецензурного контента",
|
||||||
"newVersionAvailable": "Доступна новая версия",
|
"newVersionAvailable": "Доступна новая версия",
|
||||||
"numberOfResults": "{{numberOfResults}} результатов",
|
"numberOfResults": "{{numberOfResults}} результатов",
|
||||||
"back": "Назад"
|
"back": "Назад",
|
||||||
|
"openFolder": "Открыть папку"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Альбом",
|
"album_one": "Альбом",
|
||||||
@@ -240,7 +241,10 @@
|
|||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
"view": {
|
"view": {
|
||||||
"table": "Таблица"
|
"table": "Таблица",
|
||||||
|
"detail": "Детали",
|
||||||
|
"grid": "Сетка",
|
||||||
|
"list": "Список"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"displayType": "Тип отображения",
|
"displayType": "Тип отображения",
|
||||||
@@ -250,7 +254,29 @@
|
|||||||
"followCurrentSong": "Следовать за исполняемым треком",
|
"followCurrentSong": "Следовать за исполняемым треком",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"itemSize": "Размер элементов (px)",
|
"itemSize": "Размер элементов (px)",
|
||||||
"itemGap": "Отступ между элементами (px)"
|
"itemGap": "Отступ между элементами (px)",
|
||||||
|
"advancedSettings": "Расширенные настройки",
|
||||||
|
"autosize": "Автоматический выбор размера",
|
||||||
|
"moveUp": "Переместить выше",
|
||||||
|
"moveDown": "Переместить ниже",
|
||||||
|
"pinToLeft": "Закрепить слева",
|
||||||
|
"pinToRight": "Закрепить права",
|
||||||
|
"alignLeft": "Выровнять по левой стороне",
|
||||||
|
"alignCenter": "Выровнять по центру",
|
||||||
|
"alignRight": "Выровнять по правой стороне",
|
||||||
|
"itemsPerRow": "Элементов в строке",
|
||||||
|
"size_default": "По-умолчанию",
|
||||||
|
"size_compact": "Компактный",
|
||||||
|
"size_large": "Большой",
|
||||||
|
"pagination": "Пагинация",
|
||||||
|
"pagination_itemsPerPage": "Элементов на странице",
|
||||||
|
"pagination_infinite": "Бесконечно",
|
||||||
|
"pagination_paginate": "Разбитый по страницам",
|
||||||
|
"alternateRowColors": "Переменный цвет строк",
|
||||||
|
"horizontalBorders": "Границы строки",
|
||||||
|
"rowHoverHighlight": "Подсветка строки при наведении",
|
||||||
|
"showHeader": "Показать заголовок",
|
||||||
|
"verticalBorders": "Границы колонки"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"releaseDate": "Дата выхода",
|
"releaseDate": "Дата выхода",
|
||||||
@@ -276,7 +302,10 @@
|
|||||||
"favorite": "$t(common.favorite)",
|
"favorite": "$t(common.favorite)",
|
||||||
"year": "$t(common.year)",
|
"year": "$t(common.year)",
|
||||||
"codec": "$t(common.codec)",
|
"codec": "$t(common.codec)",
|
||||||
"titleArtist": "$t(common.title) (артист)"
|
"titleArtist": "$t(common.title) (артист)",
|
||||||
|
"albumGroup": "Группа альбома",
|
||||||
|
"composer": "Композитор",
|
||||||
|
"image": "Изображение"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"column": {
|
"column": {
|
||||||
@@ -299,7 +328,14 @@
|
|||||||
"comment": "Комментарий",
|
"comment": "Комментарий",
|
||||||
"bitrate": "Битрейт",
|
"bitrate": "Битрейт",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel_other)",
|
||||||
"bpm": "BPM"
|
"bpm": "BPM",
|
||||||
|
"albumCount": "Альбомы",
|
||||||
|
"artist": "Исполнители",
|
||||||
|
"bitDepth": "Битовая глубина",
|
||||||
|
"genre": "Жанр",
|
||||||
|
"sampleRate": "Частота дискретизации",
|
||||||
|
"songCount": "Треки",
|
||||||
|
"owner": "Правообладатель"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -434,7 +470,8 @@
|
|||||||
"sleepTimer_setCustom": "Установить таймер",
|
"sleepTimer_setCustom": "Установить таймер",
|
||||||
"sleepTimer_custom": "Пользовательский",
|
"sleepTimer_custom": "Пользовательский",
|
||||||
"sleepTimer_cancel": "Отменить таймер",
|
"sleepTimer_cancel": "Отменить таймер",
|
||||||
"scrobbleForceSubmit": "Принудительная скробблинг"
|
"scrobbleForceSubmit": "Принудительная скробблинг",
|
||||||
|
"sleepTimer_endOfAlbum": "Конец этого альбома"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -756,7 +793,12 @@
|
|||||||
"input_played_optionAll": "Все треки",
|
"input_played_optionAll": "Все треки",
|
||||||
"input_played_optionUnplayed": "Только не игранные треки",
|
"input_played_optionUnplayed": "Только не игранные треки",
|
||||||
"input_played_optionPlayed": "Только воспроизведённые треки",
|
"input_played_optionPlayed": "Только воспроизведённые треки",
|
||||||
"input_genre": "$t(entity.genre, {\"count\": 1})"
|
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||||
|
"input_kind_albums": "Альбомы",
|
||||||
|
"input_kind_songs": "Песни",
|
||||||
|
"input_kind": "Случайный выбор",
|
||||||
|
"input_limit_albums": "Сколько альбомов?",
|
||||||
|
"input_limit_songs": "Сколько песен?"
|
||||||
},
|
},
|
||||||
"editRadioStation": {
|
"editRadioStation": {
|
||||||
"success": "Радиостанция успешно обновлена"
|
"success": "Радиостанция успешно обновлена"
|
||||||
@@ -1087,7 +1129,78 @@
|
|||||||
"audioFadeOnStatusChange": "плавное изменение звука",
|
"audioFadeOnStatusChange": "плавное изменение звука",
|
||||||
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
|
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
|
||||||
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
|
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
|
||||||
"preventSleepOnPlayback": "не переходить в спящий режим"
|
"preventSleepOnPlayback": "не переходить в спящий режим",
|
||||||
|
"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": "Похожие",
|
||||||
|
"hotkey_listShowPlayingSong": "Показать текущую песню в списке",
|
||||||
|
"listenbrainz_description": "Показать ссылки на ListenBrains на страницах исполнителя/альбома",
|
||||||
|
"listenbrainz": "Показать ссылки на ListenBrainz",
|
||||||
|
"qobuz_description": "Показать ссылки на Qobuz на страницах исполнителя/альбома",
|
||||||
|
"qobuz": "Показать ссылки на Qobuz",
|
||||||
|
"spotify_description": "Показать ссылки на Spotify на странице исполнителя/альбома",
|
||||||
|
"spotify": "Показать ссылки на Spotify",
|
||||||
|
"nativeSpotify_description": "Открывать в приложении Spotify вместо браузера",
|
||||||
|
"nativeSpotify": "Использовать приложение Spotify",
|
||||||
|
"imageResolution_optionTable": "Таблица",
|
||||||
|
"preventSuspendOnPlayback_description": "Не приостанавливать приложение во время проигрывания музыки",
|
||||||
|
"preventSuspendOnPlayback": "Не приостанавливать во время проигрывания",
|
||||||
|
"playerItemConfiguration_description": "Настроить какие элементы и в каком порядке видны в полноэкранном плеере",
|
||||||
|
"playerItemConfiguration": "Настройка плеера",
|
||||||
|
"sidebarPlaylistFolders": "Включить папки",
|
||||||
|
"sidebarPlaylistFolderSeparator_description": "Символ (или строка), который разделяет уровни папок в названии плейлиста",
|
||||||
|
"sidebarPlaylistFolderSeparator": "Разделитель папок",
|
||||||
|
"sidebarPlaylistFolderView_description": "Как отображать папки в боковой панели",
|
||||||
|
"sidebarPlaylistFolderView": "Вид папок",
|
||||||
|
"sidebarPlaylistFolderView_optionSingle": "Единстванная папка",
|
||||||
|
"sidebarPlaylistFolderView_optionTree": "Вид дерева",
|
||||||
|
"sidebarPlaylistFolderView_optionNavigation": "Вид навигации",
|
||||||
|
"sidebarPlaylistFolderTreeIndent_description": "Отступ в пикселях на каждом уровне дерева",
|
||||||
|
"sidebarPlaylistFolderTreeIndent": "Отступ в дереве",
|
||||||
|
"sidebarPlaylistFolderTreeLineColor_description": "Цвет линий соединения в дереве (оставь пустым, чтобы использовать настройки темы)",
|
||||||
|
"sidebarPlaylistFolderTreeLineColor": "Цвет линии в дереве",
|
||||||
|
"sidebarPlaylistMode_description": "Как отображать каждый плейлист в списке в боковой панели",
|
||||||
|
"sidebarPlaylistMode": "Режим плейлиста в боковой панели",
|
||||||
|
"sidebarPlaylistMode_optionCompact": "Компактный",
|
||||||
|
"sidebarPlaylistMode_optionExpanded": "Просторный",
|
||||||
|
"sidebarPlaylistSorting_description": "Разрешить ручную сортировку плейлистов в боковой панели с помощью перетаскивания вместо сортировки со стороны сервера",
|
||||||
|
"sidebarPlaylistSorting": "Сортировка плейлистов в боковой панели",
|
||||||
|
"sidebarPlaylistListFilterRegex_description": "Скрывать плейлисты в боковой панели, которые соответствуют этому регулярному выражению",
|
||||||
|
"sidebarPlaylistListFilterRegex_placeholder": "Например ^daily mix.*",
|
||||||
|
"sidebarPlaylistListFilterRegex": "Регулярное выражение для фильтрации плейлистов",
|
||||||
|
"sidePlayQueueLayout": "Макет очереди проигрывания сбоку",
|
||||||
|
"sidePlayQueueLayout_description": "Задает макет прикрепленной очереди проигрывания сбоку",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "Горизонтальный",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "Вертикальный",
|
||||||
|
"mediaSession_description": "Включает интеграцию сессии медиа, отображая элементы управления и метаданные медиа в системном оверлее управления громкостью и на экране блокировки. Требуется Web Audio Player.",
|
||||||
|
"mediaSession": "Включить сессию медиа",
|
||||||
|
"skipPlaylistPage_description": "Когда переходишь в плейлист, откроется страница со списком песен плейлиста, вместо страницы по-умолчанию",
|
||||||
|
"transcode": "Включить транскодирование",
|
||||||
|
"transcodeFormat_description": "Выбирает форматы для транскодирования. Оставь пустым, чтобы решение принимал сервер",
|
||||||
|
"translationApiKey_description": "Ключ API для перевода (только эндпойнт глобального сервиса)",
|
||||||
|
"translationApiKey": "Ключ API перевода",
|
||||||
|
"translationApiProvider_description": "Поставщик API для перевода",
|
||||||
|
"translationApiProvider": "Поставщик API перевода",
|
||||||
|
"translationTargetLanguage_description": "На какой язык выполнять перевод",
|
||||||
|
"translationTargetLanguage": "На какой язык переводить",
|
||||||
|
"trayEnabled_description": "Показать/скрыть иконку/меню в трее. Если скрыто, то также отключается сворачивать в трей/свернуть в трей при выходе",
|
||||||
|
"trayEnabled": "Показать в трее",
|
||||||
|
"queryBuilder": "Создатель очереди",
|
||||||
|
"queryBuilderCustomFields_inputLabel": "Метка",
|
||||||
|
"queryBuilderCustomFields_inputTag": "Тег",
|
||||||
|
"queryBuilderCustomFields": "Пользовательские поля",
|
||||||
|
"queryBuilderCustomFields_description": "Добавь пользовательские поля для использования создателями очереди",
|
||||||
|
"hotkey_listNavigateToPage": "Перейти к странице элемента",
|
||||||
|
"hotkey_listPlayDefault": "Воспроизвести список",
|
||||||
|
"hotkey_listPlayLast": "Воспроизвести последний в списке",
|
||||||
|
"hotkey_listPlayNext": "Воспроизвести следующий в списке",
|
||||||
|
"sidebarPlaylistFolders_description": "Создать вид папки для плейлистов, которые включают настраиваемый разделитель в имени"
|
||||||
},
|
},
|
||||||
"releaseType": {
|
"releaseType": {
|
||||||
"secondary": {
|
"secondary": {
|
||||||
@@ -1162,6 +1275,56 @@
|
|||||||
"presetName": "Название пресета",
|
"presetName": "Название пресета",
|
||||||
"presetNamePlaceholder": "Введите название пресета",
|
"presetNamePlaceholder": "Введите название пресета",
|
||||||
"general": "Главная",
|
"general": "Главная",
|
||||||
"lineWidth": "Ширина линии"
|
"lineWidth": "Ширина линии",
|
||||||
|
"systemAudioConsentAllow": "Разрешить",
|
||||||
|
"systemAudioConsentBody": "Для работы визуализатора требуется доступ к аудио в системе",
|
||||||
|
"systemAudioConsentDecline": "Запретить",
|
||||||
|
"systemAudioConsentTitle": "Разрешить доступ к аудио в системе?",
|
||||||
|
"systemAudioCaptureFailed": "Не удается начать захват: {{message}}",
|
||||||
|
"visualizerType": "Тип визуализатора",
|
||||||
|
"cyclePresets": "Переключаться между наборами настроек",
|
||||||
|
"cycleTime": "Время между переключениями (в секундах)",
|
||||||
|
"includeAllPresets": "Включить все наборы настроек",
|
||||||
|
"ignoredPresets": "Игнорируемые наборы настроек",
|
||||||
|
"selectedPresets": "Выбранные наборы настроек",
|
||||||
|
"randomizeNextPreset": "Выбирать следующий набор настроек случайным образом",
|
||||||
|
"blendTime": "Время смешивания",
|
||||||
|
"mode": "Режим",
|
||||||
|
"mode1To8": "Режимы 1-8",
|
||||||
|
"mode10": "Режим 10",
|
||||||
|
"maxFPS": "Максимум кадров в секунду",
|
||||||
|
"opacity": "Прозрачность",
|
||||||
|
"customGradients": "Пользовательские градиенты",
|
||||||
|
"addCustomGradient": "Добавить пользовательский градиент",
|
||||||
|
"gradientName": "Название градиента",
|
||||||
|
"gradientNamePlaceholder": "Название градиента",
|
||||||
|
"vertical": "Вертикальный",
|
||||||
|
"horizontal": "Горизонтальный",
|
||||||
|
"addColor": "Добавить цвет",
|
||||||
|
"position": "Расположение",
|
||||||
|
"level": "Уровень",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"pasteGradient": "Вставить градиент",
|
||||||
|
"pasteGradientPlaceholder": "Вставить JSON с градиентом сюда...",
|
||||||
|
"custom": "Пользовательский",
|
||||||
|
"builtIn": "Встроенный",
|
||||||
|
"colors": "Цвета",
|
||||||
|
"colorMode": "Цветовой режим",
|
||||||
|
"gradient": "Градиент",
|
||||||
|
"gradientLeft": "Градиент слева",
|
||||||
|
"gradientRight": "Градиент справа",
|
||||||
|
"smoothing": "Сглаживание",
|
||||||
|
"minimumFrequency": "Минимальная частота",
|
||||||
|
"maximumFrequency": "Максимальная частота",
|
||||||
|
"sensitivity": "Чуствительность",
|
||||||
|
"minimumDecibels": "Минимум децибел",
|
||||||
|
"maximumDecibels": "Максимум децибел",
|
||||||
|
"linearAmplitude": "Линейная амплитуда",
|
||||||
|
"showPeaks": "Показывать пики"
|
||||||
|
},
|
||||||
|
"dragDropZone": {
|
||||||
|
"error_oneFileOnly": "Выбери только 1 файл",
|
||||||
|
"error_readingFile": "Проблема при чтении файла: {{errorMessage}}",
|
||||||
|
"mainText": "Перемести файл сюда"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Стовпці таблиці",
|
"tableColumns": "Стовпці таблиці",
|
||||||
"itemsMore": "{{count}} більше",
|
"itemsMore": "{{count}} більше",
|
||||||
"numberOfResults": "{{numberOfResults}} результатів",
|
"numberOfResults": "{{numberOfResults}} результатів",
|
||||||
"newVersionAvailable": "Доступна нова версія"
|
"newVersionAvailable": "Доступна нова версія",
|
||||||
|
"back": "Повернутися"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Альбом",
|
"album_one": "Альбом",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"bitrate": "位元率",
|
"bitrate": "位元率",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"clear": "清空",
|
"clear": "清空",
|
||||||
"collapse": "折疊",
|
"collapse": "摺疊",
|
||||||
"comingSoon": "即將推出…",
|
"comingSoon": "即將推出…",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"decrease": "降低",
|
"decrease": "降低",
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
"add": "新增",
|
"add": "新增",
|
||||||
"areYouSure": "你確定嗎?",
|
"areYouSure": "你確定嗎?",
|
||||||
"ascending": "升冪",
|
"ascending": "升冪",
|
||||||
"disable": "禁用",
|
"disable": "停用",
|
||||||
"disc": "光碟",
|
"disc": "光碟",
|
||||||
"dismiss": "不再顯示",
|
"dismiss": "不理會",
|
||||||
"duration": "時長",
|
"duration": "時長",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"enable": "啟用",
|
"enable": "啟用",
|
||||||
@@ -64,9 +64,9 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"center": "中央",
|
"center": "中央",
|
||||||
"channel_other": "聲道",
|
"channel_other": "聲道",
|
||||||
"configure": "設定",
|
"configure": "配置",
|
||||||
"create": "建立",
|
"create": "建立",
|
||||||
"currentSong": "目前$t(entity.track, {\"count\": 1})",
|
"currentSong": "當前 $t(entity.track, {\"count\": 1})",
|
||||||
"minimize": "最小化",
|
"minimize": "最小化",
|
||||||
"modified": "已修改",
|
"modified": "已修改",
|
||||||
"name": "名稱",
|
"name": "名稱",
|
||||||
@@ -75,13 +75,13 @@
|
|||||||
"noResultsFromQuery": "未查詢到匹配結果",
|
"noResultsFromQuery": "未查詢到匹配結果",
|
||||||
"note": "注釋",
|
"note": "注釋",
|
||||||
"additionalParticipants": "額外參與者",
|
"additionalParticipants": "額外參與者",
|
||||||
"newVersion": "已安裝新版本 ({{version}})",
|
"newVersion": "新版本 ({{version}}) 已被安裝",
|
||||||
"viewReleaseNotes": "查看發行註記",
|
"viewReleaseNotes": "查看發行註記",
|
||||||
"albumGain": "專輯增益",
|
"albumGain": "專輯增益",
|
||||||
"albumPeak": "專輯峰值",
|
"albumPeak": "專輯峰值",
|
||||||
"bitDepth": "位元深度",
|
"bitDepth": "位元深度",
|
||||||
"close": "關閉",
|
"close": "關閉",
|
||||||
"codec": "編碼",
|
"codec": "編解碼器",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
"preview": "預覽",
|
"preview": "預覽",
|
||||||
"reload": "重新載入",
|
"reload": "重新載入",
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
"explicit": "露骨",
|
"explicit": "露骨",
|
||||||
"gridRows": "網格行",
|
"gridRows": "網格行",
|
||||||
"noFilters": "未設定任何過濾器",
|
"noFilters": "未設定任何過濾器",
|
||||||
"countSelected": "{{count}}個已選取",
|
"countSelected": "{{count}} 個已選取",
|
||||||
"retry": "重試",
|
"retry": "重試",
|
||||||
"example": "範例",
|
"example": "範例",
|
||||||
"mood": "情緒",
|
"mood": "情緒",
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
},
|
},
|
||||||
"appMenu": {
|
"appMenu": {
|
||||||
"openBrowserDevtools": "開啟瀏覽器開發者工具",
|
"openBrowserDevtools": "開啟瀏覽器開發者工具",
|
||||||
"collapseSidebar": "折疊側邊欄",
|
"collapseSidebar": "摺疊側邊欄",
|
||||||
"expandSidebar": "展開側邊欄",
|
"expandSidebar": "展開側邊欄",
|
||||||
"goBack": "返回",
|
"goBack": "返回",
|
||||||
"goForward": "前進",
|
"goForward": "前進",
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
"playbackSpeed": "播放速度",
|
"playbackSpeed": "播放速度",
|
||||||
"playRandom": "隨機播放",
|
"playRandom": "隨機播放",
|
||||||
"previous": "上一首",
|
"previous": "上一首",
|
||||||
"queue_clear": "清空播放佇列",
|
"queue_clear": "清空佇列",
|
||||||
"queue_remove": "移除所選",
|
"queue_remove": "移除所選",
|
||||||
"repeat": "循環",
|
"repeat": "循環",
|
||||||
"repeat_all": "全部循環",
|
"repeat_all": "全部循環",
|
||||||
@@ -420,7 +420,8 @@
|
|||||||
"sleepTimer_setCustom": "設定定時器",
|
"sleepTimer_setCustom": "設定定時器",
|
||||||
"sleepTimer_cancel": "取消定時器",
|
"sleepTimer_cancel": "取消定時器",
|
||||||
"albumRadio": "專輯電台",
|
"albumRadio": "專輯電台",
|
||||||
"scrobbleForceSubmit": "強制紀錄"
|
"scrobbleForceSubmit": "強制紀錄",
|
||||||
|
"sleepTimer_endOfAlbum": "專輯播完時"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
||||||
@@ -445,7 +446,7 @@
|
|||||||
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
|
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
|
||||||
"customFontPath": "自訂字型路徑",
|
"customFontPath": "自訂字型路徑",
|
||||||
"customFontPath_description": "設定應用程式要使用的自訂字型路徑",
|
"customFontPath_description": "設定應用程式要使用的自訂字型路徑",
|
||||||
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
|
"disableLibraryUpdateOnStartup": "停用啟動時檢查新版本",
|
||||||
"discordApplicationId": "{{discord}} 應用程式 ID",
|
"discordApplicationId": "{{discord}} 應用程式 ID",
|
||||||
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}})",
|
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}})",
|
||||||
"discordIdleStatus": "顯示 Rich Presence 閒置狀態",
|
"discordIdleStatus": "顯示 Rich Presence 閒置狀態",
|
||||||
@@ -531,8 +532,8 @@
|
|||||||
"showSkipButton": "顯示跳過按鈕",
|
"showSkipButton": "顯示跳過按鈕",
|
||||||
"showSkipButton_description": "在播放條上顯示/隱藏跳過按鈕",
|
"showSkipButton_description": "在播放條上顯示/隱藏跳過按鈕",
|
||||||
"sidebarPlaylistList": "側邊欄播放清單列表",
|
"sidebarPlaylistList": "側邊欄播放清單列表",
|
||||||
"sidebarCollapsedNavigation": "側邊欄(已折疊)導航",
|
"sidebarCollapsedNavigation": "側邊欄(已摺疊)導航",
|
||||||
"sidebarCollapsedNavigation_description": "在折疊的側邊欄中顯示或隱藏導航",
|
"sidebarCollapsedNavigation_description": "在摺疊的側邊欄中顯示或隱藏導航",
|
||||||
"sidebarConfiguration": "側邊欄設定",
|
"sidebarConfiguration": "側邊欄設定",
|
||||||
"sidebarConfiguration_description": "選擇側邊欄包含的項目與順序",
|
"sidebarConfiguration_description": "選擇側邊欄包含的項目與順序",
|
||||||
"sidebarPlaylistList_description": "顯示或隱藏側邊欄歌單清單",
|
"sidebarPlaylistList_description": "顯示或隱藏側邊欄歌單清單",
|
||||||
@@ -561,7 +562,7 @@
|
|||||||
"exitToTray_description": "退出應用程式時最小化到系統匣而非關閉",
|
"exitToTray_description": "退出應用程式時最小化到系統匣而非關閉",
|
||||||
"followLyric_description": "滾動歌詞到目前播放位置",
|
"followLyric_description": "滾動歌詞到目前播放位置",
|
||||||
"font": "字型",
|
"font": "字型",
|
||||||
"globalMediaHotkeys_description": "啟用或禁用系統媒體快捷鍵以控制播放",
|
"globalMediaHotkeys_description": "啟用或停用系統媒體快捷鍵以控制播放",
|
||||||
"hotkey_browserBack": "瀏覽器返回",
|
"hotkey_browserBack": "瀏覽器返回",
|
||||||
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
||||||
"hotkey_playbackStop": "停止",
|
"hotkey_playbackStop": "停止",
|
||||||
@@ -572,7 +573,7 @@
|
|||||||
"remotePassword": "遠端控制伺服器密碼",
|
"remotePassword": "遠端控制伺服器密碼",
|
||||||
"remotePassword_description": "設定遠端控制伺服器的密碼。這些憑證預設以不安全的方式傳輸,因此您應該使用一個您不在意的唯一密碼",
|
"remotePassword_description": "設定遠端控制伺服器的密碼。這些憑證預設以不安全的方式傳輸,因此您應該使用一個您不在意的唯一密碼",
|
||||||
"remotePort_description": "設定遠端控制伺服器的連接埠",
|
"remotePort_description": "設定遠端控制伺服器的連接埠",
|
||||||
"remoteUsername_description": "設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空,則身分驗證將被禁用",
|
"remoteUsername_description": "設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空,則身分驗證將被停用",
|
||||||
"replayGainClipping_description": "自動降低增益以防止{{ReplayGain}}造成削波",
|
"replayGainClipping_description": "自動降低增益以防止{{ReplayGain}}造成削波",
|
||||||
"showSkipButtons": "顯示跳過按鈕",
|
"showSkipButtons": "顯示跳過按鈕",
|
||||||
"themeDark_description": "應用程式將使用深色主題",
|
"themeDark_description": "應用程式將使用深色主題",
|
||||||
@@ -666,7 +667,7 @@
|
|||||||
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
|
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
|
||||||
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
|
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
|
||||||
"mediaSession": "啟用 Media Session",
|
"mediaSession": "啟用 Media Session",
|
||||||
"mediaSession_description": "啟用 Media Session 整合功能,於系統音量 Overlay 和鎖定畫面中顯示媒體資料與控制面板",
|
"mediaSession_description": "啟用 Media Session 整合功能,在系統音量疊加層和鎖定畫面上顯示媒體控制項與中繼資料。此功能需要使用網頁播放器。",
|
||||||
"releaseChannel": "發佈通道",
|
"releaseChannel": "發佈通道",
|
||||||
"analyticsDisable": "選擇退出使用情況分析",
|
"analyticsDisable": "選擇退出使用情況分析",
|
||||||
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
|
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
|
||||||
@@ -715,8 +716,8 @@
|
|||||||
"playerFilters": "從佇列中過濾歌曲",
|
"playerFilters": "從佇列中過濾歌曲",
|
||||||
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
|
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
|
||||||
"autoDJ": "Auto DJ",
|
"autoDJ": "Auto DJ",
|
||||||
"autoDJ_itemCount": "歌曲數量",
|
"autoDJ_itemCount": "項目數量",
|
||||||
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
|
"autoDJ_itemCount_description": "嘗試加入佇列的項目數量",
|
||||||
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
|
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
|
||||||
"autoDJ_timing": "觸發時機",
|
"autoDJ_timing": "觸發時機",
|
||||||
"logLevel": "Log等級",
|
"logLevel": "Log等級",
|
||||||
@@ -818,7 +819,16 @@
|
|||||||
"sidebarPlaylistMode_description": "各播放清單在側邊欄列表中的顯示方式",
|
"sidebarPlaylistMode_description": "各播放清單在側邊欄列表中的顯示方式",
|
||||||
"sidebarPlaylistMode": "側邊欄播放清單模式",
|
"sidebarPlaylistMode": "側邊欄播放清單模式",
|
||||||
"sidebarPlaylistMode_optionCompact": "緊湊",
|
"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": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -923,34 +933,34 @@
|
|||||||
"title": "標題",
|
"title": "標題",
|
||||||
"trackNumber": "曲目編號",
|
"trackNumber": "曲目編號",
|
||||||
"size": "大小",
|
"size": "大小",
|
||||||
"codec": "編碼",
|
"codec": "編解碼器",
|
||||||
"owner": "擁有者",
|
"owner": "擁有者",
|
||||||
"bitDepth": "位元深度",
|
"bitDepth": "位元深度",
|
||||||
"sampleRate": "取樣率"
|
"sampleRate": "取樣率"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"addToFavorites": "新增到$t(entity.favorite, {\"count\": 2})",
|
"addToFavorites": "新增至 $t(entity.favorite, {\"count\": 2})",
|
||||||
"clearQueue": "清空播放佇列",
|
"clearQueue": "清空佇列",
|
||||||
"createPlaylist": "建立$t(entity.playlist, {\"count\": 1})",
|
"createPlaylist": "建立 $t(entity.playlist, {\"count\": 1})",
|
||||||
"deletePlaylist": "刪除$t(entity.playlist, {\"count\": 1})",
|
"deletePlaylist": "刪除 $t(entity.playlist, {\"count\": 1})",
|
||||||
"addToPlaylist": "新增到$t(entity.playlist, {\"count\": 1})",
|
"addToPlaylist": "新增至 $t(entity.playlist, {\"count\": 1})",
|
||||||
"deselectAll": "取消全選",
|
"deselectAll": "取消全選",
|
||||||
"editPlaylist": "編輯 $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "編輯 $t(entity.playlist, {\"count\": 1})",
|
||||||
"goToPage": "前往頁面",
|
"goToPage": "前往頁面",
|
||||||
"moveToBottom": "移至底部",
|
"moveToBottom": "移至底部",
|
||||||
"moveToTop": "移至頂部",
|
"moveToTop": "移至頂部",
|
||||||
"refresh": "$t(common.refresh)",
|
"refresh": "$t(common.refresh)",
|
||||||
"removeFromFavorites": "從$t(entity.favorite, {\"count\": 2})移除",
|
"removeFromFavorites": "從 $t(entity.favorite, {\"count\": 2}) 移除",
|
||||||
"removeFromPlaylist": "從$t(entity.playlist, {\"count\": 1})移除",
|
"removeFromPlaylist": "從 $t(entity.playlist, {\"count\": 1}) 移除",
|
||||||
"removeFromQueue": "從播放佇列中移除",
|
"removeFromQueue": "從佇列中移除",
|
||||||
"setRating": "評分",
|
"setRating": "評分",
|
||||||
"toggleSmartPlaylistEditor": "切換$t(entity.smartPlaylist)編輯器",
|
"toggleSmartPlaylistEditor": "切換 $t(entity.smartPlaylist) 編輯器",
|
||||||
"viewPlaylists": "查看$t(entity.playlist, {\"count\": 2})",
|
"viewPlaylists": "查看 $t(entity.playlist, {\"count\": 2})",
|
||||||
"moveToNext": "移至下一項",
|
"moveToNext": "移至下一項",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "在Last.fm開啟",
|
"lastfm": "在 Last.fm 中開啟",
|
||||||
"musicbrainz": "在MusicBrainz開啟",
|
"musicbrainz": "在 MusicBrainz 開啟",
|
||||||
"spotify": "在 Spotify 中開啟",
|
"spotify": "在 Spotify 中開啟",
|
||||||
"listenbrainz": "在 ListenBrainz 中開啟",
|
"listenbrainz": "在 ListenBrainz 中開啟",
|
||||||
"qobuz": "在 Qobuz 中開啟"
|
"qobuz": "在 Qobuz 中開啟"
|
||||||
@@ -1137,7 +1147,12 @@
|
|||||||
"input_played": "播放過濾器",
|
"input_played": "播放過濾器",
|
||||||
"input_played_optionAll": "所有曲目",
|
"input_played_optionAll": "所有曲目",
|
||||||
"input_played_optionUnplayed": "僅未播放的曲目",
|
"input_played_optionUnplayed": "僅未播放的曲目",
|
||||||
"input_played_optionPlayed": "僅播放過的曲目"
|
"input_played_optionPlayed": "僅播放過的曲目",
|
||||||
|
"input_kind_albums": "專輯",
|
||||||
|
"input_kind_songs": "歌曲",
|
||||||
|
"input_kind": "隨機選取",
|
||||||
|
"input_limit_albums": "專輯數量?",
|
||||||
|
"input_limit_songs": "歌曲數量?"
|
||||||
},
|
},
|
||||||
"createRadioStation": {
|
"createRadioStation": {
|
||||||
"success": "電台建立成功",
|
"success": "電台建立成功",
|
||||||
|
|||||||
@@ -120,8 +120,14 @@ const createMpv = async (data: {
|
|||||||
}): Promise<MpvAPI> => {
|
}): Promise<MpvAPI> => {
|
||||||
const { binaryPath, extraParameters, properties } = data;
|
const { binaryPath, extraParameters, properties } = data;
|
||||||
const resolvedBinaryPath = await resolveMpvBinaryPath(binaryPath);
|
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(
|
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 => {
|
ipcMain.handle('password-get', (_event, server: string): null | string => {
|
||||||
if (safeStorage.isEncryptionAvailable()) {
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
const servers = store.get('server') as Record<string, string> | undefined;
|
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);
|
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) => {
|
process.on('uncaughtException', (error: any) => {
|
||||||
console.error('Error in main process', error);
|
console.error('Error in main process', error);
|
||||||
@@ -335,7 +337,7 @@ if (isDevelopment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RESOURCES_PATH = app.isPackaged
|
const RESOURCES_PATH = app.isPackaged
|
||||||
? path.join(process.resourcesPath, 'assets')
|
? path.join(path.dirname(app.getAppPath()), 'assets')
|
||||||
: path.join(__dirname, '../../assets');
|
: path.join(__dirname, '../../assets');
|
||||||
|
|
||||||
const getAssetPath = (...paths: string[]): string => {
|
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/collection',
|
||||||
'font/otf',
|
'font/otf',
|
||||||
'font/sfnt',
|
'font/sfnt',
|
||||||
'font/ttf',
|
'font/ttf',
|
||||||
'font/woff',
|
'font/woff',
|
||||||
'font/woff2',
|
'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();
|
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
|
||||||
|
|
||||||
@@ -1017,12 +1038,9 @@ if (!singleInstance) {
|
|||||||
|
|
||||||
app.whenReady()
|
app.whenReady()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
protocol.handle('feishin', async (request) => {
|
protocol.handle('feishin', async () => {
|
||||||
const filePath = `file:${request.url.slice('feishin:'.length)}`;
|
const filePath = store.get('local_font_path');
|
||||||
const response = await net.fetch(filePath);
|
if (typeof filePath !== 'string') {
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
|
|
||||||
if (!contentType || !FONT_HEADERS.includes(contentType)) {
|
|
||||||
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
||||||
|
|
||||||
return new Response(null, {
|
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) => {
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||||
@@ -1039,7 +1088,7 @@ if (!singleInstance) {
|
|||||||
responseHeaders: {
|
responseHeaders: {
|
||||||
...details.responseHeaders,
|
...details.responseHeaders,
|
||||||
'Content-Security-Policy': [
|
'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 });
|
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) => {
|
const get = async (property: string) => {
|
||||||
return ipcRenderer.invoke('settings-get', { property });
|
return ipcRenderer.invoke('settings-get', { property });
|
||||||
};
|
};
|
||||||
@@ -99,6 +106,7 @@ export const localSettings = {
|
|||||||
passwordSet,
|
passwordSet,
|
||||||
restart,
|
restart,
|
||||||
set,
|
set,
|
||||||
|
setSync,
|
||||||
setZoomFactor,
|
setZoomFactor,
|
||||||
themeSet,
|
themeSet,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export const utils = {
|
|||||||
rendererToggleSidebar,
|
rendererToggleSidebar,
|
||||||
rendererUpdateAvailable,
|
rendererUpdateAvailable,
|
||||||
saveCustomCss,
|
saveCustomCss,
|
||||||
|
separator: isWindows() ? '\\' : '/',
|
||||||
setInputFocused,
|
setInputFocused,
|
||||||
startPowerSaveBlocker,
|
startPowerSaveBlocker,
|
||||||
stopPowerSaveBlocker,
|
stopPowerSaveBlocker,
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
deleteArtistImage: {
|
||||||
|
body: null,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'Items/:id/Images/Primary',
|
||||||
|
responses: {
|
||||||
|
204: jfType._response.deleteArtistImage,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
deletePlaylist: {
|
deletePlaylist: {
|
||||||
body: null,
|
body: null,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -63,6 +72,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
deletePlaylistImage: {
|
||||||
|
body: null,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'Items/:id/Images/Primary',
|
||||||
|
responses: {
|
||||||
|
204: jfType._response.deletePlaylistImage,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
getAlbumArtistDetail: {
|
getAlbumArtistDetail: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'users/:userId/items/:id',
|
path: 'users/:userId/items/:id',
|
||||||
@@ -356,6 +374,24 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
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({});
|
const axiosClient = axios.create({});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import { set } from 'idb-keyval';
|
import { set } from 'idb-keyval';
|
||||||
import chunk from 'lodash/chunk';
|
import chunk from 'lodash/chunk';
|
||||||
import filter from 'lodash/filter';
|
import filter from 'lodash/filter';
|
||||||
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
|
|||||||
import {
|
import {
|
||||||
albumArtistListSortMap,
|
albumArtistListSortMap,
|
||||||
albumListSortMap,
|
albumListSortMap,
|
||||||
|
DeleteArtistImageArgs,
|
||||||
|
DeleteArtistImageResponse,
|
||||||
|
DeletePlaylistImageArgs,
|
||||||
|
DeletePlaylistImageResponse,
|
||||||
Folder,
|
Folder,
|
||||||
genreListSortMap,
|
genreListSortMap,
|
||||||
ImageArgs,
|
ImageArgs,
|
||||||
@@ -29,6 +34,10 @@ import {
|
|||||||
SortOrder,
|
SortOrder,
|
||||||
sortOrderMap,
|
sortOrderMap,
|
||||||
Tag,
|
Tag,
|
||||||
|
UploadArtistImageArgs,
|
||||||
|
UploadArtistImageResponse,
|
||||||
|
UploadPlaylistImageArgs,
|
||||||
|
UploadPlaylistImageResponse,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
|
|
||||||
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
|
|||||||
return value.join(',');
|
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
|
// 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
|
// length of the full URL, since the ids are part of the query string and
|
||||||
// not the POST body
|
// not the POST body
|
||||||
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
|
|||||||
[ServerFeature.PUBLIC_PLAYLIST]: [1],
|
[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 = {
|
const JF_FIELDS = {
|
||||||
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
id: res.body.Id,
|
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) => {
|
deleteFavorite: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
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) => {
|
getAlbumArtistDetail: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
@@ -411,8 +527,12 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
throw new Error('Failed to get album detail');
|
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(
|
return jfNormalize.album(
|
||||||
{ ...res.body, Songs: songsRes.body.Items },
|
{ ...res.body, Songs: songs },
|
||||||
apiClientProps.server,
|
apiClientProps.server,
|
||||||
args.context?.pathReplace,
|
args.context?.pathReplace,
|
||||||
args.context?.pathReplaceWith,
|
args.context?.pathReplaceWith,
|
||||||
@@ -1673,6 +1793,17 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.event === 'stop') {
|
||||||
|
jfApiClient(apiClientProps).scrobbleStopped({
|
||||||
|
body: {
|
||||||
|
ItemId: query.id,
|
||||||
|
PositionTicks: position,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
jfApiClient(apiClientProps).scrobbleProgress({
|
jfApiClient(apiClientProps).scrobbleProgress({
|
||||||
body: {
|
body: {
|
||||||
ItemId: query.id,
|
ItemId: query.id,
|
||||||
@@ -1843,6 +1974,28 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
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[]) {
|
function getLibraryId(musicFolderId?: string | string[]) {
|
||||||
|
|||||||
@@ -366,7 +366,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
|
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
|
||||||
? query.id
|
? query.id
|
||||||
: undefined,
|
: 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.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
|
||||||
? query.id
|
? query.id
|
||||||
: undefined,
|
: undefined,
|
||||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
id:
|
||||||
|
query.type === LibraryItem.SONG || query.type === LibraryItem.PLAYLIST_SONG
|
||||||
|
? query.id
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2303,7 +2309,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
|
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
|
||||||
if (query.submission) {
|
if (query.submission || query.event === 'start') {
|
||||||
const res = await ssApiClient(apiClientProps).scrobble({
|
const res = await ssApiClient(apiClientProps).scrobble({
|
||||||
query: {
|
query: {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
@@ -2315,39 +2321,54 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
throw new Error('Failed to scrobble');
|
throw new Error('Failed to scrobble');
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
if (query.submission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
|
const defaultParams = {
|
||||||
|
ignoreScrobble: true,
|
||||||
|
mediaId: query.id,
|
||||||
|
mediaType: query.mediaType,
|
||||||
|
playbackRate: query.playbackRate,
|
||||||
|
positionMs: query.position ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportPlayback = (state: 'paused' | 'playing' | 'starting' | 'stopped') => {
|
||||||
|
return ssApiClient(apiClientProps).reportPlayback({
|
||||||
|
query: {
|
||||||
|
...defaultParams,
|
||||||
|
state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const promises: Promise<any>[] = [];
|
||||||
|
|
||||||
switch (query.event) {
|
switch (query.event) {
|
||||||
case 'pause':
|
case 'pause':
|
||||||
state = 'paused';
|
promises.push(reportPlayback('paused'));
|
||||||
break;
|
break;
|
||||||
case 'start':
|
case 'start':
|
||||||
state = 'starting';
|
promises.push(reportPlayback('starting'));
|
||||||
|
promises.push(reportPlayback('playing'));
|
||||||
|
break;
|
||||||
|
case 'stop':
|
||||||
|
promises.push(reportPlayback('stopped'));
|
||||||
break;
|
break;
|
||||||
case 'timeupdate':
|
|
||||||
case 'unpause':
|
case 'unpause':
|
||||||
state = 'playing';
|
promises.push(reportPlayback('playing'));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
state = 'playing';
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await ssApiClient(apiClientProps).reportPlayback({
|
for (const promise of promises) {
|
||||||
query: {
|
const res = await promise;
|
||||||
ignoreScrobble: true,
|
|
||||||
mediaId: query.id,
|
|
||||||
mediaType: query.mediaType,
|
|
||||||
playbackRate: query.playbackRate,
|
|
||||||
positionMs: query.position ?? 0,
|
|
||||||
state,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw new Error('Failed to report playback');
|
throw new Error('Failed to report playback');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useListContext } from '/@/renderer/context/list-context';
|
|||||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||||
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
||||||
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
|
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem, SortKeyRandom } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const getListQueryKeyName = (itemType: LibraryItem): string => {
|
export const getListQueryKeyName = (itemType: LibraryItem): string => {
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
@@ -108,8 +108,19 @@ export const useItemListInfiniteLoader = ({
|
|||||||
[serverId, itemType, query],
|
[serverId, itemType, query],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isRandomSort = query?.sortBy === SortKeyRandom;
|
||||||
|
|
||||||
const fetchPage = useCallback(
|
const fetchPage = useCallback(
|
||||||
async (pageNumber: number) => {
|
async (pageNumber: number) => {
|
||||||
|
if (isRandomSort) {
|
||||||
|
const existingData =
|
||||||
|
queryClient.getQueryData<InfiniteLoaderCacheData>(dataQueryKey);
|
||||||
|
if (existingData?.pagesLoaded?.[pageNumber]) {
|
||||||
|
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startIndex = pageNumber * itemsPerPage;
|
const startIndex = pageNumber * itemsPerPage;
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
@@ -118,6 +129,7 @@ export const useItemListInfiniteLoader = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await queryClient.fetchQuery({
|
const result = await queryClient.fetchQuery({
|
||||||
|
gcTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const result = await listQueryFn({
|
const result = await listQueryFn({
|
||||||
apiClientProps: { serverId, signal },
|
apiClientProps: { serverId, signal },
|
||||||
@@ -127,6 +139,7 @@ export const useItemListInfiniteLoader = ({
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams),
|
queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams),
|
||||||
|
staleTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the query data with the fetched page
|
// Update the query data with the fetched page
|
||||||
@@ -154,13 +167,32 @@ export const useItemListInfiniteLoader = ({
|
|||||||
// Track the last fetched page
|
// Track the last fetched page
|
||||||
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
|
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
|
||||||
},
|
},
|
||||||
[itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType],
|
[
|
||||||
|
itemsPerPage,
|
||||||
|
query,
|
||||||
|
queryClient,
|
||||||
|
serverId,
|
||||||
|
dataQueryKey,
|
||||||
|
listQueryFn,
|
||||||
|
itemType,
|
||||||
|
isRandomSort,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset the loaded pages and refetch current page when the query changes
|
// Reset the loaded pages and refetch current page when the query changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentDataQueryKey = JSON.stringify(dataQueryKey);
|
const currentDataQueryKey = JSON.stringify(dataQueryKey);
|
||||||
|
|
||||||
|
if (isRandomSort) {
|
||||||
|
const existingData = queryClient.getQueryData<InfiniteLoaderCacheData | undefined>(
|
||||||
|
dataQueryKey,
|
||||||
|
);
|
||||||
|
if (existingData?.dataMap && existingData.dataMap.size > 0) {
|
||||||
|
previousDataQueryKeyRef.current = currentDataQueryKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (previousDataQueryKeyRef.current === currentDataQueryKey || isRefetchingRef.current) {
|
if (previousDataQueryKeyRef.current === currentDataQueryKey || isRefetchingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useListContext } from '/@/renderer/context/list-context';
|
|||||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||||
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
||||||
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
|
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem, SortKeyRandom } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
const getQueryKeyName = (itemType: LibraryItem): string => {
|
const getQueryKeyName = (itemType: LibraryItem): string => {
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
@@ -76,6 +76,8 @@ export const useItemListPaginatedLoader = ({
|
|||||||
const fetchRange = getFetchRange(currentPage, itemsPerPage);
|
const fetchRange = getFetchRange(currentPage, itemsPerPage);
|
||||||
const startIndex = fetchRange.startIndex;
|
const startIndex = fetchRange.startIndex;
|
||||||
|
|
||||||
|
const isRandomSort = query?.sortBy === SortKeyRandom;
|
||||||
|
|
||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
@@ -86,7 +88,7 @@ export const useItemListPaginatedLoader = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
gcTime: 1000 * 15,
|
gcTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
|
||||||
placeholderData: { items: getInitialData(itemsPerPage) },
|
placeholderData: { items: getInitialData(itemsPerPage) },
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const result = await listQueryFn({
|
const result = await listQueryFn({
|
||||||
@@ -97,7 +99,7 @@ export const useItemListPaginatedLoader = ({
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
|
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
|
||||||
staleTime: 1000 * 15,
|
staleTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshMutation = useMutation({
|
const refreshMutation = useMutation({
|
||||||
|
|||||||
@@ -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 { 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 { 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 { 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 { 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 { 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';
|
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.CHANNELS:
|
||||||
case TableColumn.DISC_NUMBER:
|
case TableColumn.DISC_NUMBER:
|
||||||
case TableColumn.SAMPLE_RATE:
|
case TableColumn.SAMPLE_RATE:
|
||||||
case TableColumn.TRACK_NUMBER:
|
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||||
return (
|
|
||||||
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
||||||
);
|
|
||||||
|
|
||||||
case TableColumn.COMPOSER:
|
case TableColumn.COMPOSER:
|
||||||
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;
|
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:
|
case TableColumn.USER_FAVORITE:
|
||||||
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
|
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 { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||||
import {
|
import { Album, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||||
Album,
|
|
||||||
AlbumArtist,
|
|
||||||
Artist,
|
|
||||||
LibraryItem,
|
|
||||||
QueueSong,
|
|
||||||
Song,
|
|
||||||
} from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
interface GoToActionProps {
|
interface GoToActionProps {
|
||||||
items: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[];
|
items: Album[] | QueueSong[] | Song[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GoToAction = ({ items }: GoToActionProps) => {
|
export const GoToAction = ({ items }: GoToActionProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { albumArtists, albumId } = useMemo(() => {
|
const { albumId, artists } = useMemo(() => {
|
||||||
const firstItem = items[0];
|
const firstItem = items[0];
|
||||||
|
|
||||||
if (firstItem._itemType === LibraryItem.ALBUM) {
|
switch (firstItem._itemType) {
|
||||||
return {
|
case LibraryItem.ALBUM:
|
||||||
albumArtists: firstItem.albumArtists || [],
|
return {
|
||||||
albumId: firstItem.id,
|
albumId: firstItem.id,
|
||||||
};
|
artists: firstItem.albumArtists || [],
|
||||||
} else if (firstItem._itemType === LibraryItem.SONG) {
|
};
|
||||||
return {
|
case LibraryItem.SONG:
|
||||||
albumArtists: firstItem.albumArtists || [],
|
return {
|
||||||
albumId: firstItem.albumId,
|
albumId: firstItem.albumId,
|
||||||
};
|
artists:
|
||||||
} else if (
|
(firstItem.artists?.length ? firstItem.artists : firstItem.albumArtists) ||
|
||||||
firstItem._itemType === LibraryItem.ARTIST ||
|
[],
|
||||||
firstItem._itemType === LibraryItem.ALBUM_ARTIST
|
};
|
||||||
) {
|
default:
|
||||||
return {
|
return {
|
||||||
albumArtists: [{ id: firstItem.id, name: firstItem.name }],
|
albumId: null,
|
||||||
albumId: null,
|
artists: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
albumArtists: [],
|
|
||||||
albumId: null,
|
|
||||||
};
|
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const handleGoToAlbum = useCallback(() => {
|
const handleGoToAlbum = useCallback(() => {
|
||||||
@@ -55,7 +43,7 @@ export const GoToAction = ({ items }: GoToActionProps) => {
|
|||||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId }));
|
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId }));
|
||||||
}, [albumId, navigate]);
|
}, [albumId, navigate]);
|
||||||
|
|
||||||
const handleGoToAlbumArtist = useCallback(
|
const handleGoToArtist = useCallback(
|
||||||
(albumArtistId: string) => {
|
(albumArtistId: string) => {
|
||||||
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId }));
|
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId }));
|
||||||
},
|
},
|
||||||
@@ -81,13 +69,13 @@ export const GoToAction = ({ items }: GoToActionProps) => {
|
|||||||
{t('page.contextMenu.goToAlbum')}
|
{t('page.contextMenu.goToAlbum')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
)}
|
)}
|
||||||
{albumArtists.map((albumArtist) => (
|
{artists.map((artist) => (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key={albumArtist.id}
|
key={artist.id}
|
||||||
leftIcon="artist"
|
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.Item>
|
||||||
))}
|
))}
|
||||||
</ContextMenu.SubmenuContent>
|
</ContextMenu.SubmenuContent>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
|
|||||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-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 { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||||
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
|
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
|
||||||
import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-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} />
|
<DownloadAction ids={ids} />
|
||||||
<ShareAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />
|
<ShareAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />
|
||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
<GoToAction items={items} />
|
|
||||||
<ContextMenu.Divider />
|
|
||||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
|
|||||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-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 { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||||
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
|
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
|
||||||
import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-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} />
|
<DownloadAction ids={ids} />
|
||||||
<ShareAction ids={ids} itemType={LibraryItem.ARTIST} />
|
<ShareAction ids={ids} itemType={LibraryItem.ARTIST} />
|
||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
<GoToAction items={items} />
|
|
||||||
<ContextMenu.Divider />
|
|
||||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
|
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
import {
|
import {
|
||||||
useIsRadioActive,
|
useIsRadioActive,
|
||||||
useRadioPlayer,
|
useRadioPlayer,
|
||||||
@@ -36,6 +37,7 @@ const DiscordStatusDisplayType = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ActivityState = [QueueSong | undefined, number, PlayerStatus];
|
type ActivityState = [QueueSong | undefined, number, PlayerStatus];
|
||||||
|
type ActivityTrigger = 'initial' | 'interval' | 'seek' | 'status_change' | 'track_change';
|
||||||
|
|
||||||
const MAX_FIELD_LENGTH = 127;
|
const MAX_FIELD_LENGTH = 127;
|
||||||
const MAX_URL_LENGTH = 256;
|
const MAX_URL_LENGTH = 256;
|
||||||
@@ -64,22 +66,24 @@ export const useDiscordRpc = () => {
|
|||||||
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
||||||
const previousEnabledRef = useRef<boolean>(discordSettings.enabled);
|
const previousEnabledRef = useRef<boolean>(discordSettings.enabled);
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
imageUrlRef.current = imageUrl;
|
imageUrlRef.current = imageUrl;
|
||||||
}, [imageUrl]);
|
}, [imageUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
discordEnabledRef.current = discordSettings.enabled;
|
||||||
|
}, [discordSettings.enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
privateModeRef.current = privateMode;
|
||||||
|
}, [privateMode]);
|
||||||
|
|
||||||
const setActivity = useCallback(
|
const setActivity = useCallback(
|
||||||
async (current: ActivityState, previous: ActivityState) => {
|
async (current: ActivityState, trigger: ActivityTrigger) => {
|
||||||
// Check if track changed by comparing with previous state
|
|
||||||
const song = current[0];
|
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 trackChanged = song ? lastUniqueId !== song._uniqueId : false;
|
||||||
|
|
||||||
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
||||||
@@ -103,6 +107,7 @@ export const useDiscordRpc = () => {
|
|||||||
meta: {
|
meta: {
|
||||||
reason,
|
reason,
|
||||||
status: current[2],
|
status: current[2],
|
||||||
|
trigger,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return discordRpc?.clearActivity();
|
return discordRpc?.clearActivity();
|
||||||
@@ -152,6 +157,7 @@ export const useDiscordRpc = () => {
|
|||||||
showAsListening: discordSettings.showAsListening,
|
showAsListening: discordSettings.showAsListening,
|
||||||
stationName: stationName || 'Radio',
|
stationName: stationName || 'Radio',
|
||||||
title,
|
title,
|
||||||
|
trigger,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
discordRpc?.setActivity(activity);
|
discordRpc?.setActivity(activity);
|
||||||
@@ -162,214 +168,177 @@ export const useDiscordRpc = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
if (trackChanged) {
|
||||||
1. If the song has just started, update status
|
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, {
|
||||||
2. If we jump more then 1.2 seconds from last state, update status to match
|
category: LogCategory.EXTERNAL,
|
||||||
3. If the current song id is completely different, update status
|
meta: {
|
||||||
4. If the player state changed, update status
|
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 (
|
if (
|
||||||
previous[1] === 0 ||
|
(discordSettings.linkType == DiscordLinkType.LAST_FM ||
|
||||||
Math.abs(current[1] - previous[1]) > 1.2 ||
|
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&
|
||||||
trackChangedByState ||
|
song?.artistName
|
||||||
trackChanged ||
|
|
||||||
current[2] !== previous[2]
|
|
||||||
) {
|
) {
|
||||||
if (trackChangedByState || trackChanged) {
|
activity.stateUrl =
|
||||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, {
|
'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);
|
||||||
category: LogCategory.EXTERNAL,
|
|
||||||
meta: {
|
const detailsUrl =
|
||||||
artistName: song.artists?.[0]?.name,
|
'https://www.last.fm/music/' +
|
||||||
songId: song._uniqueId,
|
encodeURIComponent(song.albumArtists[0].name) +
|
||||||
songName: song.name,
|
'/' +
|
||||||
},
|
encodeURIComponent(song.album || '_') +
|
||||||
});
|
'/' +
|
||||||
setlastUniqueId(song._uniqueId);
|
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 (discordSettings.showStateIcon) {
|
||||||
if (trackChangedByState || trackChanged) {
|
activity.smallImageKey = 'playing';
|
||||||
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';
|
|
||||||
activity.smallImageText = sentenceCase(current[2]);
|
activity.smallImageText = sentenceCase(current[2]);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
activity.smallImageKey = 'paused';
|
||||||
|
activity.smallImageText = sentenceCase(current[2]);
|
||||||
|
}
|
||||||
|
|
||||||
if (discordSettings.showServerImage && song) {
|
if (discordSettings.showServerImage && song) {
|
||||||
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
|
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
|
||||||
if (song._serverType === ServerType.JELLYFIN) {
|
if (song._serverType === ServerType.JELLYFIN) {
|
||||||
activity.largeImageKey = imageUrlRef.current;
|
activity.largeImageKey = imageUrlRef.current;
|
||||||
} else if (
|
} else if (
|
||||||
song._serverType === ServerType.NAVIDROME ||
|
song._serverType === ServerType.NAVIDROME ||
|
||||||
song._serverType === ServerType.SUBSONIC
|
song._serverType === ServerType.SUBSONIC
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const info = await api.controller.getAlbumInfo({
|
const info = await api.controller.getAlbumInfo({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
forceRemoteUrl: true,
|
forceRemoteUrl: true,
|
||||||
serverId: song._serverId,
|
serverId: song._serverId,
|
||||||
},
|
},
|
||||||
query: { id: song.albumId },
|
query: { id: song.albumId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (info.imageUrl) {
|
if (info.imageUrl) {
|
||||||
activity.largeImageKey = info.imageUrl;
|
activity.largeImageKey = info.imageUrl;
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
}
|
||||||
|
} 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,
|
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
|
// Quit Discord RPC if it was enabled and is now disabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -409,95 +378,110 @@ export const useDiscordRpc = () => {
|
|||||||
}
|
}
|
||||||
}, [discordSettings.clientId, privateMode, discordSettings.enabled]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!discordSettings.enabled || privateMode) {
|
if (!discordSettings.enabled || privateMode) {
|
||||||
|
clearRefreshInterval();
|
||||||
return;
|
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();
|
const initialState = getCurrentActivityState();
|
||||||
let previousUniqueId = initialState[0]?._uniqueId || '';
|
emitActivityUpdate(initialState, 'initial');
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubSongChange();
|
clearRefreshInterval();
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
debouncedSetActivity,
|
clearRefreshInterval,
|
||||||
discordSettings.clientId,
|
|
||||||
discordSettings.enabled,
|
discordSettings.enabled,
|
||||||
|
emitActivityUpdate,
|
||||||
|
getCurrentActivityState,
|
||||||
privateMode,
|
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 = () => {
|
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 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) {
|
if (Array.isArray(local) && local.length > 0) {
|
||||||
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
||||||
return { selected: item, selectedSynced: item.synced };
|
return { selected: item, selectedSynced: item.synced };
|
||||||
@@ -236,6 +232,13 @@ export function getDisplayOffset(
|
|||||||
return storedOffsetMs;
|
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 => ({
|
const emptyResult = (): LyricsQueryResult => ({
|
||||||
local: null,
|
local: null,
|
||||||
overrideData: null,
|
overrideData: null,
|
||||||
@@ -277,16 +280,11 @@ export const lyricsQueries = {
|
|||||||
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
|
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
|
||||||
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
|
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
|
||||||
|
|
||||||
// Fetch local lyrics
|
|
||||||
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
|
||||||
|
|
||||||
// Fetch remote auto lyrics
|
|
||||||
const remoteAutoPromise =
|
const remoteAutoPromise =
|
||||||
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
|
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
|
||||||
? null
|
? null
|
||||||
: fetchRemoteLyricsAuto(song);
|
: fetchRemoteLyricsAuto(song);
|
||||||
|
|
||||||
// Fetch override data
|
|
||||||
const overrideDataPromise = overrideSelection
|
const overrideDataPromise = overrideSelection
|
||||||
? fetchRemoteLyricsById({
|
? fetchRemoteLyricsById({
|
||||||
remoteSongId: overrideSelection.id,
|
remoteSongId: overrideSelection.id,
|
||||||
@@ -295,11 +293,40 @@ export const lyricsQueries = {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [local, remoteAuto, overrideData] = await Promise.all([
|
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
||||||
localPromise,
|
|
||||||
remoteAutoPromise,
|
let local: FullLyricsMetadata | null | StructuredLyric[];
|
||||||
overrideDataPromise,
|
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<
|
const partial: Pick<
|
||||||
LyricsQueryResult,
|
LyricsQueryResult,
|
||||||
@@ -320,13 +347,12 @@ export const lyricsQueries = {
|
|||||||
preferLocalLyrics,
|
preferLocalLyrics,
|
||||||
selectedStructuredIndex,
|
selectedStructuredIndex,
|
||||||
);
|
);
|
||||||
const displayOffset = getDisplayOffset(
|
const resultSelectedOffsetMs = getDisplayOffset(
|
||||||
selected,
|
selected,
|
||||||
selectedOffsetMs,
|
selectedOffsetMs,
|
||||||
selectedStructuredIndex,
|
selectedStructuredIndex,
|
||||||
local,
|
local,
|
||||||
);
|
);
|
||||||
const resultSelectedOffsetMs = displayOffset;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...emptyResult(),
|
...emptyResult(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useIsFetching } from '@tanstack/react-query';
|
import { useIsFetching } from '@tanstack/react-query';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { RefObject } from 'react';
|
import { RefObject, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import styles from './play-queue-list-controls.module.css';
|
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 { Box } from '/@/shared/components/box/box';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -135,7 +136,17 @@ const QueueRestoreActions = () => {
|
|||||||
|
|
||||||
const isFetching = useIsFetching({ queryKey: queryKeys.player.fetch({ type: 'queue' }) });
|
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();
|
const handleRestoreQueue = useRestoreQueue();
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,14 @@ export const SidebarPlayQueue = () => {
|
|||||||
))}
|
))}
|
||||||
</SplitPane>
|
</SplitPane>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={0} h="100%" w="100%">
|
<Stack
|
||||||
|
gap={0}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
w="100%"
|
||||||
|
>
|
||||||
<PlayQueueListControls
|
<PlayQueueListControls
|
||||||
handleSearch={setSearch}
|
handleSearch={setSearch}
|
||||||
searchTerm={search}
|
searchTerm={search}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { QueueSong } from '/@/shared/types/domain-types';
|
|||||||
export function useSongUrl(
|
export function useSongUrl(
|
||||||
song: QueueSong | undefined,
|
song: QueueSong | undefined,
|
||||||
current: boolean,
|
current: boolean,
|
||||||
transcode: TranscodingConfig,
|
transcode: Partial<TranscodingConfig>,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const prior = useRef(['', '']);
|
const prior = useRef(['', '']);
|
||||||
const shouldReusePrior = Boolean(
|
const shouldReusePrior = Boolean(
|
||||||
@@ -24,7 +24,7 @@ export function useSongUrl(
|
|||||||
bitrate: transcode.bitrate,
|
bitrate: transcode.bitrate,
|
||||||
format: transcode.format,
|
format: transcode.format,
|
||||||
id: song!.id,
|
id: song!.id,
|
||||||
transcode: transcode.enabled,
|
transcode: transcode.enabled ?? false,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
queryKey: [
|
queryKey: [
|
||||||
@@ -63,7 +63,7 @@ export function useSongUrl(
|
|||||||
|
|
||||||
export const getSongUrl = async (
|
export const getSongUrl = async (
|
||||||
song: QueueSong,
|
song: QueueSong,
|
||||||
transcode: TranscodingConfig,
|
transcode: Partial<TranscodingConfig>,
|
||||||
skipAutoTranscode?: boolean,
|
skipAutoTranscode?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const url = await api.controller.getStreamUrl({
|
const url = await api.controller.getStreamUrl({
|
||||||
@@ -73,7 +73,7 @@ export const getSongUrl = async (
|
|||||||
format: transcode.format,
|
format: transcode.format,
|
||||||
id: song.id,
|
id: song.id,
|
||||||
skipAutoTranscode,
|
skipAutoTranscode,
|
||||||
transcode: transcode.enabled,
|
transcode: transcode.enabled ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export function WebPlayer() {
|
|||||||
gaplessHandler({
|
gaplessHandler({
|
||||||
currentTime: e.playedSeconds,
|
currentTime: e.playedSeconds,
|
||||||
duration: getDuration(playerRef.current.player1().ref),
|
duration: getDuration(playerRef.current.player1().ref),
|
||||||
|
hasNextSong: Boolean(player2),
|
||||||
isFlac: false,
|
isFlac: false,
|
||||||
isTransitioning,
|
isTransitioning,
|
||||||
nextPlayer: playerRef.current.player2(),
|
nextPlayer: playerRef.current.player2(),
|
||||||
@@ -206,6 +207,7 @@ export function WebPlayer() {
|
|||||||
gaplessHandler({
|
gaplessHandler({
|
||||||
currentTime: e.playedSeconds,
|
currentTime: e.playedSeconds,
|
||||||
duration: getDuration(playerRef.current.player2().ref),
|
duration: getDuration(playerRef.current.player2().ref),
|
||||||
|
hasNextSong: Boolean(player1),
|
||||||
isFlac: false,
|
isFlac: false,
|
||||||
isTransitioning,
|
isTransitioning,
|
||||||
nextPlayer: playerRef.current.player1(),
|
nextPlayer: playerRef.current.player1(),
|
||||||
@@ -680,6 +682,7 @@ function exponentialEaseOut(t: number): number {
|
|||||||
function gaplessHandler(args: {
|
function gaplessHandler(args: {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
hasNextSong: boolean;
|
||||||
isFlac: boolean;
|
isFlac: boolean;
|
||||||
isTransitioning: boolean | string;
|
isTransitioning: boolean | string;
|
||||||
nextPlayer: {
|
nextPlayer: {
|
||||||
@@ -688,7 +691,19 @@ function gaplessHandler(args: {
|
|||||||
};
|
};
|
||||||
setIsTransitioning: Dispatch<boolean | string>;
|
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 (!isTransitioning) {
|
||||||
if (currentTime > duration - 2) {
|
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 { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
|
||||||
import { PlayerbarSeekSlider } from '/@/renderer/features/player/components/playerbar-seek-slider';
|
import { PlayerbarSeekSlider } from '/@/renderer/features/player/components/playerbar-seek-slider';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
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 { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
|
||||||
@@ -30,7 +36,12 @@ export const PlayerbarWaveform = () => {
|
|||||||
|
|
||||||
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
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 { color } = useAppThemeColors();
|
||||||
const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';
|
const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard position="top" width={280}>
|
<HoverCard openDelay={500} position="top" width={280}>
|
||||||
<HoverCard.Target>
|
<HoverCard.Target>
|
||||||
<Group
|
<Group
|
||||||
align="center"
|
align="center"
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
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 {
|
import {
|
||||||
useSleepTimerActions,
|
useSleepTimerActions,
|
||||||
useSleepTimerActive,
|
useSleepTimerActive,
|
||||||
@@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input';
|
|||||||
import { Popover } from '/@/shared/components/popover/popover';
|
import { Popover } from '/@/shared/components/popover/popover';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
const PRESET_OPTIONS = [
|
const PRESET_OPTIONS = [
|
||||||
{ minutes: 0, mode: 'endOfSong' as const },
|
{ minutes: 0, mode: 'endOfSong' as const },
|
||||||
|
{ minutes: 0, mode: 'endOfAlbum' as const },
|
||||||
{ minutes: 5, mode: 'timed' as const },
|
{ minutes: 5, mode: 'timed' as const },
|
||||||
{ minutes: 10, mode: 'timed' as const },
|
{ minutes: 10, mode: 'timed' as const },
|
||||||
{ minutes: 15, mode: 'timed' as const },
|
{ minutes: 15, mode: 'timed' as const },
|
||||||
@@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string {
|
|||||||
const useSleepTimer = () => {
|
const useSleepTimer = () => {
|
||||||
const active = useSleepTimerActive();
|
const active = useSleepTimerActive();
|
||||||
const mode = useSleepTimerMode();
|
const mode = useSleepTimerMode();
|
||||||
const { cancelTimer, setRemaining } = useSleepTimerActions();
|
const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions();
|
||||||
const { mediaPause } = usePlayer();
|
const { mediaPause } = usePlayer();
|
||||||
|
|
||||||
const mediaPauseRef = useRef(mediaPause);
|
const mediaPauseRef = useRef(mediaPause);
|
||||||
mediaPauseRef.current = 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(() => {
|
const handleOnCurrentSongChange = useCallback(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
@@ -65,8 +96,14 @@ const useSleepTimer = () => {
|
|||||||
if (mode === 'endOfSong') {
|
if (mode === 'endOfSong') {
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
mediaPauseRef.current();
|
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();
|
const status = usePlayerStatus();
|
||||||
|
|
||||||
@@ -104,15 +141,32 @@ const useSleepTimer = () => {
|
|||||||
// mediaAutoNext returns PAUSED status when the current song ends.
|
// mediaAutoNext returns PAUSED status when the current song ends.
|
||||||
// This is a generic player mechanism — the web player handles it
|
// This is a generic player mechanism — the web player handles it
|
||||||
// without needing to know about the sleep timer.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!active || mode !== 'endOfSong') return;
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
if (mode === 'endOfSong') {
|
||||||
|
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||||
};
|
};
|
||||||
}, [active, mode]);
|
}
|
||||||
|
|
||||||
|
if (mode === 'endOfAlbum') {
|
||||||
|
evaluateEndOfAlbum();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [active, mode, evaluateEndOfAlbum]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SleepTimerHookInner = () => {
|
export const SleepTimerHookInner = () => {
|
||||||
@@ -135,8 +189,14 @@ export const SleepTimerButton = () => {
|
|||||||
const active = useSleepTimerActive();
|
const active = useSleepTimerActive();
|
||||||
const mode = useSleepTimerMode();
|
const mode = useSleepTimerMode();
|
||||||
const remaining = useSleepTimerRemaining();
|
const remaining = useSleepTimerRemaining();
|
||||||
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
|
const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } =
|
||||||
|
useSleepTimerActions();
|
||||||
const { mediaPause } = usePlayer();
|
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 [showCustom, setShowCustom] = useState(false);
|
||||||
const [customHours, setCustomHours] = useState<number>(0);
|
const [customHours, setCustomHours] = useState<number>(0);
|
||||||
@@ -151,13 +211,15 @@ export const SleepTimerButton = () => {
|
|||||||
(option: (typeof PRESET_OPTIONS)[number]) => {
|
(option: (typeof PRESET_OPTIONS)[number]) => {
|
||||||
if (option.mode === 'endOfSong') {
|
if (option.mode === 'endOfSong') {
|
||||||
startEndOfSongTimer();
|
startEndOfSongTimer();
|
||||||
|
} else if (option.mode === 'endOfAlbum') {
|
||||||
|
startEndOfAlbumTimer();
|
||||||
} else {
|
} else {
|
||||||
startTimedTimer(option.minutes * 60);
|
startTimedTimer(option.minutes * 60);
|
||||||
}
|
}
|
||||||
setShowCustom(false);
|
setShowCustom(false);
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
},
|
},
|
||||||
[startEndOfSongTimer, startTimedTimer],
|
[startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCustomStart = useCallback(() => {
|
const handleCustomStart = useCallback(() => {
|
||||||
@@ -178,6 +240,9 @@ export const SleepTimerButton = () => {
|
|||||||
if (option.mode === 'endOfSong') {
|
if (option.mode === 'endOfSong') {
|
||||||
return t('player.sleepTimer_endOfSong');
|
return t('player.sleepTimer_endOfSong');
|
||||||
}
|
}
|
||||||
|
if (option.mode === 'endOfAlbum') {
|
||||||
|
return t('player.sleepTimer_endOfAlbum');
|
||||||
|
}
|
||||||
if (option.minutes >= 60) {
|
if (option.minutes >= 60) {
|
||||||
return t('player.sleepTimer_hours', {
|
return t('player.sleepTimer_hours', {
|
||||||
count: option.minutes / 60,
|
count: option.minutes / 60,
|
||||||
@@ -231,6 +296,10 @@ export const SleepTimerButton = () => {
|
|||||||
<Text c="primary" size="sm">
|
<Text c="primary" size="sm">
|
||||||
{t('player.sleepTimer_endOfSong')}
|
{t('player.sleepTimer_endOfSong')}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : mode === 'endOfAlbum' ? (
|
||||||
|
<Text c="primary" size="sm">
|
||||||
|
{t('player.sleepTimer_endOfAlbum')}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text c="primary" fw="600" size="lg">
|
<Text c="primary" fw="600" size="lg">
|
||||||
{formatRemaining(remaining)}
|
{formatRemaining(remaining)}
|
||||||
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
|
{PRESET_OPTIONS.filter(
|
||||||
(option, index) => (
|
(option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum',
|
||||||
|
).map((option) => {
|
||||||
|
const disabled = option.mode === 'endOfAlbum' && isTrackShuffle;
|
||||||
|
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
fullWidth
|
fullWidth
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
key={index}
|
key={option.mode}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePreset(option);
|
handlePreset(option);
|
||||||
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
|
|||||||
>
|
>
|
||||||
{getPresetLabel(option)}
|
{getPresetLabel(option)}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
);
|
||||||
)}
|
})}
|
||||||
|
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
let startupRestoreSessionHandled = false;
|
||||||
|
|
||||||
export const useQueueRestoreTimestamp = () => {
|
export const useQueueRestoreTimestamp = () => {
|
||||||
const { mediaSeekToTimestamp } = usePlayerActions();
|
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||||
|
|
||||||
@@ -51,28 +53,65 @@ export const useInitialTimestampRestore = () => {
|
|||||||
|
|
||||||
const startupRestoreInitializedRef = useRef(false);
|
const startupRestoreInitializedRef = useRef(false);
|
||||||
const startupSeekArmedRef = useRef<null | number>(null);
|
const startupSeekArmedRef = useRef<null | number>(null);
|
||||||
|
const startupSeekTargetUniqueIdRef = useRef<null | string>(null);
|
||||||
const startupSeekAppliedRef = useRef(false);
|
const startupSeekAppliedRef = useRef(false);
|
||||||
|
|
||||||
const applyStartupSeek = useCallback(() => {
|
const cancelStartupSeek = useCallback(() => {
|
||||||
if (startupSeekAppliedRef.current) {
|
if (startupSeekAppliedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekTimestamp = startupSeekArmedRef.current;
|
|
||||||
if (!seekTimestamp || seekTimestamp <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startupSeekAppliedRef.current = true;
|
startupSeekAppliedRef.current = true;
|
||||||
startupSeekArmedRef.current = null;
|
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(() => {
|
setTimeout(() => {
|
||||||
mediaSeekToTimestamp(seekTimestamp);
|
mediaSeekToTimestamp(seekTimestamp);
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [mediaSeekToTimestamp]);
|
}, [cancelStartupSeek, mediaSeekToTimestamp]);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +120,11 @@ export const useInitialTimestampRestore = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startupRestoreInitializedRef.current = true;
|
startupRestoreInitializedRef.current = true;
|
||||||
|
startupRestoreSessionHandled = true;
|
||||||
|
|
||||||
if (timestamp > 0) {
|
if (timestamp > 0) {
|
||||||
startupSeekArmedRef.current = timestamp;
|
startupSeekArmedRef.current = timestamp;
|
||||||
|
startupSeekTargetUniqueIdRef.current = currentSong._uniqueId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerStatus === PlayerStatus.PLAYING) {
|
if (playerStatus === PlayerStatus.PLAYING) {
|
||||||
@@ -129,26 +170,20 @@ export const useSaveQueue = () => {
|
|||||||
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
|
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return api.controller.savePlayQueue({
|
||||||
await api.controller.savePlayQueue({
|
apiClientProps: { serverId },
|
||||||
apiClientProps: { serverId },
|
query: {
|
||||||
query: {
|
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
||||||
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
||||||
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
songs: queue.items.map((item) => item.id),
|
||||||
songs: queue.items.map((item) => item.id),
|
},
|
||||||
},
|
});
|
||||||
});
|
},
|
||||||
|
onError: (error) => {
|
||||||
toast.success({
|
toast.error({
|
||||||
message: t('form.saveQueue.success'),
|
message: (error as Error).message,
|
||||||
});
|
title: t('error.saveQueueFailed'),
|
||||||
} catch (error) {
|
});
|
||||||
toast.error({
|
|
||||||
message: (error as Error).message,
|
|
||||||
title: t('error.saveQueueFailed'),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const getPositionValue = (seconds: number, useTicks: boolean) => {
|
|||||||
return Math.round(seconds * 1e7);
|
return Math.round(seconds * 1e7);
|
||||||
}
|
}
|
||||||
|
|
||||||
return seconds;
|
return seconds * 1000;
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -67,8 +67,9 @@ Jellyfin progress APIs still use playback position (ticks), not listen time:
|
|||||||
- pause / unpause
|
- pause / unpause
|
||||||
|
|
||||||
Other events:
|
Other events:
|
||||||
- When the song changes: sends 'start' when the new track is playing;
|
- When the song changes: sends 'stop' for the previous track; sends 'start'
|
||||||
clears submission flag and listen accumulator for the new track.
|
when the new track is playing; clears submission flag and listen accumulator
|
||||||
|
for the new track.
|
||||||
|
|
||||||
- When the song is restarted (near 0 after 10s+): clears submission flag
|
- When the song is restarted (near 0 after 10s+): clears submission flag
|
||||||
and listen accumulator.
|
and listen accumulator.
|
||||||
@@ -129,6 +130,7 @@ export const useScrobble = () => {
|
|||||||
|
|
||||||
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
||||||
const previousTimestampRef = useRef<number>(0);
|
const previousTimestampRef = useRef<number>(0);
|
||||||
|
const stopPositionRef = useRef<number>(0);
|
||||||
const lastProgressEventRef = useRef<number>(0);
|
const lastProgressEventRef = useRef<number>(0);
|
||||||
const lastSeekEventRef = useRef<number>(0);
|
const lastSeekEventRef = useRef<number>(0);
|
||||||
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
@@ -180,9 +182,6 @@ export const useScrobble = () => {
|
|||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
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 useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||||
const currentStatus = usePlayerStore.getState().player.status;
|
const currentStatus = usePlayerStore.getState().player.status;
|
||||||
const currentTime = properties.timestamp;
|
const currentTime = properties.timestamp;
|
||||||
@@ -239,36 +238,36 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send progress events every 10 seconds
|
// Send progress events every 10 seconds
|
||||||
if (hasPlaybackReport) {
|
// if (hasPlaybackReport) {
|
||||||
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
// const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||||
if (timeSinceLastProgress >= 10) {
|
// if (timeSinceLastProgress >= 10) {
|
||||||
sendScrobble.mutate(
|
// sendScrobble.mutate(
|
||||||
{
|
// {
|
||||||
apiClientProps: { serverId: serverId || '' },
|
// apiClientProps: { serverId: serverId || '' },
|
||||||
query: {
|
// query: {
|
||||||
albumId: currentSong.albumId,
|
// albumId: currentSong.albumId,
|
||||||
event: 'timeupdate',
|
// event: 'timeupdate',
|
||||||
id: currentSong.id,
|
// id: currentSong.id,
|
||||||
mediaType: mediaType,
|
// mediaType: mediaType,
|
||||||
playbackRate,
|
// playbackRate,
|
||||||
position: getPositionValue(currentTime, useTicks),
|
// position: getPositionValue(currentTime, useTicks),
|
||||||
submission: false,
|
// submission: false,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
onSuccess: () => {
|
// onSuccess: () => {
|
||||||
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
|
// logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
|
||||||
category: LogCategory.SCROBBLE,
|
// category: LogCategory.SCROBBLE,
|
||||||
meta: {
|
// meta: {
|
||||||
id: currentSong.id,
|
// id: currentSong.id,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
lastProgressEventRef.current = currentTime;
|
// lastProgressEventRef.current = currentTime;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Check if we should submit scrobble based on listened time
|
// Check if we should submit scrobble based on listened time
|
||||||
if (!isCurrentSongScrobbledRef.current) {
|
if (!isCurrentSongScrobbledRef.current) {
|
||||||
@@ -319,7 +318,10 @@ export const useScrobble = () => {
|
|||||||
) => {
|
) => {
|
||||||
const currentSong = properties.song;
|
const currentSong = properties.song;
|
||||||
const previousSong = previousSongRef.current;
|
const previousSong = previousSongRef.current;
|
||||||
|
const previousPositionSec = stopPositionRef.current;
|
||||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const previousMediaType = previousSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const useTicksForPrevious = previousSong?._serverType === ServerType.JELLYFIN;
|
||||||
|
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
if (scrobbleSettings?.notify && currentSong?.id) {
|
if (scrobbleSettings?.notify && currentSong?.id) {
|
||||||
@@ -355,6 +357,7 @@ export const useScrobble = () => {
|
|||||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||||
previousSongRef.current = currentSong;
|
previousSongRef.current = currentSong;
|
||||||
previousTimestampRef.current = 0;
|
previousTimestampRef.current = 0;
|
||||||
|
stopPositionRef.current = 0;
|
||||||
listenedMsRef.current = 0;
|
listenedMsRef.current = 0;
|
||||||
lastListenSampleTimeRef.current = null;
|
lastListenSampleTimeRef.current = null;
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
@@ -398,10 +401,42 @@ export const useScrobble = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send stop scrobble for the track that was playing before the change
|
||||||
|
if (previousSong?.id) {
|
||||||
|
sendScrobble.mutate(
|
||||||
|
{
|
||||||
|
apiClientProps: { serverId: previousSong._serverId || '' },
|
||||||
|
query: {
|
||||||
|
albumId: previousSong.albumId,
|
||||||
|
event: 'stop',
|
||||||
|
id: previousSong.id,
|
||||||
|
mediaType: previousMediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(
|
||||||
|
previousPositionSec,
|
||||||
|
useTicksForPrevious,
|
||||||
|
),
|
||||||
|
submission: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, {
|
||||||
|
category: LogCategory.SCROBBLE,
|
||||||
|
meta: {
|
||||||
|
id: previousSong.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
previousSongRef.current = currentSong;
|
previousSongRef.current = currentSong;
|
||||||
previousTimestampRef.current = 0;
|
previousTimestampRef.current = 0;
|
||||||
|
stopPositionRef.current = 0;
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -462,12 +497,14 @@ export const useScrobble = () => {
|
|||||||
lastProgressEventRef.current = properties.timestamp;
|
lastProgressEventRef.current = properties.timestamp;
|
||||||
lastSeekEventRef.current = now;
|
lastSeekEventRef.current = now;
|
||||||
|
|
||||||
|
const currentStatus = usePlayerStore.getState().player.status;
|
||||||
|
|
||||||
sendScrobble.mutate(
|
sendScrobble.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
apiClientProps: { serverId: currentSong._serverId || '' },
|
||||||
query: {
|
query: {
|
||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'timeupdate',
|
event: currentStatus === PlayerStatus.PLAYING ? 'unpause' : 'pause',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
playbackRate: playbackRate,
|
playbackRate: playbackRate,
|
||||||
@@ -592,6 +629,7 @@ export const useScrobble = () => {
|
|||||||
isCurrentSongScrobbledRef.current = false;
|
isCurrentSongScrobbledRef.current = false;
|
||||||
lastProgressEventRef.current = 0;
|
lastProgressEventRef.current = 0;
|
||||||
previousTimestampRef.current = 0;
|
previousTimestampRef.current = 0;
|
||||||
|
stopPositionRef.current = 0;
|
||||||
listenedMsRef.current = 0;
|
listenedMsRef.current = 0;
|
||||||
lastListenSampleTimeRef.current = null;
|
lastListenSampleTimeRef.current = null;
|
||||||
|
|
||||||
@@ -626,6 +664,17 @@ export const useScrobble = () => {
|
|||||||
// Update previous timestamp on progress for use in status change handler
|
// Update previous timestamp on progress for use in status change handler
|
||||||
const handleProgressUpdate = useCallback(
|
const handleProgressUpdate = useCallback(
|
||||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||||
|
// Preserve last playback position when the playhead resets to the start
|
||||||
|
// (song change can fire after progress already reports 0 for the new track).
|
||||||
|
if (
|
||||||
|
properties.timestamp < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||||
|
prev.timestamp >= SCROBBLE_TRACK_BEGIN_SEC
|
||||||
|
) {
|
||||||
|
stopPositionRef.current = prev.timestamp;
|
||||||
|
} else {
|
||||||
|
stopPositionRef.current = properties.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
previousTimestampRef.current = properties.timestamp;
|
previousTimestampRef.current = properties.timestamp;
|
||||||
handleScrobbleFromProgress(properties, prev);
|
handleScrobbleFromProgress(properties, prev);
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const PlaylistListInfiniteTable = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={loadedItems}
|
data={loadedItems}
|
||||||
enableAlternateRowColors={enableAlternateRowColors}
|
enableAlternateRowColors={enableAlternateRowColors}
|
||||||
|
enableExpansion={false}
|
||||||
enableHeader={enableHeader}
|
enableHeader={enableHeader}
|
||||||
enableHorizontalBorders={enableHorizontalBorders}
|
enableHorizontalBorders={enableHorizontalBorders}
|
||||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export const PlaylistListPaginatedTable = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={data || []}
|
data={data || []}
|
||||||
enableAlternateRowColors={enableAlternateRowColors}
|
enableAlternateRowColors={enableAlternateRowColors}
|
||||||
|
enableExpansion={false}
|
||||||
enableHeader={enableHeader}
|
enableHeader={enableHeader}
|
||||||
enableHorizontalBorders={enableHorizontalBorders}
|
enableHorizontalBorders={enableHorizontalBorders}
|
||||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { FontType } from '/@/shared/types/types';
|
|||||||
|
|
||||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||||
const ipc = isElectron() ? window.api.ipc : 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
|
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
|
||||||
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
|
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
|
||||||
|
|
||||||
@@ -289,21 +290,29 @@ export const ApplicationSettings = memo(() => {
|
|||||||
control: (
|
control: (
|
||||||
<FileInput
|
<FileInput
|
||||||
accept=".ttc,.ttf,.otf,.woff,.woff2"
|
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({
|
setSettings({
|
||||||
font: {
|
font: {
|
||||||
...fontSettings,
|
...fontSettings,
|
||||||
custom: e ? getPathForFile?.(e) || null : null,
|
custom,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
w={300}
|
w={300}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.customFontPath', {
|
description: t('setting.customFontPath', {
|
||||||
context: 'description',
|
context: 'description',
|
||||||
}),
|
}),
|
||||||
isHidden: fontSettings.type !== FontType.CUSTOM,
|
isHidden: !isElectron() || fontSettings.type !== FontType.CUSTOM,
|
||||||
title: t('setting.customFontPath'),
|
title: t('setting.customFontPath'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
|||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Table } from '/@/shared/components/table/table';
|
import { Table } from '/@/shared/components/table/table';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
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;
|
const ipc = isElectron() ? window.api.ipc : null;
|
||||||
|
|
||||||
@@ -112,25 +116,16 @@ export const HotkeyManagerSettings = memo(() => {
|
|||||||
const debouncedSetHotkey = debounce(
|
const debouncedSetHotkey = debounce(
|
||||||
(binding: BindingActions, e: KeyboardEvent<HTMLInputElement>) => {
|
(binding: BindingActions, e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const IGNORED_KEYS = ['Control', 'Alt', 'Shift', 'Meta', ' ', 'Escape'];
|
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
if (e.ctrlKey) keys.push('mod');
|
if (e.ctrlKey) keys.push('mod');
|
||||||
if (e.altKey) keys.push('alt');
|
if (e.altKey) keys.push('alt');
|
||||||
if (e.shiftKey) keys.push('shift');
|
if (e.shiftKey) keys.push('shift');
|
||||||
if (e.metaKey) keys.push('meta');
|
if (e.metaKey) keys.push('meta');
|
||||||
if (e.key === ' ') keys.push('space');
|
|
||||||
if (!IGNORED_KEYS.includes(e.key)) {
|
if (!MODIFIER_KEY_CODES.has(e.code) && e.code !== 'Escape') {
|
||||||
if (e.code.includes('Numpad')) {
|
const hotkeyKey = keyboardCodeToHotkeyKey(e.code);
|
||||||
if (e.key === '+') keys.push('numpadadd');
|
if (hotkeyKey) {
|
||||||
else if (e.key === '-') keys.push('numpadsubtract');
|
keys.push(hotkeyKey);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,13 +36,12 @@ export const WindowSettings = memo(() => {
|
|||||||
if (!e) return;
|
if (!e) return;
|
||||||
|
|
||||||
// Platform.LINUX is used as the native frame option regardless of the actual platform
|
// Platform.LINUX is used as the native frame option regardless of the actual platform
|
||||||
const hasFrame = localSettings?.get('window_has_frame') as
|
const previousWindowBarStyle = settings.windowBarStyle;
|
||||||
| boolean
|
const isSwitchingToNative =
|
||||||
| undefined;
|
previousWindowBarStyle !== Platform.LINUX && e === Platform.LINUX;
|
||||||
const isSwitchingToFrame = !hasFrame && e === Platform.LINUX;
|
const isSwitchingFromNative =
|
||||||
const isSwitchingToNoFrame = hasFrame && e !== Platform.LINUX;
|
previousWindowBarStyle === Platform.LINUX && e !== Platform.LINUX;
|
||||||
|
const requireRestart = isSwitchingToNative || isSwitchingFromNative;
|
||||||
const requireRestart = isSwitchingToFrame || isSwitchingToNoFrame;
|
|
||||||
|
|
||||||
if (requireRestart) {
|
if (requireRestart) {
|
||||||
openRestartRequiredToast();
|
openRestartRequiredToast();
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import {
|
|||||||
type HotkeyItem as MantineHotkeyItem,
|
type HotkeyItem as MantineHotkeyItem,
|
||||||
useHotkeys as useMantineHotkeys,
|
useHotkeys as useMantineHotkeys,
|
||||||
} from '@mantine/hooks';
|
} from '@mantine/hooks';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useAppStore } from '/@/renderer/store';
|
import { useAppStore } from '/@/renderer/store';
|
||||||
|
import { withPhysicalKeys } from '/@/shared/utils/hotkeys';
|
||||||
|
|
||||||
const EMPTY_HOTKEYS: MantineHotkeyItem[] = [];
|
const EMPTY_HOTKEYS: MantineHotkeyItem[] = [];
|
||||||
|
|
||||||
@@ -13,8 +15,10 @@ export const useHotkeys = (
|
|||||||
triggerOnContentEditable?: boolean,
|
triggerOnContentEditable?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const commandPaletteOpened = useAppStore((state) => state.commandPalette.opened);
|
const commandPaletteOpened = useAppStore((state) => state.commandPalette.opened);
|
||||||
|
const physicalHotkeys = useMemo(() => withPhysicalKeys(hotkeys), [hotkeys]);
|
||||||
|
|
||||||
useMantineHotkeys(
|
useMantineHotkeys(
|
||||||
commandPaletteOpened ? EMPTY_HOTKEYS : hotkeys,
|
commandPaletteOpened ? EMPTY_HOTKEYS : physicalHotkeys,
|
||||||
tagsToIgnore,
|
tagsToIgnore,
|
||||||
triggerOnContentEditable,
|
triggerOnContentEditable,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const useSyncSettingsToMain = () => {
|
|||||||
const settingsFromStore = useSettingsStore.getState();
|
const settingsFromStore = useSettingsStore.getState();
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
|
font: settingsFromStore.font,
|
||||||
general: settingsFromStore.general,
|
general: settingsFromStore.general,
|
||||||
hotkeys: settingsFromStore.hotkeys,
|
hotkeys: settingsFromStore.hotkeys,
|
||||||
lyrics: settingsFromStore.lyrics,
|
lyrics: settingsFromStore.lyrics,
|
||||||
@@ -101,6 +102,10 @@ export const useSyncSettingsToMain = () => {
|
|||||||
mainStoreKey: 'enableNeteaseTranslation',
|
mainStoreKey: 'enableNeteaseTranslation',
|
||||||
rendererValue: settings.lyrics.enableNeteaseTranslation,
|
rendererValue: settings.lyrics.enableNeteaseTranslation,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
mainStoreKey: 'local_font_path',
|
||||||
|
rendererValue: settings.font.custom,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Compare and sync each setting
|
// Compare and sync each setting
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ function calculateNextIndex(
|
|||||||
} else {
|
} else {
|
||||||
// Repeat none: move to next track, or pause if at the end
|
// Repeat none: move to next track, or pause if at the end
|
||||||
if (isLastTrack) {
|
if (isLastTrack) {
|
||||||
return { nextIndex: 0, shouldPause: true };
|
return { nextIndex: currentIndex, shouldPause: true };
|
||||||
} else {
|
} else {
|
||||||
return { nextIndex: currentIndex + 1, shouldPause: false };
|
return { nextIndex: currentIndex + 1, shouldPause: false };
|
||||||
}
|
}
|
||||||
@@ -939,10 +939,12 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
const pauseOnNext = player.pauseOnNextSongEnd;
|
const pauseOnNext = player.pauseOnNextSongEnd;
|
||||||
const newStatus =
|
const newStatus =
|
||||||
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
|
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
|
||||||
|
const shouldKeepCurrentPlayer = newStatus === PlayerStatus.PAUSED;
|
||||||
|
const shouldSwapPlayer = !isRepeatOneSameTrack && !shouldKeepCurrentPlayer;
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.player.index = nextPlaybackIndex;
|
state.player.index = nextPlaybackIndex;
|
||||||
state.player.playerNum = newPlayerNum;
|
state.player.playerNum = shouldSwapPlayer ? newPlayerNum : player.playerNum;
|
||||||
setTimestampStore(0);
|
setTimestampStore(0);
|
||||||
state.player.status = newStatus;
|
state.player.status = newStatus;
|
||||||
|
|
||||||
@@ -999,7 +1001,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { player1, player2 } = getDualPlayerSongs(
|
const { player1, player2 } = getDualPlayerSongs(
|
||||||
newPlayerNum,
|
shouldSwapPlayer ? newPlayerNum : player.playerNum,
|
||||||
currentSong,
|
currentSong,
|
||||||
nextSong,
|
nextSong,
|
||||||
repeat,
|
repeat,
|
||||||
@@ -1009,7 +1011,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
currentSong,
|
currentSong,
|
||||||
index: currentQueueIndex,
|
index: currentQueueIndex,
|
||||||
nextSong,
|
nextSong,
|
||||||
num: newPlayerNum,
|
num: shouldSwapPlayer ? newPlayerNum : player.playerNum,
|
||||||
player1,
|
player1,
|
||||||
player2,
|
player2,
|
||||||
previousSong,
|
previousSong,
|
||||||
@@ -1183,6 +1185,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
mediaSeekToTimestamp: (timestamp: number) => {
|
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) => {
|
set((state) => {
|
||||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);
|
state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);
|
||||||
});
|
});
|
||||||
@@ -1194,6 +1199,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||||
const newTimestamp = Math.max(0, currentTimestamp - timeToSkip);
|
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) => {
|
set((state) => {
|
||||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
||||||
});
|
});
|
||||||
@@ -1215,6 +1225,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||||
const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);
|
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) => {
|
set((state) => {
|
||||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
export type SleepTimerMode = 'endOfSong' | 'timed';
|
export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed';
|
||||||
|
|
||||||
interface SleepTimerActions {
|
interface SleepTimerActions {
|
||||||
cancelTimer: () => void;
|
cancelTimer: () => void;
|
||||||
setRemaining: (remaining: number) => void;
|
setRemaining: (remaining: number) => void;
|
||||||
|
setTargetAlbumId: (albumId: null | string) => void;
|
||||||
|
startEndOfAlbumTimer: () => void;
|
||||||
startEndOfSongTimer: () => void;
|
startEndOfSongTimer: () => void;
|
||||||
startTimedTimer: (durationSeconds: number) => void;
|
startTimedTimer: (durationSeconds: number) => void;
|
||||||
}
|
}
|
||||||
@@ -17,6 +19,8 @@ interface SleepTimerState {
|
|||||||
mode: SleepTimerMode;
|
mode: SleepTimerMode;
|
||||||
/** Remaining seconds (only ticks while playing) */
|
/** Remaining seconds (only ticks while playing) */
|
||||||
remaining: number;
|
remaining: number;
|
||||||
|
/** Album Id for song when mode activated */
|
||||||
|
targetAlbumId: null | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
|
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
|
||||||
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
|||||||
active: false,
|
active: false,
|
||||||
mode: 'timed',
|
mode: 'timed',
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
|
targetAlbumId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
mode: 'timed',
|
mode: 'timed',
|
||||||
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
|||||||
set({ remaining });
|
set({ remaining });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setTargetAlbumId: (albumId: null | string) => {
|
||||||
|
set({ targetAlbumId: albumId });
|
||||||
|
},
|
||||||
|
|
||||||
|
startEndOfAlbumTimer: () => {
|
||||||
|
set({
|
||||||
|
active: true,
|
||||||
|
mode: 'endOfAlbum',
|
||||||
|
remaining: 0,
|
||||||
|
targetAlbumId: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
startEndOfSongTimer: () => {
|
startEndOfSongTimer: () => {
|
||||||
set({
|
set({
|
||||||
active: true,
|
active: true,
|
||||||
mode: 'endOfSong',
|
mode: 'endOfSong',
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
|
targetAlbumId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
|||||||
active: true,
|
active: true,
|
||||||
mode: 'timed',
|
mode: 'timed',
|
||||||
remaining: durationSeconds,
|
remaining: durationSeconds,
|
||||||
|
targetAlbumId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
targetAlbumId: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
|
|||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
cancelTimer: s.cancelTimer,
|
cancelTimer: s.cancelTimer,
|
||||||
setRemaining: s.setRemaining,
|
setRemaining: s.setRemaining,
|
||||||
|
setTargetAlbumId: s.setTargetAlbumId,
|
||||||
|
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
|
||||||
startEndOfSongTimer: s.startEndOfSongTimer,
|
startEndOfSongTimer: s.startEndOfSongTimer,
|
||||||
startTimedTimer: s.startTimedTimer,
|
startTimedTimer: s.startTimedTimer,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const THEME_DATA = [
|
|||||||
{ label: 'Rosé Pine', type: 'dark', value: AppTheme.ROSE_PINE },
|
{ label: 'Rosé Pine', type: 'dark', value: AppTheme.ROSE_PINE },
|
||||||
{ label: 'Rosé Pine Moon', type: 'dark', value: AppTheme.ROSE_PINE_MOON },
|
{ label: 'Rosé Pine Moon', type: 'dark', value: AppTheme.ROSE_PINE_MOON },
|
||||||
{ label: 'Rosé Pine Dawn', type: 'light', value: AppTheme.ROSE_PINE_DAWN },
|
{ label: 'Rosé Pine Dawn', type: 'light', value: AppTheme.ROSE_PINE_DAWN },
|
||||||
|
{ label: 'Zenburn', type: 'dark', value: AppTheme.ZENBURN },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const useAppTheme = (overrideTheme?: AppTheme) => {
|
export const useAppTheme = (overrideTheme?: AppTheme) => {
|
||||||
@@ -134,6 +135,11 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
|||||||
document.body.appendChild(textStyleRef.current);
|
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 = `
|
textStyleRef.current.textContent = `
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "dynamic-font";
|
font-family: "dynamic-font";
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export const logMsg = {
|
|||||||
[LogCategory.SCROBBLE]: {
|
[LogCategory.SCROBBLE]: {
|
||||||
scrobbledPause: 'Scrobbled a pause event',
|
scrobbledPause: 'Scrobbled a pause event',
|
||||||
scrobbledStart: 'Scrobbled a start event',
|
scrobbledStart: 'Scrobbled a start event',
|
||||||
|
scrobbledStop: 'Scrobbled a stop event',
|
||||||
scrobbledSubmission: 'Scrobbled a submission event',
|
scrobbledSubmission: 'Scrobbled a submission event',
|
||||||
scrobbledTimeupdate: 'Scrobbled a timeupdate event',
|
scrobbledTimeupdate: 'Scrobbled a timeupdate event',
|
||||||
scrobbledUnpause: 'Scrobbled an unpause event',
|
scrobbledUnpause: 'Scrobbled an unpause event',
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
|
|||||||
playCount: item.UserData?.PlayCount || 0,
|
playCount: item.UserData?.PlayCount || 0,
|
||||||
similarArtists,
|
similarArtists,
|
||||||
songCount: item.SongCount ?? null,
|
songCount: item.SongCount ?? null,
|
||||||
|
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||||
userFavorite: item.UserData?.IsFavorite || false,
|
userFavorite: item.UserData?.IsFavorite || false,
|
||||||
userRating: null,
|
userRating: null,
|
||||||
};
|
};
|
||||||
@@ -434,6 +435,7 @@ const normalizePlaylist = (
|
|||||||
size: null,
|
size: null,
|
||||||
songCount: item?.ChildCount || null,
|
songCount: item?.ChildCount || null,
|
||||||
sync: null,
|
sync: null,
|
||||||
|
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
|
|||||||
|
|
||||||
const deletePlaylist = z.null();
|
const deletePlaylist = z.null();
|
||||||
|
|
||||||
|
const deletePlaylistImage = z.null();
|
||||||
|
|
||||||
|
const deleteArtistImage = deletePlaylistImage;
|
||||||
|
|
||||||
|
const uploadPlaylistImage = z.null();
|
||||||
|
|
||||||
|
const uploadArtistImage = uploadPlaylistImage;
|
||||||
|
|
||||||
const deletePlaylistParameters = z.object({
|
const deletePlaylistParameters = z.object({
|
||||||
Id: z.string(),
|
Id: z.string(),
|
||||||
});
|
});
|
||||||
@@ -886,7 +894,9 @@ export const jfType = {
|
|||||||
albumList,
|
albumList,
|
||||||
authenticate,
|
authenticate,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
deleteArtistImage,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
|
deletePlaylistImage,
|
||||||
error,
|
error,
|
||||||
favorite,
|
favorite,
|
||||||
filters,
|
filters,
|
||||||
@@ -912,6 +922,8 @@ export const jfType = {
|
|||||||
studioList,
|
studioList,
|
||||||
topSongsList,
|
topSongsList,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
|
uploadArtistImage,
|
||||||
|
uploadPlaylistImage,
|
||||||
user,
|
user,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ import {
|
|||||||
type HotkeyItem as MantineHotkeyItem,
|
type HotkeyItem as MantineHotkeyItem,
|
||||||
useHotkeys as useMantineHotkeys,
|
useHotkeys as useMantineHotkeys,
|
||||||
} from '@mantine/hooks';
|
} 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;
|
export type HotkeyItem = MantineHotkeyItem;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export enum AppTheme {
|
|||||||
TOKYO_NIGHT = 'tokyoNight',
|
TOKYO_NIGHT = 'tokyoNight',
|
||||||
VSCODE_DARK_PLUS = 'vscodeDarkPlus',
|
VSCODE_DARK_PLUS = 'vscodeDarkPlus',
|
||||||
VSCODE_LIGHT_PLUS = 'vscodeLightPlus',
|
VSCODE_LIGHT_PLUS = 'vscodeLightPlus',
|
||||||
|
ZENBURN = 'zenburn',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppThemeConfiguration = Partial<BaseAppThemeConfiguration>;
|
export type AppThemeConfiguration = Partial<BaseAppThemeConfiguration>;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { solarizedLight } from '/@/shared/themes/solarized-light/solarized-light
|
|||||||
import { tokyoNight } from '/@/shared/themes/tokyo-night/tokyo-night';
|
import { tokyoNight } from '/@/shared/themes/tokyo-night/tokyo-night';
|
||||||
import { vscodeDarkPlus } from '/@/shared/themes/vscode-dark-plus/vscode-dark-plus';
|
import { vscodeDarkPlus } from '/@/shared/themes/vscode-dark-plus/vscode-dark-plus';
|
||||||
import { vscodeLightPlus } from '/@/shared/themes/vscode-light-plus/vscode-light-plus';
|
import { vscodeLightPlus } from '/@/shared/themes/vscode-light-plus/vscode-light-plus';
|
||||||
|
import { zenburn } from '/@/shared/themes/zenburn/zenburn';
|
||||||
|
|
||||||
export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
|
export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
|
||||||
[AppTheme.AYU_DARK]: ayuDark,
|
[AppTheme.AYU_DARK]: ayuDark,
|
||||||
@@ -68,6 +69,7 @@ export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
|
|||||||
[AppTheme.TOKYO_NIGHT]: tokyoNight,
|
[AppTheme.TOKYO_NIGHT]: tokyoNight,
|
||||||
[AppTheme.VSCODE_DARK_PLUS]: vscodeDarkPlus,
|
[AppTheme.VSCODE_DARK_PLUS]: vscodeDarkPlus,
|
||||||
[AppTheme.VSCODE_LIGHT_PLUS]: vscodeLightPlus,
|
[AppTheme.VSCODE_LIGHT_PLUS]: vscodeLightPlus,
|
||||||
|
[AppTheme.ZENBURN]: zenburn,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAppTheme = (theme: AppTheme): AppThemeConfiguration => {
|
export const getAppTheme = (theme: AppTheme): AppThemeConfiguration => {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';
|
||||||
|
|
||||||
|
export const zenburn: AppThemeConfiguration = {
|
||||||
|
app: {
|
||||||
|
'overlay-header':
|
||||||
|
'linear-gradient(transparent 0%, rgb(40 44 52 / 85%) 100%), var(--theme-background-noise)',
|
||||||
|
'overlay-subheader':
|
||||||
|
'linear-gradient(180deg, rgb(40 44 52 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',
|
||||||
|
'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',
|
||||||
|
'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: '#3f3f3f',
|
||||||
|
'background-alternate': '#313131',
|
||||||
|
black: '#313131',
|
||||||
|
foreground: '#dcdccc',
|
||||||
|
'foreground-muted': '#d9d9d9',
|
||||||
|
primary: '#95a4b2',
|
||||||
|
'state-error': '#dca3a3',
|
||||||
|
'state-info': '#95a4b2',
|
||||||
|
'state-success': '#7f9f7f',
|
||||||
|
'state-warning': '#efdcbc',
|
||||||
|
surface: '#636363',
|
||||||
|
'surface-foreground': '#95a4b2',
|
||||||
|
white: '#dcdccc',
|
||||||
|
},
|
||||||
|
mode: 'dark',
|
||||||
|
};
|
||||||
@@ -465,6 +465,8 @@ export const tagListSortMap: TagListSortMap = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SortKeyRandom = 'random';
|
||||||
|
|
||||||
export enum AlbumListSort {
|
export enum AlbumListSort {
|
||||||
ALBUM_ARTIST = 'albumArtist',
|
ALBUM_ARTIST = 'albumArtist',
|
||||||
ARTIST = 'artist',
|
ARTIST = 'artist',
|
||||||
@@ -476,7 +478,7 @@ export enum AlbumListSort {
|
|||||||
ID = 'id',
|
ID = 'id',
|
||||||
NAME = 'name',
|
NAME = 'name',
|
||||||
PLAY_COUNT = 'playCount',
|
PLAY_COUNT = 'playCount',
|
||||||
RANDOM = 'random',
|
RANDOM = SortKeyRandom,
|
||||||
RATING = 'rating',
|
RATING = 'rating',
|
||||||
RECENTLY_ADDED = 'recentlyAdded',
|
RECENTLY_ADDED = 'recentlyAdded',
|
||||||
RECENTLY_PLAYED = 'recentlyPlayed',
|
RECENTLY_PLAYED = 'recentlyPlayed',
|
||||||
@@ -598,7 +600,7 @@ export enum SongListSort {
|
|||||||
ID = 'id',
|
ID = 'id',
|
||||||
NAME = 'name',
|
NAME = 'name',
|
||||||
PLAY_COUNT = 'playCount',
|
PLAY_COUNT = 'playCount',
|
||||||
RANDOM = 'random',
|
RANDOM = SortKeyRandom,
|
||||||
RATING = 'rating',
|
RATING = 'rating',
|
||||||
RECENTLY_ADDED = 'recentlyAdded',
|
RECENTLY_ADDED = 'recentlyAdded',
|
||||||
RECENTLY_PLAYED = 'recentlyPlayed',
|
RECENTLY_PLAYED = 'recentlyPlayed',
|
||||||
@@ -725,7 +727,7 @@ export enum AlbumArtistListSort {
|
|||||||
FAVORITED = 'favorited',
|
FAVORITED = 'favorited',
|
||||||
NAME = 'name',
|
NAME = 'name',
|
||||||
PLAY_COUNT = 'playCount',
|
PLAY_COUNT = 'playCount',
|
||||||
RANDOM = 'random',
|
RANDOM = SortKeyRandom,
|
||||||
RATING = 'rating',
|
RATING = 'rating',
|
||||||
RECENTLY_ADDED = 'recentlyAdded',
|
RECENTLY_ADDED = 'recentlyAdded',
|
||||||
RELEASE_DATE = 'releaseDate',
|
RELEASE_DATE = 'releaseDate',
|
||||||
@@ -814,7 +816,7 @@ export enum ArtistListSort {
|
|||||||
FAVORITED = 'favorited',
|
FAVORITED = 'favorited',
|
||||||
NAME = 'name',
|
NAME = 'name',
|
||||||
PLAY_COUNT = 'playCount',
|
PLAY_COUNT = 'playCount',
|
||||||
RANDOM = 'random',
|
RANDOM = SortKeyRandom,
|
||||||
RATING = 'rating',
|
RATING = 'rating',
|
||||||
RECENTLY_ADDED = 'recentlyAdded',
|
RECENTLY_ADDED = 'recentlyAdded',
|
||||||
RELEASE_DATE = 'releaseDate',
|
RELEASE_DATE = 'releaseDate',
|
||||||
@@ -1363,7 +1365,7 @@ export type ScrobbleArgs = BaseEndpointArgs & {
|
|||||||
|
|
||||||
export type ScrobbleQuery = {
|
export type ScrobbleQuery = {
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
|
event?: 'pause' | 'start' | 'stop' | 'unpause';
|
||||||
id: string;
|
id: string;
|
||||||
mediaType: 'podcast' | 'song';
|
mediaType: 'podcast' | 'song';
|
||||||
playbackRate: number;
|
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