From 9576daf70a941292d79cefd0cf8940cdfdcdc07c Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sat, 15 Nov 2025 18:03:26 +0100 Subject: [PATCH] WallpaperPanel: add wallhaven support --- Assets/Translations/de.json | 26 +- Assets/Translations/en.json | 33 +- Assets/Translations/es.json | 26 +- Assets/Translations/fr.json | 26 +- Assets/Translations/nl.json | 26 +- Assets/Translations/pt.json | 26 +- Assets/Translations/ru.json | 26 +- Assets/Translations/tr.json | 26 +- Assets/Translations/uk-UA.json | 26 +- Assets/Translations/zh-CN.json | 26 +- Commons/Settings.qml | 7 + Modules/Panels/Wallpaper/WallpaperPanel.qml | 889 +++++++++++++++++++- Services/UI/WallhavenService.qml | 257 ++++++ 13 files changed, 1363 insertions(+), 57 deletions(-) create mode 100644 Services/UI/WallhavenService.qml diff --git a/Assets/Translations/de.json b/Assets/Translations/de.json index d4555bba..c0ed242a 100644 --- a/Assets/Translations/de.json +++ b/Assets/Translations/de.json @@ -580,6 +580,7 @@ "search-icons": "z.B. noctalia, niri, battery, cloud", "search-launcher": "Einträge suchen... oder > für Befehle verwenden", "search-wallpapers": "Zum Filtern von Hintergrundbildern eingeben...", + "search-wallhaven": "Wallhaven durchsuchen...", "select": "Auswählen", "test": "Test" }, @@ -2010,6 +2011,7 @@ "previous-month": "Vorheriger Monat", "refresh": "Aktualisieren", "refresh-devices": "Geräte aktualisieren", + "refresh-wallhaven": "Wallhaven-Ergebnisse aktualisieren", "refresh-wallpaper-list": "Hintergrundbild-Liste aktualisieren", "remove-widget": "Widget entfernen", "screen-recorder-not-installed": "Bildschirmrekorder ist nicht installiert", @@ -2039,7 +2041,24 @@ "description": "Ausgewähltes Hintergrundbild auf alle Monitore gleichzeitig anwenden.", "label": "Auf alle Monitore anwenden" }, + "categories": { + "anime": "Anime", + "general": "Allgemein", + "label": "Kategorien", + "people": "Personen" + }, + "purity": { + "all": "Alle", + "label": "Inhaltsfilter", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "Suchen:", + "source": { + "label": "Quelle", + "local": "Lokal", + "wallhaven": "Wallhaven" + }, "title": "Hintergrundbild-Auswahl" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "Streifen", "wipe": "Wischen" }, - "try-different-search": "Versuchen Sie eine andere Suchanfrage." + "try-different-search": "Versuchen Sie eine andere Suchanfrage.", + "wallhaven": { + "loading": "Hintergrundbilder werden geladen...", + "no-results": "Keine Hintergrundbilder gefunden. Versuchen Sie eine andere Suchanfrage.", + "page": "Seite {current} von {total}" + } }, "weather": { "clear-sky": "Klarer Himmel", diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 85b9e905..bd682989 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -580,6 +580,7 @@ "search-icons": "e.g., noctalia, niri, battery, cloud", "search-launcher": "Search entries... or use > for commands", "search-wallpapers": "Type to filter wallpapers...", + "search-wallhaven": "Search Wallhaven...", "select": "Select", "test": "Test" }, @@ -2010,6 +2011,7 @@ "previous-month": "Previous month", "refresh": "Refresh", "refresh-devices": "Refresh devices", + "refresh-wallhaven": "Refresh Wallhaven results", "refresh-wallpaper-list": "Refresh wallpaper list", "remove-widget": "Remove widget", "screen-recorder-not-installed": "Screen recorder is not installed", @@ -2036,11 +2038,28 @@ "no-wallpaper": "No wallpaper found.", "panel": { "apply-all-monitors": { - "description": "Apply selected wallpaper to all monitors at once.", + "description": "Apply the selected wallpaper to all monitors.", "label": "Apply to all monitors" }, - "search": "Search:", - "title": "Wallpaper selector" + "categories": { + "anime": "Anime", + "general": "General", + "label": "Categories", + "people": "People" + }, + "purity": { + "all": "All", + "label": "Content filter", + "sfw": "SFW", + "sketchy": "Sketchy" + }, + "search": "Search", + "source": { + "label": "Source", + "local": "Local", + "wallhaven": "Wallhaven" + }, + "title": "Wallpaper Selector" }, "transitions": { "disc": "Disc", @@ -2050,7 +2069,13 @@ "stripes": "Stripes", "wipe": "Wipe" }, - "try-different-search": "Try a different search query." + "try-different-search": "Try a different search query.", + "unknown": "Unknown", + "wallhaven": { + "loading": "Loading wallpapers...", + "no-results": "No wallpapers found. Try a different search query.", + "page": "Page {current} of {total}" + } }, "weather": { "clear-sky": "Clear sky", diff --git a/Assets/Translations/es.json b/Assets/Translations/es.json index 5f027b7a..48b2447a 100644 --- a/Assets/Translations/es.json +++ b/Assets/Translations/es.json @@ -580,6 +580,7 @@ "search-icons": "ej., noctalia, niri, battery, cloud", "search-launcher": "Buscar entradas... o usa > para comandos", "search-wallpapers": "Escribe para filtrar fondos de pantalla...", + "search-wallhaven": "Buscar en Wallhaven...", "select": "Seleccionar", "test": "Probar" }, @@ -2010,6 +2011,7 @@ "previous-month": "Mes anterior", "refresh": "Actualizar", "refresh-devices": "Actualizar dispositivos", + "refresh-wallhaven": "Actualizar resultados de Wallhaven", "refresh-wallpaper-list": "Actualizar lista de fondos de pantalla", "remove-widget": "Eliminar widget", "screen-recorder-not-installed": "La grabadora de pantalla no está instalada", @@ -2039,7 +2041,24 @@ "description": "Aplica el fondo de pantalla seleccionado a todos los monitores a la vez.", "label": "Aplicar a todos los monitores" }, + "categories": { + "anime": "Anime", + "general": "General", + "label": "Categorías", + "people": "Personas" + }, + "purity": { + "all": "Todo", + "label": "Filtro de contenido", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "Buscar:", + "source": { + "label": "Fuente", + "local": "Local", + "wallhaven": "Wallhaven" + }, "title": "Selector de fondo de pantalla" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "Rayas", "wipe": "Barrido" }, - "try-different-search": "Intenta con una búsqueda diferente." + "try-different-search": "Intenta con una búsqueda diferente.", + "wallhaven": { + "loading": "Cargando fondos de pantalla...", + "no-results": "No se encontraron fondos de pantalla. Intenta con una búsqueda diferente.", + "page": "Página {current} de {total}" + } }, "weather": { "clear-sky": "Cielo despejado", diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json index 5a01129f..48e2287f 100644 --- a/Assets/Translations/fr.json +++ b/Assets/Translations/fr.json @@ -580,6 +580,7 @@ "search-icons": "ex: noctalia, niri, batterie, nuage", "search-launcher": "Rechercher des entrées... ou utilisez > pour les commandes", "search-wallpapers": "Tapez pour filtrer les fonds d'écran...", + "search-wallhaven": "Rechercher sur Wallhaven...", "select": "Sélectionner", "test": "Tester" }, @@ -2010,6 +2011,7 @@ "previous-month": "Mois précédent", "refresh": "Actualiser", "refresh-devices": "Actualiser les appareils", + "refresh-wallhaven": "Actualiser les résultats de Wallhaven", "refresh-wallpaper-list": "Actualiser la liste des fonds d'écran", "remove-widget": "Supprimer le widget", "screen-recorder-not-installed": "L'enregistreur d'écran n'est pas installé", @@ -2039,7 +2041,24 @@ "description": "Appliquer le fond d'écran sélectionné à tous les moniteurs en même temps.", "label": "Appliquer à tous les moniteurs" }, + "categories": { + "anime": "Anime", + "general": "Général", + "label": "Catégories", + "people": "Personnes" + }, + "purity": { + "all": "Tout", + "label": "Filtre de contenu", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "Rechercher :", + "source": { + "label": "Source", + "local": "Local", + "wallhaven": "Wallhaven" + }, "title": "Sélecteur de fond d'écran" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "Rayures", "wipe": "Balayage" }, - "try-different-search": "Essayez une autre requête de recherche." + "try-different-search": "Essayez une autre requête de recherche.", + "wallhaven": { + "loading": "Chargement des fonds d'écran...", + "no-results": "Aucun fond d'écran trouvé. Essayez une autre requête de recherche.", + "page": "Page {current} sur {total}" + } }, "weather": { "clear-sky": "Ciel dégagé", diff --git a/Assets/Translations/nl.json b/Assets/Translations/nl.json index fb0327b5..8390e76f 100644 --- a/Assets/Translations/nl.json +++ b/Assets/Translations/nl.json @@ -580,6 +580,7 @@ "search-icons": "bijv. noctalia, niri, battery, cloud", "search-launcher": "Items zoeken... of gebruik > voor commando's", "search-wallpapers": "Typ om achtergronden te filteren...", + "search-wallhaven": "Zoek in Wallhaven...", "select": "Selecteren", "test": "Test" }, @@ -2010,6 +2011,7 @@ "previous-month": "Vorige maand", "refresh": "Verversen", "refresh-devices": "Apparaten verversen", + "refresh-wallhaven": "Wallhaven-resultaten verversen", "refresh-wallpaper-list": "Achtergrondlijst verversen", "remove-widget": "Widget verwijderen", "screen-recorder-not-installed": "Schermrecorder is niet geïnstalleerd", @@ -2039,7 +2041,24 @@ "description": "Pas de geselecteerde achtergrond in één keer op alle monitoren toe.", "label": "Toepassen op alle monitoren" }, + "categories": { + "anime": "Anime", + "general": "Algemeen", + "label": "Categorieën", + "people": "Mensen" + }, + "purity": { + "all": "Alles", + "label": "Inhoudsfilter", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "Zoeken:", + "source": { + "label": "Bron", + "local": "Lokaal", + "wallhaven": "Wallhaven" + }, "title": "Achtergrondkiezer" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "Strepen", "wipe": "Vegen" }, - "try-different-search": "Probeer een andere zoekopdracht." + "try-different-search": "Probeer een andere zoekopdracht.", + "wallhaven": { + "loading": "Achtergronden laden...", + "no-results": "Geen achtergronden gevonden. Probeer een andere zoekopdracht.", + "page": "Pagina {current} van {total}" + } }, "weather": { "clear-sky": "Onbewolkt", diff --git a/Assets/Translations/pt.json b/Assets/Translations/pt.json index 32cfc5ce..c7f576d5 100644 --- a/Assets/Translations/pt.json +++ b/Assets/Translations/pt.json @@ -580,6 +580,7 @@ "search-icons": "ex., noctalia, niri, battery, cloud", "search-launcher": "Pesquisar entradas... ou use > para comandos", "search-wallpapers": "Digite para filtrar papéis de parede...", + "search-wallhaven": "Pesquisar no Wallhaven...", "select": "Selecionar", "test": "Testar" }, @@ -2010,6 +2011,7 @@ "previous-month": "Mês anterior", "refresh": "Atualizar", "refresh-devices": "Atualizar dispositivos", + "refresh-wallhaven": "Atualizar resultados do Wallhaven", "refresh-wallpaper-list": "Atualizar lista de papéis de parede", "remove-widget": "Remover widget", "screen-recorder-not-installed": "O gravador de tela não está instalado", @@ -2039,7 +2041,24 @@ "description": "Aplica o papel de parede selecionado a todos os monitores de uma só vez.", "label": "Aplicar a todos os monitores" }, + "categories": { + "anime": "Anime", + "general": "Geral", + "label": "Categorias", + "people": "Pessoas" + }, + "purity": { + "all": "Tudo", + "label": "Filtro de conteúdo", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "Pesquisar:", + "source": { + "label": "Fonte", + "local": "Local", + "wallhaven": "Wallhaven" + }, "title": "Seletor de papel de parede" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "Listras", "wipe": "Limpar" }, - "try-different-search": "Tente uma busca diferente." + "try-different-search": "Tente uma busca diferente.", + "wallhaven": { + "loading": "Carregando papéis de parede...", + "no-results": "Nenhum papel de parede encontrado. Tente uma busca diferente.", + "page": "Página {current} de {total}" + } }, "weather": { "clear-sky": "Céu limpo", diff --git a/Assets/Translations/ru.json b/Assets/Translations/ru.json index b4d5c664..d936d49a 100644 --- a/Assets/Translations/ru.json +++ b/Assets/Translations/ru.json @@ -580,6 +580,7 @@ "search-icons": "например, noctalia, niri, battery, cloud", "search-launcher": "Поиск записей... или используйте > для команд", "search-wallpapers": "Введите для фильтрации обоев...", + "search-wallhaven": "Поиск в Wallhaven...", "select": "Выбрать", "test": "Тест" }, @@ -2010,6 +2011,7 @@ "previous-month": "Предыдущий месяц", "refresh": "Обновить", "refresh-devices": "Обновить устройства", + "refresh-wallhaven": "Обновить результаты Wallhaven", "refresh-wallpaper-list": "Обновить список обоев", "remove-widget": "Удалить виджет", "screen-recorder-not-installed": "Запись экрана не установлена", @@ -2039,7 +2041,24 @@ "description": "Применить выбранные обои ко всем мониторам одновременно.", "label": "Применить ко всем мониторам" }, + "categories": { + "anime": "Аниме", + "general": "Общее", + "label": "Категории", + "people": "Люди" + }, + "purity": { + "all": "Все", + "label": "Фильтр контента", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "Поиск:", + "source": { + "label": "Источник", + "local": "Локальный", + "wallhaven": "Wallhaven" + }, "title": "Выбор обоев" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "Полосы", "wipe": "Стирание" }, - "try-different-search": "Попробуйте другой поисковый запрос." + "try-different-search": "Попробуйте другой поисковый запрос.", + "wallhaven": { + "loading": "Загрузка обоев...", + "no-results": "Обои не найдены. Попробуйте другой поисковый запрос.", + "page": "Страница {current} из {total}" + } }, "weather": { "clear-sky": "Ясное небо", diff --git a/Assets/Translations/tr.json b/Assets/Translations/tr.json index e45901c5..fe43bead 100644 --- a/Assets/Translations/tr.json +++ b/Assets/Translations/tr.json @@ -580,6 +580,7 @@ "search-icons": "örn., noctalia, niri, battery, cloud", "search-launcher": "Girişleri arayın... veya > komutları için kullanın", "search-wallpapers": "Duvar kağıtlarını filtrelemek için yazın...", + "search-wallhaven": "Wallhaven'da ara...", "select": "Seç", "test": "Test" }, @@ -2010,6 +2011,7 @@ "previous-month": "Önceki ay", "refresh": "Yenile", "refresh-devices": "Cihazları yenile", + "refresh-wallhaven": "Wallhaven sonuçlarını yenile", "refresh-wallpaper-list": "Duvar kağıdı listesini yenile", "remove-widget": "Widget kaldır", "screen-recorder-not-installed": "Ekran kaydedici yüklü değil", @@ -2039,7 +2041,24 @@ "description": "Seçilen duvar kağıdını tüm monitörlere aynı anda uygulayın.", "label": "Tüm monitörler için uygula" }, + "categories": { + "anime": "Anime", + "general": "Genel", + "label": "Kategoriler", + "people": "İnsanlar" + }, + "purity": { + "all": "Tümü", + "label": "İçerik filtresi", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "Ara:", + "source": { + "label": "Kaynak", + "local": "Yerel", + "wallhaven": "Wallhaven" + }, "title": "Duvar kağıdı seçici" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "Çizgiler", "wipe": "Silme" }, - "try-different-search": "Farklı bir arama sorgusu deneyin." + "try-different-search": "Farklı bir arama sorgusu deneyin.", + "wallhaven": { + "loading": "Duvar kağıtları yükleniyor...", + "no-results": "Duvar kağıdı bulunamadı. Farklı bir arama sorgusu deneyin.", + "page": "Sayfa {current} / {total}" + } }, "weather": { "clear-sky": "Açık hava", diff --git a/Assets/Translations/uk-UA.json b/Assets/Translations/uk-UA.json index 08994435..7118ea9c 100644 --- a/Assets/Translations/uk-UA.json +++ b/Assets/Translations/uk-UA.json @@ -580,6 +580,7 @@ "search-icons": "напр., noctalia, niri, battery, cloud", "search-launcher": "Пошук записів... або використовуйте > для команд", "search-wallpapers": "Введіть для фільтрації шпалер...", + "search-wallhaven": "Пошук у Wallhaven...", "select": "Вибрати", "test": "Тест" }, @@ -2010,6 +2011,7 @@ "previous-month": "Попередній місяць", "refresh": "Оновити", "refresh-devices": "Оновити пристрої", + "refresh-wallhaven": "Оновити результати Wallhaven", "refresh-wallpaper-list": "Оновити список шпалер", "remove-widget": "Видалити віджет", "screen-recorder-not-installed": "Запис екрана не встановлено", @@ -2039,7 +2041,24 @@ "description": "Застосувати вибрані шпалери до всіх моніторів одночасно.", "label": "Застосувати до всіх моніторів" }, + "categories": { + "anime": "Аніме", + "general": "Загальне", + "label": "Категорії", + "people": "Люди" + }, + "purity": { + "all": "Всі", + "label": "Фільтр контенту", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "Пошук:", + "source": { + "label": "Джерело", + "local": "Локальний", + "wallhaven": "Wallhaven" + }, "title": "Вибір шпалер" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "Смуги", "wipe": "Змітання" }, - "try-different-search": "Спробуйте інший пошуковий запит." + "try-different-search": "Спробуйте інший пошуковий запит.", + "wallhaven": { + "loading": "Завантаження шпалер...", + "no-results": "Шпалери не знайдено. Спробуйте інший пошуковий запит.", + "page": "Сторінка {current} з {total}" + } }, "weather": { "clear-sky": "Ясне небо", diff --git a/Assets/Translations/zh-CN.json b/Assets/Translations/zh-CN.json index 1fa9d5d3..d7bee27c 100644 --- a/Assets/Translations/zh-CN.json +++ b/Assets/Translations/zh-CN.json @@ -580,6 +580,7 @@ "search-icons": "例如:noctalia, niri, battery, cloud", "search-launcher": "搜索条目...或使用 > 执行命令", "search-wallpapers": "输入以筛选壁纸...", + "search-wallhaven": "搜索 Wallhaven...", "select": "选择", "test": "测试" }, @@ -2010,6 +2011,7 @@ "previous-month": "上个月", "refresh": "刷新", "refresh-devices": "刷新设备", + "refresh-wallhaven": "刷新 Wallhaven 结果", "refresh-wallpaper-list": "刷新壁纸列表", "remove-widget": "移除小部件", "screen-recorder-not-installed": "屏幕录制器未安装", @@ -2039,7 +2041,24 @@ "description": "一次性将选定的壁纸应用到所有显示器。", "label": "应用到所有显示器" }, + "categories": { + "anime": "动漫", + "general": "通用", + "label": "分类", + "people": "人物" + }, + "purity": { + "all": "全部", + "label": "内容过滤器", + "sfw": "SFW", + "sketchy": "Sketchy" + }, "search": "搜索:", + "source": { + "label": "来源", + "local": "本地", + "wallhaven": "Wallhaven" + }, "title": "壁纸选择器" }, "transitions": { @@ -2050,7 +2069,12 @@ "stripes": "条纹", "wipe": "擦除" }, - "try-different-search": "尝试不同的搜索查询。" + "try-different-search": "尝试不同的搜索查询。", + "wallhaven": { + "loading": "正在加载壁纸...", + "no-results": "未找到壁纸。请尝试不同的搜索查询。", + "page": "第 {current} 页,共 {total} 页" + } }, "weather": { "clear-sky": "晴朗", diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 1dc65a52..d1ea1fbb 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -263,6 +263,13 @@ Singleton { property list monitors: [] property string panelPosition: "follow_bar" property bool hideWallpaperFilenames: false + // Wallhaven settings + property bool useWallhaven: false + property string wallhavenQuery: "" + property string wallhavenSorting: "date_added" + property string wallhavenOrder: "desc" + property string wallhavenCategories: "111" // general,anime,people + property string wallhavenPurity: "100" // sfw only } // applauncher diff --git a/Modules/Panels/Wallpaper/WallpaperPanel.qml b/Modules/Panels/Wallpaper/WallpaperPanel.qml index bf016505..effc687b 100644 --- a/Modules/Panels/Wallpaper/WallpaperPanel.qml +++ b/Modules/Panels/Wallpaper/WallpaperPanel.qml @@ -193,9 +193,17 @@ SmartPanel { NIconButton { icon: "refresh" - tooltipText: I18n.tr("tooltips.refresh-wallpaper-list") + tooltipText: Settings.data.wallpaper.useWallhaven ? I18n.tr("tooltips.refresh-wallhaven") : I18n.tr("tooltips.refresh-wallpaper-list") baseSize: Style.baseWidgetSize * 0.8 - onClicked: WallpaperService.refreshWallpapersList() + onClicked: { + if (Settings.data.wallpaper.useWallhaven) { + if (typeof WallhavenService !== "undefined") { + WallhavenService.search(Settings.data.wallpaper.wallhavenQuery, 1) + } + } else { + WallpaperService.refreshWallpapersList() + } + } } NIconButton { @@ -210,6 +218,425 @@ SmartPanel { Layout.fillWidth: true } + // Unified search input + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NText { + text: I18n.tr("wallpaper.panel.search") + color: Color.mOnSurface + pointSize: Style.fontSizeM + Layout.preferredWidth: implicitWidth + } + + NTextInput { + id: searchInput + placeholderText: Settings.data.wallpaper.useWallhaven ? I18n.tr("placeholders.search-wallhaven") : I18n.tr("placeholders.search-wallpapers") + Layout.fillWidth: true + + property bool initializing: true + Component.onCompleted: { + // Initialize text based on current mode + if (Settings.data.wallpaper.useWallhaven) { + searchInput.text = Settings.data.wallpaper.wallhavenQuery || "" + } else { + searchInput.text = wallpaperPanel.filterText || "" + } + // Give focus to search input + if (searchInput.inputItem && searchInput.inputItem.visible) { + searchInput.inputItem.forceActiveFocus() + } + // Mark initialization as complete after a short delay + Qt.callLater(function() { + searchInput.initializing = false + }) + } + + Connections { + target: Settings.data.wallpaper + function onUseWallhavenChanged() { + // Update text when mode changes + if (Settings.data.wallpaper.useWallhaven) { + searchInput.text = Settings.data.wallpaper.wallhavenQuery || "" + } else { + searchInput.text = wallpaperPanel.filterText || "" + } + } + } + + onTextChanged: { + // Don't trigger search during initialization - Component.onCompleted will handle initial search + if (initializing) { + return + } + if (Settings.data.wallpaper.useWallhaven) { + wallhavenSearchDebounceTimer.restart() + } else { + searchDebounceTimer.restart() + } + } + + onEditingFinished: { + if (Settings.data.wallpaper.useWallhaven) { + wallhavenSearchDebounceTimer.stop() + Settings.data.wallpaper.wallhavenQuery = text + if (typeof WallhavenService !== "undefined") { + wallhavenView.loading = true + WallhavenService.search(text, 1) + } + } + } + + Keys.onDownPressed: { + if (Settings.data.wallpaper.useWallhaven) { + if (wallhavenView && wallhavenView.gridView) { + wallhavenView.gridView.forceActiveFocus() + } + } else { + let currentView = screenRepeater.itemAt(currentScreenIndex) + if (currentView && currentView.gridView) { + currentView.gridView.forceActiveFocus() + } + } + } + } + } + + // Debounce timer for Wallhaven search + Timer { + id: wallhavenSearchDebounceTimer + interval: 500 + onTriggered: { + Settings.data.wallpaper.wallhavenQuery = searchInput.text + if (typeof WallhavenService !== "undefined") { + wallhavenView.loading = true + WallhavenService.search(searchInput.text, 1) + } + } + } + + // Source selector + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NText { + text: I18n.tr("wallpaper.panel.source.label") + color: Color.mOnSurface + pointSize: Style.fontSizeM + Layout.preferredWidth: implicitWidth + } + + NComboBox { + id: sourceComboBox + Layout.fillWidth: true + model: [ + { "key": "local", "name": I18n.tr("wallpaper.panel.source.local") }, + { "key": "wallhaven", "name": I18n.tr("wallpaper.panel.source.wallhaven") } + ] + currentKey: Settings.data.wallpaper.useWallhaven ? "wallhaven" : "local" + property bool skipNextSelected: false + Component.onCompleted: { + // Skip the first onSelected if it fires during initialization + skipNextSelected = true + Qt.callLater(function() { + skipNextSelected = false + }) + } + onSelected: key => { + if (skipNextSelected) { + return + } + var useWallhaven = (key === "wallhaven") + Settings.data.wallpaper.useWallhaven = useWallhaven + // Update search input text based on mode + if (useWallhaven) { + searchInput.text = Settings.data.wallpaper.wallhavenQuery || "" + } else { + searchInput.text = wallpaperPanel.filterText || "" + } + if (useWallhaven && typeof WallhavenService !== "undefined") { + // Update service properties when switching to Wallhaven + // Don't search here - Component.onCompleted will handle it when the component is created + // This prevents duplicate searches + WallhavenService.categories = Settings.data.wallpaper.wallhavenCategories + WallhavenService.purity = Settings.data.wallpaper.wallhavenPurity + WallhavenService.sorting = Settings.data.wallpaper.wallhavenSorting + WallhavenService.order = Settings.data.wallpaper.wallhavenOrder + // If the view is already initialized, trigger a new search when switching to it + if (wallhavenView && wallhavenView.initialized && !WallhavenService.fetching) { + wallhavenView.loading = true + WallhavenService.search(Settings.data.wallpaper.wallhavenQuery || "", 1) + } + } + } + } + } + + // Wallhaven category toggles + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + visible: Settings.data.wallpaper.useWallhaven + + NText { + text: I18n.tr("wallpaper.panel.categories.label") + color: Color.mOnSurface + pointSize: Style.fontSizeM + Layout.preferredWidth: implicitWidth + } + + Item { + Layout.fillWidth: true + } + + RowLayout { + id: categoriesRow + spacing: Style.marginL + + function getCategoryValue(index) { + var cats = Settings.data.wallpaper.wallhavenCategories || "111" + return cats.length > index && cats.charAt(index) === "1" + } + + function updateCategories(general, anime, people) { + var categories = (general ? "1" : "0") + (anime ? "1" : "0") + (people ? "1" : "0") + Settings.data.wallpaper.wallhavenCategories = categories + // Update checkboxes immediately + generalToggle.checked = general + animeToggle.checked = anime + peopleToggle.checked = people + if (typeof WallhavenService !== "undefined") { + WallhavenService.categories = categories + WallhavenService.search(Settings.data.wallpaper.wallhavenQuery, 1) + } + } + + Connections { + target: Settings.data.wallpaper + function onWallhavenCategoriesChanged() { + generalToggle.checked = categoriesRow.getCategoryValue(0) + animeToggle.checked = categoriesRow.getCategoryValue(1) + peopleToggle.checked = categoriesRow.getCategoryValue(2) + } + } + + Component.onCompleted: { + generalToggle.checked = categoriesRow.getCategoryValue(0) + animeToggle.checked = categoriesRow.getCategoryValue(1) + peopleToggle.checked = categoriesRow.getCategoryValue(2) + } + + // General checkbox + Item { + Layout.preferredWidth: generalCheckboxRow.implicitWidth + Layout.preferredHeight: generalCheckboxRow.implicitHeight + + RowLayout { + id: generalCheckboxRow + anchors.fill: parent + spacing: Style.marginS + + NText { + text: I18n.tr("wallpaper.panel.categories.general") + color: Color.mOnSurface + pointSize: Style.fontSizeM + } + + Rectangle { + id: generalBox + implicitWidth: Math.round(Style.baseWidgetSize * 0.7) + implicitHeight: Math.round(Style.baseWidgetSize * 0.7) + radius: Style.radiusXS + color: generalToggle.checked ? Color.mPrimary : Color.mSurface + border.color: Color.mOutline + border.width: Style.borderS + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + + NIcon { + visible: generalToggle.checked + anchors.centerIn: parent + anchors.horizontalCenterOffset: -1 + icon: "check" + color: Color.mOnPrimary + pointSize: Math.max(Style.fontSizeXS, generalBox.width * 0.5) + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: generalToggle.toggled(!generalToggle.checked) + } + } + } + } + + // Anime checkbox + Item { + Layout.preferredWidth: animeCheckboxRow.implicitWidth + Layout.preferredHeight: animeCheckboxRow.implicitHeight + + RowLayout { + id: animeCheckboxRow + anchors.fill: parent + spacing: Style.marginS + + NText { + text: I18n.tr("wallpaper.panel.categories.anime") + color: Color.mOnSurface + pointSize: Style.fontSizeM + } + + Rectangle { + id: animeBox + implicitWidth: Math.round(Style.baseWidgetSize * 0.7) + implicitHeight: Math.round(Style.baseWidgetSize * 0.7) + radius: Style.radiusXS + color: animeToggle.checked ? Color.mPrimary : Color.mSurface + border.color: Color.mOutline + border.width: Style.borderS + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + + NIcon { + visible: animeToggle.checked + anchors.centerIn: parent + anchors.horizontalCenterOffset: -1 + icon: "check" + color: Color.mOnPrimary + pointSize: Math.max(Style.fontSizeXS, animeBox.width * 0.5) + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: animeToggle.toggled(!animeToggle.checked) + } + } + } + } + + // People checkbox + Item { + Layout.preferredWidth: peopleCheckboxRow.implicitWidth + Layout.preferredHeight: peopleCheckboxRow.implicitHeight + + RowLayout { + id: peopleCheckboxRow + anchors.fill: parent + spacing: Style.marginS + + NText { + text: I18n.tr("wallpaper.panel.categories.people") + color: Color.mOnSurface + pointSize: Style.fontSizeM + } + + Rectangle { + id: peopleBox + implicitWidth: Math.round(Style.baseWidgetSize * 0.7) + implicitHeight: Math.round(Style.baseWidgetSize * 0.7) + radius: Style.radiusXS + color: peopleToggle.checked ? Color.mPrimary : Color.mSurface + border.color: Color.mOutline + border.width: Style.borderS + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + + NIcon { + visible: peopleToggle.checked + anchors.centerIn: parent + anchors.horizontalCenterOffset: -1 + icon: "check" + color: Color.mOnPrimary + pointSize: Math.max(Style.fontSizeXS, peopleBox.width * 0.5) + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: peopleToggle.toggled(!peopleToggle.checked) + } + } + } + } + + // Invisible checkboxes to maintain the signal handlers + QtObject { + id: generalToggle + property bool checked: false + signal toggled(bool checked) + onToggled: checked => { + categoriesRow.updateCategories(checked, categoriesRow.getCategoryValue(1), categoriesRow.getCategoryValue(2)) + } + } + + QtObject { + id: animeToggle + property bool checked: false + signal toggled(bool checked) + onToggled: checked => { + categoriesRow.updateCategories(categoriesRow.getCategoryValue(0), checked, categoriesRow.getCategoryValue(2)) + } + } + + QtObject { + id: peopleToggle + property bool checked: false + signal toggled(bool checked) + onToggled: checked => { + categoriesRow.updateCategories(categoriesRow.getCategoryValue(0), categoriesRow.getCategoryValue(1), checked) + } + } + } + } + + // Wallhaven purity selector + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + visible: Settings.data.wallpaper.useWallhaven + + NText { + text: I18n.tr("wallpaper.panel.purity.label") + color: Color.mOnSurface + pointSize: Style.fontSizeM + Layout.preferredWidth: implicitWidth + } + + NComboBox { + id: purityComboBox + Layout.fillWidth: true + model: [ + { "key": "111", "name": I18n.tr("wallpaper.panel.purity.all") }, + { "key": "100", "name": I18n.tr("wallpaper.panel.purity.sfw") }, + { "key": "010", "name": I18n.tr("wallpaper.panel.purity.sketchy") } + ] + currentKey: Settings.data.wallpaper.wallhavenPurity + onSelected: key => { + Settings.data.wallpaper.wallhavenPurity = key + if (typeof WallhavenService !== "undefined") { + WallhavenService.purity = key + WallhavenService.search(Settings.data.wallpaper.wallhavenQuery, 1) + } + } + } + } + NToggle { label: I18n.tr("wallpaper.panel.apply-all-monitors.label") description: I18n.tr("wallpaper.panel.apply-all-monitors.description") @@ -221,7 +648,7 @@ SmartPanel { // Monitor tabs NTabBar { id: screenTabBar - visible: !Settings.data.wallpaper.setWallpaperOnAllMonitors || Settings.data.wallpaper.enableMultiMonitorDirectories + visible: (!Settings.data.wallpaper.setWallpaperOnAllMonitors || Settings.data.wallpaper.enableMultiMonitorDirectories) && !Settings.data.wallpaper.useWallhaven Layout.fillWidth: true currentIndex: currentScreenIndex onCurrentIndexChanged: currentScreenIndex = currentIndex @@ -238,55 +665,30 @@ SmartPanel { } } } - // StackLayout for each screen's wallpaper content + // Content stack: Wallhaven or Local StackLayout { - id: screenStack + id: contentStack Layout.fillWidth: true Layout.fillHeight: true - currentIndex: currentScreenIndex + currentIndex: Settings.data.wallpaper.useWallhaven ? 1 : 0 - Repeater { - id: screenRepeater - model: Quickshell.screens - delegate: WallpaperScreenView { - targetScreen: modelData - } - } - } + // Local wallpapers + StackLayout { + id: screenStack + currentIndex: currentScreenIndex - // Filter input - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - NText { - text: I18n.tr("wallpaper.panel.search") - color: Color.mOnSurface - pointSize: Style.fontSizeM - Layout.preferredWidth: implicitWidth - } - - NTextInput { - id: searchInput - placeholderText: I18n.tr("placeholders.search-wallpapers") - Layout.fillWidth: true - - onTextChanged: { - searchDebounceTimer.restart() - } - - Keys.onDownPressed: { - let currentView = screenRepeater.itemAt(currentScreenIndex) - if (currentView && currentView.gridView) { - currentView.gridView.forceActiveFocus() + Repeater { + id: screenRepeater + model: Quickshell.screens + delegate: WallpaperScreenView { + targetScreen: modelData } } + } - Component.onCompleted: { - if (searchInput.inputItem && searchInput.inputItem.visible) { - searchInput.inputItem.forceActiveFocus() - } - } + // Wallhaven wallpapers + WallhavenView { + id: wallhavenView } } } @@ -622,4 +1024,403 @@ SmartPanel { } } } + + // Component for Wallhaven wallpapers view + component WallhavenView: Item { + id: wallhavenViewRoot + property alias gridView: wallhavenGridView + + property var wallpapers: [] + property bool loading: false + property string errorMessage: "" + property bool initialized: false + property bool searchScheduled: false + + Connections { + target: typeof WallhavenService !== "undefined" ? WallhavenService : null + function onSearchCompleted(results, meta) { + wallhavenViewRoot.wallpapers = results || [] + wallhavenViewRoot.loading = false + wallhavenViewRoot.errorMessage = "" + wallhavenViewRoot.searchScheduled = false + } + function onSearchFailed(error) { + wallhavenViewRoot.loading = false + wallhavenViewRoot.errorMessage = error || "" + wallhavenViewRoot.searchScheduled = false + } + } + + Component.onCompleted: { + // Initialize service properties and perform initial search if Wallhaven is active + if (typeof WallhavenService !== "undefined" && Settings.data.wallpaper.useWallhaven && !initialized) { + // Set flags immediately to prevent race conditions + if (WallhavenService.initialSearchScheduled) { + // Another instance already scheduled the search, just initialize properties + initialized = true + return + } + + // We're the first one - claim the search + initialized = true + WallhavenService.initialSearchScheduled = true + WallhavenService.categories = Settings.data.wallpaper.wallhavenCategories + WallhavenService.purity = Settings.data.wallpaper.wallhavenPurity + WallhavenService.sorting = Settings.data.wallpaper.wallhavenSorting + WallhavenService.order = Settings.data.wallpaper.wallhavenOrder + + // Now check if we can actually search (fetching check is in WallhavenService.search) + loading = true + WallhavenService.search(Settings.data.wallpaper.wallhavenQuery || "", 1) + } + } + + + ColumnLayout { + anchors.fill: parent + spacing: Style.marginM + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + GridView { + id: wallhavenGridView + + anchors.fill: parent + + visible: !loading && errorMessage === "" && (wallpapers && wallpapers.length > 0) + interactive: true + clip: true + focus: true + keyNavigationEnabled: true + keyNavigationWraps: false + + model: wallpapers || [] + + property int columns: (screen.width > 1920) ? 5 : 4 + property int itemSize: cellWidth + + cellWidth: Math.floor((width - leftMargin - rightMargin) / columns) + cellHeight: Math.floor(itemSize * 0.7) + Style.marginXS + Style.fontSizeXS + Style.marginM + + leftMargin: Style.marginS + rightMargin: Style.marginS + topMargin: Style.marginS + bottomMargin: Style.marginS + + onCurrentIndexChanged: { + if (currentIndex >= 0) { + let row = Math.floor(currentIndex / columns) + let itemY = row * cellHeight + let viewportTop = contentY + let viewportBottom = viewportTop + height + + if (itemY < viewportTop) { + contentY = Math.max(0, itemY - cellHeight) + } else if (itemY + cellHeight > viewportBottom) { + contentY = itemY + cellHeight - height + cellHeight + } + } + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Space) { + if (currentIndex >= 0 && currentIndex < wallpapers.length) { + let wallpaper = wallpapers[currentIndex] + if (typeof WallhavenService !== "undefined") { + WallhavenService.downloadWallpaper(wallpaper, function (success, localPath) { + if (success) { + if (Settings.data.wallpaper.setWallpaperOnAllMonitors) { + WallpaperService.changeWallpaper(localPath, undefined) + } else { + WallpaperService.changeWallpaper(localPath, screen.name) + } + } + }) + } + } + event.accepted = true + } + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + parent: wallhavenGridView + x: wallhavenGridView.mirrored ? 0 : wallhavenGridView.width - width + y: 0 + height: wallhavenGridView.height + + property color handleColor: Qt.alpha(Color.mHover, 0.8) + property color handleHoverColor: handleColor + property color handlePressedColor: handleColor + property real handleWidth: 6 + property real handleRadius: Style.radiusM + + contentItem: Rectangle { + implicitWidth: parent.handleWidth + implicitHeight: 100 + radius: parent.handleRadius + color: parent.pressed ? parent.handlePressedColor : parent.hovered ? parent.handleHoverColor : parent.handleColor + opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + background: Rectangle { + implicitWidth: parent.handleWidth + implicitHeight: 100 + color: Color.transparent + opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 0.3 : 0.0 + radius: parent.handleRadius / 2 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } + + delegate: ColumnLayout { + id: wallhavenItem + + required property var modelData + required property int index + property string thumbnailUrl: (modelData && typeof WallhavenService !== "undefined") ? WallhavenService.getThumbnailUrl(modelData, "large") : "" + property string wallpaperId: (modelData && modelData.id) ? modelData.id : "" + + width: wallhavenGridView.itemSize + spacing: Style.marginXS + + Rectangle { + id: imageContainer + Layout.fillWidth: true + Layout.preferredHeight: Math.round(wallhavenGridView.itemSize * 0.67) + color: Color.transparent + + Image { + id: img + source: thumbnailUrl + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + smooth: true + sourceSize.width: Math.round(wallhavenGridView.itemSize * 0.67) + sourceSize.height: Math.round(wallhavenGridView.itemSize * 0.67) + } + + Rectangle { + anchors.fill: parent + color: Color.transparent + border.color: wallhavenGridView.currentIndex === index ? Color.mHover : Color.mSurface + border.width: Math.max(1, Style.borderL * 1.5) + } + + Rectangle { + anchors.fill: parent + color: Color.mSurface + opacity: hoverHandler.hovered || wallhavenGridView.currentIndex === index ? 0 : 0.3 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + + HoverHandler { + id: hoverHandler + } + + TapHandler { + onTapped: { + wallhavenGridView.currentIndex = index + if (typeof WallhavenService !== "undefined") { + WallhavenService.downloadWallpaper(modelData, function (success, localPath) { + if (success) { + if (Settings.data.wallpaper.setWallpaperOnAllMonitors) { + WallpaperService.changeWallpaper(localPath, undefined) + } else { + WallpaperService.changeWallpaper(localPath, screen.name) + } + } + }) + } + } + } + } + + NText { + text: wallpaperId || I18n.tr("wallpaper.unknown") + color: hoverHandler.hovered || wallhavenGridView.currentIndex === index ? Color.mOnSurface : Color.mOnSurfaceVariant + pointSize: Style.fontSizeXS + Layout.fillWidth: true + Layout.leftMargin: Style.marginS + Layout.rightMargin: Style.marginS + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + + // Loading overlay - fills same space as GridView to prevent jumping + Rectangle { + anchors.fill: parent + color: Color.mSurface + radius: Style.radiusM + border.color: Color.mOutline + border.width: Style.borderS + visible: loading + z: 10 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + Item { + Layout.fillHeight: true + } + + NBusyIndicator { + size: Style.baseWidgetSize * 1.5 + color: Color.mPrimary + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: I18n.tr("wallpaper.wallhaven.loading") + color: Color.mOnSurfaceVariant + pointSize: Style.fontSizeM + Layout.alignment: Qt.AlignHCenter + } + + Item { + Layout.fillHeight: true + } + } + } + + // Error overlay + Rectangle { + anchors.fill: parent + color: Color.mSurface + radius: Style.radiusM + border.color: Color.mOutline + border.width: Style.borderS + visible: errorMessage !== "" && !loading + z: 10 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + Item { + Layout.fillHeight: true + } + + NIcon { + icon: "alert-circle" + pointSize: Style.fontSizeXXL + color: Color.mError + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: errorMessage + color: Color.mOnSurface + wrapMode: Text.WordWrap + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + Item { + Layout.fillHeight: true + } + } + } + + // Empty state overlay + Rectangle { + anchors.fill: parent + color: Color.mSurface + radius: Style.radiusM + border.color: Color.mOutline + border.width: Style.borderS + visible: (!wallpapers || wallpapers.length === 0) && !loading && errorMessage === "" + z: 10 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + Item { + Layout.fillHeight: true + } + + NIcon { + icon: "image" + pointSize: Style.fontSizeXXL + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: I18n.tr("wallpaper.wallhaven.no-results") + color: Color.mOnSurface + wrapMode: Text.WordWrap + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + Item { + Layout.fillHeight: true + } + } + } + } + + // Pagination + RowLayout { + Layout.fillWidth: true + visible: !loading && errorMessage === "" && typeof WallhavenService !== "undefined" + spacing: Style.marginM + + NIconButton { + icon: "chevron-left" + enabled: WallhavenService.currentPage > 1 && !WallhavenService.fetching + onClicked: WallhavenService.previousPage() + } + + NText { + text: I18n.tr("wallpaper.wallhaven.page").replace("{current}", WallhavenService.currentPage).replace("{total}", WallhavenService.lastPage) + color: Color.mOnSurface + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + NIconButton { + icon: "chevron-right" + enabled: WallhavenService.currentPage < WallhavenService.lastPage && !WallhavenService.fetching + onClicked: WallhavenService.nextPage() + } + } + } + } } diff --git a/Services/UI/WallhavenService.qml b/Services/UI/WallhavenService.qml new file mode 100644 index 00000000..c9ad417f --- /dev/null +++ b/Services/UI/WallhavenService.qml @@ -0,0 +1,257 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Commons + +Singleton { + id: root + + // State + property bool fetching: false + property bool initialSearchScheduled: false + property var currentResults: [] + property var currentMeta: ({}) + property string lastError: "" + property string currentQuery: "" + property int currentPage: 1 + property int lastPage: 1 + + // Search parameters + property string categories: "111" // general,anime,people (all enabled by default) + property string purity: "100" // sfw + property string sorting: "date_added" // date_added, relevance, random, views, favorites, toplist + property string order: "desc" // desc, asc + property string topRange: "1M" // 1d, 3d, 1w, 1M, 3M, 6M, 1y + property string seed: "" // For random sorting + property string minResolution: "" // e.g., "1920x1080" + property string resolutions: "" // e.g., "1920x1080,1920x1200" + property string ratios: "" // e.g., "16x9,16x10" + property string colors: "" // Color hex codes + + // Signals + signal searchCompleted(var results, var meta) + signal searchFailed(string error) + signal wallpaperDownloaded(string wallpaperId, string localPath) + + // Base API URL + readonly property string apiBaseUrl: "https://wallhaven.cc/api/v1" + + // ------------------------------------------------- + function search(query, page) { + if (fetching) { + return + } + + // Reset initial search flag once we start a search + if (initialSearchScheduled) { + initialSearchScheduled = false + } + + fetching = true + lastError = "" + currentQuery = query || "" + currentPage = page || 1 + + var url = apiBaseUrl + "/search" + var params = [] + + if (currentQuery) { + params.push("q=" + encodeURIComponent(currentQuery)) + } + + params.push("categories=" + categories) + params.push("purity=" + purity) + params.push("sorting=" + sorting) + params.push("order=" + order) + + if (sorting === "toplist") { + params.push("topRange=" + topRange) + } + + if (sorting === "random" && seed) { + params.push("seed=" + seed) + } + + if (minResolution) { + params.push("atleast=" + minResolution) + } + + if (resolutions) { + params.push("resolutions=" + resolutions) + } + + if (ratios) { + params.push("ratios=" + ratios) + } + + if (colors) { + params.push("colors=" + colors) + } + + params.push("page=" + currentPage) + + url += "?" + params.join("&") + + Logger.d("Wallhaven", "Searching:", url) + + var xhr = new XMLHttpRequest() + xhr.onreadystatechange = function () { + if (xhr.readyState === XMLHttpRequest.DONE) { + fetching = false + if (xhr.status === 200) { + try { + var response = JSON.parse(xhr.responseText) + if (response.data && Array.isArray(response.data)) { + currentResults = response.data + currentMeta = response.meta || {} + lastPage = currentMeta.last_page || 1 + + // Store seed for random sorting + if (currentMeta.seed) { + seed = currentMeta.seed + } + + Logger.i("Wallhaven", "Search completed:", currentResults.length, "results, page", currentPage, "of", lastPage) + searchCompleted(currentResults, currentMeta) + } else { + var errorMsg = "Invalid API response" + lastError = errorMsg + Logger.e("Wallhaven", errorMsg) + searchFailed(errorMsg) + } + } catch (e) { + var errorMsg = "Failed to parse API response: " + e.toString() + lastError = errorMsg + Logger.e("Wallhaven", errorMsg) + searchFailed(errorMsg) + } + } else if (xhr.status === 429) { + var errorMsg = "Rate limit exceeded (45 requests/minute)" + lastError = errorMsg + Logger.e("Wallhaven", errorMsg) + searchFailed(errorMsg) + } else { + var errorMsg = "API error: " + xhr.status + lastError = errorMsg + Logger.e("Wallhaven", "Search failed:", errorMsg) + searchFailed(errorMsg) + } + } + } + + xhr.open("GET", url) + xhr.send() + } + + // ------------------------------------------------- + function getWallpaperUrl(wallpaper) { + // Use the 'path' field which contains the full resolution image URL + if (wallpaper.path) { + return wallpaper.path + } + // Fallback to constructing URL from ID + if (wallpaper.id) { + var idPrefix = wallpaper.id.substring(0, 2) + return "https://w.wallhaven.cc/full/" + idPrefix + "/wallhaven-" + wallpaper.id + ".jpg" + } + return "" + } + + // ------------------------------------------------- + function getThumbnailUrl(wallpaper, size) { + // size: "small", "large", "original" + if (wallpaper.thumbs && wallpaper.thumbs[size]) { + return wallpaper.thumbs[size] + } + // Fallback + if (wallpaper.id) { + var idPrefix = wallpaper.id.substring(0, 2) + var sizeMap = { + "small": "small", + "large": "lg", + "original": "orig" + } + var sizePath = sizeMap[size] || "lg" + return "https://th.wallhaven.cc/" + sizePath + "/" + idPrefix + "/" + wallpaper.id + ".jpg" + } + return "" + } + + // ------------------------------------------------- + function downloadWallpaper(wallpaper, callback) { + var url = getWallpaperUrl(wallpaper) + if (!url) { + Logger.e("Wallhaven", "No URL available for wallpaper", wallpaper.id) + if (callback) callback(false, "") + return + } + + var wallpaperId = wallpaper.id + + // Get the user's wallpaper directory + var wallpaperDir = Settings.preprocessPath(Settings.data.wallpaper.directory) + if (!wallpaperDir || wallpaperDir === "") { + wallpaperDir = Settings.defaultWallpapersDirectory + } + + // Ensure directory ends with / + if (!wallpaperDir.endsWith("/")) { + wallpaperDir += "/" + } + + var localPath = wallpaperDir + "wallhaven_" + wallpaperId + ".jpg" + + Logger.d("Wallhaven", "Downloading wallpaper", wallpaperId, "to", localPath) + + // Use curl or wget to download the file, ensuring directory exists first + var downloadProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + id: downloadProcess + command: ["sh", "-c", "mkdir -p '` + wallpaperDir + `' && (curl -L -s -o '` + localPath + `' '` + url + `' || wget -q -O '` + localPath + `' '` + url + `')"] + } + `, root, "DownloadProcess_" + wallpaperId) + + downloadProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + Logger.i("Wallhaven", "Wallpaper downloaded:", localPath) + wallpaperDownloaded(wallpaperId, localPath) + if (callback) callback(true, localPath) + } else { + Logger.e("Wallhaven", "Failed to download wallpaper, exit code:", exitCode) + if (callback) callback(false, "") + } + downloadProcess.destroy() + }) + + downloadProcess.running = true + } + + // ------------------------------------------------- + function reset() { + currentResults = [] + currentMeta = {} + currentQuery = "" + currentPage = 1 + lastPage = 1 + seed = "" + lastError = "" + } + + // ------------------------------------------------- + function nextPage() { + if (currentPage < lastPage && !fetching) { + search(currentQuery, currentPage + 1) + } + } + + // ------------------------------------------------- + function previousPage() { + if (currentPage > 1 && !fetching) { + search(currentQuery, currentPage - 1) + } + } +} +