Compare commits

...

5 Commits

Author SHA1 Message Date
Hosted Weblate 2193fa4251 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate

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

Translated using Weblate

Currently translated at 15.8% (195 of 1231 strings) (Arabic)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ar/

Translated using Weblate

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

Translated using Weblate

Currently translated at 54.3% (669 of 1230 strings) (German)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Laalo <hyohnoo3@gmail.com>
Co-authored-by: Timon Seidel <timongcraft@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translation: feishin/Translation
2026-05-26 06:05:29 +02:00
jeffvli 9124604b89 update to v1.12.0 2026-05-25 21:01:37 -07:00
jeffvli 239ef4a4ec add album mode for autodj
- add selection modes: similar, random
- add autodj settings in playerbar popover
2026-05-25 19:23:37 -07:00
jeffvli f3b72504f1 fix missing count configuration for track radio 2026-05-25 19:10:45 -07:00
Simon Bråten f098f848a3 feat: reading custom css from external file (#2012)
* feat: reading custom css from external file

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-05-25 14:53:53 -07:00
38 changed files with 1501 additions and 233 deletions
+3
View File
@@ -114,8 +114,11 @@ These variables override app settings **on first run** when no persisted setting
| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `autoDJ.albumStrategy` | `similar` | `FS_AUTO_DJ_ALBUM_STRATEGY` | `similar` / `library_random`. |
| `autoDJ.enabled` | `false` | `FS_AUTO_DJ_ENABLED` | `true` / `false`. |
| `autoDJ.itemCount` | `5` | `FS_AUTO_DJ_ITEM_COUNT` | Number of items to add. |
| `autoDJ.mode` | `songs` | `FS_AUTO_DJ_MODE` | `songs` / `albums`. |
| `autoDJ.songStrategy` | `similar` | `FS_AUTO_DJ_SONG_STRATEGY` | `similar` / `library_random`. |
| `autoDJ.timing` | `1` | `FS_AUTO_DJ_TIMING` | Timing value (number). |
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.11.0",
"version": "1.12.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
+3
View File
@@ -88,8 +88,11 @@ window.FS_LYRICS_TRANSLATION_API_KEY = "${FS_LYRICS_TRANSLATION_API_KEY}";
window.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}";
window.FS_LYRICS_ALIGNMENT = "${FS_LYRICS_ALIGNMENT}";
window.FS_AUTO_DJ_ALBUM_STRATEGY = "${FS_AUTO_DJ_ALBUM_STRATEGY}";
window.FS_AUTO_DJ_ENABLED = "${FS_AUTO_DJ_ENABLED}";
window.FS_AUTO_DJ_ITEM_COUNT = "${FS_AUTO_DJ_ITEM_COUNT}";
window.FS_AUTO_DJ_MODE = "${FS_AUTO_DJ_MODE}";
window.FS_AUTO_DJ_SONG_STRATEGY = "${FS_AUTO_DJ_SONG_STRATEGY}";
window.FS_AUTO_DJ_TIMING = "${FS_AUTO_DJ_TIMING}";
window.FS_CSS_CONTENT = "${FS_CSS_CONTENT}";
+312 -15
View File
@@ -2,32 +2,48 @@
"action": {
"addToFavorites": "إضافة الى $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "إضافة الى $t(entity.playlist, {\"count\": 1})",
"clearQueue": "مسح قائمة الإنتظار",
"clearQueue": "مسح قائمة التشغيل",
"createPlaylist": "إنشاء $t(entity.playlist, {\"count\": 1})",
"deletePlaylist": "حذف $t(entity.playlist, {\"count\": 1})",
"deselectAll": "إلغاء تحديد الكل",
"editPlaylist": "تعديل $t(entity.playlist, {\"count\": 1})",
"goToPage": "اذهب الى صفحة",
"moveToNext": "الذهاب الى التالي",
"moveToBottom": "الذهاب الى الأسفل",
"moveToTop": "الذهاب الى الأعلى",
"goToPage": "اذهب الى الصفحة",
"moveToNext": "نقل إلى التالي",
"moveToBottom": "نقل إلى الأسفل",
"moveToTop": "نقل إلى الأعلى",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "حذف من $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "حذف من $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "حذف من قائمة الإنتظار",
"removeFromQueue": "حذف من قائمة التشغيل",
"setRating": "تحديد التقييم",
"toggleSmartPlaylistEditor": "تشغيل / إطفاء وضع التعديل لـ $t(entity.smartPlaylist)",
"viewPlaylists": "إظهار $t(entity.playlist, {\"count\": 2})",
"toggleSmartPlaylistEditor": "إظهار / إخفاء وضع التعديل لـ $t(entity.smartPlaylist)",
"viewPlaylists": "عرض $t(entity.playlist, {\"count\": 2})",
"openIn": {
"lastfm": "فتح في Last.fm",
"musicbrainz": "فتح في MusicBrainz"
"musicbrainz": "فتح في MusicBrainz",
"listenbrainz": "فتح في ListenBrainz",
"qobuz": "فتح في Qobuz",
"spotify": "فتح في Spotify"
},
"addOrRemoveFromSelection": "إضافة أو إزالة من الإختيارات",
"selectRangeOfItems": "اختر مجموعة من العناصر",
"goToCurrent": "الانتقال إلى العنصر الحالي",
"createRadioStation": "يخلق $t(entity.radioStation, {\"count\": 1})",
"createRadioStation": "إنشاء $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "يمسح $t(entity.radioStation, {\"count\": 1})",
"selectAll": "تحديد الكل"
"selectAll": "تحديد الكل",
"shuffle": "لخبط",
"shuffleAll": "لخبط الكل",
"shuffleSelected": "لخبط المحدد",
"collapseAllFolders": "اطو جميع المجلدات",
"expandAllFolders": "بسط الملفات",
"downloadStarted": "بدأ تحميل {{count}} عنصر",
"moveUp": "نقل إلى فوق",
"moveDown": "نقل إلى تحت",
"holdToMoveToTop": "اضغط مطولاً للنقل إلى الأعلى",
"holdToMoveToBottom": "اضغط مطولاً للنقل إلى الأسفل",
"moveItems": "نقل العناصر",
"viewMore": "عرض المزيد",
"openApplicationDirectory": "فتح مجلد التطبيق"
},
"common": {
"action_zero": "عملية",
@@ -39,13 +55,13 @@
"add": "إضافة",
"additionalParticipants": "مشاركين إضافيين",
"newVersion": "تم تثبيت تحديث جديد {{version}}",
"viewReleaseNotes": "عرض معلومات الإصدار",
"viewReleaseNotes": "عرض ملاحظات الإصدار",
"albumGain": "مستوى صوت الألبوم",
"albumPeak": "اعلى مستوى للألبوم",
"areYouSure": "هل أنت متأكد؟",
"ascending": "تصاعدي",
"backward": "خلف",
"biography": "سيرة",
"biography": "السيرة",
"bitDepth": "عمق البت",
"bitrate": "معدل البت (البت ريت)",
"bpm": "نبضة في الدقيقة",
@@ -141,7 +157,35 @@
"unknown": "غير معروف",
"version": "الإصدار",
"year": "السنة",
"yes": "نعم"
"yes": "نعم",
"explicitStatus": "حالة المحتوى الصريح",
"countSelected": "{{count}} عنصر محدد",
"back": "للخلف",
"doNotShowAgain": "لا تظهر هذا مجدداً",
"view": "عرض",
"example": "مثال",
"externalLinks": "روابط الخارجية",
"openFolder": "فتح المجلد",
"faster": "أسرع",
"filter_single": "فردي",
"filter_multiple": "متعدد",
"grouping": "مجموعات",
"mood": "مزاج",
"numberOfResults": "{{numberOfResults}} نتيجة",
"noFilters": "لا توجد فلاتر معينة",
"private": "خاص",
"public": "عام",
"retry": "إعادة المحاولة",
"recordLabel": "شركة التسجيل",
"releaseType": "نوع الإصدار",
"rename": "إعادة تسمية",
"slower": "أبطأ",
"sort": "فرز",
"explicit": "صريح",
"clean": "نظيف",
"gridRows": "صفوف الشبكة",
"tableColumns": "أعمدة الجدول",
"newVersionAvailable": "هناك نسخة جديدة متاحة"
},
"entity": {
"album_zero": "الالبوم",
@@ -155,6 +199,259 @@
"albumArtist_two": "فنان الالبومين",
"albumArtist_few": "فنان الالبومات",
"albumArtist_many": "فنان الالبومات",
"albumArtist_other": "فنان الالبومات"
"albumArtist_other": "فنان الالبومات",
"albumArtistCount_zero": "{{count}} فنان الالبوم",
"albumArtistCount_one": "{{count}} فنان الالبوم",
"albumArtistCount_two": "{{count}} فنان الالبومين",
"albumArtistCount_few": "{{count}} فنان الالبومات",
"albumArtistCount_many": "{{count}} فنان الالبومات",
"albumArtistCount_other": "{{count}} فنان الالبومات",
"albumWithCount_zero": "{{count}} البوم",
"albumWithCount_one": "{{count}} البوم",
"albumWithCount_two": "{{count}} البومين",
"albumWithCount_few": "{{count}} البومات",
"albumWithCount_many": "{{count}} البومات",
"albumWithCount_other": "{{count}} البومات",
"radioStation_zero": "محطة راديو",
"radioStation_one": "محطة راديو",
"radioStation_two": "محطتان راديو",
"radioStation_few": "محطات راديو",
"radioStation_many": "محطات راديو",
"radioStation_other": "محطات راديو",
"radioStationWithCount_zero": "{{count}} محطة راديو",
"radioStationWithCount_one": "{{count}} محطة راديو",
"radioStationWithCount_two": "{{count}} محطتان راديو",
"radioStationWithCount_few": "{{count}} محطات راديو",
"radioStationWithCount_many": "{{count}} محطات راديو",
"radioStationWithCount_other": "{{count}} محطات راديو",
"artist_zero": "فنان",
"artist_one": "فنان",
"artist_two": "فنانان",
"artist_few": "فنانين",
"artist_many": "فنانين",
"artist_other": "فنانين",
"artistWithCount_zero": "{{count}} فنان",
"artistWithCount_one": "{{count}} فنان",
"artistWithCount_two": "{{count}} فنانان",
"artistWithCount_few": "{{count}} فنانين",
"artistWithCount_many": "{{count}} فنانين",
"artistWithCount_other": "{{count}} فنانين",
"favorite_zero": "مفضلة",
"favorite_one": "مفضلة",
"favorite_two": "مفضلتان",
"favorite_few": "مفضلات",
"favorite_many": "مفضلات",
"favorite_other": "مفضلات",
"folder_zero": "مجلد",
"folder_one": "مجلد",
"folder_two": "مجلدان",
"folder_few": "مجلدات",
"folder_many": "مجلدات",
"folder_other": "مجلدات",
"folderWithCount_zero": "{{count}} مجلد",
"folderWithCount_one": "{{count}} مجلد",
"folderWithCount_two": "{{count}} مجلدان",
"folderWithCount_few": "{{count}} مجلدات",
"folderWithCount_many": "{{count}} مجلدات",
"folderWithCount_other": "{{count}} مجلدات",
"genre_zero": "نوع",
"genre_one": "نوع",
"genre_two": "نوعان",
"genre_few": "أنواع",
"genre_many": "أنواع",
"genre_other": "أنواع",
"genreWithCount_zero": "{{count}} نوع",
"genreWithCount_one": "{{count}} نوع",
"genreWithCount_two": "{{count}} نوعان",
"genreWithCount_few": "{{count}} أنواع",
"genreWithCount_many": "{{count}} أنواع",
"genreWithCount_other": "{{count}} أنواع",
"playlist_zero": "قائمة تشغيل",
"playlist_one": "قائمة تشغيل",
"playlist_two": "قائمتان تشغيل",
"playlist_few": "قوائم تشغيل",
"playlist_many": "قوائم تشغيل",
"playlist_other": "قوائم تشغيل",
"play_zero": "{{count}} قائمة تشغيل",
"play_one": "{{count}} قائمة تشغيل",
"play_two": "{{count}} قائمتان تشغيل",
"play_few": "{{count}} قوائم تشغيل",
"play_many": "{{count}} قوائم تشغيل",
"play_other": "{{count}} قوائم تشغيل",
"playlistWithCount_zero": "{{count}} قائمة تشغيل",
"playlistWithCount_one": "{{count}} قائمة تشغيل",
"playlistWithCount_two": "{{count}} قائمتان تشغيل",
"playlistWithCount_few": "{{count}} قوائم تشغيل",
"playlistWithCount_many": "{{count}} قوائم تشغيل",
"playlistWithCount_other": "{{count}} قوائم تشغيل",
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) قائمة تشغيل ذكية",
"track_zero": "مقطع",
"track_one": "مقطع",
"track_two": "مقطعان",
"track_few": "مقاطع",
"track_many": "مقاطع",
"track_other": "مقاطع",
"song_zero": "أغنية",
"song_one": "أغنية",
"song_two": "أغنيتان",
"song_few": "أغاني",
"song_many": "أغاني",
"song_other": "أغاني",
"trackWithCount_zero": "{{count}} مقطع",
"trackWithCount_one": "{{count}} مقطع",
"trackWithCount_two": "{{count}} مقطعان",
"trackWithCount_few": "{{count}} مقاطع",
"trackWithCount_many": "{{count}} مقاطع",
"trackWithCount_other": "{{count}} مقاطع"
},
"error": {
"apiRouteError": "تعذّر توجيه الطلب",
"audioDeviceFetchError": "حصل خطأ أثناء محاولة الحصول على أجهزة الصوت",
"authenticationFailed": "فشلت المصادقة",
"badAlbum": "أنت ترى هذة الصفحة لأن هذه الأغنية ليست جزءاً من ألبوم. على الأرجح تظهر لك هذه المشكلة إذا كان لديك أغنية في المستوى الأعلى من مجلد الموسيقى. يقوم Jellyfin بتجميع الأغاني فقط إذا كانت داخل مجلد",
"credentialsRequired": "يتطلب بيانات اعتماد",
"genericError": "حدث خطأ",
"loginRateError": "تجاوزت الحد لمحاولات الدخول. حاول مجدداً بعد بضع ثوان",
"mpvRequired": "يتطلب MPV",
"multipleServerSaveQueueError": "قائمة التشغيل تحتوي على أغنية أو أكثر من خادم مختلف. هذا غير مدعوم",
"networkError": "حصل خطأ في الشبكة",
"noNetwork": "الخادم غير متوفر",
"noNetworkDescription": "تعذر الإتصال بالخادم",
"notificationDenied": "تم رفض أذن الإشعارات. هذا الإعداد لن يكون له أي تأثير",
"openError": "تعذر فتح الملف",
"playbackError": "حدث خطأ أثناء محاولة تشغيل الوسائط",
"playbackPausedDueToError": "تم ايقاف التشغيل بسبب خطأ",
"remoteDisableError": "حدث خطأ أثناء محاولة $t(common.disable) الخادم البعيد",
"remoteEnableError": "حدث خطأ أثناء محاولة $t(common.enable) الخادم البعيد",
"remotePortError": "حدث خطأ أثناء محاولة تعيين الخادم البعيد",
"remotePortWarning": "أعد تشغيل الخادم لتطبيق المنفذ الجديد",
"saveQueueFailed": "فشل حفظ قائمة التشغيل",
"serverLockSingleServer": "فقط خادم واحد متاح إذا الخادم مقفل",
"serverNotSelectedError": "لم يتم اختيار أي خادم",
"serverRequired": "يتطلب خادم",
"sessionExpiredError": "انتهت صلاحية جلستك",
"systemFontError": "حدث خطأ أثناء محاولة الحصول على خطوط النظام",
"settingsSyncError": "تم اكتشاف تعارضات بين إعدادات العارض والعملية الرئيسية. أعد تشغيل التطبيق لتطبيق التغييرات"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"matchAnd": "و",
"matchOr": "أو",
"biography": "السيرة",
"bitrate": "معدل البت (البت ريت)",
"bpm": "نبضة في الدقيقة",
"comment": "تعليق",
"communityRating": "تقييم المجتمع",
"criticRating": "تقييم الناقد",
"dateAdded": "تاريخ الإضافة",
"disc": "قرص",
"duration": "المدة",
"favorited": "مفضل",
"fromYear": "من سنة",
"id": "معرف",
"isFavorited": "مفضل",
"isPublic": "عام",
"isRated": "مقيم",
"isRecentlyPlayed": "تم التشغيل حديثاً",
"lastPlayed": "أخر تشغيل",
"mostPlayed": "أكثر تشغيل",
"name": "الأسم",
"note": "الملاحظة",
"path": "المسار",
"playCount": "عدد التشغيلات",
"random": "عشوائي",
"rating": "التقييم",
"recentlyAdded": "مضاف حديثاً",
"recentlyPlayed": "تم التشغيل حديثاً",
"recentlyUpdated": "محدث حديثاً",
"releaseDate": "تاريخ الإصدار",
"releaseYear": "سنة الإصدار",
"search": "بحث",
"songCount": "عدد الأغاني",
"sortName": "أسم الفرز",
"title": "العنوان",
"toYear": "إلى سنة",
"trackNumber": "مقطع"
},
"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": "يبدأ بـ"
},
"form": {
"addServer": {
"error_savePassword": "حدث خطأ أثناء محاولة حفظ كلمة السر",
"input_legacyAuthentication": "تفعيل المصادقة القديمة",
"input_name": "أسم الخادم",
"input_password": "كلمة السر",
"input_preferRemoteUrl": "تفضيل رابط عام",
"input_remoteUrl": "رابط عام",
"input_savePassword": "حفظ كلمة السر",
"input_url": "الرابط",
"input_username": "أسم المستخدم",
"success": "تمت إضافة الخادم بنجاح",
"title": "إضافة خادم"
},
"largeFetchConfirmation": {
"title": "أضف العناصر إلى قائمة التشغيل"
},
"addToPlaylist": {
"input_skipDuplicates": "تخطي العناصر المكررة",
"title": "أضف إلى $t(entity.playlist, {\"count\": 1})"
},
"createPlaylist": {
"input_public": "عام"
},
"createRadioStation": {
"input_homepageUrl": "رابط الرئيسية",
"input_name": "الأسم",
"input_streamUrl": "رابط البث"
},
"editRadioStation": {
"success": "تم تحديث محطة الراديو بنجاح"
},
"deletePlaylist": {
"input_confirm": "أكتب أسم $t(entity.playlist, {\"count\": 1}) للتأكيد",
"success": "تم حذف $t(entity.playlist, {\"count\": 1}) بنجاح",
"title": "حذف $t(entity.playlist, {\"count\": 1})"
},
"editPlaylist": {
"success": "تم تحديث $t(entity.playlist, {\"count\": 1}) بنجاح",
"title": "تعديل $t(entity.playlist, {\"count\": 1})"
},
"lyricsExport": {
"export": "تصدير الكلمات",
"input_synced": "تصدير الكلمات المتزامنة"
},
"lyricSearch": {
"title": "البحث بالكلمات"
},
"queryEditor": {
"input_optionMatchAll": "تطابق الجميع",
"input_optionMatchAny": "تطابق أي"
}
}
}
-1
View File
@@ -827,7 +827,6 @@
"notify_description": "Mostra notificacions quan la cançó actual canviï",
"transcode": "Activa la transcodificació",
"autoDJ": "DJ automàtic",
"autoDJ_description": "Afegeix cançons similars a la cua automàticament",
"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_timing": "Temps",
-1
View File
@@ -344,7 +344,6 @@
"playerFilters_description": "Vynechat skladby z přidání do fronty na základě následujících kritérií",
"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_description": "Automaticky přidávat podobné skladby do fronty",
"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_timing": "Časování",
-1
View File
@@ -695,7 +695,6 @@
},
"setting": {
"autoDJ": "Auto-DJ",
"autoDJ_description": "Tilføj automatisk lignende sange til køen",
"autoDJ_itemCount": "Antal elementer",
"autoDJ_itemCount_description": "Antallet af elementer der forsøges tilføjet til køen, når auto-DJ er aktiveret",
"autoDJ_timing": "Tidspunkt",
+17 -17
View File
@@ -41,14 +41,14 @@
"selectRangeOfItems": "Wählen sie eine reihe von elementen",
"holdToMoveToTop": "Halten um nach oben zu bewegen",
"holdToMoveToBottom": "Halten um nach unten zu bewegen",
"goToCurrent": "Zu aktuellem eintrag wechseln",
"goToCurrent": "Zu aktuellem Eintrag wechseln",
"collapseAllFolders": "Alle Ordner einklappen",
"expandAllFolders": "Alle Ordner ausklappen"
},
"common": {
"backward": "Zurück",
"increase": "Erhöhen",
"rating": "Wertung",
"rating": "Bewertung",
"bpm": "Bpm",
"refresh": "Aktualisieren",
"unknown": "Unbekannt",
@@ -167,7 +167,7 @@
"rename": "Umbenennen",
"filter_single": "Einzeln",
"filter_multiple": "Mehrfach",
"retry": "Wiederholen",
"retry": "Erneut versuchen",
"newVersionAvailable": "Eine neue version ist verfügbar",
"numberOfResults": "{{numberOfResults}} ergebnisse"
},
@@ -179,7 +179,7 @@
"remotePortError": "Beim Versuch, den Remote-Server-Port festzulegen, ist ein Fehler aufgetreten",
"serverRequired": "Server benötigt",
"authenticationFailed": "Authentifizierung fehlgeschlagen",
"apiRouteError": "Anforderung kann nicht weitergeleitet werden",
"apiRouteError": "Anfrage kann nicht weitergeleitet werden",
"genericError": "Ein Fehler ist aufgetreten",
"credentialsRequired": "Anmeldeinformationen erforderlich",
"sessionExpiredError": "Deine Sitzung ist abgelaufen",
@@ -220,7 +220,7 @@
"recentlyAdded": "Kürzlich hinzugefügt",
"note": "Hinweis",
"name": "Name",
"dateAdded": "Datum hinzugefügt",
"dateAdded": "Hinzugefügt am",
"releaseDate": "Veröffentlichungsdatum",
"albumCount": "$t(entity.album, {\"count\": 2}) anzahl",
"communityRating": "Community-wertung",
@@ -250,7 +250,8 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "Und",
"matchOr": "Oder"
"matchOr": "Oder",
"sortName": "Sortierungsname"
},
"form": {
"deletePlaylist": {
@@ -326,9 +327,9 @@
"successMustClick": "Freigabe erfolgreich erstellt. Hier klicken um diese zu öffnen"
},
"privateMode": {
"enabled": "Privatmodus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben",
"disabled": "Privatmodus deaktiviert, Wiedergabe-Status wird externen Quellen preisgegeben",
"title": "Privatmodus"
"enabled": "Privater Modus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben",
"disabled": "Privater Modus deaktiviert, Wiedergabe-Status wird externen Quellen preisgegeben",
"title": "Privater Modus"
},
"largeFetchConfirmation": {
"title": "Elemente der wiedergabeliste hinzufügen",
@@ -357,7 +358,7 @@
},
"lyricsExport": {
"input_offset": "$t(setting.lyricOffset)",
"export": "Songtexte exportieren",
"export": "Liedtext exportieren",
"input_synced": "Synchronisierte songtexte exportieren"
},
"editRadioStation": {
@@ -541,15 +542,15 @@
"selectServer": "Server auswählen",
"version": "Version {{version}}",
"manageServers": "Server verwalten",
"expandSidebar": "Seitenleiste erweitern",
"expandSidebar": "Seitenleiste ausklappen",
"collapseSidebar": "Seitenleiste einklappen",
"openBrowserDevtools": "Browser-entwicklungswerkzeuge öffnen",
"goBack": "Gehe zurück",
"goForward": "Gehe vorwärts",
"settings": "$t(common.setting, {\"count\": 2})",
"quit": "$t(common.quit)",
"privateModeOff": "Privatmodus deaktivieren",
"privateModeOn": "Privatmodus aktivieren",
"privateModeOff": "Privaten Modus deaktivieren",
"privateModeOn": "Privaten Modus aktivieren",
"commandPalette": "Kommandopalette öffnen",
"selectMusicFolder": "Musikordner wählen",
"noMusicFolder": "Kein musikordner gewählt",
@@ -681,8 +682,8 @@
"relatedArtists": "Ähnliche $t(entity.artist, {\"count\": 2})",
"groupingTypeAll": "Alle veröffentlichungsformate",
"groupingTypePrimary": "Primäre veröffentlichungsformate",
"favoriteSongs": "Lieblingssongs",
"favoriteSongsFrom": "Liebslingssongs von {{title}}",
"favoriteSongs": "Lieblingslieder",
"favoriteSongsFrom": "Liebslingslieder von {{title}}",
"topSongsCommunity": "Community",
"topSongsPersonal": "Persönlich"
},
@@ -713,7 +714,7 @@
},
"windowBar": {
"paused": "(Pausiert) ",
"privateMode": "(Privater modus)"
"privateMode": "(Privater Modus)"
},
"collections": {
"saveAsCollection": "Als sammlung speichern",
@@ -977,7 +978,6 @@
"logLevel_optionError": "Fehler",
"logLevel_optionInfo": "Info",
"logLevel_optionWarn": "Warnung",
"autoDJ_description": "Füge automatisch ähnliche Lieder der Wiedergabeliste hinzu",
"autoDJ": "Auto DJ",
"autoDJ_itemCount": "Anzahl",
"autoDJ_itemCount_description": "Die anzahl der lieder, die bei aktiviertem auto DJ zur wiedergabeliste hinzugefügt werden sollen",
+17 -3
View File
@@ -92,6 +92,7 @@
"expand": "Expand",
"example": "Example",
"externalLinks": "External links",
"openFolder": "Open folder",
"faster": "Faster",
"favorite": "Favorite",
"filter_one": "Filter",
@@ -415,6 +416,11 @@
},
"shuffleAll": {
"title": "Play random",
"input_kind_albums": "Albums",
"input_kind_songs": "Songs",
"input_kind": "Random picks",
"input_limit_albums": "How many albums?",
"input_limit_songs": "How many songs?",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "How many songs?",
"input_minYear": "From year",
@@ -731,11 +737,19 @@
},
"setting": {
"autoDJ": "Auto DJ",
"autoDJ_description": "Automatically add similar songs to the queue",
"autoDJ_itemCount": "Item count",
"autoDJ_itemCount_description": "The number of items attempted to be added to the queue when auto DJ is enabled",
"autoDJ_itemCount_description": "The number of items attempted to be added to the queue",
"autoDJ_timing": "Timing",
"autoDJ_timing_description": "The number of songs remaining in the queue before auto DJ is triggered",
"autoDJ_mode": "Mode",
"autoDJ_mode_albums": "Albums",
"autoDJ_mode_description": "Choose to add either songs or entire albums to the queue",
"autoDJ_mode_songs": "Songs",
"autoDJ_enabled": "Enable Auto DJ",
"autoDJ_albumStrategy": "Album selection mode",
"autoDJ_songStrategy": "Song selection mode",
"autoDJ_strategy_option_library_random": "Random",
"autoDJ_strategy_option_similar": "Similar",
"autosave": "Automatically save play queue",
"autosave_description": "Enable automatically saving the play queue to your server. This is only possible when using Navidrome/Subsonic, and you cannot have a mixed play queue.",
"autosaveCount": "Automatic play queue save frequency",
@@ -785,7 +799,7 @@
"crossfadeDuration": "Crossfade duration",
"crossfadeStyle": "Crossfade style",
"crossfadeStyle_description": "Select the crossfade style to use for the audio player",
"customCss_description": "Custom CSS content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization",
"customCss_description": "Custom CSS content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization. Desktop: feishin reads and writes custom.css in the app config directory and reloads it when the file changes",
"customCss": "Custom CSS",
"customCssEnable_description": "Allow for writing custom CSS",
"customCssEnable": "Enable custom CSS",
-1
View File
@@ -344,7 +344,6 @@
"playerFilters_description": "Omite la adición de canciones a la cola basado en los siguientes criterios",
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
"autoDJ": "DJ Automático",
"autoDJ_description": "Añade canciones similares a las de la cola automáticamente",
"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_timing_description": "El número de canciones restantes en la cola antes de que DJ automático se dispare",
-1
View File
@@ -658,7 +658,6 @@
"transcodeFormat": "Transkodetzeko formatua",
"queryBuilderCustomFields_inputLabel": "Etiketa",
"autoDJ": "DJ automatikoa",
"autoDJ_description": "Automatikoki gehitu antzeko abestiak ilaran",
"autoDJ_itemCount_description": "DJ automatikoa gaituta dagoenean ilaran gehitzen saiatu diren elementuen kopurua",
"autoDJ_timing_description": "DJ automatikoa aktibatu aurretik ilaran geratzen diren abestien kopurua",
"analyticsDisable": "Erabileran oinarritutako analisiei uko egin",
-1
View File
@@ -630,7 +630,6 @@
"releaseChannel_description": "Valitse vakaiden ja beetaversioiden välillä automaattisille päivityksille",
"discordDisplayType_artistname": "Artistin nimi / artistien nimet",
"autoDJ": "Auto DJ",
"autoDJ_description": "Lisää automaattisesti samanlaisia kappaleita jonoon",
"autoDJ_itemCount": "Kohteiden määrä",
"autoDJ_itemCount_description": "Jonoon lisättäväksi yritettyjen kohteiden määrä, kun auto DJ on käytössä",
"autoDJ_timing": "Ajastus"
-1
View File
@@ -812,7 +812,6 @@
"queryBuilderCustomFields": "Champs personnalisé",
"queryBuilderCustomFields_description": "Ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
"autoDJ": "DJ auto",
"autoDJ_description": "Ajouter automatiquement des titres similaire à la file d'attente",
"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_timing": "Timing",
-1
View File
@@ -917,7 +917,6 @@
"queryBuilderCustomFields_description": "Egyéni mezők hozzáadása a lekérdezés-építőhöz",
"autoDJ": "Auto DJ",
"autoDJ_timing": "Időzítés",
"autoDJ_description": "Hasonló dalokat automatikusan hozzáad a műsorlistához",
"autoDJ_itemCount": "Elem szám",
"autoDJ_itemCount_description": "Az auto DJ engedélyezésekor a műsorsorba felvenni kívánt elemek száma",
"autoDJ_timing_description": "Az auto DJ elindulása előtt a műsorlistában maradt dalok száma",
-1
View File
@@ -862,7 +862,6 @@
"sampleRate": "Rasio sampel",
"savePlayQueue": "Simpan antrean pemutaran",
"autoDJ": "DJ Otomatis",
"autoDJ_description": "Tambahkan lagu serupa secara otomatis ke antrean",
"autoDJ_itemCount": "Jumlah item",
"autoDJ_itemCount_description": "Jumlah item yang dicoba ditambahkan ke antrean saat DJ otomatis diaktifkan",
"autoDJ_timing": "Waktu",
-1
View File
@@ -438,7 +438,6 @@
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} con {{lastfm}} fallback",
"autoDJ": "Auto DJ",
"autoDJ_description": "Aggiungi automaticamente canzoni simili alla coda",
"autoDJ_itemCount": "Conteggio elementi",
"analyticsDisable_description": "Alcuni dati anonimi sull'utilizzo vengono inviati allo sviluppatore per migliorare l'applicazione",
"artistBackground": "Immagine dello sfondo dell'artista",
-1
View File
@@ -315,7 +315,6 @@
"exportImportSettings_importSuccess": "設定が正常にインポートされました!",
"exportImportSettings_importModalTitle": "Feishin 設定をインポート",
"exportImportSettings_importBtn": "設定をインポート",
"autoDJ_description": "類似の曲を自動でキューに追加します",
"autoDJ": "自動 DJ",
"autoDJ_itemCount_description": "自動 DJ が有効なときにキューに追加しようとした曲数",
"autoDJ_itemCount": "曲数",
-1
View File
@@ -653,7 +653,6 @@
"globalMediaHotkeys_description": "Het gebruik van systeem mediahotkeys voor controle van afspelen aan-/uitzetten",
"globalMediaHotkeys": "Globale mediasneltoetsen",
"autoDJ": "Auto-DJ",
"autoDJ_description": "Soortgelijke nummers automatisch aan wachtrij toevoegen",
"autoDJ_itemCount": "Aantal items",
"autoDJ_itemCount_description": "Het aantal items dat aan de wachtrij wordt geprobeerd toe te voegen als auto-DJ is ingeschakeld",
"autoDJ_timing": "Timing",
+3 -3
View File
@@ -173,7 +173,8 @@
"newVersionAvailable": "Nowa wersja jest dostępna",
"numberOfResults": "{{numberOfResults}} wyników",
"grouping": "Grupowanie",
"back": "Wstecz"
"back": "Wstecz",
"openFolder": "Otwórz folder"
},
"entity": {
"genre_one": "Gatunek",
@@ -883,7 +884,7 @@
"customCssEnable": "Włącz niestandardowy CSS",
"customCssEnable_description": "Pozwalaj na pisanie niestandardowego CSS",
"customCssNotice": "Ostrzeżenie: chociaż istnieje pewne filtrowanie (uniemożliwia używanie URL() i content:), używanie niestandardowego CSS-a może stwarzać ryzyko przez zmiany w interfejsie",
"customCss_description": "Zawartość niestandardowego CSS. Uwaga: content i zdalne URL są niedozwolonymi właściwościami. Podgląd twojej zawartości jest pokazana poniżej. Dodatkowe pola których nie ustawiłeś, są obecne z powodu sanityzacji",
"customCss_description": "Zawartość niestandardowego CSS. Uwaga: content i zdalne URL są niedozwolonymi właściwościami. Podgląd twojej zawartości jest pokazany poniżej. Dodatkowe pola których nie ustawiłeś są obecne z powodu sanityzacji. Aplikacja komputerowa: feishin odczytuje i zapisuje custom.css w katalogu ustawień aplikacji i przeładowuje go gdy plik się zmieni",
"customCss": "Niestandardowy css",
"trayEnabled_description": "Pokaż/ukryj ikonę/menu w zasobniku. jeżeli wyłączone, wyłącza też minimalizowanie.wyjście do zasobnika",
"webAudio_description": "Używaj web audio. Włącza to zaawansowane funkcje takie jak ReplayGain. Wyłącz jeżeli nie działa poprawnie",
@@ -989,7 +990,6 @@
"audioFadeOnStatusChange": "Przenikanie dźwięku przy zmianie statusu",
"audioFadeOnStatusChange_description": "Umożliwia zanikanie lub pojawianie się dźwięku gdy zmieni się status play/pauza",
"autoDJ": "Automatyczny DJ",
"autoDJ_description": "Automatycznie dodawaj podobne piosenki do kolejki",
"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_timing": "Czas dodawania",
-1
View File
@@ -957,7 +957,6 @@
"artistBackground_description": "Добавляет фоновое изображение для страниц исполнителя, содержащих обложку исполнителя",
"artistBackgroundBlur": "Процент размытия обложки исполнителя",
"artistBackgroundBlur_description": "Регулирует процент размытия к заднему фону исполнителя",
"autoDJ_description": "Автоматически добавлять похожие песни в очередь воспроизведения",
"autoDJ_itemCount": "Количество элементов",
"autoDJ_itemCount_description": "Количество элементов, которые пытаются добавить в очередь при включенной функции автоматического диджеинга",
"autoDJ_timing": "Расчетное время",
-1
View File
@@ -881,7 +881,6 @@
"preservePitch": "சுருதியைப் பாதுகாக்கவும்",
"preservePitch_description": "பின்னணி வேகத்தை மாற்றும்போது சுருதியைப் பாதுகாக்கிறது",
"autoDJ": "ஆட்டோ டி.சே",
"autoDJ_description": "தானாக வரிசையில் ஒத்த பாடல்களைச் சேர்க்கவும்",
"autoDJ_itemCount": "பொருள் எண்ணிக்கை",
"autoDJ_itemCount_description": "ஆட்டோ DJ இயக்கப்பட்டிருக்கும் போது, வரிசையில் சேர்க்க முயற்சிக்கும் உருப்படிகளின் எண்ணிக்கை",
"autoDJ_timing": "நேரவிவரம்",
-1
View File
@@ -508,7 +508,6 @@
"combinedLyricsAndVisualizer_description": "将歌词和可视化界面合并到同一面板中",
"queryBuilderCustomFields_description": "在查询构建器添加自定义字段",
"combinedLyricsAndVisualizer": "在播放器侧边栏合并歌词和可视化界面",
"autoDJ_description": "自动添加相似歌曲到队列中",
"notify_description": "歌曲变更时显示通知",
"mpvExtraParameters_description": "向MPV传递额外参数",
"audioFadeOnStatusChange": "音频改变时淡入淡出",
+4 -4
View File
@@ -119,7 +119,8 @@
"newVersionAvailable": "有新的版本可供使用",
"numberOfResults": "{{numberOfResults}} 項結果",
"grouping": "分組",
"back": "返回"
"back": "返回",
"openFolder": "開啟資料夾"
},
"error": {
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
@@ -594,7 +595,7 @@
"customCssEnable_description": "允許撰寫自訂CSS",
"customCssNotice": "警告:即使已限制某些用法(不允許 URL() 和 content:),但使用自訂 CSS 仍然會透過更改介面帶來風險",
"customCss": "自訂CSS",
"customCss_description": "自訂 CSS 內容。注意:內容和遠端 URL 是不允許使用的屬性。您的內容預覽如下所示。由於需要進行清理,因此存在一些您未設定的其他欄位",
"customCss_description": "自訂 CSS 內容。注意:內容和遠端 URL 是不允許使用的屬性。您的內容預覽如下所示。由於需要進行清理,因此存在一些您未設定的其他欄位。桌面端:feishin在應用程式配置目錄中讀取和寫入custom.css,並在檔案更改時重新載入",
"discordPausedStatus": "暫停時顯示 Rich Presence",
"discordPausedStatus_description": "啟用後,播放器暫停時將顯示狀態",
"discordListening": "將狀態設為\"正在聽\"",
@@ -714,7 +715,6 @@
"playerFilters": "從佇列中過濾歌曲",
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
"autoDJ": "Auto DJ",
"autoDJ_description": "自動將相似的歌曲加入到播放佇列",
"autoDJ_itemCount": "歌曲數量",
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
@@ -1174,7 +1174,7 @@
"fieldRecording": "現場錄音",
"demo": "Demo",
"interview": "訪談",
"live": "Live",
"live": "現場演出",
"mixtape": "混音帶",
"remix": "Remix",
"soundtrack": "原聲帶",
+112 -1
View File
@@ -1,7 +1,18 @@
import type { TitleTheme } from '/@/shared/types/types';
import type { FSWatcher } from 'fs';
import { app, dialog, ipcMain, nativeTheme, OpenDialogOptions, safeStorage } from 'electron';
import {
app,
BrowserWindow,
dialog,
ipcMain,
nativeTheme,
OpenDialogOptions,
safeStorage,
shell,
} from 'electron';
import Store from 'electron-store';
import { promises as fs, watch as fsWatch } from 'fs';
import path from 'path';
const getFrame = () => {
@@ -26,6 +37,67 @@ const storePath = isDevelopment
? path.normalize(`${defaultUserDataPath}-dev`)
: path.normalize(defaultUserDataPath);
const CUSTOM_CSS_FILENAME = 'custom.css';
const customCssPath = path.join(storePath, CUSTOM_CSS_FILENAME);
let customCssWatcher: FSWatcher | null = null;
let customCssDebounce: NodeJS.Timeout | null = null;
const readCustomCss = async (): Promise<{ content: string; exists: boolean }> => {
try {
const content = await fs.readFile(customCssPath, 'utf8');
return { content, exists: true };
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code === 'ENOENT') {
return { content: '', exists: false };
}
console.error('Failed to read custom css file', error);
return { content: '', exists: false };
}
};
const notifyCustomCssUpdate = async () => {
const { content, exists } = await readCustomCss();
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send('custom-css-updated', {
content,
exists,
path: customCssPath,
});
});
};
const scheduleCustomCssUpdate = () => {
if (customCssDebounce) {
clearTimeout(customCssDebounce);
}
customCssDebounce = setTimeout(() => {
notifyCustomCssUpdate().catch((error) => {
console.error('Failed to broadcast custom css update', error);
});
}, 100);
};
const startCustomCssWatcher = async () => {
if (customCssWatcher) return;
try {
await fs.mkdir(storePath, { recursive: true });
customCssWatcher = fsWatch(storePath, (eventType, filename) => {
if (!filename) return;
if (filename.toString() !== CUSTOM_CSS_FILENAME) return;
if (eventType === 'change' || eventType === 'rename') {
scheduleCustomCssUpdate();
}
});
} catch (error) {
console.error('Failed to watch custom css file', error);
}
};
export const store = new Store<any>({
beforeEachMigration: (_store, context) => {
console.log(`settings migrate from ${context.fromVersion}${context.toVersion}`);
@@ -120,3 +192,42 @@ ipcMain.handle('open-file-selector', async (_event, options: OpenDialogOptions)
return result.filePaths[0] || null;
});
ipcMain.handle('custom-css-get', async () => {
const { content, exists } = await readCustomCss();
return {
content,
exists,
path: customCssPath,
};
});
ipcMain.handle('custom-css-save', async (_event, data: { content: string }) => {
const content = typeof data?.content === 'string' ? data.content : '';
await fs.mkdir(storePath, { recursive: true });
await fs.writeFile(customCssPath, content, 'utf8');
await notifyCustomCssUpdate();
return true;
});
ipcMain.handle('custom-css-open-folder', async () => {
await fs.mkdir(storePath, { recursive: true });
await shell.openPath(storePath);
return true;
});
app.whenReady()
.then(() => startCustomCssWatcher())
.catch((error) => console.error('Failed to start custom css watcher', error));
app.on('before-quit', () => {
if (customCssWatcher) {
customCssWatcher.close();
customCssWatcher = null;
}
if (customCssDebounce) {
clearTimeout(customCssDebounce);
customCssDebounce = null;
}
});
+34
View File
@@ -10,6 +10,36 @@ const openApplicationDirectory = async () => {
return ipcRenderer.invoke('open-application-directory');
};
const getCustomCss = async (): Promise<
| undefined
| {
content: string;
exists: boolean;
path?: string;
}
> => {
return ipcRenderer.invoke('custom-css-get');
};
const saveCustomCss = async (content: string) => {
return ipcRenderer.invoke('custom-css-save', { content });
};
const openCustomCssFolder = async () => {
return ipcRenderer.invoke('custom-css-open-folder');
};
const customCssUpdatedListener = (
cb: (data: { content?: string; exists?: boolean; path?: string }) => void,
) => {
const listener = (_event: unknown, data: { content?: string; exists?: boolean }) => cb(data);
ipcRenderer.on('custom-css-updated', listener);
return () => {
ipcRenderer.removeListener('custom-css-updated', listener);
};
};
const playerErrorListener = (cb: (data: { code: number }) => void) => {
ipcRenderer.on('player-error-listener', (_, data) => cb(data));
};
@@ -88,14 +118,17 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
export const utils = {
checkForUpdates,
customCssUpdatedListener,
disableAutoUpdates,
download,
forceGarbageCollection,
getCustomCss,
isLinux,
isMacOS,
isWindows,
mainMessageListener,
openApplicationDirectory,
openCustomCssFolder,
openItem,
playerErrorListener,
rendererOpenCommandPalette,
@@ -105,6 +138,7 @@ export const utils = {
rendererTogglePrivateMode,
rendererToggleSidebar,
rendererUpdateAvailable,
saveCustomCss,
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
+73 -1
View File
@@ -15,7 +15,12 @@ import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
import {
useCssSettings,
useHotkeySettings,
useLanguage,
useSettingsStoreActions,
} from '/@/renderer/store';
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { WebAudio } from '/@/shared/types/types';
@@ -31,6 +36,7 @@ const UpdateAvailableDialog = lazy(() =>
);
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
export const App = () => {
return <ThemedApp />;
@@ -89,6 +95,7 @@ const AppEffects = () => (
<>
<SyncSettingsEffect />
<UpdateCheckEffect />
<CustomCssFileEffect />
<CssSettingsEffect />
<GlobalShortcutsEffect />
<LanguageEffect />
@@ -142,6 +149,71 @@ const CssSettingsEffect = () => {
return null;
};
const CustomCssFileEffect = () => {
const { setSettings } = useSettingsStoreActions();
const { content } = useCssSettings();
const latestContentRef = useRef(content);
useEffect(() => {
latestContentRef.current = content;
}, [content]);
useEffect(() => {
if (!isElectron() || !utils) return;
let disposed = false;
const applyContent = (rawContent: string | undefined) => {
const sanitized = sanitizeCss(`<style>${rawContent ?? ''}`);
if (sanitized !== latestContentRef.current) {
setSettings({
css: {
content: sanitized,
},
});
}
};
const loadCustomCss = async () => {
try {
const result = await utils.getCustomCss();
if (disposed || !result) return;
if (!result.exists && latestContentRef.current) {
await utils.saveCustomCss(latestContentRef.current);
return;
}
applyContent(result.content);
} catch (error) {
console.error('Failed to load custom css', error);
}
};
const handleCustomCssUpdated = (data: { content?: string; exists?: boolean }) => {
if (disposed) return;
if (data?.exists === false) {
applyContent('');
return;
}
applyContent(data?.content);
};
const removeCustomCssUpdatedListener =
utils.customCssUpdatedListener(handleCustomCssUpdated);
loadCustomCss();
return () => {
disposed = true;
removeCustomCssUpdatedListener();
};
}, [setSettings]);
return null;
};
const GlobalShortcutsEffect = () => {
const { bindings } = useHotkeySettings();
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { queryKeys } from '/@/renderer/api/query-keys';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store';
import { useArtistRadioCount, useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@@ -27,6 +27,8 @@ export const PlayTrackRadioAction = ({
const queryClient = useQueryClient();
const playButtonBehavior = usePlayButtonBehavior();
const radioCount = useArtistRadioCount();
const handlePlayTrackRadio = useCallback(
async (playType: Play) => {
if (!serverId || !song) return;
@@ -35,6 +37,7 @@ export const PlayTrackRadioAction = ({
const similarSongs = await queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: radioCount,
songId: song.id,
},
serverId,
@@ -53,7 +56,7 @@ export const PlayTrackRadioAction = ({
console.error('Failed to load track radio:', error);
}
},
[player, queryClient, serverId, skipFirstSong, song],
[player, queryClient, radioCount, serverId, skipFirstSong, song],
);
const handlePlayTrackRadioNow = useCallback(() => {
@@ -0,0 +1,202 @@
import type { QueryClient } from '@tanstack/react-query';
import { autoDjGenreIdsForSongGenre, autoDjPushUniqueAlbumIds } from './auto-dj-utils';
import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store';
import { shuffle } from '/@/renderer/utils/shuffle';
import {
AlbumListSort,
type QueueSong,
type ServerListItem,
SortOrder,
} from '/@/shared/types/domain-types';
export type AutoDjAlbumCollectArgs = {
albumStrategy: AutoDJStrategy;
currentSong: QueueSong;
itemCount: number;
musicFolderId: string | string[] | undefined;
queryClient: QueryClient;
queueAlbumIdSet: Set<string>;
server: null | ServerListItem | undefined;
serverId: string;
trySimilarSongs: boolean;
};
export const runAutoDjAlbumIds = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
switch (args.albumStrategy) {
case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: {
return collectAlbumsLibraryRandom(args);
}
default: {
return collectAlbumsSimilar(args);
}
}
};
const collectAlbumsLibraryRandom = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
const page = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: Math.max(args.itemCount, 1),
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ autoDjAlbumLibraryRandom: args.currentSong?.id }),
});
const ids = page.items.map((a) => a.id).filter((id) => id && !args.queueAlbumIdSet.has(id));
return shuffle(ids).slice(0, args.itemCount);
};
const collectAlbumsSimilar = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
const targetAlbumCount = args.itemCount;
const candidateAlbumIds: string[] = [];
const seenAlbumCandidates = new Set<string>();
if (args.trySimilarSongs && args.currentSong?.id) {
const similarSongsFromSimilarApi = await args.queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: args.itemCount * 4,
songId: args.currentSong.id,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
similarSongAlbumDj: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...similarSongsFromSimilarApi.map((s) => s.albumId),
);
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong && args.server) {
const genre = args.currentSong.genres?.[0];
if (genre) {
const genreIds = autoDjGenreIdsForSongGenre(genre, args.server.type);
const genreAlbums = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
genreIds,
limit: 50,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genreAlbumDj: genreIds,
song: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...genreAlbums.items.map((album) => album.id),
);
if (!args.trySimilarSongs) {
const randomAlbumMixCount = Math.max(1, Math.ceil(50 * 0.2));
const randomAlbumsMix = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: randomAlbumMixCount,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genreAlbumDjMixRandom: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...randomAlbumsMix.items.map((album) => album.id),
);
}
}
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) {
const albumArtist = args.currentSong.albumArtists?.[0];
if (albumArtist) {
const albumsByArtist = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
artistIds: [albumArtist.id],
limit: 50,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
artistAlbumDj: albumArtist.id,
song: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...albumsByArtist.items.map((album) => album.id),
);
}
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) {
const randomAlbumsFallback = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: 80,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
fallbackAlbumDj: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...randomAlbumsFallback.items.map((album) => album.id),
);
}
const shuffledAlbums = shuffle(candidateAlbumIds);
return shuffledAlbums.slice(0, targetAlbumCount);
};
@@ -0,0 +1,164 @@
import type { QueryClient } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import {
Played,
type QueueSong,
type ServerListItem,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
export type AutoDjSongCollectArgs = {
currentSong: QueueSong;
itemCount: number;
musicFolderId: string | string[] | undefined;
queryClient: QueryClient;
queueSongIdSet: Set<string>;
server: null | ServerListItem | undefined;
serverId: string;
songStrategy: AutoDJStrategy;
trySimilarSongs: boolean;
};
export const runAutoDjSongs = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
switch (args.songStrategy) {
case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: {
return collectSongsLibraryRandom(args);
}
default: {
return collectSongsSimilar(args);
}
}
};
const collectSongsLibraryRandom = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: {
limit: Math.max(args.itemCount * 3, 50),
played: Played.All,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ autoDjLibraryRandomSongs: args.currentSong.id }),
});
const pool = randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id));
const shuffled = shuffleInPlace(pool);
return shuffled.slice(0, args.itemCount);
};
const collectSongsSimilar = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
let uniqueSimilarSongs: Song[] = [];
if (args.trySimilarSongs) {
const similarSongs = await args.queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: args.itemCount,
songId: args.currentSong?.id,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ similarSongs: args.currentSong?.id }),
});
uniqueSimilarSongs = similarSongs.filter((song) => !args.queueSongIdSet.has(song.id));
}
if (uniqueSimilarSongs.length < args.itemCount) {
const genre = args.currentSong?.genres?.[0];
if (genre) {
const genreLimit = 50;
const genreSimilarSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: {
genre: genre.id,
limit: genreLimit,
played: Played.All,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genre,
similarSongs: args.currentSong?.id,
}),
});
const genreSongs = genreSimilarSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
);
if (!args.trySimilarSongs) {
const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: { limit: randomSongCount, played: Played.All },
serverId: args.serverId,
}),
});
const uniqueRandomSongs = randomSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
);
const randomSongsToAdd = uniqueRandomSongs.slice(0, randomSongCount);
uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);
} else {
uniqueSimilarSongs.push(...genreSongs);
}
}
}
if (uniqueSimilarSongs.length < args.itemCount) {
const albumArtist = args.currentSong?.albumArtists?.[0];
if (albumArtist) {
const albumArtistSimilarSongs = await args.queryClient.fetchQuery({
...songsQueries.list({
query: {
albumArtistIds: [albumArtist.id],
limit: 50,
sortBy: SongListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
albumArtist,
similarSongs: args.currentSong?.id,
}),
});
uniqueSimilarSongs.push(
...albumArtistSimilarSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
),
);
}
}
if (uniqueSimilarSongs.length < args.itemCount) {
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: { limit: 50, played: Played.All },
serverId: args.serverId,
}),
});
uniqueSimilarSongs.push(
...randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id)),
);
}
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
return shuffledSongs.slice(0, args.itemCount);
};
@@ -0,0 +1,28 @@
import type { Genre } from '/@/shared/types/domain-types';
import { ServerType } from '/@/shared/types/domain-types';
export const autoDjPushUniqueAlbumIds = (
accumulator: string[],
seenAlbums: Set<string>,
queueAlbumIdSet: Set<string>,
...ids: (string | undefined)[]
) => {
for (const id of ids) {
if (!id || queueAlbumIdSet.has(id) || seenAlbums.has(id)) continue;
seenAlbums.add(id);
accumulator.push(id);
}
};
export const autoDjGenreIdsForSongGenre = (genre: Genre, serverType: ServerType): string[] => {
if (serverType === ServerType.JELLYFIN) {
return [genre.id];
}
if (serverType === ServerType.NAVIDROME || serverType === ServerType.SUBSONIC) {
return [genre.name];
}
return [genre.id];
};
@@ -1,5 +1,5 @@
import { t } from 'i18next';
import { useCallback, useEffect, useState, WheelEvent } from 'react';
import { useCallback, useEffect, useMemo, useState, WheelEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
@@ -12,6 +12,9 @@ import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import {
AUTO_DJ_MODE,
AUTO_DJ_STRATEGY,
type AutoDJStrategy,
useAppStoreActions,
useAutoDJSettings,
useCurrentServer,
@@ -34,7 +37,15 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Paper } from '/@/shared/components/paper/paper';
import { Popover } from '/@/shared/components/popover/popover';
import { Rating } from '/@/shared/components/rating/rating';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { useMediaQuery } from '/@/shared/hooks/use-media-query';
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { useThrottledValue } from '/@/shared/hooks/use-throttled-value';
@@ -90,28 +101,148 @@ const AutoDJButton = () => {
const settings = useAutoDJSettings();
const { setSettings } = useSettingsStoreActions();
const toggleAutoDJ = () => {
setSettings({
autoDJ: {
...settings,
enabled: !settings.enabled,
const itemLabels = useMemo(() => {
return {
description: t('setting.autoDJ_itemCount_description'),
title: t('setting.autoDJ_itemCount'),
};
}, [t]);
const strategySelectData = useMemo(
() => [
{
label: t('setting.autoDJ_strategy_option_similar'),
value: AUTO_DJ_STRATEGY.SIMILAR,
},
});
};
{
label: t('setting.autoDJ_strategy_option_library_random'),
value: AUTO_DJ_STRATEGY.LIBRARY_RANDOM,
},
],
[t],
);
const strategyLabels =
settings.mode === AUTO_DJ_MODE.ALBUMS
? {
description: '',
title: t('setting.autoDJ_albumStrategy'),
}
: {
description: '',
title: t('setting.autoDJ_songStrategy'),
};
const strategyValue =
settings.mode === AUTO_DJ_MODE.ALBUMS
? (settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR)
: (settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR);
return (
<Button
onClick={(e) => {
e.stopPropagation();
toggleAutoDJ();
}}
size="compact-xs"
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
uppercase
variant="transparent"
>
{t('setting.autoDJ')}
</Button>
<Popover position="top-end" withArrow>
<Popover.Target>
<Button
onClick={(e) => {
e.stopPropagation();
}}
size="compact-xs"
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
uppercase
variant="transparent"
>
{t('setting.autoDJ')}
</Button>
</Popover.Target>
<Popover.Dropdown maw={320} miw={260} onClick={(e) => e.stopPropagation()} p="sm">
<Stack gap="sm">
<Paper p="md" radius="md">
<Group align="center" gap="xs" justify="space-between" wrap="nowrap">
<Text fw={600} isNoSelect size="sm">
{t('setting.autoDJ_enabled')}
</Text>
<Switch
checked={settings.enabled}
onChange={(e) =>
setSettings({
autoDJ: { enabled: e.currentTarget.checked },
})
}
/>
</Group>
</Paper>
<SegmentedControl
data={[
{ label: t('setting.autoDJ_mode_songs'), value: AUTO_DJ_MODE.SONGS },
{
label: t('setting.autoDJ_mode_albums'),
value: AUTO_DJ_MODE.ALBUMS,
},
]}
onChange={(value) =>
setSettings({
autoDJ: {
mode: value as 'albums' | 'songs',
},
})
}
value={settings.mode}
w="100%"
/>
<Select
comboboxProps={{ withinPortal: false }}
data={strategySelectData}
description={strategyLabels.description}
label={strategyLabels.title}
onChange={(value) => {
if (!value) return;
setSettings({
autoDJ:
settings.mode === AUTO_DJ_MODE.ALBUMS
? { albumStrategy: value as AutoDJStrategy }
: { songStrategy: value as AutoDJStrategy },
});
}}
size="md"
value={strategyValue}
w="100%"
/>
<NumberInput
aria-label={itemLabels.title}
description={itemLabels.description}
hideControls={false}
label={itemLabels.title}
max={50}
min={1}
onChange={(e) =>
setSettings({
autoDJ: {
itemCount: Number(e),
},
})
}
size="md"
value={Number(settings.itemCount)}
/>
<NumberInput
aria-label={t('setting.autoDJ_timing')}
description={t('setting.autoDJ_timing_description')}
hideControls={false}
label={t('setting.autoDJ_timing')}
max={5}
min={1}
onChange={(e) =>
setSettings({
autoDJ: {
timing: Number(e),
},
})
}
size="md"
value={Number(settings.timing)}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -10,6 +10,7 @@ import { createWithEqualityFn } from 'zustand/traditional';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
@@ -18,9 +19,18 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Played, RandomSongListQuery, ServerType } from '/@/shared/types/domain-types';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
Played,
RandomSongListQuery,
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface ShuffleAllSlice extends RandomSongListQuery {
@@ -29,6 +39,7 @@ interface ShuffleAllSlice extends RandomSongListQuery {
};
enableMaxYear: boolean;
enableMinYear: boolean;
playbackKind: 'albums' | 'songs';
}
const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
@@ -42,16 +53,28 @@ const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
enableMaxYear: false,
enableMinYear: false,
genre: '',
limit: 100,
maxYear: 2020,
minYear: 2000,
musicFolder: '',
playbackKind: 'songs',
played: Played.All,
songCount: 100,
})),
{
merge: (persistedState, currentState) => merge(currentState, persistedState),
migrate: (persisted, version: number) => {
if (!persisted) {
return persisted;
}
if (version >= 2) {
return persisted;
}
return persisted;
},
name: 'store_shuffle_all',
version: 1,
version: 2,
},
),
);
@@ -66,13 +89,24 @@ export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => sta
export const ShuffleAllContextModal = () => {
const server = useCurrentServer();
const { addToQueueByData } = usePlayer();
const { addToQueueByData, addToQueueByFetch } = usePlayer();
const { t } = useTranslation();
const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =
useShuffleAllStore();
const {
enableMaxYear,
enableMinYear,
genre,
limit,
maxYear,
minYear,
musicFolderId,
playbackKind,
played,
} = useShuffleAllStore();
const { setStore } = useShuffleAllStoreActions();
const { isFetching, refetch } = useQuery({
const clampedLimit = Math.min(500, Math.max(1, limit || 100));
const { isFetching: isFetchingSongs, refetch: refetchSongs } = useQuery({
...randomFetchQuery({
query: {
genre: genre || undefined,
@@ -89,22 +123,75 @@ export const ShuffleAllContextModal = () => {
staleTime: 0,
});
const { isFetching: isFetchingAlbums, refetch: refetchAlbums } = useQuery({
...shuffleAlbumListQuery({
query: {
genreIds: genre ? [genre] : undefined,
limit: clampedLimit,
minYear: enableMinYear ? minYear || undefined : undefined,
musicFolderId: musicFolderId || undefined,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server.id,
}),
enabled: false,
gcTime: 0,
staleTime: 0,
});
const fetchTypeRef = useRef<Play>(null);
const handlePlay = async (playType: Play) => {
fetchTypeRef.current = playType;
const { data } = await refetch();
if (playbackKind === 'albums') {
const { data } = await refetchAlbums();
addToQueueByData(data?.items || [], playType);
addToQueueByFetch(
server.id,
data?.items.map((a) => a.id) ?? [],
LibraryItem.ALBUM,
playType,
);
} else {
const { data } = await refetchSongs();
addToQueueByData(data?.items || [], playType);
}
closeAllModals();
};
return (
<Stack gap="md">
<SegmentedControl
data={[
{
label: t('form.shuffleAll.input_kind_songs'),
value: 'songs',
},
{
label: t('form.shuffleAll.input_kind_albums'),
value: 'albums',
},
]}
onChange={(value) =>
setStore({
playbackKind: value as 'albums' | 'songs',
})
}
size="sm"
value={playbackKind}
w="100%"
/>
<NumberInput
label={t('form.shuffleAll.input_limit')}
label={
playbackKind === 'albums'
? t('form.shuffleAll.input_limit_albums')
: t('form.shuffleAll.input_limit_songs')
}
max={500}
min={1}
onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}
@@ -127,6 +214,7 @@ export const ShuffleAllContextModal = () => {
value={minYear}
/>
<NumberInput
disabled={playbackKind === 'albums'}
label={t('form.shuffleAll.input_maxYear')}
max={2050}
min={1850}
@@ -134,6 +222,7 @@ export const ShuffleAllContextModal = () => {
rightSection={
<Checkbox
checked={enableMaxYear}
disabled={playbackKind === 'albums'}
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
@@ -144,7 +233,7 @@ export const ShuffleAllContextModal = () => {
<Suspense fallback={<Select data={[]} />}>
<GenreSelect />
</Suspense>
{server?.type === ServerType.JELLYFIN && (
{server?.type === ServerType.JELLYFIN && playbackKind === 'songs' && (
<Select
clearable
data={PLAYED_DATA}
@@ -156,10 +245,7 @@ export const ShuffleAllContextModal = () => {
/>
)}
<Divider />
<PlayButtonGroup
loading={(isFetching && fetchTypeRef.current) || false}
onPlay={handlePlay}
/>
<PlayButtonGroup loading={isFetchingSongs || isFetchingAlbums} onPlay={handlePlay} />
</Stack>
);
};
@@ -186,6 +272,13 @@ const randomFetchQuery = (args: {
});
};
const shuffleAlbumListQuery = (args: { query: AlbumListQuery; serverId: string }) => {
return albumQueries.list({
query: args.query,
serverId: args.serverId,
});
};
export const openShuffleAllModal = async () => {
openContextModal({
innerProps: {},
+66 -127
View File
@@ -1,11 +1,12 @@
import { useQueryClient } from '@tanstack/react-query';
import React, { useEffect } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { runAutoDjAlbumIds } from '/@/renderer/features/player/auto-dj/auto-dj-albums';
import { runAutoDjSongs } from '/@/renderer/features/player/auto-dj/auto-dj-songs';
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import {
AUTO_DJ_STRATEGY,
isShuffleEnabled,
mapShuffledToQueueIndex,
useAutoDJSettings,
@@ -17,9 +18,8 @@ import {
} from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import { hasFeature } from '/@/shared/api/utils';
import { Played, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types';
@@ -34,6 +34,9 @@ export const useAutoDJ = () => {
const hasSimilarSongsMusicFolder = hasFeature(server, ServerFeature.SIMILAR_SONGS_MUSIC_FOLDER);
useEffect(() => {
const albumStrategy = settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR;
const songStrategy = settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR;
const unsubscribe = usePlayerStoreBase.subscribe(
(state) => {
const queue = state.getQueue();
@@ -54,7 +57,6 @@ export const useAutoDJ = () => {
return;
}
// If no current song, don't autoplay
if (!properties.song?.id) {
return;
}
@@ -70,142 +72,76 @@ export const useAutoDJ = () => {
try {
const queue = usePlayerStore.getState().getQueue();
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
let uniqueSimilarSongs: Song[] = [];
const hasMusicFolder = server?.musicFolderId && server.musicFolderId.length > 0;
const musicFolderId =
hasMusicFolder && server?.musicFolderId ? server.musicFolderId : undefined;
const trySimilarSongs =
!hasMusicFolder || (hasMusicFolder && hasSimilarSongsMusicFolder);
// Skip similar songs fetch if a music folder is selected and does not support musicFolderId on similar songs
if (trySimilarSongs) {
// First, try to fetch similar songs based on the current song
const similarSongs = await queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: settings.itemCount,
songId: properties.song?.id,
},
serverId,
}),
queryKey: queryKeys.player.fetch({ similarSongs: properties.song?.id }),
const runnerDepsBase = {
itemCount: settings.itemCount,
musicFolderId,
queryClient,
server,
serverId,
trySimilarSongs,
};
if (settings.mode === 'albums') {
if (!serverId) {
return;
}
const queueAlbumIdSet = new Set(
queue.items
.map((item) => item.albumId)
.filter((id): id is string => Boolean(id)),
);
const albumsToAdd = await runAutoDjAlbumIds({
...runnerDepsBase,
albumStrategy,
currentSong: properties.song,
queueAlbumIdSet,
});
uniqueSimilarSongs = similarSongs.filter(
(song) => !queueSongIdSet.has(song.id),
);
}
// If not enough songs, try to fetch more similar songs based on the genre of the current song
if (uniqueSimilarSongs.length < settings.itemCount) {
const genre = properties.song?.genres?.[0];
if (genre) {
const genreLimit = 50;
const genreSimilarSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: {
genre: genre.id,
limit: genreLimit,
played: Played.All,
},
serverId,
}),
queryKey: queryKeys.player.fetch({
genre,
similarSongs: properties.song?.id,
}),
});
const genreSongs = genreSimilarSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
);
// If trySimilarSongs is false, add variation by mixing in random songs
if (!trySimilarSongs) {
// Calculate how many random songs we need: 20% or at least 1
const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));
const randomSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: { limit: randomSongCount, played: Played.All },
serverId,
}),
});
const uniqueRandomSongs = randomSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
);
// Add minimum required random songs for variation
const randomSongsToAdd = uniqueRandomSongs.slice(
0,
randomSongCount,
);
uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);
} else {
uniqueSimilarSongs.push(...genreSongs);
}
}
}
// If not enough songs, try to fetch more similar songs based on the album artist of the current song
if (uniqueSimilarSongs.length < settings.itemCount) {
const albumArtist = properties.song?.albumArtists?.[0];
if (albumArtist) {
const albumArtistSimilarSongs = await queryClient.fetchQuery({
...songsQueries.list({
query: {
albumArtistIds: [albumArtist.id],
limit: 50,
sortBy: SongListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
}),
queryKey: queryKeys.player.fetch({
albumArtist,
similarSongs: properties.song?.id,
}),
});
uniqueSimilarSongs.push(
...albumArtistSimilarSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
),
);
}
}
// If not enough songs, just fetch fully random songs
if (uniqueSimilarSongs.length < settings.itemCount) {
const randomSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: { limit: 50, played: Played.All },
if (albumsToAdd.length > 0) {
await player.addToQueueByFetch(
serverId,
}),
});
albumsToAdd,
LibraryItem.ALBUM,
Play.LAST,
);
uniqueSimilarSongs.push(
...randomSongs.items.filter((song) => !queueSongIdSet.has(song.id)),
);
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: albumsToAdd.length,
});
}
return;
}
// Shuffle the songs and then add to the queue
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
if (!serverId) {
return;
}
// Splice the first itemCount songs and add to the queue
const songsToAdd = shuffledSongs.slice(0, settings.itemCount);
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
// Add to the end of the queue
player.addToQueueByData(songsToAdd, Play.LAST);
// Emit event to trigger queue follow
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: songsToAdd.length,
const songsToAdd = await runAutoDjSongs({
...runnerDepsBase,
currentSong: properties.song,
queueSongIdSet,
songStrategy,
});
if (songsToAdd.length > 0) {
player.addToQueueByData(songsToAdd, Play.LAST);
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: songsToAdd.length,
});
}
} catch (error) {
logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, {
category: LogCategory.PLAYER,
@@ -229,7 +165,10 @@ export const useAutoDJ = () => {
server,
serverId,
settings.enabled,
settings.albumStrategy,
settings.itemCount,
settings.mode,
settings.songStrategy,
settings.timing,
]);
};
@@ -1,3 +1,4 @@
import isElectron from 'is-electron';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,18 +15,39 @@ export const StylesSettings = memo(() => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const utils = isElectron() ? window.api.utils : null;
const isDesktop = isElectron();
const { content, enabled } = useCssSettings();
const [css, setCss] = useState(content);
const { setSettings } = useSettingsStoreActions();
const handleSave = () => {
const handleSave = async () => {
setSettings({
css: {
content: css,
enabled,
},
});
if (utils) {
try {
await utils.saveCustomCss(css);
} catch (error) {
console.error('Failed to save custom css file', error);
}
}
};
const handleOpenFolder = async () => {
if (!utils) return;
try {
await utils.openCustomCssFolder();
} catch (error) {
console.error('Failed to open custom css folder', error);
}
};
useEffect(() => {
@@ -62,6 +84,15 @@ export const StylesSettings = memo(() => {
<SettingsOptions
control={
<>
{isDesktop && (
<Button
onClick={handleOpenFolder}
size="compact-md"
variant="subtle"
>
{t('common.openFolder', { postProcess: 'titleCase' })}
</Button>
)}
{open && (
<Button
onClick={handleSave}
@@ -1,23 +1,112 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useAutoDJSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import {
AUTO_DJ_MODE,
AUTO_DJ_STRATEGY,
type AutoDJStrategy,
useAutoDJSettings,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
export const AutoDJSettings = memo(() => {
const { t } = useTranslation();
const settings = useAutoDJSettings();
const { setSettings } = useSettingsStoreActions();
const itemLabels = useMemo(() => {
return {
description: t('setting.autoDJ_itemCount_description'),
title: t('setting.autoDJ_itemCount'),
};
}, [t]);
const strategySelectData = useMemo(
() => [
{
label: t('setting.autoDJ_strategy_option_similar'),
value: AUTO_DJ_STRATEGY.SIMILAR,
},
{
label: t('setting.autoDJ_strategy_option_library_random'),
value: AUTO_DJ_STRATEGY.LIBRARY_RANDOM,
},
],
[t],
);
const autoDJOptions: SettingOption[] = [
{
control: (
<SegmentedControl
data={[
{ label: t('setting.autoDJ_mode_songs'), value: AUTO_DJ_MODE.SONGS },
{ label: t('setting.autoDJ_mode_albums'), value: AUTO_DJ_MODE.ALBUMS },
]}
onChange={(value) => {
setSettings({
autoDJ: {
mode: value as 'albums' | 'songs',
},
});
}}
size="sm"
value={settings.mode}
w="100%"
/>
),
description: t('setting.autoDJ_mode_description'),
title: t('setting.autoDJ_mode'),
},
{
control: (
<Select
data={strategySelectData}
onChange={(value) =>
value &&
setSettings({
autoDJ: {
songStrategy: value as AutoDJStrategy,
},
})
}
value={settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR}
w="100%"
/>
),
description: '',
title: t('setting.autoDJ_songStrategy'),
},
{
control: (
<Select
data={strategySelectData}
onChange={(value) =>
value &&
setSettings({
autoDJ: {
albumStrategy: value as AutoDJStrategy,
},
})
}
value={settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR}
w="100%"
/>
),
description: '',
title: t('setting.autoDJ_albumStrategy'),
},
{
control: (
<NumberInput
aria-label="Auto DJ item count"
aria-label={itemLabels.title}
hideControls={false}
max={50}
min={1}
@@ -31,10 +120,8 @@ export const AutoDJSettings = memo(() => {
value={Number(settings.itemCount)}
/>
),
description: t('setting.autoDJ_itemCount', {
context: 'description',
}),
title: t('setting.autoDJ_itemCount'),
description: itemLabels.description,
title: itemLabels.title,
},
{
control: (
+3
View File
@@ -1,8 +1,11 @@
declare global {
interface Window {
ANALYTICS_DISABLED?: boolean | string;
FS_AUTO_DJ_ALBUM_STRATEGY?: string;
FS_AUTO_DJ_ENABLED?: string;
FS_AUTO_DJ_ITEM_COUNT?: string;
FS_AUTO_DJ_MODE?: string;
FS_AUTO_DJ_SONG_STRATEGY?: string;
FS_AUTO_DJ_TIMING?: string;
FS_CSS_CONTENT?: string;
FS_CSS_ENABLED?: string;
@@ -111,6 +111,8 @@ const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
const SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']);
const SIDEBAR_PLAYLIST_FOLDER_VIEWS = new Set(['navigation', 'single', 'tree']);
const SIDEBAR_PLAYLIST_MODES = new Set(['compact', 'expanded']);
const AUTO_DJ_MODES = new Set(['albums', 'songs']);
const AUTO_DJ_STRATEGIES = new Set(['library_random', 'similar']);
export type EnvSettingsOverrides = DeepPartial<
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
@@ -422,8 +424,21 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
path: ['lyrics', 'alignment'],
type: 'enum',
},
{
enumSet: AUTO_DJ_STRATEGIES,
key: 'FS_AUTO_DJ_ALBUM_STRATEGY',
path: ['autoDJ', 'albumStrategy'],
type: 'enum',
},
{ key: 'FS_AUTO_DJ_ENABLED', path: ['autoDJ', 'enabled'], type: 'bool' },
{ key: 'FS_AUTO_DJ_ITEM_COUNT', path: ['autoDJ', 'itemCount'], type: 'num' },
{ enumSet: AUTO_DJ_MODES, key: 'FS_AUTO_DJ_MODE', path: ['autoDJ', 'mode'], type: 'enum' },
{
enumSet: AUTO_DJ_STRATEGIES,
key: 'FS_AUTO_DJ_SONG_STRATEGY',
path: ['autoDJ', 'songStrategy'],
type: 'enum',
},
{ key: 'FS_AUTO_DJ_TIMING', path: ['autoDJ', 'timing'], type: 'num' },
{
key: 'FS_CSS_CONTENT',
+54 -1
View File
@@ -675,9 +675,28 @@ const QueryBuilderSettingsSchema = z.object({
tag: z.array(QueryBuilderCustomFieldSchema),
});
export const AUTO_DJ_MODE = {
ALBUMS: 'albums',
SONGS: 'songs',
} as const;
export type AutoDJMode = (typeof AUTO_DJ_MODE)[keyof typeof AUTO_DJ_MODE];
export const AUTO_DJ_STRATEGY = {
LIBRARY_RANDOM: 'library_random',
SIMILAR: 'similar',
} as const;
export type AutoDJStrategy = (typeof AUTO_DJ_STRATEGY)[keyof typeof AUTO_DJ_STRATEGY];
const autoDjStrategyEnum = z.enum(['similar', 'library_random']);
const AutoDJSettingsSchema = z.object({
albumStrategy: autoDjStrategyEnum,
enabled: z.boolean(),
itemCount: z.number(),
mode: z.enum(['songs', 'albums']),
songStrategy: autoDjStrategyEnum,
timing: z.number(),
});
@@ -1091,8 +1110,11 @@ const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle
const initialState: SettingsState = {
autoDJ: {
albumStrategy: AUTO_DJ_STRATEGY.SIMILAR,
enabled: false,
itemCount: 5,
mode: 'songs',
songStrategy: AUTO_DJ_STRATEGY.SIMILAR,
timing: 1,
},
css: {
@@ -2427,10 +2449,41 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
}
}
if (version < 28) {
if (!state.autoDJ) {
state.autoDJ = { ...initialState.autoDJ };
}
if (state.autoDJ.mode !== 'albums' && state.autoDJ.mode !== 'songs') {
state.autoDJ.mode = initialState.autoDJ.mode;
}
const normalizeAutoDjStrategy = (stored: unknown) => {
if (stored === 'library_random') {
return AUTO_DJ_STRATEGY.LIBRARY_RANDOM;
}
if (
stored === 'similar' ||
stored === 'default' ||
stored === 'similar_forward'
) {
return AUTO_DJ_STRATEGY.SIMILAR;
}
return initialState.autoDJ.songStrategy;
};
state.autoDJ.songStrategy = normalizeAutoDjStrategy(state.autoDJ.songStrategy);
state.autoDJ.albumStrategy = normalizeAutoDjStrategy(
state.autoDJ.albumStrategy,
);
}
return persistedState;
},
name: 'store_settings',
version: 27,
version: 28,
},
),
);