diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 32f2efadf..0d6ffcf84 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -7695,3 +7695,27 @@ msgstr "Suche nach anderen Versionen…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Diese Veröffentlichung" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Hintergrundbild" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Hintergrundbild, unscharf" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Hintergrundmotiv" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "Das Hintergrundbild des Systems. Alternativ ein eigenes Bild wählen." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Bild wählen…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "System-Hintergrundbild" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 3d21c3910..28bb38d97 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3350,3 +3350,27 @@ msgstr "Looking for other versions…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "This release" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Wallpaper" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Wallpaper, blurred" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Background image" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "The system wallpaper. Choose an image of your own instead." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Choose image…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "System wallpaper" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 5179bf451..41a5faa7b 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -7701,3 +7701,27 @@ msgstr "Buscando otras versiones…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Este lanzamiento" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Fondo de pantalla" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Fondo de pantalla difuminado" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Imagen de fondo" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "El fondo de pantalla del sistema. También puedes elegir una imagen propia." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Elegir imagen…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Fondo del sistema" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index beafc9386..373e941ab 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -7693,3 +7693,27 @@ msgstr "Recherche d'autres versions…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Cette sortie" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Fond d'écran" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Fond d'écran flouté" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Image de fond" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "Le fond d'écran du système. Vous pouvez aussi choisir votre propre image." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Choisir une image…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Fond d'écran du système" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 01603bf64..8f3db3988 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -7676,3 +7676,27 @@ msgstr "他のバージョンを探しています…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "このリリース" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "壁紙" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "ぼかし壁紙" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "背景画像" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "システムの壁紙。代わりに自分の画像を選ぶこともできます。" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "画像を選択…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "システムの壁紙" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 9a53d4814..ed59317fc 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -7683,3 +7683,27 @@ msgstr "Andere versies zoeken…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Deze release" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Bureaubladachtergrond" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Vervaagde bureaubladachtergrond" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Achtergrondafbeelding" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "De bureaubladachtergrond van het systeem. Je kunt ook een eigen afbeelding kiezen." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Afbeelding kiezen…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Systeemachtergrond" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index e64cdb11f..b4b3dbd79 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -7693,3 +7693,27 @@ msgstr "A procurar outras versões…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Este lançamento" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Papel de parede" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Papel de parede desfocado" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Imagem de fundo" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "O papel de parede do sistema. Também pode escolher uma imagem sua." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Escolher imagem…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Papel de parede do sistema" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index aea8cc68e..b72c4540c 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -7714,3 +7714,27 @@ msgstr "Ищем другие версии…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Этот релиз" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Обои" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Размытые обои" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Фоновое изображение" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "Обои системы. Можно выбрать собственное изображение." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Выбрать изображение…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Обои системы" diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 53dd03f98..31e86e5aa 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -656,6 +656,7 @@ fn main() { "qml/settings/OrbitSettings.qml", "qml/settings/OrbitSettingsPanel.qml", "qml/shell/AmbientField.qml", + "qml/shell/WallpaperField.qml", "qml/shell/AppShell.qml", "qml/shell/OrbitBanner.qml", "qml/shell/ArtPreviewOverlay.qml", diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index dd21a2f6b..90869ca31 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -148,6 +148,27 @@ Column { onSelected: function (i) { QbzBridge.settingsSelect("app-background", i) } } } + // The picture behind the two Wallpaper modes: the desktop's, or one of + // the user's own (wallpaper_qt.rs). + SettingRow { kioskHost: root.kioskHost; + visible: (root.doc.appBackgroundIndex || 0) >= 3 + label: QbzSession.tr("Background image", QbzSession.trRev) + description: (root.doc.appBackgroundImage || "") !== "" + ? root.doc.appBackgroundImage + : QbzSession.tr("The system wallpaper. Choose an image of your own instead.", QbzSession.trRev) + Row { + spacing: 8 + SettingsButton { kioskHost: root.kioskHost; + text: QbzSession.tr("Choose image…", QbzSession.trRev) + onClicked: QbzBridge.settingsPickBackgroundImage() + } + SettingsButton { kioskHost: root.kioskHost; + visible: (root.doc.appBackgroundImage || "") !== "" + text: QbzSession.tr("System wallpaper", QbzSession.trRev) + onClicked: QbzBridge.settingsClearBackgroundImage() + } + } + } // Auto-theme rows (the "auto" theme only). SettingRow { kioskHost: root.kioskHost; visible: QbzShell.themeSlug === "auto" diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index e2e5a84e5..9d0659f45 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -121,6 +121,55 @@ Rectangle { opacity: root.veilStrength } + // Mode 3 — Wallpaper: the desktop wallpaper, the part of it that lies + // BEHIND this window (where the platform tells the window its place: + // X11, macOS, Windows — Wayland hides it, so a centred crop stands in), + // lightly blurred under the half-alpha chrome. Static: it costs a frame + // only when the window moves or resizes (shell/WallpaperField.qml). + // Mode 4 — Wallpaper, blurred: the wallpaper through the SAME atmosphere + // pass Blurred art gives the cover, held still (no transport to follow). + // Neither waits for a playing track (QbzTheme.ambientOn). + readonly property bool wallpaperModeOn: root.ambientOn && QbzShell.ambientMode === 3 + readonly property bool wallpaperBlurOn: root.ambientOn && QbzShell.ambientMode === 4 + WallpaperField { + anchors.fill: parent + visible: root.wallpaperModeOn + source: root.wallpaperModeOn ? QbzShell.wallpaperUrl : "" + hostWindow: root.hostWindow + blur: 0.35 + dim: theme.isDark ? QbzShell.ambientDim : 0.0 + } + Rectangle { + anchors.fill: parent + visible: root.wallpaperModeOn && !theme.isDark + color: theme.surfaceMain + opacity: root.veilStrength + } + ImmersiveAtmosphere { + anchors.fill: parent + visible: root.wallpaperBlurOn + source: root.wallpaperBlurOn ? QbzShell.wallpaperAtmosphereUrl : "" + animated: false + dim: theme.isDark ? QbzShell.ambientDim : 0.0 + } + Rectangle { + anchors.fill: parent + visible: root.wallpaperBlurOn && !theme.isDark + color: theme.surfaceMain + opacity: root.veilStrength + } + // The wallpaper can change while QBZ runs: re-resolve when the window + // comes back to the front (wallpaper_qt memoises path + mtime, so a + // wallpaper that did not change republishes nothing). + Connections { + target: root.hostWindow + ignoreUnknownSignals: true + function onActiveChanged() { + if (root.hostWindow && root.hostWindow.active && QbzShell.ambientMode >= 3) + QbzShell.refreshWallpaper() + } + } + // The host ApplicationWindow (custom chrome: drag / maximize / resize). property var hostWindow: null diff --git a/crates/qbz-qt/qml/shell/WallpaperField.qml b/crates/qbz-qt/qml/shell/WallpaperField.qml new file mode 100644 index 000000000..272788ad0 --- /dev/null +++ b/crates/qbz-qt/qml/shell/WallpaperField.qml @@ -0,0 +1,73 @@ +// WallpaperField — the desktop wallpaper behind the window (2026-09-14): the +// part of it that lies UNDER this window, so moving the window slides the +// picture the way a translucent window would, lightly blurred beneath the +// half-alpha chrome. Nothing animates: it costs a frame only when the window +// moves or resizes, and a screen-sized texture at most (`sourceSize` caps the +// decode — a 3x-upscaled wallpaper never reaches the GPU whole). +// +// WHERE THE WINDOW IS. X11, macOS and Windows report the window's position; +// Wayland hides it from clients by design, so there the crop is centred on +// the screen until the Plasma window-management protocol is wired +// (wallpaper_qt.rs header). `hostWindow` is the ApplicationWindow. + +import QtQuick +import QtQuick.Window +import QtQuick.Effects + +Item { + id: root + + /// file:// URL of the wallpaper (QbzShell.wallpaperUrl). + property string source: "" + property var hostWindow: null + /// MultiEffect blur amount, 0 = crisp. + property real blur: 0.0 + /// Darkening on top (the dark-theme legibility veil). + property real dim: 0.0 + + clip: true + + readonly property bool positionKnown: root.hostWindow !== null + && Qt.platform.pluginName !== "wayland" + readonly property real screenW: Math.max(1, Screen.width) + readonly property real screenH: Math.max(1, Screen.height) + // The window's origin inside its screen; centred when unknown. + readonly property real offX: root.positionKnown + ? root.hostWindow.x - Screen.virtualX + : Math.max(0, (root.screenW - root.width) / 2) + readonly property real offY: root.positionKnown + ? root.hostWindow.y - Screen.virtualY + : Math.max(0, (root.screenH - root.height) / 2) + + Image { + id: picture + source: root.source + x: -root.offX + y: -root.offY + width: root.screenW + height: root.screenH + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + sourceSize.width: Math.round(root.screenW * Screen.devicePixelRatio) + sourceSize.height: Math.round(root.screenH * Screen.devicePixelRatio) + smooth: true + } + MultiEffect { + visible: root.blur > 0 && picture.status === Image.Ready + source: picture + x: picture.x + y: picture.y + width: picture.width + height: picture.height + autoPaddingEnabled: false + blurEnabled: true + blur: root.blur + blurMax: 48 + } + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: root.dim + } +} diff --git a/crates/qbz-qt/qml/theme/QbzTheme.qml b/crates/qbz-qt/qml/theme/QbzTheme.qml index 8f6fbe22f..27ddb1378 100644 --- a/crates/qbz-qt/qml/theme/QbzTheme.qml +++ b/crates/qbz-qt/qml/theme/QbzTheme.qml @@ -161,7 +161,10 @@ QtObject { // renderer-tier arm: the Qt field renders on the software path too // (ShaderEffect where shaders exist, Canvas where they do not), so the // feature is never taken away from a weak GPU here. - readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack + // The wallpaper modes (3, 4) do not depend on a playing track: the + // picture is always there. + readonly property bool ambientOn: QbzShell.ambientMode > 0 + && (QbzShell.ambientMode >= 3 || QbzPlayer.npHasTrack) // --- ON AN ACCENT FILL: the glyph tint and its colour twin ----------- // diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index ebaefafd0..6e5903bc8 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -84,6 +84,10 @@ pub mod qbz_bridge { /// Select rows: the picked OPTION INDEX within the row's list. #[qinvokable] fn settings_select(self: Pin<&mut QbzBridge>, key: QString, index: i32); + #[qinvokable] + fn settings_pick_background_image(self: Pin<&mut QbzBridge>); + #[qinvokable] + fn settings_clear_background_image(self: Pin<&mut QbzBridge>); /// Slider rows (initial buffer size). #[qinvokable] fn settings_slider(self: Pin<&mut QbzBridge>, key: QString, value: i32); @@ -281,6 +285,14 @@ impl qbz_bridge::QbzBridge { crate::settings_bool(key.to_string(), value); } + pub fn settings_pick_background_image(self: Pin<&mut Self>) { + crate::settings_qt::pick_background_image(); + } + + pub fn settings_clear_background_image(self: Pin<&mut Self>) { + crate::settings_qt::clear_background_image(); + } + pub fn settings_select(self: Pin<&mut Self>, key: QString, index: i32) { crate::settings_select(key.to_string(), index); } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index b14b6614a..2bd778fb8 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -291,6 +291,7 @@ mod label_qt; mod local_media_info_qt; mod nav_qt; mod page_restore_qt; +mod wallpaper_qt; mod now_playing; mod offline_fwd; mod output_labels; @@ -837,6 +838,8 @@ fn on_session_entered() { }; nav_qt::record(&entry_view); hydrate_view(&entry_view); + // The desktop wallpaper, when a background mode paints it (a no-op otherwise). + wallpaper_qt::refresh(); // Logged-off startup is the ONLY automatic redirect. An authenticated // account keeps the established startup/restore flow even when physical // connectivity is down. The selected tab is the first user-ordered Local diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 484c173b4..0c8058454 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -778,6 +778,34 @@ pub fn ambient_bar_alpha() -> f32 { /// Same one-document rule as `toggle_system_title_bar`: the current key is /// read inside the write closure, so a torn read can no longer make the app /// commit "ambient" over a user who had just turned it off (or the reverse). +/// Settings > Appearance > Background image > "Choose image…": the user's +/// own picture behind the wallpaper modes, outranking the desktop's. +pub fn pick_background_image() { + crate::spawn(async { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title(qbz_i18n::t("Background image")) + .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp", "avif"]) + .pick_file() + .await + else { + return; + }; + let path = file.path().to_string_lossy().to_string(); + save_pref("app_background_image", serde_json::json!(path)); + publish_snapshot().await; + crate::wallpaper_qt::refresh(); + }); +} + +/// "System wallpaper": drop the user's own picture. +pub fn clear_background_image() { + save_pref("app_background_image", serde_json::json!("")); + crate::spawn(async { + publish_snapshot().await; + crate::wallpaper_qt::refresh(); + }); +} + pub fn toggle_ambient_background() -> i32 { edit_prefs(|doc| { let current = doc @@ -1643,8 +1671,10 @@ const QCONNECT_CONFLICT_POLICY_LABELS: &[&str] = &[ "Continue local playback and replace the Qobuz Connect queue", ]; // Appearance option tables (AppearanceSettings.slint / ui_prefs.rs). -const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"]; -const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"]; +// 3 and 4 paint the desktop wallpaper (wallpaper_qt.rs): behind the window, +// or through the Blurred-art atmosphere pass. +const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art", "Wallpaper", "Wallpaper, blurred"]; +const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred", "wallpaper", "wallpaper-blurred"]; const LANGUAGE_LABELS: &[&str] = &[ "Auto", "English", @@ -2155,6 +2185,9 @@ pub struct SettingsDoc { pub app_background_modes: Vec, #[serde(rename = "appBackgroundIndex")] pub app_background_index: i32, + /// The user's own background image ("" = the desktop wallpaper). + #[serde(rename = "appBackgroundImage")] + pub app_background_image: String, #[serde(rename = "autoThemeSources")] pub auto_theme_sources: Vec, #[serde(rename = "autoThemeSourceIndex")] @@ -2714,6 +2747,7 @@ pub async fn publish_snapshot() { .iter() .position(|v| *v == pref_str("app_background", "off")) .unwrap_or(0) as i32, + app_background_image: pref_str("app_background_image", ""), auto_theme_sources: AUTO_THEME_SOURCE_LABELS .iter() .map(|l| qbz_i18n::t(l)) @@ -4225,6 +4259,8 @@ pub async fn settings_select(runtime: &Arc>, key: &st // ImmersiveAtmosphere for 2, exactly like AppShell.slint:213-231). let ambient = index as i32; crate::shell_bridge::ui(move |mut b| b.as_mut().set_ambient_mode(ambient)); + // The wallpaper modes need their image (a no-op for the others). + crate::wallpaper_qt::refresh(); } "language" => { let Some(lang) = LANGUAGE_VALUES.get(index) else { diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index da00fb2bc..760aab970 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -296,6 +296,11 @@ pub mod qbz_shell { /// May the app-wide dynamic background be offered? /// Same tier, same reason (`main.rs:8563`); gates the whole picker row. #[qproperty(bool, app_background_available)] + /// The desktop wallpaper (or the user's own image) as a file:// URL, + /// and its atmosphere bitmap — the "wallpaper" background modes + /// (wallpaper_qt.rs). Empty until a mode that paints it resolves one. + #[qproperty(QString, wallpaper_url)] + #[qproperty(QString, wallpaper_atmosphere_url)] /// Is the kiosk touch shell the one currently mounted? /// /// The Qt counterpart of `ShellState.kiosk-profile` @@ -533,6 +538,9 @@ pub mod qbz_shell { /// Header history buttons. #[qinvokable] fn navigate_back(self: Pin<&mut QbzShell>); + /// Re-resolve the desktop wallpaper (window activation, a mode switch). + #[qinvokable] + fn refresh_wallpaper(self: Pin<&mut QbzShell>); #[qinvokable] fn navigate_forward(self: Pin<&mut QbzShell>); @@ -896,6 +904,8 @@ pub struct QbzShellRust { gpu_tier: bool, shader_scenes_available: bool, app_background_available: bool, + wallpaper_url: QString, + wallpaper_atmosphere_url: QString, kiosk_profile: bool, kiosk_fullscreen_boot: bool, ambient_mode: i32, @@ -1011,6 +1021,8 @@ impl Default for QbzShellRust { gpu_tier: crate::renderer_qt::gpu_tier(), shader_scenes_available: crate::renderer_qt::gpu_tier(), app_background_available: crate::renderer_qt::gpu_tier(), + wallpaper_url: QString::default(), + wallpaper_atmosphere_url: QString::default(), kiosk_profile: crate::kiosk_profile_qt::active(), kiosk_fullscreen_boot: crate::kiosk_profile_qt::active() && crate::kiosk_profile_qt::fullscreen_at_boot(), @@ -1433,6 +1445,10 @@ impl qbz_shell::QbzShell { crate::nav_qt::back(); } + pub fn refresh_wallpaper(self: Pin<&mut Self>) { + crate::wallpaper_qt::refresh(); + } + pub fn navigate_forward(self: Pin<&mut Self>) { crate::nav_qt::forward(); } diff --git a/crates/qbz-qt/src/wallpaper_qt.rs b/crates/qbz-qt/src/wallpaper_qt.rs new file mode 100644 index 000000000..b891b24ac --- /dev/null +++ b/crates/qbz-qt/src/wallpaper_qt.rs @@ -0,0 +1,416 @@ +//! The desktop wallpaper as the app-wide background (2026-09-14). +//! +//! Two `app_background` modes ride on it: "wallpaper" (3) shows the part of +//! the wallpaper that lies BEHIND the window, lightly blurred under the +//! translucent chrome (shell/WallpaperField.qml — the window's place on the +//! screen comes from Qt where the platform tells it: X11, macOS, Windows; +//! Wayland hides window positions, so a centred crop stands in until the +//! Plasma window-management protocol is wired); "wallpaper-blurred" (4) +//! passes the wallpaper through the same atmosphere pass Blurred art gives +//! the cover (atmosphere_qt::for_cover_blocking), so it reads like the album +//! blur with the desktop's colours. +//! +//! The image is the system wallpaper, resolved per desktop — KDE Plasma's +//! `plasma-org.kde.plasma.desktop-appletsrc` (the containment on screen 0 +//! wins; a wallpaper PACKAGE resolves to its largest `contents/images/*` +//! bitmap, a slideshow folder to its first image), GNOME through `gsettings` +//! (the dark variant under a dark colour scheme), Windows' current +//! `TranscodedWallpaper`, macOS through System Events — or an image the user +//! chose (`app_background_image`), which outranks the desktop. `refresh()` +//! is cheap to call often: the resolved path and its mtime are memoised, and +//! nothing is republished while they hold. + +use cxx_qt_lib::QString; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; + +pub const MODE_WALLPAPER: i32 = 3; + +static LAST: Mutex)>> = Mutex::new(None); + +const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "bmp", "avif", "jxl"]; + +/// Does this `app_background` mode paint the wallpaper? +pub fn mode_uses_wallpaper(mode: i32) -> bool { + mode >= MODE_WALLPAPER +} + +/// Resolve the wallpaper (the user's own image first) and publish its URL and +/// its atmosphere bitmap on the shell bridge. No-op while the mode does not +/// paint it, and quiet while the same file (same mtime) is already published. +pub fn refresh() { + if !mode_uses_wallpaper(crate::settings_qt::app_background_mode()) { + return; + } + crate::spawn(async { + let resolved = tokio::task::spawn_blocking(|| { + let custom = crate::settings_qt::pref_str("app_background_image", ""); + let path = if custom.trim().is_empty() { + resolve_system() + } else { + Some(PathBuf::from(custom.trim())) + }; + let path = path.filter(|p| p.is_file())?; + let mtime = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()); + { + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + if last + .as_ref() + .is_some_and(|(p, t)| *p == path && *t == mtime) + { + return None; + } + *last = Some((path.clone(), mtime)); + } + let atmosphere = crate::atmosphere_qt::for_cover_blocking(&path.to_string_lossy()) + .unwrap_or_default(); + Some((file_url(&path), atmosphere)) + }) + .await + .ok() + .flatten(); + if let Some((url, atmosphere)) = resolved { + log::info!("[qbz-qt] wallpaper: {url}"); + crate::shell_bridge::ui(move |mut b| { + b.as_mut().set_wallpaper_url(QString::from(url.as_str())); + b.as_mut() + .set_wallpaper_atmosphere_url(QString::from(atmosphere.as_str())); + }); + } + }); +} + +/// The desktop's current wallpaper, if this desktop tells us. +pub fn resolve_system() -> Option { + #[cfg(target_os = "windows")] + return windows_wallpaper(); + #[cfg(target_os = "macos")] + return macos_wallpaper(); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + return kde_wallpaper().or_else(gnome_wallpaper); +} + +fn file_url(path: &Path) -> String { + let text = path.to_string_lossy(); + if text.starts_with('/') { + format!("file://{text}") + } else { + // Windows: `C:\a\b.png` -> `file:///C:/a/b.png`. + format!("file:///{}", text.replace('\\', "/")) + } +} + +/// `%20` and friends back to bytes (the desktops store wallpaper paths as +/// URLs). Anything malformed passes through untouched. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = &s[i + 1..i + 3]; + if let Ok(v) = u8::from_str_radix(hex, 16) { + out.push(v); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// A wallpaper setting's value (`file:///…` or a bare path) to a path. +fn path_from_setting(value: &str) -> PathBuf { + let v = value.trim().trim_matches('\'').trim_matches('"'); + let v = v.strip_prefix("file://").unwrap_or(v); + PathBuf::from(percent_decode(v)) +} + +fn is_image(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| IMAGE_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str())) +} + +/// A file stays a file; a directory is either a KDE wallpaper PACKAGE +/// (`contents/images/.`, the largest wins) or a slideshow folder +/// (its first image, sorted). +fn resolve_image_source(path: PathBuf) -> Option { + if path.is_file() { + return Some(path); + } + if !path.is_dir() { + return None; + } + let package = path.join("contents").join("images"); + let dir = if package.is_dir() { package } else { path }; + let mut files: Vec = std::fs::read_dir(&dir) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && is_image(p)) + .collect(); + if files.is_empty() { + return None; + } + files.sort(); + let pixels = |p: &Path| -> u64 { + p.file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| { + let (w, h) = s.split_once('x')?; + Some(w.parse::().ok()? * h.parse::().ok()?) + }) + .unwrap_or(0) + }; + // The FIRST of equals wins (a slideshow folder has no sizes in its names), + // which `max_by_key` would not give: it returns the last maximum. + let mut best = files[0].clone(); + let mut best_pixels = pixels(&best); + for file in &files[1..] { + let px = pixels(file); + if px > best_pixels { + best = file.clone(); + best_pixels = px; + } + } + Some(best) +} + +fn section_ids(section: &str) -> Vec<&str> { + section + .trim_matches(|c| c == '[' || c == ']') + .split("][") + .collect() +} + +/// The image Plasma paints on screen 0 (else the lowest screen, else any): +/// `[Containments][N][Wallpaper][org.kde.image][General] Image=`, for the +/// containments whose wallpaper plugin is the image one. +fn kde_image_from_config(text: &str) -> Option { + let mut section = String::new(); + let mut screen_of: HashMap = HashMap::new(); + let mut plugin_of: HashMap = HashMap::new(); + let mut image_of: HashMap = HashMap::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.starts_with('[') { + section = line.to_string(); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let ids = section_ids(§ion); + if ids.len() == 2 && ids[0] == "Containments" { + match key { + "lastScreen" => { + if let Ok(n) = value.trim().parse::() { + screen_of.insert(ids[1].to_string(), n); + } + } + "wallpaperplugin" => { + plugin_of.insert(ids[1].to_string(), value.trim().to_string()); + } + _ => {} + } + } else if ids.len() == 5 + && ids[0] == "Containments" + && ids[2] == "Wallpaper" + && ids[3] == "org.kde.image" + && ids[4] == "General" + && key == "Image" + && !value.trim().is_empty() + { + image_of.insert(ids[1].to_string(), value.trim().to_string()); + } + } + let mut best: Option<(i32, String)> = None; + for (id, image) in image_of { + if plugin_of.get(&id).is_some_and(|p| p != "org.kde.image") { + continue; + } + let screen = screen_of.get(&id).copied().unwrap_or(i32::MAX); + let rank = if screen < 0 { i32::MAX } else { screen }; + if best.as_ref().is_none_or(|(r, _)| rank < *r) { + best = Some((rank, image)); + } + } + best.map(|(_, image)| image) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn kde_wallpaper() -> Option { + let config = dirs::config_dir()?.join("plasma-org.kde.plasma.desktop-appletsrc"); + let text = std::fs::read_to_string(config).ok()?; + let value = kde_image_from_config(&text)?; + resolve_image_source(path_from_setting(&value)) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn gsettings(schema: &str, key: &str) -> Option { + let out = std::process::Command::new("gsettings") + .args(["get", schema, key]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn gnome_wallpaper() -> Option { + let dark = gsettings("org.gnome.desktop.interface", "color-scheme") + .is_some_and(|v| v.contains("prefer-dark")); + let key = if dark { + "picture-uri-dark" + } else { + "picture-uri" + }; + let value = gsettings("org.gnome.desktop.background", key) + .filter(|v| !v.trim_matches('\'').is_empty()) + .or_else(|| gsettings("org.gnome.desktop.background", "picture-uri"))?; + resolve_image_source(path_from_setting(&value)) +} + +#[cfg(target_os = "windows")] +fn windows_wallpaper() -> Option { + let appdata = std::env::var_os("APPDATA")?; + let transcoded = PathBuf::from(appdata) + .join("Microsoft") + .join("Windows") + .join("Themes") + .join("TranscodedWallpaper"); + if transcoded.is_file() { + return Some(transcoded); + } + let out = std::process::Command::new("reg") + .args(["query", r"HKCU\Control Panel\Desktop", "/v", "WallPaper"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let line = text.lines().find(|l| l.contains("WallPaper"))?; + let value = line.split("REG_SZ").nth(1)?.trim(); + resolve_image_source(PathBuf::from(value)) +} + +#[cfg(target_os = "macos")] +fn macos_wallpaper() -> Option { + let out = std::process::Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to get picture of current desktop", + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let value = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if value.is_empty() { + return None; + } + resolve_image_source(PathBuf::from(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const APPLETSRC: &str = r#" +[Containments][1] +activityId=abc +lastScreen=1 +plugin=org.kde.plasma.folder +wallpaperplugin=org.kde.image + +[Containments][1][Wallpaper][org.kde.image][General] +Image=file:///home/me/Pictures/Second%20Screen.png + +[Containments][2] +activityId=abc +lastScreen=0 +plugin=org.kde.plasma.folder +wallpaperplugin=org.kde.image + +[Containments][2][Wallpaper][org.kde.color][General] +Color=0,0,0 + +[Containments][2][Wallpaper][org.kde.image][General] +Image=file:///usr/share/wallpapers/MilkyWay/ + +[Containments][3] +lastScreen=0 +wallpaperplugin=org.kde.color + +[Containments][3][Wallpaper][org.kde.image][General] +Image=file:///home/me/stale.png +"#; + + #[test] + fn plasma_picks_the_image_containment_on_screen_zero() { + assert_eq!( + kde_image_from_config(APPLETSRC).as_deref(), + Some("file:///usr/share/wallpapers/MilkyWay/") + ); + // Without screen 0 the lowest screen wins; a colour containment never does. + let only_second = APPLETSRC.replace("lastScreen=0", "lastScreen=5"); + assert_eq!( + kde_image_from_config(&only_second).as_deref(), + Some("file:///home/me/Pictures/Second%20Screen.png") + ); + assert!(kde_image_from_config("[Containments][9]\nlastScreen=0\n").is_none()); + } + + #[test] + fn settings_values_become_decoded_paths() { + assert_eq!( + path_from_setting("file:///home/me/Pictures/Second%20Screen.png"), + PathBuf::from("/home/me/Pictures/Second Screen.png") + ); + assert_eq!( + path_from_setting("'file:///usr/share/backgrounds/a.jpg'"), + PathBuf::from("/usr/share/backgrounds/a.jpg") + ); + assert_eq!( + path_from_setting("/plain/path.png"), + PathBuf::from("/plain/path.png") + ); + assert_eq!(percent_decode("100%"), "100%"); + } + + #[test] + fn a_package_resolves_to_its_largest_bitmap_and_a_folder_to_its_first_image() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("MilkyWay"); + let images = package.join("contents").join("images"); + std::fs::create_dir_all(&images).unwrap(); + for name in ["1080x1920.png", "5120x2880.png", "notes.txt"] { + std::fs::write(images.join(name), b"x").unwrap(); + } + assert_eq!( + resolve_image_source(package.clone()), + Some(images.join("5120x2880.png")) + ); + let slides = temp.path().join("slides"); + std::fs::create_dir_all(&slides).unwrap(); + for name in ["b.jpg", "a.png", "readme.md"] { + std::fs::write(slides.join(name), b"x").unwrap(); + } + assert_eq!( + resolve_image_source(slides.clone()), + Some(slides.join("a.png")) + ); + let file = temp.path().join("one.webp"); + std::fs::write(&file, b"x").unwrap(); + assert_eq!(resolve_image_source(file.clone()), Some(file)); + assert!(resolve_image_source(temp.path().join("missing")).is_none()); + } +}