merge: integrate rounds 7-10 (search boxes, exact page restore, ambient chrome, Wallpaper modes, keyed lists, clean modal forms, bulk playlist removal) into pre-release
cc
This commit is contained in:
145 files changed
+5506
-569
No files matched your search
@@ -221,7 +221,12 @@ jobs:
|
||||
# The desktop file goes in under the MPRIS DesktopEntry basename
|
||||
# (com.blitzfc.qbz.desktop), so an integrated AppImage resolves the
|
||||
# media-widget icon like the deb/rpm do. Icon=qbz -> qbz.png.
|
||||
cp packaging/linux/qbz.desktop com.blitzfc.qbz.desktop
|
||||
# linuxdeploy looks the Exec name up under AppDir/usr/bin, so the
|
||||
# AppImage entry keeps the bare name; the deb/rpm install the source
|
||||
# entry as-is, with the absolute Exec KWin matches against the
|
||||
# running binary (packaging/linux/qbz.desktop).
|
||||
sed 's|^Exec=/usr/bin/qbz |Exec=qbz |' packaging/linux/qbz.desktop > com.blitzfc.qbz.desktop
|
||||
grep -q '^Exec=qbz ' com.blitzfc.qbz.desktop
|
||||
cp packaging/icons/256x256.png qbz.png
|
||||
./linuxdeploy --appimage-extract-and-run --appdir AppDir \
|
||||
-e qbz -d com.blitzfc.qbz.desktop -i qbz.png --plugin qt
|
||||
|
||||
@@ -86,6 +86,10 @@ on:
|
||||
- "scripts/test-text-edit-menu-qml.sh"
|
||||
- "scripts/test-compact-seek-qml.sh"
|
||||
- "scripts/test-library-folders-qml.sh"
|
||||
- "scripts/test-array-model-qml.sh"
|
||||
- "scripts/test-keyed-model-qml.sh"
|
||||
- "scripts/test-wallpaper-field-qml.sh"
|
||||
- "scripts/test-card-menu-qml.sh"
|
||||
- "scripts/test-orbit-daemon.py"
|
||||
- ".github/workflows/test-crates.yml"
|
||||
push:
|
||||
@@ -114,6 +118,10 @@ on:
|
||||
- "scripts/test-text-edit-menu-qml.sh"
|
||||
- "scripts/test-compact-seek-qml.sh"
|
||||
- "scripts/test-library-folders-qml.sh"
|
||||
- "scripts/test-array-model-qml.sh"
|
||||
- "scripts/test-keyed-model-qml.sh"
|
||||
- "scripts/test-wallpaper-field-qml.sh"
|
||||
- "scripts/test-card-menu-qml.sh"
|
||||
- "scripts/test-orbit-daemon.py"
|
||||
- ".github/workflows/test-crates.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
Generated
+78
@@ -1458,6 +1458,12 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dst-decoder"
|
||||
version = "0.1.2"
|
||||
@@ -4494,6 +4500,8 @@ dependencies = [
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid 1.26.0",
|
||||
"wayland-client",
|
||||
"wayland-protocols-plasma",
|
||||
"windows-sys 0.61.2",
|
||||
"winresource",
|
||||
"zbus 5.19.0",
|
||||
@@ -6812,6 +6820,76 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix 1.1.4",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"rustix 1.1.4",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.104"
|
||||
|
||||
@@ -7695,3 +7695,31 @@ 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"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} Titel werden aus dieser Playlist entfernt."
|
||||
@@ -3350,3 +3350,31 @@ 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"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} tracks will be removed from this playlist."
|
||||
@@ -7701,3 +7701,31 @@ 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"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "Se quitarán {} pistas de esta playlist."
|
||||
@@ -7693,3 +7693,31 @@ 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"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} titres seront retirés de cette playlist."
|
||||
@@ -7676,3 +7676,31 @@ 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 "システムの壁紙"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} 曲をこのプレイリストから削除します。"
|
||||
@@ -7683,3 +7683,31 @@ 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"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} nummers worden uit deze afspeellijst verwijderd."
|
||||
@@ -7693,3 +7693,31 @@ 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"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} faixas serão removidas desta playlist."
|
||||
@@ -7714,3 +7714,31 @@ 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 "Обои системы"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "Из этого плейлиста будет удалено {} треков."
|
||||
@@ -152,6 +152,11 @@ ksni = { version = "0.3", default-features = false, features = ["blocking", "asy
|
||||
# Session-bus single-instance guard. Keep the same async-io executor as ksni;
|
||||
# enabling zbus/tokio would poison the graph-wide executor choice.
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
# The "Wallpaper" background reads this window's place on a Plasma Wayland
|
||||
# desktop through org_kde_plasma_window_management (wallpaper_wayland_qt.rs)
|
||||
# on its OWN client connection; Qt's display is never shared.
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols-plasma = { version = "0.3", features = ["client"] }
|
||||
|
||||
[build-dependencies]
|
||||
cxx-qt-build = "0.7"
|
||||
|
||||
@@ -574,6 +574,11 @@ fn main() {
|
||||
"qml/controls/PlaylistImportModal.qml",
|
||||
"qml/controls/PlaylistPickerModal.qml",
|
||||
"qml/controls/PmFolderIcon.qml",
|
||||
"qml/controls/QbzArrayModel.qml",
|
||||
"qml/controls/QbzKeyedModel.qml",
|
||||
"qml/controls/QbzRowAdd.qml",
|
||||
"qml/controls/QbzRowDisplaced.qml",
|
||||
"qml/controls/QbzRowRemove.qml",
|
||||
"qml/controls/QbzCircleAction.qml",
|
||||
"qml/controls/QbzClipboard.qml",
|
||||
"qml/controls/QbzColorPicker.qml",
|
||||
@@ -597,6 +602,7 @@ fn main() {
|
||||
"qml/controls/ScrollMemory.qml",
|
||||
"qml/controls/ScopePanel.qml",
|
||||
"qml/controls/QbzSegToggle.qml",
|
||||
"qml/controls/QbzSearchField.qml",
|
||||
"qml/controls/QbzSelect.qml",
|
||||
"qml/controls/QbzSelectableText.qml",
|
||||
"qml/controls/QbzSplitButton.qml",
|
||||
@@ -654,6 +660,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",
|
||||
|
||||
@@ -650,7 +650,7 @@ Item {
|
||||
|
||||
height: 38
|
||||
radius: theme.radiusSm
|
||||
color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ Rectangle {
|
||||
// .slint:77 — the same chip in three mounts). Accent and hover keep theirs.
|
||||
color: root.active ? theme.accent
|
||||
: genreArea.containsMouse
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
|
||||
Row {
|
||||
id: genreRow
|
||||
|
||||
@@ -34,6 +34,98 @@ QbzContextMenu {
|
||||
property var entries: []
|
||||
signal picked(string action)
|
||||
|
||||
// --- Submenus (2026-09-13) ------------------------------------------
|
||||
// An entry with `submenu: [ { label, icon, action }, … ]` opens a
|
||||
// second CardMenu beside its row ON HOVER (a click does the same and
|
||||
// never closes this one). The child forwards its pick here, so hosts
|
||||
// handle sub-actions in the same `onPicked`. Both menus stay open
|
||||
// while the pointer is over EITHER; once it has left both for a
|
||||
// moment they close together, as does a press outside or Escape.
|
||||
property var _sub: null
|
||||
property var _subRow: null
|
||||
// The child is the same type; QML refuses a type that contains itself
|
||||
// ("instantiated recursively"), so it is created from its file at run
|
||||
// time instead of through a Component.
|
||||
function _makeSubmenu() {
|
||||
var component = Qt.createComponent("CardMenu.qml")
|
||||
if (component.status !== Component.Ready) {
|
||||
console.warn("[CardMenu] submenu component: " + component.errorString())
|
||||
return null
|
||||
}
|
||||
var sub = component.createObject(cmRoot.contentItem, {
|
||||
"kioskHost": cmRoot.kioskHost,
|
||||
"menuWidth": cmRoot.menuWidth
|
||||
})
|
||||
if (sub)
|
||||
sub.picked.connect(function (a) {
|
||||
cmRoot.close()
|
||||
cmRoot.picked(a)
|
||||
})
|
||||
return sub
|
||||
}
|
||||
function hasSubmenu(entry) {
|
||||
return entry && entry.submenu !== undefined && entry.submenu !== null
|
||||
&& entry.submenu.length !== undefined && entry.submenu.length > 0
|
||||
}
|
||||
function openSubmenu(row) {
|
||||
if (cmRoot._subRow === row && cmRoot._sub && cmRoot._sub.opened)
|
||||
return
|
||||
if (!cmRoot._sub)
|
||||
cmRoot._sub = cmRoot._makeSubmenu()
|
||||
if (!cmRoot._sub)
|
||||
return
|
||||
cmRoot._subRow = row
|
||||
cmRoot._sub.entries = row.modelData.submenu
|
||||
// Beside the row: to the right, or to the left when the window
|
||||
// has no room there. A 4px overlap leaves no gap to cross.
|
||||
var win = row.Window.window
|
||||
var g = row.mapToItem(null, 0, 0)
|
||||
var gx = cmRoot.x + cmRoot.width - 4
|
||||
if (win && gx + cmRoot._sub.width > win.width - 8)
|
||||
gx = cmRoot.x - cmRoot._sub.width + 4
|
||||
cmRoot._sub._place(gx, g.y - cmRoot._sub.topPadding, win)
|
||||
leaveTimer.stop()
|
||||
}
|
||||
function closeSubmenu() {
|
||||
cmRoot._subRow = null
|
||||
if (cmRoot._sub && cmRoot._sub.opened)
|
||||
cmRoot._sub.close()
|
||||
leaveTimer.stop()
|
||||
}
|
||||
readonly property bool subOpen: cmRoot._sub !== null && cmRoot._sub.opened
|
||||
// The pointer is over THIS menu: its panel or one of its rows. The rows'
|
||||
// MouseAreas take the hover events, so the panel's HoverHandler alone
|
||||
// sees only the 5px padding — the pair looked "left" the moment the
|
||||
// pointer crossed from that padding into a child row, and the leave
|
||||
// timer closed both (2026-09-14). One row is hot at a time, so an item
|
||||
// reference beats a counter that a destroyed row could leave high.
|
||||
property Item _hotRow: null
|
||||
readonly property bool hovering: cmRoot.menuHovered || cmRoot._hotRow !== null
|
||||
readonly property bool pairHovered: cmRoot.hovering
|
||||
|| (cmRoot._sub !== null && cmRoot._sub.hovering)
|
||||
onPairHoveredChanged: {
|
||||
if (!cmRoot.subOpen)
|
||||
return
|
||||
if (cmRoot.pairHovered)
|
||||
leaveTimer.stop()
|
||||
else
|
||||
leaveTimer.restart()
|
||||
}
|
||||
Timer {
|
||||
id: leaveTimer
|
||||
interval: 350
|
||||
onTriggered: {
|
||||
if (cmRoot.subOpen && !cmRoot.pairHovered) {
|
||||
cmRoot.closeSubmenu()
|
||||
cmRoot.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
onClosed: {
|
||||
cmRoot._hotRow = null
|
||||
closeSubmenu()
|
||||
}
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
Repeater {
|
||||
@@ -43,14 +135,19 @@ QbzContextMenu {
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isSep: modelData.sep === true
|
||||
readonly property bool hasSub: cmRoot.hasSubmenu(modelData)
|
||||
readonly property bool rowEnabled: modelData.enabled !== false
|
||||
readonly property bool isDanger: modelData.danger === true
|
||||
readonly property bool hot: cmiArea.containsMouse && rowEnabled
|
||||
readonly property bool hot: (cmiArea.containsMouse || cmRoot._subRow === row) && rowEnabled
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: isSep ? 7 : (cmRoot.kioskHost ? 44 : 33)
|
||||
radius: isSep ? 0 : 5
|
||||
color: hot ? theme.surfaceHover : "transparent"
|
||||
Component.onDestruction: {
|
||||
if (cmRoot._hotRow === row)
|
||||
cmRoot._hotRow = null
|
||||
}
|
||||
// ContextMenuItem.slint: `opacity: enabled ? 1.0 : 0.4`.
|
||||
opacity: (isSep || rowEnabled) ? 1.0 : 0.4
|
||||
|
||||
@@ -80,7 +177,7 @@ QbzContextMenu {
|
||||
}
|
||||
Text {
|
||||
height: parent.height
|
||||
width: parent.width - 23 - (row.modelData.submenu === true ? 22 : 0)
|
||||
width: parent.width - 23 - (row.hasSub ? 22 : 0)
|
||||
text: row.modelData.label || ""
|
||||
color: row.isDanger ? theme.danger
|
||||
: (row.hot ? theme.textPrimary : theme.textSecondary)
|
||||
@@ -88,10 +185,9 @@ QbzContextMenu {
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// Submenu marker (`submenu: true`): the host answers the pick
|
||||
// by opening a second menu on the same anchor.
|
||||
// Submenu marker: the row opens its child menu on hover.
|
||||
QbzIcon {
|
||||
visible: row.modelData.submenu === true
|
||||
visible: row.hasSub
|
||||
name: "chevron-right"
|
||||
width: 14
|
||||
height: 14
|
||||
@@ -110,9 +206,28 @@ QbzContextMenu {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (row.hasSub) {
|
||||
cmRoot.openSubmenu(row)
|
||||
return
|
||||
}
|
||||
cmRoot.close()
|
||||
cmRoot.picked(row.modelData.action)
|
||||
}
|
||||
// Hovering a submenu row opens its child; hovering any other
|
||||
// row puts it away. Leaving the row itself does nothing —
|
||||
// the pointer is usually on its way into the child.
|
||||
onContainsMouseChanged: {
|
||||
if (!containsMouse) {
|
||||
if (cmRoot._hotRow === row)
|
||||
cmRoot._hotRow = null
|
||||
return
|
||||
}
|
||||
cmRoot._hotRow = row
|
||||
if (row.hasSub)
|
||||
cmRoot.openSubmenu(row)
|
||||
else if (cmRoot.subOpen)
|
||||
cmRoot.closeSubmenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ Item {
|
||||
height: 30
|
||||
radius: 6
|
||||
color: active ? theme.accent
|
||||
: (pArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
|
||||
: (pArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated)
|
||||
Text {
|
||||
id: pLabel
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -408,7 +408,7 @@ Item {
|
||||
width: refreshRow.width + 24
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: refreshArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: refreshArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Row {
|
||||
@@ -481,7 +481,7 @@ Item {
|
||||
width: footerRow.width + 24
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: footerArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: footerArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ Item {
|
||||
root.draftPreset = root.doc.iconPreset || "folder"
|
||||
root.draftColor = root.doc.iconColor || ""
|
||||
root.draftHidden = root.doc.isHidden === true
|
||||
// The box as well as the draft (QbzLineEdit.reset()).
|
||||
nameField.reset()
|
||||
}
|
||||
|
||||
/// Focus the name field. Split out so the host can call it AFTER the panel
|
||||
@@ -180,7 +182,7 @@ Item {
|
||||
radius: 8
|
||||
color: presetTile.active
|
||||
? theme.accent
|
||||
: (presetArea.containsMouse ? theme.surfaceHover
|
||||
: (presetArea.containsMouse ? theme.surfaceElevatedHover
|
||||
: theme.surfaceElevated)
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
@@ -217,7 +219,7 @@ Item {
|
||||
radius: 8
|
||||
color: imageTile.active
|
||||
? theme.accent
|
||||
: (imageArea.containsMouse ? theme.surfaceHover
|
||||
: (imageArea.containsMouse ? theme.surfaceElevatedHover
|
||||
: theme.surfaceElevated)
|
||||
// No `clip: true` — it is a rectangular scissor and does
|
||||
// not follow `radius`. RoundedImage masks its own corners.
|
||||
|
||||
@@ -103,6 +103,7 @@ Item {
|
||||
if (root.createOpen) {
|
||||
// Its OWN draft, reset on its OWN open transition.
|
||||
root.draftCreateName = ""
|
||||
createField.reset("")
|
||||
createScope.forceActiveFocus()
|
||||
createField.focusField()
|
||||
} else {
|
||||
|
||||
@@ -415,7 +415,7 @@ Item {
|
||||
height: 38
|
||||
radius: 6
|
||||
color: chip.modelData.selected ? theme.accent
|
||||
: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
@@ -508,7 +508,7 @@ Item {
|
||||
radius: 6
|
||||
opacity: root.liveCount > 0 ? 1.0 : 0.5
|
||||
color: clearArea.containsMouse && root.liveCount > 0
|
||||
? theme.surfaceHover : theme.surfaceElevated
|
||||
? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("Clear filter", QbzSession.trRev)
|
||||
|
||||
@@ -36,7 +36,7 @@ Rectangle {
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: hoverArea.containsMouse && root.btnEnabled ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: hoverArea.containsMouse && root.btnEnabled ? theme.elevatedHoverFill : theme.elevatedFill
|
||||
opacity: root.btnEnabled ? 1.0 : 0.4
|
||||
|
||||
Row {
|
||||
|
||||
@@ -25,6 +25,9 @@ Item {
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
// Rust cleared `url` before opening; sync the box regardless of
|
||||
// where focus sat at the last close (QbzLineEdit.reset()).
|
||||
urlInput.reset(QbzLink.url)
|
||||
urlInput.focusField()
|
||||
} else {
|
||||
// Return focus only if it still belongs to this modal. A search
|
||||
|
||||
@@ -76,10 +76,19 @@ Item {
|
||||
property string name: ""
|
||||
readonly property bool canCreate: createPanel.name !== "" && !createPanel.creating
|
||||
|
||||
// A fresh open starts with an empty field.
|
||||
// A fresh open starts with an empty field — reset on the OPEN edge,
|
||||
// never the close edge: at close the box still holds focus for a
|
||||
// frame, its focus-gated re-seed skips, and the next open found the
|
||||
// draft already "" with the old text still in the box (the
|
||||
// dirty-form report of 2026-09-14). `reset()` syncs the box itself.
|
||||
Connections {
|
||||
target: root
|
||||
function onCreateOpenChanged() { createPanel.name = "" }
|
||||
function onCreateOpenChanged() {
|
||||
if (!root.createOpen)
|
||||
return
|
||||
createPanel.name = ""
|
||||
createNameField.reset("")
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -166,6 +175,7 @@ Item {
|
||||
font.letterSpacing: 1.5
|
||||
}
|
||||
QbzLineEdit {
|
||||
id: createNameField
|
||||
width: parent.width
|
||||
height: 34
|
||||
text: createPanel.name
|
||||
@@ -255,10 +265,17 @@ Item {
|
||||
// frame, and then `open` never changes and the prefill would be stale.
|
||||
// Keying it on the whole document instead would clobber what the user
|
||||
// is typing every time an unrelated field (`busy`) flips.
|
||||
readonly property string editKey: editPanel.mode + "|" + (root.editDoc.name || "")
|
||||
// The open flag rides along so a reopen on the SAME collection
|
||||
// re-seeds too (a cancelled rename left its draft behind).
|
||||
readonly property string editKey: (root.editOpen ? "1" : "0") + "|"
|
||||
+ editPanel.mode + "|" + (root.editDoc.name || "")
|
||||
onEditKeyChanged: {
|
||||
editPanel.draftName = root.editDoc.name || ""
|
||||
editPanel.draftDescription = root.editDoc.description || ""
|
||||
if (root.editOpen) {
|
||||
renameField.reset()
|
||||
descriptionField.reset()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -338,6 +355,7 @@ Item {
|
||||
|
||||
// --- Body: rename
|
||||
QbzLineEdit {
|
||||
id: renameField
|
||||
visible: editPanel.isRename
|
||||
width: parent.width
|
||||
height: visible ? 34 : 0
|
||||
@@ -350,6 +368,7 @@ Item {
|
||||
|
||||
// --- Body: description
|
||||
QbzTextArea {
|
||||
id: descriptionField
|
||||
visible: editPanel.isDescription
|
||||
width: parent.width
|
||||
// A FLAT 96, never `visible ? 96 : 0`: a Column does not
|
||||
|
||||
@@ -128,7 +128,7 @@ Item {
|
||||
width: parent.width
|
||||
height: allCol.implicitHeight + 24
|
||||
radius: theme.radiusSm
|
||||
color: allArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: allArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: allArea.containsMouse ? theme.accent : theme.borderSubtle
|
||||
Row {
|
||||
@@ -178,7 +178,7 @@ Item {
|
||||
width: parent.width
|
||||
height: missingCol.implicitHeight + 24
|
||||
radius: theme.radiusSm
|
||||
color: missingArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: missingArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: missingArea.containsMouse ? theme.accent : theme.borderSubtle
|
||||
Row {
|
||||
@@ -229,7 +229,7 @@ Item {
|
||||
width: cancelLabel.implicitWidth + 32
|
||||
height: 36
|
||||
radius: theme.radiusSm
|
||||
color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: cancelArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
Text {
|
||||
id: cancelLabel
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -103,6 +103,10 @@ Item {
|
||||
root.draftIsPublic = false
|
||||
// Offline forces it ON and locks it (reference :184-216).
|
||||
root.draftOfflineOnly = root.offlineLocked
|
||||
// The boxes too (QbzLineEdit.reset()): "" is also what the
|
||||
// drafts held after the last cancel, so nothing above re-seeds.
|
||||
nameField.reset("")
|
||||
descriptionField.reset("")
|
||||
scope.forceActiveFocus()
|
||||
nameField.focusField()
|
||||
} else {
|
||||
@@ -274,6 +278,7 @@ Item {
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
QbzTextArea {
|
||||
id: descriptionField
|
||||
width: parent.width
|
||||
height: 80
|
||||
text: root.draftDescription
|
||||
@@ -410,7 +415,7 @@ Item {
|
||||
height: 36
|
||||
radius: theme.radiusSm
|
||||
color: (cancelArea.containsMouse && !root.busy)
|
||||
? theme.surfaceHover : theme.surfaceElevated
|
||||
? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
opacity: root.busy ? 0.5 : 1.0
|
||||
Text {
|
||||
id: cancelLabel
|
||||
|
||||
@@ -135,6 +135,11 @@ Item {
|
||||
root.draftDescription = root.doc.description || ""
|
||||
root.draftOfflineOnly = root.doc.offlineOnly === true
|
||||
root.draftFolderId = root.doc.folderId || ""
|
||||
// The boxes, not just the drafts: a seed equal to the previous
|
||||
// one changes nothing above, and a box that was still focused
|
||||
// at close never re-seeded (QbzLineEdit.reset()).
|
||||
nameField.reset()
|
||||
descriptionField.reset()
|
||||
scope.forceActiveFocus()
|
||||
nameField.focusField()
|
||||
}
|
||||
@@ -332,6 +337,7 @@ Item {
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
QbzTextArea {
|
||||
id: descriptionField
|
||||
width: parent.width
|
||||
height: 90
|
||||
text: root.draftDescription
|
||||
@@ -360,6 +366,10 @@ Item {
|
||||
}
|
||||
QbzSelect {
|
||||
menuWidth: 260
|
||||
// A folder tree grows past what a plain list can
|
||||
// scan: the filter box, like the ui-control standard's
|
||||
// searchable dropdown.
|
||||
searchable: true
|
||||
options: root.folderOptions
|
||||
currentIndex: root.folderIndex
|
||||
enabled: !root.busy
|
||||
|
||||
@@ -462,7 +462,7 @@ Item {
|
||||
height: 36
|
||||
radius: theme.radiusSm
|
||||
color: (pickArea.containsMouse && !root.loading)
|
||||
? theme.surfaceHover : theme.surfaceElevated
|
||||
? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
opacity: root.loading ? 0.5 : 1.0
|
||||
Text {
|
||||
id: pickLabel
|
||||
|
||||
@@ -317,7 +317,7 @@ Item {
|
||||
width: 20
|
||||
height: 20
|
||||
radius: 10
|
||||
color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "x"
|
||||
|
||||
@@ -31,8 +31,8 @@ Rectangle {
|
||||
// Translucent at rest under the dynamic background, like every other chip
|
||||
// in this toolbar band (PlaylistTagFilter.slint:77).
|
||||
color: tagArea.containsMouse
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
|
||||
QbzFilterTip {
|
||||
id: tagTip
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// QbzArrayModel — a DelegateModel over a JS array that a view can SWAP
|
||||
// (2026-09-13). Assigning a fresh array straight to `ListView.model` after
|
||||
// the view has completed has two side effects in Qt 6.11
|
||||
// (qquickitemview.cpp, QQuickItemViewPrivate::connectModel):
|
||||
//
|
||||
// 1. `currentIndex` is forced to 0 and updateCurrent() calls
|
||||
// setFocus(true) on delegate 0. The view is a focus scope, so a search
|
||||
// box living in its header loses the keyboard on every rebuild — the
|
||||
// album track filter lost focus on each keystroke (qt.quick.focus
|
||||
// trace: "focus trackCell in scope pageFlick", activeFocusItem
|
||||
// TextInput -> trackCell). A `currentIndex: -1` on the view does not
|
||||
// survive the assignment.
|
||||
// 2. setModel() clears the view and resets contentY to the content start.
|
||||
//
|
||||
// Swapping the array HERE instead reaches the view as remove+insert changes:
|
||||
// `currentIndex: -1` stays honoured (nothing is ever focused) and the view
|
||||
// keeps its rows. ListView re-seats the inserted rows around the viewport,
|
||||
// which drifts the content origin, so the viewport is re-expressed relative
|
||||
// to that origin (0 = the header's top edge) before the change and restored
|
||||
// once it is laid out. A swap that replaces the page calls `scrollToTop()`.
|
||||
//
|
||||
// Probe that measured both behaviours against Qt 6.11.2:
|
||||
// scripts/qml-tests/tst_array_model.qml.
|
||||
|
||||
import QtQuick
|
||||
import QtQml.Models
|
||||
|
||||
DelegateModel {
|
||||
id: swap
|
||||
|
||||
/// The rows. Replace the array freely; the view keeps its place.
|
||||
property var rows: []
|
||||
/// The ListView (or GridView) this model feeds — needed for the restore.
|
||||
property Item view: null
|
||||
|
||||
model: rows
|
||||
|
||||
property bool _live: false
|
||||
property int _rowsBefore: 0
|
||||
property real _offset: 0
|
||||
property int _epoch: 0
|
||||
property bool _toTop: false
|
||||
|
||||
Component.onCompleted: {
|
||||
swap._live = true
|
||||
swap._rowsBefore = swap.count
|
||||
}
|
||||
|
||||
onModelChanged: {
|
||||
if (!swap._live || !swap.view)
|
||||
return
|
||||
var had = swap._rowsBefore
|
||||
swap._rowsBefore = swap.count
|
||||
// Nothing to keep: the page had no rows (first document, or the
|
||||
// empty document a detail publishes while the next one loads), or a
|
||||
// page replacement already asked for the top.
|
||||
if (had === 0 || swap._toTop)
|
||||
return
|
||||
// Before the pending remove+insert is laid out: how far the viewport
|
||||
// sits below the content origin.
|
||||
swap._offset = swap.view.contentY - swap.view.originY
|
||||
var epoch = ++swap._epoch
|
||||
Qt.callLater(function () {
|
||||
if (epoch !== swap._epoch || !swap.view)
|
||||
return
|
||||
swap.view.forceLayout()
|
||||
var minY = swap.view.originY
|
||||
var maxY = minY + Math.max(0, swap.view.contentHeight - swap.view.height)
|
||||
swap.view.contentY = Math.max(minY, Math.min(minY + swap._offset, maxY))
|
||||
})
|
||||
}
|
||||
|
||||
/// A swap that replaces the page (a different document): the viewport
|
||||
/// goes to the top once the new rows are laid out. Works whether it is
|
||||
/// called before or after the `rows` assignment in the same turn.
|
||||
function scrollToTop() {
|
||||
swap._toTop = true
|
||||
var epoch = ++swap._epoch
|
||||
Qt.callLater(function () {
|
||||
swap._toTop = false
|
||||
if (epoch !== swap._epoch || !swap.view)
|
||||
return
|
||||
swap.view.forceLayout()
|
||||
swap.view.positionViewAtBeginning()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -111,8 +111,8 @@ Rectangle {
|
||||
// (CircleAction.slint:53-55). The overlay variant above sits on
|
||||
// artwork and keeps its white scale.
|
||||
: ((cbArea.containsMouse || active)
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)))
|
||||
? theme.elevatedHoverFill
|
||||
: theme.elevatedFill))
|
||||
border.width: primary ? 0 : 1.5
|
||||
border.color: overlay ? "#ccffffff" : theme.borderStrong
|
||||
// A loading button keeps FULL opacity — it is working, not unavailable,
|
||||
|
||||
@@ -220,7 +220,7 @@ Item {
|
||||
width: cancelLbl.implicitWidth + 32
|
||||
height: root.kioskHost ? 44 : 36
|
||||
radius: theme.radiusSm
|
||||
color: cancelArea.containsMouse ? theme.surfaceHover
|
||||
color: cancelArea.containsMouse ? theme.surfaceElevatedHover
|
||||
: theme.surfaceElevated
|
||||
activeFocusOnTab: root.opened
|
||||
border.width: activeFocus ? 2 : 0
|
||||
|
||||
@@ -49,11 +49,16 @@ Popup {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
/// The pointer is over the panel (padding included). CardMenu's
|
||||
/// submenus read it on both menus to decide when leaving closes them.
|
||||
readonly property bool menuHovered: panelHover.hovered
|
||||
|
||||
background: Rectangle {
|
||||
color: theme.surfaceMain
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
HoverHandler { id: panelHover }
|
||||
}
|
||||
implicitHeight: col.implicitHeight + topPadding + bottomPadding
|
||||
height: kioskHost && parent ? Math.min(implicitHeight, parent.height - 16) : implicitHeight
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
// QbzKeyedModel — a list model that CHANGES instead of being replaced
|
||||
// (2026-09-14).
|
||||
//
|
||||
// Handing an item view a new JS array is a model swap: QQuickItemView::
|
||||
// setModel() throws every delegate away, resets the scroll offset and builds
|
||||
// the page again, so one album leaving the Library grid used to rebuild the
|
||||
// whole grid at the top with every cover fading back in. This model takes the
|
||||
// same arrays (`rows`) but tells the view only what changed: rows whose key
|
||||
// disappeared are REMOVED, new keys are INSERTED where they belong, keys that
|
||||
// changed places are MOVED, and a row whose data changed is UPDATED in place.
|
||||
// The view keeps its delegates, its scroll offset and its covers, and with the
|
||||
// row transitions next door (QbzRowAdd / QbzRowRemove / QbzRowDisplaced) the
|
||||
// leaving row fades out while its neighbours slide into the gap.
|
||||
//
|
||||
// WHAT THE VIEW SEES. Each model row carries two roles, `rowKey` and `rowRev`,
|
||||
// never the row itself. A delegate looks its row up by key:
|
||||
//
|
||||
// required property string rowKey
|
||||
// required property int rowRev
|
||||
// readonly property var modelData: keyedModel.row(rowKey, rowRev)
|
||||
// ListView.onReused: { opacity = 1; scale = 1 } // GridView.onReused too
|
||||
//
|
||||
// The delegate reads the SAME JS object the host derived (no copy into a
|
||||
// ListModel map, no conversion per read, and an in-place mutation of a row is
|
||||
// visible to delegates built later exactly as it was with a plain array).
|
||||
// `rowRev` is bumped when a row's data changed, which re-runs that binding and
|
||||
// hands the delegate its new object; an unchanged row keeps its delegate
|
||||
// untouched. A removed row stays readable (a small graveyard) because its
|
||||
// delegate may still be fading out: Qt does not re-notify a removed item's
|
||||
// roles (QQmlDMAbstractItemModelData reads its cache once the index is -1), so
|
||||
// its binding keeps the value it had. The `onReused` restore matters because
|
||||
// a transition cut short (a fast flick during an insert) can pool a delegate
|
||||
// half faded, and the pool hands it back exactly as it was left.
|
||||
//
|
||||
// SCOPE. A change of `scope` (another tab, sort, search, filter set) is not a
|
||||
// reconcile — the rows are replaced wholesale, WITHOUT animation (`animate`
|
||||
// drops for the reset and the registered `views` lay out synchronously, so no
|
||||
// transition ever sees it), which is what a tab switch has always looked like.
|
||||
// Inside one scope everything reconciles and animates.
|
||||
//
|
||||
// COST. Keys are compared as strings in one pass; unchanged row objects are
|
||||
// skipped by identity; a new object for an existing key is compared field by
|
||||
// field (`ignoredKeys` excluded) and only a real difference bumps its rev. A
|
||||
// reorder is applied with the fewest moves (the longest increasing run stays
|
||||
// put); past `maxMoves` a reset is both cheaper and calmer than a cascade of
|
||||
// slides. The reconcile is deferred with Qt.callLater, so a derive that
|
||||
// changes `rows` and `scope` together lands as one operation.
|
||||
|
||||
import QtQuick
|
||||
|
||||
ListModel {
|
||||
id: root
|
||||
|
||||
/// The rows to show, in order. Assign a new array whenever the source
|
||||
/// changes; the array itself does not have to be stable.
|
||||
property var rows: []
|
||||
/// A row's identity. Equal keys are the same entity; repeats are told
|
||||
/// apart by occurrence (the second "x" is "x#1").
|
||||
property var keyOf: function (row) { return row.id }
|
||||
/// Another scope replaces the rows without animation.
|
||||
property string scope: ""
|
||||
/// Row fields whose change is not a change (positional caches).
|
||||
property var ignoredKeys: []
|
||||
/// While true, `rows` changes wait and apply when it clears.
|
||||
property bool paused: false
|
||||
/// Item views bound to this model: laid out synchronously around a reset
|
||||
/// so no add/remove transition runs for it.
|
||||
property var views: []
|
||||
/// Beyond this many moves in one reconcile, reset instead.
|
||||
property int maxMoves: 48
|
||||
|
||||
/// Bind each row transition's `enabled` to this.
|
||||
readonly property bool animate: root._animate
|
||||
/// The array the model currently reflects, index for index — what an
|
||||
/// index-based reader (an artwork window report) must use.
|
||||
readonly property var published: root._published
|
||||
|
||||
/// After every applied change; `reset` is true for a wholesale replace.
|
||||
signal reconciled(bool reset)
|
||||
|
||||
property bool _animate: true
|
||||
property var _published: []
|
||||
// Registries live on one never-reassigned object so that reading them
|
||||
// inside a delegate binding does not subscribe the binding to anything.
|
||||
property var _state: ({
|
||||
"initialized": false,
|
||||
"scope": "",
|
||||
"keys": [],
|
||||
"byKey": new Map(),
|
||||
"revs": new Map(),
|
||||
"graveyard": new Map(),
|
||||
"rev": 0
|
||||
})
|
||||
|
||||
onRowsChanged: Qt.callLater(root.sync)
|
||||
onScopeChanged: Qt.callLater(root.sync)
|
||||
onPausedChanged: if (!root.paused) Qt.callLater(root.sync)
|
||||
Component.onCompleted: Qt.callLater(root.sync)
|
||||
|
||||
/// The row behind `key` — also for a row that just left, while its
|
||||
/// delegate fades out. `rev` is only a binding dependency.
|
||||
function row(key, rev) {
|
||||
var st = root._state
|
||||
var r = st.byKey.get(key)
|
||||
if (r === undefined)
|
||||
r = st.graveyard.get(key)
|
||||
return r === undefined ? ({}) : r
|
||||
}
|
||||
|
||||
/// Model index of `key`, or -1.
|
||||
function indexOfKey(key) {
|
||||
return root._state.keys.indexOf(key)
|
||||
}
|
||||
|
||||
/// Re-hand `key`'s row to its delegate after the host mutated that row
|
||||
/// in place and a mounted delegate must re-read it.
|
||||
function touch(key) {
|
||||
var st = root._state
|
||||
var i = st.keys.indexOf(key)
|
||||
if (i < 0)
|
||||
return
|
||||
var rev = ++st.rev
|
||||
st.revs.set(key, rev)
|
||||
root.setProperty(i, "rowRev", rev)
|
||||
}
|
||||
|
||||
function keysFor(rows) {
|
||||
var seen = new Map()
|
||||
var out = new Array(rows.length)
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var k = String(root.keyOf(rows[i]))
|
||||
var n = seen.get(k) || 0
|
||||
seen.set(k, n + 1)
|
||||
out[i] = n === 0 ? k : k + "#" + n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Field-by-field equality, `ignoredKeys` excluded. Arrays of scalars are
|
||||
/// compared element-wise; anything deeper falls back to JSON.
|
||||
function sameRow(a, b) {
|
||||
if (a === b)
|
||||
return true
|
||||
if (!a || !b || typeof a !== "object" || typeof b !== "object")
|
||||
return false
|
||||
var ignored = root.ignoredKeys
|
||||
var k
|
||||
var counted = 0
|
||||
for (k in a) {
|
||||
if (ignored.indexOf(k) >= 0)
|
||||
continue
|
||||
counted++
|
||||
if (!(k in b) || !root._sameValue(a[k], b[k]))
|
||||
return false
|
||||
}
|
||||
for (k in b) {
|
||||
if (ignored.indexOf(k) >= 0)
|
||||
continue
|
||||
counted--
|
||||
}
|
||||
return counted === 0
|
||||
}
|
||||
|
||||
function _sameValue(x, y) {
|
||||
if (x === y)
|
||||
return true
|
||||
if (x === null || y === null || typeof x !== "object" || typeof y !== "object")
|
||||
return false
|
||||
if (Array.isArray(x) && Array.isArray(y) && x.length === y.length) {
|
||||
for (var i = 0; i < x.length; i++) {
|
||||
if (x[i] !== y[i]) {
|
||||
if (typeof x[i] !== "object" || typeof y[i] !== "object")
|
||||
return false
|
||||
return JSON.stringify(x) === JSON.stringify(y)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return JSON.stringify(x) === JSON.stringify(y)
|
||||
}
|
||||
|
||||
function sync() {
|
||||
if (root.paused)
|
||||
return
|
||||
var next = root.rows || []
|
||||
var keys = root.keysFor(next)
|
||||
var st = root._state
|
||||
if (!st.initialized || st.scope !== root.scope) {
|
||||
root._reset(next, keys)
|
||||
return
|
||||
}
|
||||
var old = st.keys
|
||||
var sameOrder = old.length === keys.length
|
||||
for (var s = 0; sameOrder && s < keys.length; s++)
|
||||
sameOrder = old[s] === keys[s]
|
||||
if (!sameOrder && !root._restructure(next, keys))
|
||||
return
|
||||
var changed = sameOrder ? root._update(next, keys) : true
|
||||
root._published = next
|
||||
if (changed)
|
||||
root.reconciled(false)
|
||||
}
|
||||
|
||||
function _reset(next, keys) {
|
||||
var st = root._state
|
||||
var had = root.count > 0
|
||||
root._animate = false
|
||||
st.byKey = new Map()
|
||||
st.revs = new Map()
|
||||
st.graveyard = new Map()
|
||||
var batch = new Array(next.length)
|
||||
for (var i = 0; i < next.length; i++) {
|
||||
st.byKey.set(keys[i], next[i])
|
||||
batch[i] = { "rowKey": keys[i], "rowRev": 0 }
|
||||
}
|
||||
root.clear()
|
||||
if (batch.length > 0)
|
||||
root.append(batch)
|
||||
st.keys = keys
|
||||
st.scope = root.scope
|
||||
st.initialized = true
|
||||
root._published = next
|
||||
root._layoutViews(had)
|
||||
root._animate = true
|
||||
root.reconciled(true)
|
||||
}
|
||||
|
||||
/// Apply the reset now, with transitions off, and — when it replaced
|
||||
/// rows the user was looking at — start from the top, as the model swap
|
||||
/// it replaces did. (A ListView lands there by itself; a GridView keeps
|
||||
/// its offset clamped into the new, shorter content.)
|
||||
function _layoutViews(toTop) {
|
||||
var vs = root.views || []
|
||||
for (var i = 0; i < vs.length; i++) {
|
||||
if (!vs[i] || typeof vs[i].forceLayout !== "function")
|
||||
continue
|
||||
vs[i].forceLayout()
|
||||
if (toTop && typeof vs[i].positionViewAtBeginning === "function")
|
||||
vs[i].positionViewAtBeginning()
|
||||
}
|
||||
}
|
||||
|
||||
/// Removals, then inserts and moves; false when it fell back to a reset.
|
||||
function _restructure(next, keys) {
|
||||
var st = root._state
|
||||
var wanted = new Map()
|
||||
for (var w = 0; w < keys.length; w++)
|
||||
wanted.set(keys[w], w)
|
||||
|
||||
// Survivors in their new order, and the longest run of them that is
|
||||
// already in order: those never move.
|
||||
var current = st.keys.slice()
|
||||
var positions = []
|
||||
var survivors = []
|
||||
for (var c = 0; c < current.length; c++) {
|
||||
if (wanted.has(current[c])) {
|
||||
survivors.push(current[c])
|
||||
positions.push(wanted.get(current[c]))
|
||||
}
|
||||
}
|
||||
var steady = root._longestIncreasing(positions, survivors)
|
||||
if (survivors.length - steady.size > root.maxMoves) {
|
||||
root._reset(next, keys)
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. Removals, bottom-up, contiguous runs in one call.
|
||||
var i = current.length - 1
|
||||
while (i >= 0) {
|
||||
if (wanted.has(current[i])) {
|
||||
i--
|
||||
continue
|
||||
}
|
||||
var end = i
|
||||
while (i - 1 >= 0 && !wanted.has(current[i - 1]))
|
||||
i--
|
||||
if (st.graveyard.size > 512)
|
||||
st.graveyard = new Map()
|
||||
for (var g = i; g <= end; g++) {
|
||||
st.graveyard.set(current[g], st.byKey.get(current[g]))
|
||||
st.byKey.delete(current[g])
|
||||
st.revs.delete(current[g])
|
||||
}
|
||||
root.remove(i, end - i + 1)
|
||||
current.splice(i, end - i + 1)
|
||||
i--
|
||||
}
|
||||
|
||||
// 2. Inserts (contiguous runs) and moves, walking the new order.
|
||||
var p = 0
|
||||
var guard = 3 * keys.length + 16
|
||||
while (p < keys.length) {
|
||||
if (--guard < 0) {
|
||||
root._reset(next, keys)
|
||||
return false
|
||||
}
|
||||
if (current[p] === keys[p]) {
|
||||
p++
|
||||
continue
|
||||
}
|
||||
var key = keys[p]
|
||||
if (!st.byKey.has(key)) {
|
||||
var runEnd = p
|
||||
while (runEnd + 1 < keys.length && !st.byKey.has(keys[runEnd + 1]))
|
||||
runEnd++
|
||||
var batch = []
|
||||
for (var r = p; r <= runEnd; r++) {
|
||||
batch.push({ "rowKey": keys[r], "rowRev": 0 })
|
||||
st.byKey.set(keys[r], next[r])
|
||||
}
|
||||
root.insert(p, batch)
|
||||
Array.prototype.splice.apply(current, [p, 0].concat(keys.slice(p, runEnd + 1)))
|
||||
p = runEnd + 1
|
||||
continue
|
||||
}
|
||||
if (!steady.has(key)) {
|
||||
// A row that has to move: bring it here.
|
||||
var from = current.indexOf(key, p + 1)
|
||||
if (from < 0) {
|
||||
root._reset(next, keys)
|
||||
return false
|
||||
}
|
||||
root.move(from, p, 1)
|
||||
current.splice(p, 0, current.splice(from, 1)[0])
|
||||
p++
|
||||
continue
|
||||
}
|
||||
// `key` stays put, so the row sitting here is one that has to
|
||||
// move further down: send it next to the first steady row that
|
||||
// follows its place in the new order.
|
||||
var floating = current[p]
|
||||
var target = wanted.get(floating)
|
||||
var anchor = -1
|
||||
for (var t = target + 1; t < keys.length; t++) {
|
||||
if (steady.has(keys[t])) {
|
||||
anchor = current.indexOf(keys[t])
|
||||
break
|
||||
}
|
||||
}
|
||||
var to = anchor < 0 ? current.length - 1 : anchor - 1
|
||||
if (to <= p) {
|
||||
root._reset(next, keys)
|
||||
return false
|
||||
}
|
||||
root.move(p, to, 1)
|
||||
current.splice(to, 0, current.splice(p, 1)[0])
|
||||
}
|
||||
|
||||
st.keys = keys
|
||||
root._update(next, keys)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Hand changed rows to their delegates; true when anything changed.
|
||||
function _update(next, keys) {
|
||||
var st = root._state
|
||||
var changed = false
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i]
|
||||
var prev = st.byKey.get(key)
|
||||
var row = next[i]
|
||||
if (prev === row)
|
||||
continue
|
||||
st.byKey.set(key, row)
|
||||
if (prev !== undefined && root.sameRow(prev, row))
|
||||
continue
|
||||
var rev = ++st.rev
|
||||
st.revs.set(key, rev)
|
||||
root.setProperty(i, "rowRev", rev)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/// Keys (of `items`) on one longest strictly increasing run of
|
||||
/// `positions` — O(n log n) patience sorting.
|
||||
function _longestIncreasing(positions, items) {
|
||||
var n = positions.length
|
||||
var tails = []
|
||||
var tailIdx = []
|
||||
var prevIdx = new Array(n)
|
||||
for (var i = 0; i < n; i++) {
|
||||
var v = positions[i]
|
||||
var lo = 0
|
||||
var hi = tails.length
|
||||
while (lo < hi) {
|
||||
var mid = (lo + hi) >> 1
|
||||
if (tails[mid] < v)
|
||||
lo = mid + 1
|
||||
else
|
||||
hi = mid
|
||||
}
|
||||
tails[lo] = v
|
||||
tailIdx[lo] = i
|
||||
prevIdx[i] = lo > 0 ? tailIdx[lo - 1] : -1
|
||||
}
|
||||
var out = new Set()
|
||||
var k = tails.length > 0 ? tailIdx[tails.length - 1] : -1
|
||||
while (k >= 0) {
|
||||
out.add(items[k])
|
||||
k = prevIdx[k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
// detail, queue) is this control.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
@@ -100,6 +101,20 @@ Rectangle {
|
||||
property bool _cleared: false
|
||||
onTextChanged: root._cleared = false
|
||||
|
||||
/// Re-seed the box from `value` (default: `text`) NOW, focus or not.
|
||||
///
|
||||
/// The re-seed below reacts only to a CHANGE of `text`, and only while
|
||||
/// the box is not being edited. A modal that resets its draft on the
|
||||
/// CLOSE edge (the input still holds focus for that frame), or that
|
||||
/// reopens with the same seed it closed on, produces neither — and the
|
||||
/// box kept the last thing typed while the draft said otherwise (the
|
||||
/// dirty-form report of 2026-09-14). Every modal form calls this from
|
||||
/// its open handler, after seeding its drafts.
|
||||
function reset(value) {
|
||||
root._cleared = false
|
||||
input.text = (value === undefined) ? root.text : String(value)
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
input.text = ""
|
||||
root._cleared = true
|
||||
@@ -110,6 +125,39 @@ Rectangle {
|
||||
root.open = false
|
||||
input.focus = false
|
||||
}
|
||||
/// Hand the keyboard back to the shell root (the walk the header search
|
||||
/// does); plain unfocus when no shell root is above (previews, tests).
|
||||
function blurToShell() {
|
||||
var p = root
|
||||
while (p.parent) {
|
||||
if (p.parent.isQbzShellRoot === true) {
|
||||
p.parent.forceActiveFocus()
|
||||
return
|
||||
}
|
||||
p = p.parent
|
||||
}
|
||||
input.focus = false
|
||||
}
|
||||
// FOCUS RETENTION (2026-09-13, every search box): a field that filters a
|
||||
// list on each keystroke sits above a surface that rebuilds on each
|
||||
// keystroke, and must not lose the keyboard to that rebuild. After an
|
||||
// edit, once the event loop settles, reclaim focus unless another text
|
||||
// input took it (a click elsewhere is a decision, a rebuilt list is not).
|
||||
property bool _reclaim: false
|
||||
function _reclaimFocus() {
|
||||
if (!root._reclaim)
|
||||
return
|
||||
root._reclaim = false
|
||||
if (input.activeFocus)
|
||||
return
|
||||
var win = root.Window.window
|
||||
var current = win ? win.activeFocusItem : null
|
||||
if (current instanceof TextInput || current instanceof TextEdit)
|
||||
return
|
||||
console.log("[QbzLineEdit] focus left the field after an edit; reclaimed (holder was "
|
||||
+ (current ? current.toString() : "none") + ")")
|
||||
input.forceActiveFocus()
|
||||
}
|
||||
|
||||
// Immersive-port contract D16 (2026-08-02, additive one-liner): the ONE
|
||||
// read-only seam into the inner TextInput's focus. The immersive root
|
||||
@@ -237,11 +285,23 @@ Rectangle {
|
||||
text: root.text
|
||||
onAccepted: { root.accepted(text); root.committed(text) }
|
||||
onActiveFocusChanged: if (!activeFocus) root.committed(text)
|
||||
onTextEdited: root.edited(text)
|
||||
onTextEdited: {
|
||||
root.edited(text)
|
||||
if (root.searchMode) {
|
||||
root._reclaim = true
|
||||
Qt.callLater(root._reclaimFocus)
|
||||
}
|
||||
}
|
||||
// Escape on a search box: empty it AND hand the keyboard
|
||||
// back (2026-09-13). Form fields keep propagating it.
|
||||
Keys.onEscapePressed: function (event) {
|
||||
if (root.expandable) {
|
||||
root.closeSearch()
|
||||
event.accepted = true
|
||||
} else if (root.searchMode) {
|
||||
root.clearSearch()
|
||||
root.blurToShell()
|
||||
event.accepted = true
|
||||
} else {
|
||||
event.accepted = false
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ Column {
|
||||
width: pillText.implicitWidth + 36
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
color: pillArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard
|
||||
color: pillArea.containsMouse ? theme.surfaceElevated : (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard)
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
|
||||
@@ -47,7 +47,7 @@ Rectangle {
|
||||
|
||||
height: 44
|
||||
radius: 8
|
||||
color: theme.surfaceElevated
|
||||
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -83,7 +83,10 @@ Rectangle {
|
||||
? Qt.darker(theme.danger, 1.15) : theme.danger)
|
||||
: ((btnArea.containsMouse && root.btnEnabled)
|
||||
? theme.accentHover : theme.accent)
|
||||
Behavior on color { NumberAnimation { duration: 150 } }
|
||||
// ColorAnimation, NOT NumberAnimation: a NumberAnimation on a colour
|
||||
// interpolates it as a number and lands on BLACK, which is how every
|
||||
// accent button in the modals went dark under the pointer (2026-09-14).
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
Text {
|
||||
id: lbl
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// QbzRowAdd — a row joining a QbzKeyedModel view fades in (a card also grows
|
||||
// from a touch smaller) once its neighbours made room (2026-09-14). Assign
|
||||
// it to the view's `add`, with `enabled: <model>.animate`; a scope reset
|
||||
// never runs it.
|
||||
|
||||
import QtQuick
|
||||
|
||||
Transition {
|
||||
id: root
|
||||
property int duration: 200
|
||||
/// 1.0 for list rows; cards read better growing from slightly smaller.
|
||||
property real grow: 1.0
|
||||
|
||||
NumberAnimation {
|
||||
property: "opacity"
|
||||
from: 0.0
|
||||
to: 1.0
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
property: "scale"
|
||||
from: root.grow
|
||||
to: 1.0
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// QbzRowDisplaced — the neighbours of an inserted, removed or moved row slide
|
||||
// to their new place (QbzKeyedModel, 2026-09-14). Assign it to a ListView or
|
||||
// GridView's `displaced` and `move`, with `enabled: <model>.animate`.
|
||||
//
|
||||
// A row displaced while it was still fading in or out would otherwise keep
|
||||
// that partial opacity or scale for good (Qt's ViewTransition notes), so the
|
||||
// slide also settles both back to 1.
|
||||
//
|
||||
// Not a continuous animation: it runs once per structural change, for the
|
||||
// rows actually on screen, and leaves nothing ticking (the repaint pulse
|
||||
// rule in CLAUDE.md is about animations that keep presenting frames).
|
||||
|
||||
import QtQuick
|
||||
|
||||
Transition {
|
||||
id: root
|
||||
property int duration: 220
|
||||
|
||||
NumberAnimation {
|
||||
properties: "x,y"
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
property: "opacity"
|
||||
to: 1.0
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
property: "scale"
|
||||
to: 1.0
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// QbzRowRemove — a row leaving a QbzKeyedModel view fades out, a card also
|
||||
// shrinks a touch (2026-09-14). Assign it to the view's `remove`, with
|
||||
// `enabled: <model>.animate`. The view keeps the delegate alive until this
|
||||
// ends; its content stays because a removed row's roles are not re-notified.
|
||||
//
|
||||
// THE LAST STEP PUTS OPACITY AND SCALE BACK. With `reuseItems` the view pools
|
||||
// the delegate the moment this transition finishes (QQuickItemViewPrivate::
|
||||
// viewItemTransitionFinished -> releaseItem -> setVisible(false), all in one
|
||||
// call), and a pooled delegate is reused as it was left: without the restore
|
||||
// the next row it renders is invisible. Measured 2026-09-14: 76 blank rows
|
||||
// after three removals and a scroll. The restore runs before the release, in
|
||||
// the same call chain, so it never paints.
|
||||
|
||||
import QtQuick
|
||||
|
||||
Transition {
|
||||
id: root
|
||||
property int duration: 180
|
||||
/// 1.0 for list rows; cards read better with a slight shrink.
|
||||
property real shrink: 1.0
|
||||
|
||||
SequentialAnimation {
|
||||
ParallelAnimation {
|
||||
NumberAnimation {
|
||||
property: "opacity"
|
||||
to: 0.0
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
property: "scale"
|
||||
to: root.shrink
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
PropertyAction { property: "opacity"; value: 1.0 }
|
||||
PropertyAction { property: "scale"; value: 1.0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// QbzSearchField — the inline search box the views share (2026-09-13): the
|
||||
// rounded elevated field with a leading search glyph, a placeholder, a clear
|
||||
// cross floating at the right while there is text, and one key contract —
|
||||
// Escape clears the text AND hands the keyboard back to the shell root (the
|
||||
// walk the header search does), so no search box keeps a stale query or a
|
||||
// blinking cursor after Escape.
|
||||
//
|
||||
// FOCUS RETENTION. A search box that filters a list on every keystroke sits
|
||||
// above a surface that rebuilds on every keystroke, and the field must never
|
||||
// lose the keyboard mid-word to that rebuild. After each edit, once the event
|
||||
// loop has settled, the field checks it still has focus and reclaims it
|
||||
// unless another text input took it: a click elsewhere is a decision, a
|
||||
// rebuilt list is not.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias text: input.text
|
||||
property string placeholder: ""
|
||||
/// The leading magnifier; off for hosts whose own toggle already shows one
|
||||
/// (the sidebar's inline filters).
|
||||
property bool showGlyph: true
|
||||
/// Escape emptied the field and handed the keyboard back; a host that
|
||||
/// folds the field away listens here.
|
||||
signal escaped()
|
||||
/// The clear cross scales with a short field (the sidebar's 22px rows).
|
||||
readonly property int clearSize: Math.min(22, Math.max(16, height - 2))
|
||||
property int glyphSize: 14
|
||||
property int fontPx: 13
|
||||
property int sidePadding: 10
|
||||
/// Fired on every keystroke and on clear, with the live text.
|
||||
signal edited(string text)
|
||||
readonly property bool fieldActive: input.activeFocus
|
||||
|
||||
implicitWidth: 168
|
||||
implicitHeight: 34
|
||||
radius: 6
|
||||
// The header search's ambient-aware fill, so every search box reads alike.
|
||||
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
function clear() {
|
||||
if (input.text === "")
|
||||
return
|
||||
input.text = ""
|
||||
root.edited("")
|
||||
}
|
||||
/// Escape's contract: empty AND hand the keyboard to the shell root.
|
||||
function clearAndBlur() {
|
||||
root.clear()
|
||||
root.blur()
|
||||
}
|
||||
function blur() {
|
||||
var p = root
|
||||
while (p.parent) {
|
||||
if (p.parent.isQbzShellRoot === true) {
|
||||
p.parent.forceActiveFocus()
|
||||
return
|
||||
}
|
||||
p = p.parent
|
||||
}
|
||||
input.focus = false
|
||||
}
|
||||
function focusField() { input.forceActiveFocus() }
|
||||
|
||||
property bool _reclaim: false
|
||||
function _reclaimFocus() {
|
||||
if (!root._reclaim)
|
||||
return
|
||||
root._reclaim = false
|
||||
if (input.activeFocus)
|
||||
return
|
||||
var win = root.Window.window
|
||||
var current = win ? win.activeFocusItem : null
|
||||
if (current instanceof TextInput || current instanceof TextEdit)
|
||||
return
|
||||
console.log("[QbzSearchField] focus left the field after an edit; reclaimed (holder was "
|
||||
+ (current ? current.toString() : "none") + ")")
|
||||
input.forceActiveFocus()
|
||||
}
|
||||
|
||||
QbzIcon {
|
||||
visible: root.showGlyph
|
||||
name: "search"
|
||||
width: root.glyphSize
|
||||
height: root.glyphSize
|
||||
x: root.sidePadding
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
id: input
|
||||
QbzTextEditMenu { }
|
||||
anchors.left: parent.left
|
||||
anchors.right: clearSlot.left
|
||||
anchors.leftMargin: root.sidePadding + (root.showGlyph ? root.glyphSize + 7 : 0)
|
||||
anchors.rightMargin: 4
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: root.fontPx
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: {
|
||||
root.edited(text)
|
||||
root._reclaim = true
|
||||
Qt.callLater(root._reclaimFocus)
|
||||
}
|
||||
Keys.onEscapePressed: function (event) {
|
||||
root.clearAndBlur()
|
||||
root.escaped()
|
||||
event.accepted = true
|
||||
}
|
||||
Text {
|
||||
visible: parent.text === ""
|
||||
anchors.fill: parent
|
||||
text: root.placeholder
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.fontPx
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
// The clear cross: floats at the right while there is text; a click
|
||||
// empties the field and keeps the keyboard in it.
|
||||
Item {
|
||||
id: clearSlot
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 5
|
||||
width: input.text !== "" ? root.clearSize : 0
|
||||
height: parent.height
|
||||
visible: input.text !== ""
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: root.clearSize
|
||||
height: root.clearSize
|
||||
radius: root.clearSize / 2
|
||||
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: "x"
|
||||
width: Math.round(root.clearSize * 0.55)
|
||||
height: Math.round(root.clearSize * 0.55)
|
||||
anchors.centerIn: parent
|
||||
tintName: clearArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: clearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.clear()
|
||||
input.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,8 +74,8 @@ Rectangle {
|
||||
// shows through the control (QbzSelect.slint:110-116). Hover keeps its own
|
||||
// token, which is already translucent.
|
||||
color: selArea.containsMouse && enabled
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
activeFocusOnTab: enabled
|
||||
Accessible.role: Accessible.ComboBox
|
||||
|
||||
@@ -28,12 +28,18 @@ Rectangle {
|
||||
property int wrapMode: TextEdit.WordWrap
|
||||
signal edited(string value)
|
||||
|
||||
/// Re-seed the body from `value` (default: `text`) NOW, focus or not —
|
||||
/// the QbzLineEdit.reset() twin (the why is documented there).
|
||||
function reset(value) {
|
||||
input.text = (value === undefined) ? root.text : String(value)
|
||||
}
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: 240
|
||||
height: 96
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Rectangle {
|
||||
: 2 * root.padH + row.implicitWidth
|
||||
radius: 6
|
||||
color: root.filled ? theme.accent
|
||||
: (toolArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
|
||||
: (toolArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated)
|
||||
border.width: (root.large && !(root.active && !root.fillActive)) ? 0 : 1
|
||||
border.color: (root.active && !root.fillActive) ? theme.accent : theme.borderSubtle
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@ Item {
|
||||
album = doc.album || ""
|
||||
albumArtist = doc.albumArtist || ""
|
||||
year = doc.year || ""
|
||||
// The boxes too (QbzLineEdit.reset()): a disc re-read with the same
|
||||
// tags changes no draft above.
|
||||
albumField.reset()
|
||||
yearField.reset()
|
||||
albumArtistField.reset()
|
||||
libraryChoice = "none"
|
||||
var copy = []
|
||||
var src = doc.tracks || []
|
||||
@@ -254,6 +259,7 @@ Item {
|
||||
font.pixelSize: theme.fontLegal }
|
||||
QbzLineEdit {
|
||||
width: parent.width
|
||||
id: albumField
|
||||
text: root.album
|
||||
onEdited: function (v) { root.album = v }
|
||||
}
|
||||
@@ -265,6 +271,7 @@ Item {
|
||||
font.pixelSize: theme.fontLegal }
|
||||
QbzLineEdit {
|
||||
width: parent.width
|
||||
id: yearField
|
||||
text: root.year
|
||||
onEdited: function (v) { root.year = v }
|
||||
}
|
||||
@@ -277,6 +284,7 @@ Item {
|
||||
font.pixelSize: theme.fontLegal }
|
||||
QbzLineEdit {
|
||||
width: parent.width
|
||||
id: albumArtistField
|
||||
text: root.albumArtist
|
||||
onEdited: function (v) { root.albumArtist = v }
|
||||
}
|
||||
|
||||
@@ -44,10 +44,12 @@
|
||||
// every one of those snaps the list back to the top — over a restore that had
|
||||
// already run correctly.
|
||||
//
|
||||
// (The same reset also throws a plain forward visit to the top if a rail lands
|
||||
// while you are reading. The re-assert below only stops it from eating the
|
||||
// restore; fixing that properly means giving those rails a model of their own,
|
||||
// which is an AlbumView change and not this file's business.)
|
||||
// (The album page no longer resets: since 2026-09-13 its tape is swapped on a
|
||||
// QbzArrayModel, which reaches the ListView as remove+insert changes and keeps
|
||||
// the viewport — see that control for the Qt 6.11 focus theft that forced the
|
||||
// move. Those swaps drift `originY` instead, which is what `relativeToOrigin`
|
||||
// is for. The re-assert below stays for every page that still assigns
|
||||
// `model` directly.)
|
||||
//
|
||||
// So the offset is held in `_wanted` and re-applied on every contentHeight
|
||||
// change, every count change and every contentY move we did not make, until
|
||||
|
||||
@@ -36,7 +36,7 @@ Rectangle {
|
||||
border.width: root.activeFocus ? 2 : 1
|
||||
border.color: root.activeFocus ? theme.accent
|
||||
: (danger ? theme.danger : theme.borderSubtle)
|
||||
color: enabled && btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: enabled && btnArea.containsMouse ? theme.elevatedHoverFill : theme.elevatedFill
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
activeFocusOnTab: enabled
|
||||
Accessible.role: Accessible.Button
|
||||
|
||||
@@ -411,6 +411,18 @@ Item {
|
||||
tierOverride: cand.modelData.qualityTier || ""
|
||||
label: cand.modelData.qualityDetail || ""
|
||||
}
|
||||
// The mark alone says "CD" or "Hi-Res"; the
|
||||
// exact tier is the decision, so it is spelled
|
||||
// out beside it. (The badge's own hover tooltip
|
||||
// never shows on this row: the row's MouseArea
|
||||
// takes the hover.)
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: (cand.modelData.qualityDetail || "") !== ""
|
||||
text: cand.modelData.qualityDetail || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.releaseMode ? "" : (cand.modelData.duration || "")
|
||||
|
||||
@@ -53,14 +53,28 @@ Rectangle {
|
||||
function toggleSelected(id, mods) { selected = selection.next(selected, id, visibleTracks, mods === undefined ? Qt.NoModifier : mods) }
|
||||
readonly property string navigationStateJson: JSON.stringify({trackQuery: trackQuery})
|
||||
function restoreNavigationState(state) { trackQuery = state.trackQuery || "" }
|
||||
onDocChanged: exitMultiSelectMode()
|
||||
// An in-place album switch starts at the top; the first document of a
|
||||
// mount does not touch the viewport (ScrollMemory may be restoring it).
|
||||
property string loadedAlbumId: ""
|
||||
onDocChanged: {
|
||||
exitMultiSelectMode()
|
||||
var id = root.album ? String(root.album.id || "") : ""
|
||||
if (id === root.loadedAlbumId)
|
||||
return
|
||||
if (root.loadedAlbumId !== "")
|
||||
rowsModel.scrollToTop()
|
||||
root.loadedAlbumId = id
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
clip: true
|
||||
model: root.visibleTracks
|
||||
// Swapped on a QbzArrayModel, never on `model:` — a fresh array there
|
||||
// makes Qt 6.11 focus delegate 0 and the header's search box loses the
|
||||
// keyboard on every keystroke (see the control's header).
|
||||
model: QbzArrayModel { id: rowsModel; view: list; rows: root.visibleTracks }
|
||||
cacheBuffer: height
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
@@ -168,6 +182,6 @@ Rectangle {
|
||||
]
|
||||
onPicked: function(a) { if (root.selectedCount > 0) root.bulkAction(a) }
|
||||
}
|
||||
ScrollMemory { target: list; scope: "localalbum" }
|
||||
ScrollMemory { target: list; scope: "localalbum"; relativeToOrigin: true }
|
||||
QbzScrollBar { target: list; anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom }
|
||||
}
|
||||
@@ -890,7 +890,7 @@ Rectangle {
|
||||
border.width: 1
|
||||
border.color: chip.active ? theme.accent : theme.borderSubtle
|
||||
color: chip.active ? theme.accent
|
||||
: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
Text {
|
||||
id: chipLabel
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -84,6 +84,10 @@ Rectangle {
|
||||
var id = doc.id || ""
|
||||
if (id === loadedPlaylistId)
|
||||
return
|
||||
// An in-place playlist switch starts at the top; the first document
|
||||
// of a mount leaves the viewport to ScrollMemory.
|
||||
if (loadedPlaylistId !== "")
|
||||
rowsModel.scrollToTop()
|
||||
loadedPlaylistId = id
|
||||
setMultiSelect(false)
|
||||
}
|
||||
@@ -396,7 +400,10 @@ Rectangle {
|
||||
cacheBuffer: height
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.tracks
|
||||
// Swapped on a QbzArrayModel, never on `model:` — a fresh array there
|
||||
// makes Qt 6.11 focus delegate 0, and the header's search box lost the
|
||||
// keyboard whenever a filtered document landed (see the control).
|
||||
model: QbzArrayModel { id: rowsModel; view: trackList; rows: root.tracks }
|
||||
header: Column {
|
||||
width: trackList.width
|
||||
spacing: 12
|
||||
@@ -586,6 +593,6 @@ Rectangle {
|
||||
]
|
||||
onPicked:function(a){if(root.selectedCount>0)root.bulkAction(a)}
|
||||
}
|
||||
ScrollMemory { target:trackList; scope:"playlist" }
|
||||
ScrollMemory { target:trackList; scope:"playlist"; relativeToOrigin:true }
|
||||
QbzScrollBar { target:trackList; anchors.right:parent.right; anchors.top:parent.top; anchors.bottom:parent.bottom }
|
||||
}
|
||||
@@ -990,13 +990,28 @@ Rectangle {
|
||||
// Main artist first, then the featured performers, one comma
|
||||
// separated list. Local / media-server rows carry no
|
||||
// `featured` and stay as they are. With links enabled and
|
||||
// featured names present, every name is its own StyledText
|
||||
// link: the main artist opens its page as before; a featured
|
||||
// featured names present, every name is its own clickable
|
||||
// SPAN: the main artist opens its page as before; a featured
|
||||
// name opens its page when the album credits gave it an id
|
||||
// and the search for that name otherwise. StyledText elides
|
||||
// like plain text (probed: truncated stays true on one line).
|
||||
// and the search for that name otherwise. Spans, not `<a>`
|
||||
// anchors: StyledText underlines every anchor unconditionally
|
||||
// (qquickstyledtext.cpp, setFontUnderline(true)) and nothing
|
||||
// else in the app underlines a link, so the hit test is done
|
||||
// by measuring the prefix up to each name with the same font
|
||||
// (TextMetrics below). StyledText still elides like plain
|
||||
// text (probed: truncated stays true on one line).
|
||||
readonly property var featured: root.showFeaturedArtists ? (root.item.featured || []) : []
|
||||
readonly property bool linkMode: root.artistLink && featured.length > 0
|
||||
/// The names as displayed, in order: {name, href} — href ""
|
||||
/// = the main artist with no page to open.
|
||||
readonly property var spans: {
|
||||
var out = [{ "name": String(root.item.artist || ""), "href": root.item.artistId ? "main" : "" }]
|
||||
for (var i = 0; i < featured.length; i++)
|
||||
out.push({ "name": String(featured[i].name || ""), "href": "f:" + i })
|
||||
return out
|
||||
}
|
||||
/// The span under the pointer (-1 = none).
|
||||
property int hotSpan: -1
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
}
|
||||
@@ -1005,51 +1020,84 @@ Rectangle {
|
||||
return ("0" + Math.round(v * 255).toString(16)).slice(-2)
|
||||
}).join("")
|
||||
}
|
||||
function link(href, name) {
|
||||
var hot = artistLine.hoveredLink === href
|
||||
return '<a href="' + href + '"><font color="'
|
||||
+ hex(hot ? theme.textPrimary : theme.textMuted) + '">' + esc(name) + '</font></a>'
|
||||
function span(i) {
|
||||
var s = artistLine.spans[i]
|
||||
var hot = artistLine.hotSpan === i && s.href !== ""
|
||||
return '<font color="' + hex(hot ? theme.textPrimary : theme.textMuted) + '">' + esc(s.name) + '</font>'
|
||||
}
|
||||
textFormat: linkMode ? Text.StyledText : Text.PlainText
|
||||
text: {
|
||||
var names = featured.map(function (f) { return f.name })
|
||||
if (!linkMode)
|
||||
return (root.item.artist || "") + (names.length ? ", " + names.join(", ") : "")
|
||||
var parts = [root.item.artistId ? link("main", root.item.artist) : esc(root.item.artist)]
|
||||
for (var i = 0; i < featured.length; i++)
|
||||
parts.push(link("f:" + i, featured[i].name))
|
||||
var parts = []
|
||||
for (var i = 0; i < spans.length; i++)
|
||||
parts.push(span(i))
|
||||
return parts.join(", ")
|
||||
}
|
||||
linkColor: theme.textMuted
|
||||
color: root.artistLink && root.item.artistId && artistLinkArea.containsMouse
|
||||
? theme.textPrimary : theme.textMuted
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
onLinkActivated: function (href) {
|
||||
if (href === "main") {
|
||||
/// Which span sits at `x` (-1 = a separator or past the text):
|
||||
/// the prefix up to each name's end is measured with this
|
||||
/// Text's own font, so the edges match what is drawn.
|
||||
function spanAt(x) {
|
||||
if (!linkMode || x < 0)
|
||||
return -1
|
||||
var prefix = ""
|
||||
for (var i = 0; i < spans.length; i++) {
|
||||
spanMetrics.text = prefix
|
||||
var from = spanMetrics.advanceWidth
|
||||
prefix += spans[i].name
|
||||
spanMetrics.text = prefix
|
||||
if (x >= from && x < spanMetrics.advanceWidth)
|
||||
return i
|
||||
prefix += ", "
|
||||
}
|
||||
return -1
|
||||
}
|
||||
function activate(i) {
|
||||
var s = artistLine.spans[i]
|
||||
if (!s || s.href === "")
|
||||
return
|
||||
if (s.href === "main") {
|
||||
if (root.routeGoToExternally) root.goToRequested("artist")
|
||||
else QbzArtist.openArtist(root.item.artistId)
|
||||
return
|
||||
}
|
||||
var f = featured[parseInt(href.slice(2))]
|
||||
var f = featured[parseInt(s.href.slice(2))]
|
||||
if (!f) return
|
||||
if (f.id) QbzArtist.openArtist(f.id)
|
||||
else QbzSearch.searchSubmit(f.name)
|
||||
}
|
||||
HoverHandler {
|
||||
enabled: artistLine.linkMode
|
||||
cursorShape: artistLine.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
TextMetrics {
|
||||
id: spanMetrics
|
||||
font: artistLine.font
|
||||
}
|
||||
MouseArea {
|
||||
id: artistLinkArea
|
||||
anchors.fill: parent
|
||||
enabled: root.artistLink && !!root.item.artistId && !artistLine.linkMode
|
||||
enabled: root.artistLink && (artistLine.linkMode || !!root.item.artistId)
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
cursorShape: (artistLine.linkMode
|
||||
? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "")
|
||||
: enabled) ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onPositionChanged: function (mouse) {
|
||||
if (artistLine.linkMode)
|
||||
artistLine.hotSpan = artistLine.spanAt(mouse.x)
|
||||
}
|
||||
onExited: artistLine.hotSpan = -1
|
||||
// Same routing as the menu's "Go to artist" — Slint drives
|
||||
// both through one handler, so gating only the menu would
|
||||
// leave the LINK navigating to the track's own artist.
|
||||
onClicked: {
|
||||
onClicked: function (mouse) {
|
||||
if (artistLine.linkMode) {
|
||||
var i = artistLine.spanAt(mouse.x)
|
||||
if (i >= 0)
|
||||
artistLine.activate(i)
|
||||
return
|
||||
}
|
||||
if (root.routeGoToExternally) root.goToRequested("artist")
|
||||
else QbzArtist.openArtist(root.item.artistId)
|
||||
}
|
||||
@@ -1275,43 +1323,19 @@ Rectangle {
|
||||
/// Build the popup on first use, then open it. Called by `openRowMenu`
|
||||
/// below, which owns the empty-menu guard.
|
||||
function openRowMenuLazy(anchor, x, y) {
|
||||
root._menuAnchor = anchor
|
||||
root._menuX = x
|
||||
root._menuY = y
|
||||
rowMenuLoader.active = true
|
||||
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
|
||||
// --- Copy submenu + clipboard (2026-09-13) --------------------------------
|
||||
// Both lazy, like the row menu: a list row must not carry a Popup tree
|
||||
// or a TextEdit per delegate. Reopened where the row menu was.
|
||||
property Item _menuAnchor: null
|
||||
property real _menuX: 0
|
||||
property real _menuY: 0
|
||||
Loader {
|
||||
id: copyMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
kioskHost: root.kioskHost
|
||||
menuWidth: 224
|
||||
entries: [
|
||||
{ "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" },
|
||||
{ "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" }
|
||||
]
|
||||
onPicked: function (a) { root.copyRow(a) }
|
||||
}
|
||||
}
|
||||
// --- Copy (2026-09-13) ----------------------------------------------------
|
||||
// The Copy entry is a CardMenu submenu (hover-opened, declared as data
|
||||
// in menuModel); the clipboard carrier stays lazy, like the row menu —
|
||||
// a list row must not carry a TextEdit per delegate.
|
||||
Loader {
|
||||
id: clipboardLoader
|
||||
active: false
|
||||
sourceComponent: QbzClipboard { }
|
||||
}
|
||||
function openCopyMenu() {
|
||||
if (!root._menuAnchor)
|
||||
return
|
||||
copyMenuLoader.active = true
|
||||
copyMenuLoader.item.openAtCursor(root._menuAnchor, root._menuX, root._menuY)
|
||||
}
|
||||
function copyRow(which) {
|
||||
var parts = [root.item.title || ""]
|
||||
if (which === "copy-full") {
|
||||
@@ -1426,7 +1450,10 @@ Rectangle {
|
||||
m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" })
|
||||
// Copy (2026-09-13): a submenu — the track name, or "Track - Album - Artist".
|
||||
if ((root.item.title || "") !== "")
|
||||
m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true })
|
||||
m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": [
|
||||
{ "label": t("Track name", r), "icon": "copy", "action": "copy-title" },
|
||||
{ "label": t("Track - Album - Artist", r), "icon": "clipboard", "action": "copy-full" }
|
||||
] })
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -1453,7 +1480,7 @@ Rectangle {
|
||||
else QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "")
|
||||
}
|
||||
else if (a === "buy") QbzAlbum.buyTrack(root.item.id || "")
|
||||
else if (a === "copy") root.openCopyMenu()
|
||||
else if (a === "copy-title" || a === "copy-full") root.copyRow(a)
|
||||
else if (a === "favorite") root.toggleFavorite()
|
||||
else if (a === "mixtape") root.mixtapeRequested()
|
||||
// Add to playlist. The internal arm is the CATALOG one and nothing
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -754,7 +754,7 @@ Item {
|
||||
width: parent.width
|
||||
height: 40
|
||||
radius: theme.radiusSm
|
||||
color: cfgHead.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: cfgHead.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: activeFocus ? theme.accent : theme.borderSubtle
|
||||
activeFocusOnTab: visible && enabled
|
||||
@@ -978,7 +978,7 @@ Item {
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
color: prevTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: prevTa.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
width: 16
|
||||
@@ -1016,7 +1016,7 @@ Item {
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
color: nextTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: nextTa.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
width: 16
|
||||
|
||||
@@ -64,7 +64,7 @@ Item {
|
||||
root.draftIsNetwork = root.st.isNetwork === true
|
||||
root.draftOverride = root.st.userOverrideNetwork === true
|
||||
root.draftFsIndex = root.st.fsTypeIndex || 0
|
||||
aliasField.text = root.draftAlias
|
||||
aliasField.reset(root.draftAlias)
|
||||
closeButton.forceActiveFocus()
|
||||
}
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ Item {
|
||||
width: resetRow.width + 24
|
||||
height: root.kioskHost ? 44 : 34
|
||||
radius: theme.radiusSm
|
||||
color: resetArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: resetArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: activeFocus ? theme.accent : theme.borderSubtle
|
||||
activeFocusOnTab: root.opened
|
||||
|
||||
@@ -221,7 +221,7 @@ Item {
|
||||
width: body.width
|
||||
height: 92
|
||||
radius: theme.radiusMd
|
||||
color: selected ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: selected ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: selected ? 2 : 1
|
||||
border.color: selected ? theme.accent : theme.borderSubtle
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ Item {
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: lbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: lbArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
|
||||
Row {
|
||||
id: lbRow
|
||||
@@ -162,7 +162,7 @@ Item {
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: hcArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: hcArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
|
||||
Row {
|
||||
id: hcRow
|
||||
@@ -224,7 +224,7 @@ Item {
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: (sc.clickable && scArea.containsMouse) ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: (sc.clickable && scArea.containsMouse) ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
|
||||
Text {
|
||||
id: scText
|
||||
|
||||
@@ -121,6 +121,59 @@ Rectangle {
|
||||
opacity: root.veilStrength
|
||||
}
|
||||
|
||||
// Mode 3 — Wallpaper: the window reads as translucent to the desktop:
|
||||
// ALWAYS the part of the wallpaper that lies under it, wherever it is
|
||||
// moved (X11, macOS and Windows say where the window is; KDE Plasma
|
||||
// Wayland says so through its window-management protocol; anywhere
|
||||
// else a centred crop stands in), lightly blurred so nothing in the
|
||||
// picture competes with the text, yet still recognisable. Static: it
|
||||
// costs a frame only when the window moves or resizes
|
||||
// (shell/WallpaperField.qml).
|
||||
// Mode 4 — Wallpaper, blurred: Blurred art with the wallpaper in the
|
||||
// cover's place — the SAME atmosphere pass, the SAME drift while the
|
||||
// transport plays, the same still pose when it does not.
|
||||
// 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.75
|
||||
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: root.wallpaperBlurOn && QbzPlayer.npPlaying
|
||||
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
|
||||
|
||||
|
||||
@@ -528,7 +528,7 @@ Popup {
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: selected ? theme.accent
|
||||
: (tabArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
|
||||
: (tabArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated)
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -44,6 +44,46 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// THE PAGE'S TAB, LIVE (2026-09-13). Any mounted view with an `activeTab`
|
||||
// string that does not report a richer state of its own (Local Library
|
||||
// does, and restores it itself) reports its tab on every change: nav_qt
|
||||
// stamps it on the history entry, so Back/Forward land on the tab the
|
||||
// user left, and page_restore_qt keeps it in the per-profile page
|
||||
// document, so "Where you left off" reopens on it. Never during the
|
||||
// two-phase route commit, when the item on screen is still the outgoing
|
||||
// view while `currentView` already names the next one.
|
||||
Connections {
|
||||
target: viewLoader.item
|
||||
ignoreUnknownSignals: true
|
||||
function onActiveTabChanged() { root._reportTab() }
|
||||
}
|
||||
function _reportTab() {
|
||||
var item = viewLoader.item
|
||||
if (!item || root._armed || root.kiosk)
|
||||
return
|
||||
if (typeof item.activeTab !== "string" || typeof item.navigationStateJson === "string")
|
||||
return
|
||||
QbzShell.reportNavState(QbzShell.currentView, JSON.stringify({ "activeTab": item.activeTab }))
|
||||
}
|
||||
// The other direction: a Back/Forward destination whose entry carries a
|
||||
// tab (armed by nav_qt::step before `currentView` is published) mounts
|
||||
// on it. Views that restore their own state consume the arming
|
||||
// themselves and are left alone.
|
||||
function _restoreTab() {
|
||||
var item = viewLoader.item
|
||||
if (!item || root.kiosk || QbzShell.restoreStateScope === "")
|
||||
return
|
||||
if (QbzShell.restoreStateScope !== QbzShell.currentView)
|
||||
return
|
||||
if (typeof item.activeTab !== "string" || typeof item.restoreNavigationState === "function")
|
||||
return
|
||||
var saved = null
|
||||
try { saved = JSON.parse(QbzShell.stateRestore) } catch (e) { saved = null }
|
||||
QbzShell.restoreStateScope = ""
|
||||
if (saved && typeof saved.activeTab === "string" && saved.activeTab !== "")
|
||||
item.activeTab = saved.activeTab
|
||||
}
|
||||
|
||||
// The mounted view. The replacement for AppShell's four `viewLoader.item`
|
||||
// references (multi-select: the two Ctrl+A / Escape routers and the two
|
||||
// duck-typed reporters) — a QML `id` is scoped to its own document, so
|
||||
@@ -595,6 +635,7 @@ Item {
|
||||
&& typeof viewLoader.item.activeTab === "string"
|
||||
root._itemTabRequest = viewLoader.item !== null
|
||||
&& typeof viewLoader.item.tabNavigationRequest !== "undefined"
|
||||
root._restoreTab()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -452,7 +452,7 @@ Item {
|
||||
radius: theme.radiusSm
|
||||
color: entryRow.recording
|
||||
? theme.bgHover
|
||||
: (capArea.containsMouse ? theme.surfaceHover
|
||||
: (capArea.containsMouse ? theme.surfaceElevatedHover
|
||||
: theme.surfaceElevated)
|
||||
border.width: (entryRow.recording || entryRow.entry.modified) ? 2 : 1
|
||||
// Colour priority (trap 6): danger on
|
||||
@@ -499,7 +499,7 @@ Item {
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: theme.radiusSm
|
||||
color: resetArea.containsMouse ? theme.surfaceHover
|
||||
color: resetArea.containsMouse ? theme.surfaceElevatedHover
|
||||
: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
@@ -63,6 +63,9 @@ Rectangle {
|
||||
// HeaderBar.slint's with-alpha(app-background-surface-alpha)).
|
||||
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
/// The fill of an ACTIVE nav item / open button: elevated, at half alpha
|
||||
/// under the ambient field like every other chrome control.
|
||||
readonly property color activeFill: root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
|
||||
|
||||
|
||||
/// Raised by the app menu's "Report an issue" row; AppShell owns the modal.
|
||||
@@ -312,7 +315,7 @@ Rectangle {
|
||||
height: 30
|
||||
width: purchaseRow.implicitWidth
|
||||
radius: theme.radiusSm
|
||||
color: purchaseTab.isActive ? theme.surfaceElevated
|
||||
color: purchaseTab.isActive ? root.activeFill
|
||||
: purchaseArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
@@ -363,7 +366,7 @@ Rectangle {
|
||||
width: tabRow.implicitWidth
|
||||
radius: theme.radiusSm
|
||||
opacity: isEnabled ? 1.0 : 0.5
|
||||
color: isActive ? theme.surfaceElevated
|
||||
color: isActive ? root.activeFill
|
||||
: (tabArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
@@ -439,7 +442,7 @@ Rectangle {
|
||||
height: 30
|
||||
radius: theme.radiusSm
|
||||
opacity: isEnabled ? 1.0 : 0.5
|
||||
color: isActive ? theme.surfaceElevated
|
||||
color: isActive ? root.activeFill
|
||||
: (cnbArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent"
|
||||
// Baked glyph, or the section's own raw image when it carries one
|
||||
// (My QBZ branding) — see shell/NavSectionGlyph.qml.
|
||||
@@ -640,7 +643,7 @@ Rectangle {
|
||||
height: 30
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: plPopup.opened ? theme.surfaceElevated
|
||||
color: plPopup.opened ? root.activeFill
|
||||
: plBtnArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: "list-music"
|
||||
@@ -942,15 +945,39 @@ Rectangle {
|
||||
function focusSearch() {
|
||||
searchInput.forceActiveFocus()
|
||||
}
|
||||
// FOCUS RETENTION (2026-09-13, every search box): after an edit, once the
|
||||
// event loop settles, reclaim the keyboard unless another text input took
|
||||
// it — the dropdown opening or closing must never steal it mid-word.
|
||||
property bool _searchReclaim: false
|
||||
function _reclaimSearchFocus() {
|
||||
if (!root._searchReclaim)
|
||||
return
|
||||
root._searchReclaim = false
|
||||
if (searchInput.activeFocus)
|
||||
return
|
||||
var win = root.Window.window
|
||||
var current = win ? win.activeFocusItem : null
|
||||
if (current instanceof TextInput || current instanceof TextEdit)
|
||||
return
|
||||
console.log("[HeaderBar] search lost focus after an edit; reclaimed (holder was "
|
||||
+ (current ? current.toString() : "none") + ")")
|
||||
searchInput.forceActiveFocus()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: searchBox
|
||||
x: (root.width - width) / 2
|
||||
y: (root.height - height) / 2
|
||||
// 80% of the prior search width; gives up 60px to the section nav
|
||||
// whenever that nav lives in the header (HeaderBar.slint:569). The
|
||||
// width animates and `x` re-centers with it.
|
||||
width: (root.width < 960 ? 179 : 256) - (QbzShell.navInSidebar ? 0 : 60)
|
||||
// Grows with the window from 960px up (a quarter of it), between the
|
||||
// 256px the layout was designed around and a 480px cap: wide enough
|
||||
// to read a query on a 1720px window, never half the screen. Small
|
||||
// windows keep the fixed 179px. The section nav still takes its 60px
|
||||
// whenever it lives in the header (HeaderBar.slint:569); the fit rule
|
||||
// reads this box's real edge, so the nav goes compact by itself when
|
||||
// the wider box leaves it no room. The width animates and `x`
|
||||
// re-centers with it.
|
||||
width: (root.width < 960 ? 179 : Math.round(Math.max(256, Math.min(root.width * 0.24, 480))))
|
||||
- (QbzShell.navInSidebar ? 0 : 60)
|
||||
height: 32
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 220; easing.type: Easing.InOutQuad }
|
||||
@@ -976,9 +1003,13 @@ Rectangle {
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
// Stop short of whatever sits at the right end (the clear cross,
|
||||
// the Enter glyph while the cortinilla is open): TextInput scrolls
|
||||
// its text within its own width, so nothing hides under them.
|
||||
anchors.right: clearCross.visible ? clearCross.left
|
||||
: (enterHint.visible ? enterHint.left : parent.right)
|
||||
anchors.leftMargin: 30
|
||||
anchors.rightMargin: 8
|
||||
anchors.rightMargin: (clearCross.visible || enterHint.visible) ? 2 : 8
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
@@ -1001,7 +1032,11 @@ Rectangle {
|
||||
QbzSearch.searchLive(text)
|
||||
}
|
||||
}
|
||||
onTextEdited: applyLiveQuery()
|
||||
onTextEdited: {
|
||||
applyLiveQuery()
|
||||
root._searchReclaim = true
|
||||
Qt.callLater(root._reclaimSearchFocus)
|
||||
}
|
||||
|
||||
// The Enter rule (HeaderBar.slint on-enter): cortinilla open +
|
||||
// a keyboard selection -> activate the row; open + none -> full
|
||||
@@ -1045,8 +1080,9 @@ Rectangle {
|
||||
// the modals use, not merely cleared: a null
|
||||
// activeFocusItem passes the gate but leaves AppShell's
|
||||
// Keys handler receiving nothing at all.
|
||||
if (QbzSearch.cortinillaOpen)
|
||||
QbzSearch.cortinillaDismiss()
|
||||
// …and it empties the box too (2026-09-13, every search
|
||||
// box): clearSearch() drops the text and the dropdown.
|
||||
root.clearSearch()
|
||||
event.accepted = true
|
||||
var p = searchInput
|
||||
while (p.parent) {
|
||||
@@ -1078,6 +1114,7 @@ Rectangle {
|
||||
// open (Slint: it lives in the box, opposite the magnifier), else
|
||||
// the × clear.
|
||||
Text {
|
||||
id: enterHint
|
||||
visible: QbzSearch.cortinillaOpen
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
@@ -1087,10 +1124,14 @@ Rectangle {
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
// The clear cross: always there while there is text (2026-09-13); it
|
||||
// sits to the LEFT of the ↵ hint while the dropdown is open, at the
|
||||
// right edge otherwise.
|
||||
Rectangle {
|
||||
visible: !QbzSearch.cortinillaOpen && searchInput.text !== ""
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 5
|
||||
id: clearCross
|
||||
visible: searchInput.text !== ""
|
||||
anchors.right: QbzSearch.cortinillaOpen ? enterHint.left : parent.right
|
||||
anchors.rightMargin: QbzSearch.cortinillaOpen ? 4 : 5
|
||||
width: 22
|
||||
height: 22
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -1230,7 +1271,7 @@ Rectangle {
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
opacity: QbzSession.connectivity === 2 ? 0.4 : 1.0
|
||||
color: signInArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: signInArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
Text {
|
||||
id: signInText
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -272,7 +272,7 @@ Item {
|
||||
width: customizeLabel.implicitWidth + 40
|
||||
height: 40
|
||||
radius: theme.radiusSm
|
||||
color: customizeArea.containsMouse ? theme.surfaceHover
|
||||
color: customizeArea.containsMouse ? theme.surfaceElevatedHover
|
||||
: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: customizeArea.containsMouse ? theme.accent
|
||||
|
||||
@@ -66,6 +66,12 @@ Item {
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
root.advancedOpen = false
|
||||
// A modal's fields open clean (2026-09-14): the search box and the
|
||||
// filter it drives both outlived the close.
|
||||
if (searchInput.text !== "") {
|
||||
searchInput.text = ""
|
||||
QbzShell.logSetSearch("")
|
||||
}
|
||||
keyScope.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ Rectangle {
|
||||
property string hostName: ""
|
||||
visible: remoteActive
|
||||
height: remoteActive ? Math.max(38, label.implicitHeight + 16) : 0
|
||||
color: theme.surfaceElevated
|
||||
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
border.width: 1
|
||||
border.color: theme.accent
|
||||
Accessible.role: Accessible.StaticText
|
||||
|
||||
@@ -657,7 +657,7 @@ Rectangle {
|
||||
favorite: root.npFavorite
|
||||
ephemeral: root.npEphemeral
|
||||
tooltip: root.tooltip
|
||||
onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) }
|
||||
onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
|
||||
onTrackInfoRequested: root.openTrackInfo()
|
||||
}
|
||||
}
|
||||
@@ -680,7 +680,7 @@ Rectangle {
|
||||
playCircle: true
|
||||
favorite: root.npFavorite
|
||||
tooltip: root.tooltip
|
||||
onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) }
|
||||
onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
|
||||
onTrackInfoRequested: root.openTrackInfo()
|
||||
}
|
||||
}
|
||||
@@ -908,25 +908,8 @@ Rectangle {
|
||||
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
|
||||
// add-menu), on the shared CardMenu surface. Same seven entries, same
|
||||
// order, same icons.
|
||||
// The "+" button that opened the add-menu: the Copy submenu reopens on it.
|
||||
property Item addAnchor: null
|
||||
// The Copy submenu of the add-menu writes through this carrier.
|
||||
QbzClipboard { id: npClipboard }
|
||||
CardMenu {
|
||||
id: npCopyMenu
|
||||
menuWidth: 232
|
||||
entries: [
|
||||
{ "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" },
|
||||
{ "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" }
|
||||
]
|
||||
onPicked: function (a) {
|
||||
var parts = [QbzPlayer.npTitle || ""]
|
||||
if (a === "copy-full") {
|
||||
if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum)
|
||||
if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist)
|
||||
}
|
||||
npClipboard.copy(parts.join(" - "))
|
||||
}
|
||||
}
|
||||
|
||||
CardMenu {
|
||||
id: addMenu
|
||||
@@ -992,13 +975,16 @@ Rectangle {
|
||||
"action": "buy"
|
||||
})
|
||||
}
|
||||
// Copy (2026-09-13): a submenu — the track name, or
|
||||
// Copy (2026-09-13): a hover-opened submenu — the track name, or
|
||||
// "Track - Album - Artist".
|
||||
m.push({
|
||||
"label": QbzSession.tr("Copy", QbzSession.trRev),
|
||||
"icon": "copy",
|
||||
"action": "copy",
|
||||
"submenu": true
|
||||
"submenu": [
|
||||
{ "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" },
|
||||
{ "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" }
|
||||
]
|
||||
})
|
||||
return m.concat(root.abEntries())
|
||||
}
|
||||
@@ -1017,8 +1003,13 @@ Rectangle {
|
||||
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
|
||||
} else if (a === "buy") {
|
||||
if (id !== "") QbzAlbum.buyTrack(id)
|
||||
} else if (a === "copy") {
|
||||
if (root.addAnchor) npCopyMenu.openBelowRight(root.addAnchor)
|
||||
} else if (a === "copy-title" || a === "copy-full") {
|
||||
var parts = [QbzPlayer.npTitle || ""]
|
||||
if (a === "copy-full") {
|
||||
if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum)
|
||||
if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist)
|
||||
}
|
||||
npClipboard.copy(parts.join(" - "))
|
||||
} else if (a === "ab-mark") {
|
||||
QbzPlayer.abMark()
|
||||
} else if (a === "ab-clear") {
|
||||
|
||||
@@ -126,7 +126,7 @@ Popup {
|
||||
width: parent.width
|
||||
height: 92
|
||||
radius: theme.radiusSm
|
||||
color: optionArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: optionArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: optionArea.containsMouse ? theme.accent : theme.borderSubtle
|
||||
|
||||
|
||||
@@ -38,6 +38,24 @@ Rectangle {
|
||||
readonly property var currentRow: doc.current || null
|
||||
readonly property var upcoming: doc.upcoming || []
|
||||
readonly property var historyRows: doc.history || []
|
||||
// Keyed models for both lists (2026-09-14): a track leaving Up Next (it
|
||||
// started playing, it was removed) disappears and the rows below slide up;
|
||||
// a track joining slides the list open; nothing is rebuilt. The rows are
|
||||
// Repeaters in a Column, so the motion is the Column's own add / move
|
||||
// transitions — a positioner has no remove transition. Identity is the
|
||||
// track (source + id, repeats by occurrence); another page or search
|
||||
// replaces the list at once.
|
||||
QbzKeyedModel {
|
||||
id: upcomingModel
|
||||
rows: root.upcoming
|
||||
keyOf: function (row) { return (row.source || "") + ":" + row.id }
|
||||
scope: JSON.stringify([root.doc.searchQuery || "", root.doc.page || 0])
|
||||
}
|
||||
QbzKeyedModel {
|
||||
id: historyModel
|
||||
rows: root.historyRows
|
||||
keyOf: function (row) { return (row.source || "") + ":" + row.id }
|
||||
}
|
||||
readonly property var settingsDoc: {
|
||||
try { return JSON.parse(QbzBridge.settingsJson) }
|
||||
catch (e) { return ({}) }
|
||||
@@ -138,7 +156,8 @@ Rectangle {
|
||||
// arithmetic model to keep in sync. Top half of row k -> slot k, bottom
|
||||
// half -> slot k+1, past the last row -> N (append). <=40 rows per page.
|
||||
function slotFromPointer(winY) {
|
||||
var n = root.upcoming.length
|
||||
// The Repeater's own count: its items are what the pointer is over.
|
||||
var n = upcomingRepeater.count
|
||||
for (var i = 0; i < n; i++) {
|
||||
var d = upcomingRepeater.itemAt(i)
|
||||
if (d && winY < d.rowCenterY())
|
||||
@@ -223,7 +242,13 @@ Rectangle {
|
||||
root.coverMap = Object.assign({}, m)
|
||||
}
|
||||
}
|
||||
Component.onCompleted: dispatchCovers()
|
||||
Component.onCompleted: {
|
||||
// The lists' Columns, laid out synchronously around a reset so a new
|
||||
// page or search appears at once (QbzKeyedModel).
|
||||
upcomingModel.views = [upNextSection]
|
||||
historyModel.views = [historyBody]
|
||||
dispatchCovers()
|
||||
}
|
||||
onDocChanged: dispatchCovers()
|
||||
onQueueTrackArtworkChanged: dispatchCovers()
|
||||
// Collect the covers of the document CURRENTLY on the property and ask the
|
||||
@@ -316,6 +341,41 @@ Rectangle {
|
||||
// Row action icon button (the panel's small IconButton).
|
||||
|
||||
|
||||
// Go to album / Go to artist, by SOURCE (2026-09-14). A catalog row routes
|
||||
// through its catalog ids (title and artist ride along as the
|
||||
// unavailable-page hint); a local row — file, Plex, Jellyfin, Subsonic —
|
||||
// through the queue's NAVIGATION KEY (local_playback.rs stamps the album
|
||||
// key the card itself opens, prefixed for media servers) and the artist
|
||||
// NAME into the Local Library Artists tab, the local surfaces' own routes
|
||||
// (local/LocalTrackRow.qml). A row without the key or the name shows no
|
||||
// entry.
|
||||
function rowCanGoAlbum(row) {
|
||||
return (row.albumId || "") !== ""
|
||||
}
|
||||
function rowCanGoArtist(row) {
|
||||
return row.isLocal === true ? (row.artist || "") !== "" : (row.artistId || "") !== ""
|
||||
}
|
||||
function goToAlbum(row) {
|
||||
if (!rowCanGoAlbum(row))
|
||||
return
|
||||
if (row.isLocal === true) {
|
||||
QbzLocal.openAlbum(row.albumId)
|
||||
QbzShell.navigateTo("localalbum")
|
||||
} else {
|
||||
QbzAlbum.openAlbumFrom(row.albumId, row.album || "", row.artist || "")
|
||||
}
|
||||
}
|
||||
function goToArtist(row) {
|
||||
if (!rowCanGoArtist(row))
|
||||
return
|
||||
if (row.isLocal === true) {
|
||||
QbzLocal.openArtistByName(row.artist)
|
||||
QbzShell.navigateTo("local")
|
||||
} else {
|
||||
QbzArtist.openArtist(row.artistId)
|
||||
}
|
||||
}
|
||||
|
||||
// One UP NEXT / History row (QueueRow.slint).
|
||||
component QueueRow: Rectangle {
|
||||
id: qrRoot
|
||||
@@ -429,7 +489,7 @@ Rectangle {
|
||||
height: 34
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
// No clip: RoundedImage self-confines. One batch root per
|
||||
// visible queue row.
|
||||
RoundedImage {
|
||||
@@ -556,19 +616,16 @@ Rectangle {
|
||||
: QbzSession.tr("Stop after this", QbzSession.trRev), "icon": "circle-stop", "action": "stop-after" },
|
||||
{ "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" },
|
||||
]
|
||||
if (root.rowCanGoAlbum(row))
|
||||
items.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
|
||||
if (root.rowCanGoArtist(row))
|
||||
items.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
|
||||
if (row.isEphemeral !== true) {
|
||||
// LOCAL/Plex rows drop it too: the queue's
|
||||
// `add_to_playlist` refuses them
|
||||
// (queue_qt.rs — a queue row's id is a
|
||||
// library.db / Plex synthetic id and this
|
||||
// panel has no source-aware resolver to
|
||||
// turn it into a local-mode ref), so the
|
||||
// entry would render and no-op. Absent
|
||||
// beats dead; the LOCAL surfaces that DO
|
||||
// have the resolver offer it instead
|
||||
// (local/LocalTrackRow.qml).
|
||||
if (row.isLocal !== true)
|
||||
items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" })
|
||||
// Local/Plex/media rows too (2026-09-14):
|
||||
// the queue resolves them to picker refs
|
||||
// by source (local_playlist_qt::
|
||||
// local_picker_ref_for_queue_track).
|
||||
items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" })
|
||||
items.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" })
|
||||
items.push({ "label": root.favState(row) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.favState(row) ? "heart-filled" : "heart", "action": "favorite" })
|
||||
}
|
||||
@@ -609,6 +666,8 @@ Rectangle {
|
||||
else if (a === "add-to-playlist") QbzQueue.queueAddToPlaylist(rowIndex)
|
||||
else if (a === "track-info") qrRoot.openTrackInfo()
|
||||
else if (a === "favorite") root.toggleFav(row)
|
||||
else if (a === "go-album") root.goToAlbum(row)
|
||||
else if (a === "go-artist") root.goToArtist(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -893,7 +952,8 @@ Rectangle {
|
||||
width: parent.width
|
||||
height: 44
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
// Ambient-aware: the one opaque card left in a translucent sidebar.
|
||||
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
@@ -906,7 +966,7 @@ Rectangle {
|
||||
height: 34
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceCard
|
||||
color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard)
|
||||
// No clip: RoundedImage self-confines.
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
@@ -991,13 +1051,18 @@ Rectangle {
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
|
||||
add: QbzRowAdd { enabled: upcomingModel.animate }
|
||||
move: QbzRowDisplaced { enabled: upcomingModel.animate }
|
||||
|
||||
Repeater {
|
||||
id: upcomingRepeater
|
||||
model: root.upcoming
|
||||
model: upcomingModel
|
||||
delegate: Column {
|
||||
id: upDelegate
|
||||
required property var modelData
|
||||
required property string rowKey
|
||||
required property int rowRev
|
||||
required property int index
|
||||
readonly property var modelData: upcomingModel.row(rowKey, rowRev)
|
||||
width: parent ? parent.width : 0
|
||||
// Row center in window space — slotFromPointer()
|
||||
// reads it off the Repeater's items.
|
||||
@@ -1132,9 +1197,16 @@ Rectangle {
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
add: QbzRowAdd { enabled: historyModel.animate }
|
||||
move: QbzRowDisplaced { enabled: historyModel.animate }
|
||||
|
||||
Repeater {
|
||||
model: root.historyRows
|
||||
model: historyModel
|
||||
delegate: QueueRow {
|
||||
required property string rowKey
|
||||
required property int rowRev
|
||||
required property int index
|
||||
readonly property var modelData: historyModel.row(rowKey, rowRev)
|
||||
width: parent.width
|
||||
row: modelData
|
||||
rowIndex: index
|
||||
@@ -1271,7 +1343,7 @@ Rectangle {
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: cancelArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Cancel Timer", QbzSession.trRev)
|
||||
|
||||
@@ -138,7 +138,7 @@ Item {
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: goArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: goArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
Text {
|
||||
id: goLabel
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -903,12 +903,10 @@ Rectangle {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.myqbzSearchOpen = !root.myqbzSearchOpen
|
||||
if (!root.myqbzSearchOpen) {
|
||||
myqbzSearchEdit.text = ""
|
||||
root.myqbzSearch = ""
|
||||
} else {
|
||||
myqbzSearchEdit.forceActiveFocus()
|
||||
}
|
||||
if (!root.myqbzSearchOpen)
|
||||
myqbzSearchEdit.clear()
|
||||
else
|
||||
myqbzSearchEdit.focusField()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,8 +997,11 @@ Rectangle {
|
||||
onClicked: QbzShell.navigateTo("collections")
|
||||
}
|
||||
}
|
||||
// Inline search field (filters the tree client-side).
|
||||
Rectangle {
|
||||
// Inline search field (filters the tree client-side). The
|
||||
// shared search box: Escape empties it AND folds it away,
|
||||
// the cross empties it and keeps the caret.
|
||||
QbzSearchField {
|
||||
id: myqbzSearchEdit
|
||||
visible: root.myqbzSearchOpen
|
||||
anchors.left: myqbzBrandIcon.right
|
||||
anchors.leftMargin: 8
|
||||
@@ -1009,28 +1010,12 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 22
|
||||
radius: 4
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: myqbzSearchEdit
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: root.myqbzSearch = text
|
||||
Text {
|
||||
visible: myqbzSearchEdit.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search My QBZ", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
showGlyph: false
|
||||
sidePadding: 6
|
||||
fontPx: 12
|
||||
placeholder: QbzSession.tr("Search My QBZ", QbzSession.trRev)
|
||||
onEdited: function (text) { root.myqbzSearch = text }
|
||||
onEscaped: root.myqbzSearchOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1280,34 +1265,21 @@ Rectangle {
|
||||
font.letterSpacing: 1
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
// Inline search input (filters entries, recursive).
|
||||
Rectangle {
|
||||
// Inline search input (filters entries, recursive). The shared
|
||||
// search box: Escape empties it AND folds it back into the title,
|
||||
// the cross empties it and keeps the caret.
|
||||
QbzSearchField {
|
||||
id: searchEdit
|
||||
visible: root.searchOpen
|
||||
width: parent.width - 4 * 26
|
||||
height: 22
|
||||
radius: 4
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: searchEdit
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: QbzShell.sidebarSearch(text)
|
||||
Text {
|
||||
visible: searchEdit.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search playlists", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
showGlyph: false
|
||||
sidePadding: 6
|
||||
fontPx: 12
|
||||
placeholder: QbzSession.tr("Search playlists", QbzSession.trRev)
|
||||
onEdited: function (text) { QbzShell.sidebarSearch(text) }
|
||||
onEscaped: root.searchOpen = false
|
||||
}
|
||||
|
||||
// Search toggle.
|
||||
@@ -1330,12 +1302,10 @@ Rectangle {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.searchOpen = !root.searchOpen
|
||||
if (!root.searchOpen) {
|
||||
searchEdit.text = ""
|
||||
QbzShell.sidebarSearch("")
|
||||
} else {
|
||||
searchEdit.forceActiveFocus()
|
||||
}
|
||||
if (!root.searchOpen)
|
||||
searchEdit.clear()
|
||||
else
|
||||
searchEdit.focusField()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// WallpaperField — the desktop wallpaper behind the window (2026-09-14): the
|
||||
// part of it that lies UNDER this window, wherever the window is, so the
|
||||
// window reads as translucent to the desktop — an X-ray straight to the
|
||||
// wallpaper, never to the windows in between (a really translucent surface
|
||||
// would show those). Lightly blurred beneath the half-alpha chrome: enough
|
||||
// that nothing in the picture competes with the text, little enough that the
|
||||
// picture is still recognisable. 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 tell Qt (`hostWindow.x/y`).
|
||||
// Wayland hides it from clients by design; on KDE Plasma the compositor
|
||||
// reports every window's geometry through org_kde_plasma_window_management
|
||||
// (wallpaper_wayland_qt.rs), published as QbzShell.wallpaperWindowsJson —
|
||||
// this process's windows with absolute coordinates — and the one whose size
|
||||
// is this window's is the one the picture sits under (the miniplayer and the
|
||||
// dialogs are this process's windows too). Where neither can say, the crop
|
||||
// is centred on the screen. `hostWindow` is the ApplicationWindow.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import QtQuick.Effects
|
||||
import com.blitzfc.qbz
|
||||
|
||||
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
|
||||
|
||||
/// Where the window's place comes from: the compositor's report on
|
||||
/// Wayland, Qt everywhere else. Writable only so a test can take the
|
||||
/// Wayland branch under the offscreen platform.
|
||||
property bool isWayland: Qt.platform.pluginName === "wayland"
|
||||
|
||||
// Ask the compositor as soon as this field is actually painting on a
|
||||
// Wayland session (idempotent on the Rust side; a no-op elsewhere).
|
||||
function _track() {
|
||||
if (root.visible && root.isWayland && root.source !== "")
|
||||
QbzShell.trackWindowPosition()
|
||||
}
|
||||
onVisibleChanged: _track()
|
||||
onSourceChanged: _track()
|
||||
onIsWaylandChanged: _track()
|
||||
Component.onCompleted: _track()
|
||||
|
||||
// Read only while this field is the background: the tracker keeps
|
||||
// reporting moves after the mode is switched away, and an invisible item
|
||||
// whose geometry changes still dirties the window (the repaint rule).
|
||||
readonly property var compositorRects: {
|
||||
if (!root.visible || !root.isWayland)
|
||||
return []
|
||||
try {
|
||||
var a = JSON.parse(QbzShell.wallpaperWindowsJson || "[]")
|
||||
return Array.isArray(a) ? a : []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
/// The compositor's rectangle for THIS window, or null when it has said
|
||||
/// nothing (no protocol, or nothing our size).
|
||||
readonly property var compositorRect: root.pickRect(root.compositorRects,
|
||||
root.hostWindow ? root.hostWindow.width : root.width,
|
||||
root.hostWindow ? root.hostWindow.height : root.height)
|
||||
|
||||
/// The rectangle whose size is closest to `w` x `h`, when it is close
|
||||
/// enough to be this window at all: a geometry that is not our size
|
||||
/// belongs to another of our windows, and the centred crop beats that
|
||||
/// window's place.
|
||||
function pickRect(rects, w, h) {
|
||||
var best = null
|
||||
var bestScore = 0
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
var r = rects[i]
|
||||
if (!r || typeof r.w !== "number" || typeof r.h !== "number"
|
||||
|| typeof r.x !== "number" || typeof r.y !== "number")
|
||||
continue
|
||||
var score = Math.abs(r.w - w) + Math.abs(r.h - h)
|
||||
if (best === null || score < bestScore) {
|
||||
best = r
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return (best !== null && bestScore <= 64) ? best : null
|
||||
}
|
||||
|
||||
readonly property bool positionKnown: root.visible && root.hostWindow !== null
|
||||
&& (!root.isWayland || root.compositorRect !== null)
|
||||
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
|
||||
? Math.max(0, (root.screenW - root.width) / 2)
|
||||
: (root.isWayland ? root.compositorRect.x : root.hostWindow.x) - Screen.virtualX
|
||||
readonly property real offY: !root.positionKnown
|
||||
? Math.max(0, (root.screenH - root.height) / 2)
|
||||
: (root.isWayland ? root.compositorRect.y : root.hostWindow.y) - Screen.virtualY
|
||||
|
||||
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: 64
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#000000"
|
||||
opacity: root.dim
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,18 @@ QtObject {
|
||||
readonly property color surfaceCard: _c("surfaceCard")
|
||||
readonly property color surfaceElevated: _c("surfaceElevated")
|
||||
readonly property color surfaceHover: _c("surfaceHover")
|
||||
/// Hover fill for a control whose REST fill is surfaceElevated: a lift of
|
||||
/// that surface. NOT `surfaceHover` — that is a 6% white wash meant for
|
||||
/// rows that rest transparent, and over an elevated button it lands
|
||||
/// DARKER than the rest fill (the modal buttons that "went black on
|
||||
/// hover", 2026-09-14). Derived, so every palette gets it for free.
|
||||
readonly property color surfaceElevatedHover: Qt.lighter(surfaceElevated, isDark ? 1.35 : 0.94)
|
||||
/// The same lift for the ambient half-alpha fill, a little more opaque so
|
||||
/// the hover still reads as a lift over the field.
|
||||
readonly property color surfaceElevatedHoverA50: Qt.rgba(surfaceElevatedHover.r, surfaceElevatedHover.g, surfaceElevatedHover.b, 0.62)
|
||||
/// The ambient-aware pair an elevated control paints: rest and hover.
|
||||
readonly property color elevatedFill: ambientOn ? surfaceElevatedA50 : surfaceElevated
|
||||
readonly property color elevatedHoverFill: ambientOn ? surfaceElevatedHoverA50 : surfaceElevatedHover
|
||||
readonly property color bgHover: _c("bgHover")
|
||||
readonly property color textPrimary: _c("textPrimary")
|
||||
// Weak-ramp hardening under ambient on LIGHT themes (owner, 2026-08-31):
|
||||
@@ -149,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 -----------
|
||||
//
|
||||
|
||||
@@ -59,14 +59,15 @@ Rectangle {
|
||||
|
||||
// The view's album + url-keyed cover map (artwork pipeline).
|
||||
//
|
||||
// Applied imperatively so a SAME-ALBUM deferred rail publish can preserve
|
||||
// the ListView offset. Binding `model` to a freshly parsed JS array makes
|
||||
// QQuickItemView reset contentY whenever Similar albums / Suggestions / More
|
||||
// from this artist lands — the forced jump the owner caught while reading.
|
||||
// Applied imperatively so the page can tell a SAME-ALBUM deferred rail
|
||||
// publish (Similar albums / Suggestions / More from this artist landing
|
||||
// while the user reads: the viewport stays put) from a NEW album (the
|
||||
// track filter resets and the page starts at the top). The tape itself is
|
||||
// swapped on `cellsModel`, a QbzArrayModel: reassigning the ListView's
|
||||
// `model` would reset contentY on every publish and, on Qt 6.11, focus a
|
||||
// delegate — see that control's header for both.
|
||||
property var album: ({})
|
||||
property string documentAlbumId: ""
|
||||
property int albumDocumentEpoch: 0
|
||||
property real albumRestoreY: 0
|
||||
|
||||
function parseAlbumDocument() {
|
||||
try {
|
||||
@@ -82,27 +83,18 @@ Rectangle {
|
||||
var nextId = nextHeader.id !== undefined && nextHeader.id !== null
|
||||
? String(nextHeader.id) : ""
|
||||
var sameAlbum = nextId !== "" && nextId === root.documentAlbumId
|
||||
var savedY = sameAlbum ? pageFlick.contentY : 0
|
||||
root.albumDocumentEpoch += 1
|
||||
var epoch = root.albumDocumentEpoch
|
||||
if (!sameAlbum && root.documentAlbumId !== "") {
|
||||
// A different release replacing the one on screen (the empty
|
||||
// document a load publishes first counts): its track list starts
|
||||
// unfiltered — the header's search box mirrors `trackQuery` — and
|
||||
// at the top. The first document of a mount leaves the viewport
|
||||
// alone: ScrollMemory may be restoring a Back navigation.
|
||||
root.trackQuery = ""
|
||||
cellsModel.scrollToTop()
|
||||
}
|
||||
root.album = next
|
||||
if (nextId !== "")
|
||||
root.documentAlbumId = nextId
|
||||
if (!sameAlbum || savedY <= pageFlick.originY)
|
||||
return
|
||||
root.albumRestoreY = savedY
|
||||
// The model reset and its content-height polish happen after the
|
||||
// document assignment. One deferred restore coalesces a publish burst
|
||||
// and clamps if the replacement document is genuinely shorter.
|
||||
Qt.callLater(function () {
|
||||
if (epoch !== root.albumDocumentEpoch
|
||||
|| nextId !== root.documentAlbumId)
|
||||
return
|
||||
pageFlick.forceLayout()
|
||||
var minY = pageFlick.originY
|
||||
var maxY = minY + Math.max(0, pageFlick.contentHeight - pageFlick.height)
|
||||
pageFlick.contentY = Math.max(minY, Math.min(root.albumRestoreY, maxY))
|
||||
})
|
||||
}
|
||||
|
||||
readonly property var albumHeader: album.header || ({})
|
||||
@@ -850,7 +842,17 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.listCells
|
||||
// The tape is swapped on this model, never on `model:` itself: a
|
||||
// fresh array assigned to the ListView after completion makes Qt 6.11
|
||||
// force currentIndex 0 and focus delegate 0, which robbed the header's
|
||||
// track filter of the keyboard on every keystroke (2026-09-13). The
|
||||
// wrapper also keeps the viewport across a same-album rail publish;
|
||||
// applyAlbumDocument() asks for the top when the album changes.
|
||||
model: QbzArrayModel {
|
||||
id: cellsModel
|
||||
view: pageFlick
|
||||
rows: root.listCells
|
||||
}
|
||||
reuseItems: true
|
||||
currentIndex: -1
|
||||
// The track tape is cheap enough that a fast wheel can consume one
|
||||
@@ -1560,43 +1562,24 @@ Rectangle {
|
||||
- 168 - 30 - 2 * 16)
|
||||
height: 1
|
||||
}
|
||||
Rectangle {
|
||||
// Track filter (QbzSearchField: Escape clears + blurs,
|
||||
// clear cross). The keyboard survives the tape rebuild
|
||||
// because the tape lives on a QbzArrayModel.
|
||||
QbzSearchField {
|
||||
id: trackSearch
|
||||
width: 168
|
||||
height: 34
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 7
|
||||
QbzIcon {
|
||||
name: "search"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
width: parent.width - 21
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: root.trackQuery = text
|
||||
Text {
|
||||
visible: parent.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search tracks...", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
placeholder: QbzSession.tr("Search tracks...", QbzSession.trRev)
|
||||
onEdited: function (text) { root.trackQuery = text }
|
||||
// A new release resets `trackQuery`
|
||||
// (applyAlbumDocument); the box follows, so no
|
||||
// stale query outlives the album it was typed on.
|
||||
Connections {
|
||||
target: root
|
||||
function onTrackQueryChanged() {
|
||||
if (trackSearch.text !== root.trackQuery)
|
||||
trackSearch.text = root.trackQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1608,7 +1591,8 @@ Rectangle {
|
||||
height: 30
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: selectToggleArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: selectToggleArea.containsMouse ? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
border.width: 1
|
||||
border.color: root.multiSelect ? theme.accent : theme.borderSubtle
|
||||
QbzIcon {
|
||||
@@ -2060,7 +2044,9 @@ Rectangle {
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: pageFlick; scope: "album" }
|
||||
// Origin-relative: the tape swaps on cellsModel drift ListView.originY,
|
||||
// so an absolute contentY would restore a different row after Back.
|
||||
ScrollMemory { target: pageFlick; scope: "album"; relativeToOrigin: true }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -257,7 +257,7 @@ Rectangle {
|
||||
width: retryLabel.implicitWidth + 32
|
||||
height: 34
|
||||
radius: 6
|
||||
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
|
||||
Text {
|
||||
id: retryLabel
|
||||
|
||||
@@ -1695,7 +1695,7 @@ Rectangle {
|
||||
enabled: !popRow.selectMode
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) { popRow._menuAnchor = moreArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
|
||||
onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1779,7 +1779,10 @@ Rectangle {
|
||||
m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
|
||||
if (!popRow.pulledDead)
|
||||
m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" })
|
||||
m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true })
|
||||
m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": [
|
||||
{ "label": t("Track name", r), "icon": "copy", "action": "copy-title" },
|
||||
{ "label": t("Track - Album - Artist", r), "icon": "clipboard", "action": "copy-full" }
|
||||
] })
|
||||
return m
|
||||
}
|
||||
onPicked: function (a) {
|
||||
@@ -1814,27 +1817,14 @@ Rectangle {
|
||||
else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId)
|
||||
else if (a === "track-info") root.openTrackInfo(id)
|
||||
else if (a === "buy") QbzAlbum.buyTrack(id)
|
||||
else if (a === "copy") popCopyMenu.openAtCursor(popRow._menuAnchor, popRow._menuX, popRow._menuY)
|
||||
}
|
||||
}
|
||||
// Copy submenu (2026-09-13), reopened where the row menu was.
|
||||
property Item _menuAnchor: null
|
||||
property real _menuX: 0
|
||||
property real _menuY: 0
|
||||
CardMenu {
|
||||
id: popCopyMenu
|
||||
menuWidth: 224
|
||||
entries: [
|
||||
{ "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" },
|
||||
{ "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" }
|
||||
]
|
||||
onPicked: function (a) {
|
||||
var parts = [popRow.row.title || ""]
|
||||
if (a === "copy-full") {
|
||||
if ((popRow.row.album || "") !== "") parts.push(popRow.row.album)
|
||||
if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist)
|
||||
else if (a === "copy-title" || a === "copy-full") {
|
||||
var parts = [popRow.row.title || ""]
|
||||
if (a === "copy-full") {
|
||||
if ((popRow.row.album || "") !== "") parts.push(popRow.row.album)
|
||||
if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist)
|
||||
}
|
||||
artistClipboard.copy(parts.join(" - "))
|
||||
}
|
||||
artistClipboard.copy(parts.join(" - "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1860,7 +1850,7 @@ Rectangle {
|
||||
id: popRcArea
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: function (mouse) { popRow._menuAnchor = popRcArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) }
|
||||
onClicked: function (mouse) { popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ Rectangle {
|
||||
width: toggleRow.implicitWidth + 28
|
||||
height: root.kioskHost ? 44 : 34
|
||||
radius: theme.radiusSm
|
||||
color: toggleArea.containsMouse ? theme.surfaceHover
|
||||
color: toggleArea.containsMouse ? theme.surfaceElevatedHover
|
||||
: theme.surfaceElevated
|
||||
Row {
|
||||
id: toggleRow
|
||||
|
||||
@@ -1796,7 +1796,7 @@ Rectangle {
|
||||
height: 32
|
||||
radius: 6
|
||||
color: retryArea.containsMouse
|
||||
? theme.surfaceHover : theme.surfaceElevated
|
||||
? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
|
||||
@@ -323,7 +323,10 @@ Rectangle {
|
||||
root.artistsGroup = p.artistsGroup
|
||||
root.artistsView = p.artistsView
|
||||
}
|
||||
Component.onCompleted: root.applyPrefs()
|
||||
Component.onCompleted: {
|
||||
root.applyPrefs()
|
||||
feedRows.views = [grid, list, playlistsList, albumsList.listView]
|
||||
}
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
function onLibraryPrefsJsonChanged() { root.applyPrefs() }
|
||||
@@ -562,6 +565,59 @@ Rectangle {
|
||||
onVisibleRowsChanged: Qt.callLater(root.reportSurfaceState)
|
||||
onVisibleChanged: Qt.callLater(root.reportSurfaceState)
|
||||
|
||||
// THE MODEL every body binds to (2026-09-14). `visibleRows` stays the
|
||||
// logical truth for everything that reasons about rows (selection, play
|
||||
// context, the A-Z strip); the views see it through QbzKeyedModel, which
|
||||
// turns a new derive into removes / inserts / moves instead of a model
|
||||
// swap. That is the whole fix for "an album leaving the Library rebuilds
|
||||
// the grid at the top": the heart settles, Rust republishes the feed
|
||||
// (`publish_library_document`), the derive runs, and only the leaving
|
||||
// card fades out while its neighbours slide into the gap — scroll offset,
|
||||
// delegates and covers untouched.
|
||||
//
|
||||
// A new tab, sort, search or filter set is a different page, not a change
|
||||
// to this one: `scope` replaces the rows at once and starts at the top,
|
||||
// exactly as the swap did. `favoriteFilterRev` is deliberately NOT in the
|
||||
// scope — a heart removed under "Favorites only" is precisely the row
|
||||
// that should animate out.
|
||||
//
|
||||
// Index-coupled readers (the artwork window reports) read
|
||||
// `feedRows.published`, the array the model reflects index for index; the
|
||||
// reconcile is deferred one turn, and `onReconciled` re-reports because a
|
||||
// removal above the window shifts every index.
|
||||
/// The page identity `feedRows` resets on — for bodies that derive their
|
||||
/// own keyed rows from the feed (the Artists sidepanel rail).
|
||||
readonly property string rowsScope: feedRows.scope
|
||||
QbzKeyedModel {
|
||||
id: feedRows
|
||||
rows: root.visibleRows
|
||||
// The All feed shows a purchased favourite twice (a favourites row and
|
||||
// a purchases row), so there the group is part of the identity; the
|
||||
// tabs keep one representative per entity, whose group may change.
|
||||
keyOf: function (row) {
|
||||
if (row.kind === "group-header")
|
||||
return row.id
|
||||
return (row._membershipKey || (row.kind + ":" + row.id))
|
||||
+ (root.activeTab === "all" ? "|" + (row.group || "") : "")
|
||||
}
|
||||
scope: JSON.stringify([root.activeTab, root.activeSort, root.search,
|
||||
root.tabSearch, root.showPurchases, root.showFavorites,
|
||||
root.showFollowing, root.showLocal, root.hiresOnly, root.genreNames,
|
||||
root.tracksGroup, root.albumsGroup, root.artistsGroup,
|
||||
root.artistsView, root.playlistsSubTab])
|
||||
// Positional cache from parseFeed: one row leaving renumbers it for
|
||||
// every row after it, which is not a change to those rows.
|
||||
ignoredKeys: ["_feedOrder"]
|
||||
// `views` is handed over in Component.onCompleted below: all four
|
||||
// bodies are declared after this model.
|
||||
onReconciled: {
|
||||
root.gridWindowReport()
|
||||
root.listWindowReport()
|
||||
albumsList.report()
|
||||
playlistsList.report()
|
||||
}
|
||||
}
|
||||
|
||||
/// A-Z bucket for a title: its first letter uppercased, "#" for anything
|
||||
/// that is not a letter (a leading digit, "*", punctuation). Matches the
|
||||
/// jump strip's own buckets, so a letter in the strip always has a
|
||||
@@ -1093,13 +1149,14 @@ Rectangle {
|
||||
id: windowDebounce
|
||||
interval: 180
|
||||
onTriggered: root.reportWindow(
|
||||
pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast, pendingVisibleFirst)
|
||||
pendingRows ? pendingRows : feedRows.published, pendingFirst, pendingLast, pendingVisibleFirst)
|
||||
property int pendingFirst: 0
|
||||
property int pendingLast: 0
|
||||
property int pendingVisibleFirst: 0
|
||||
property var pendingRows: null
|
||||
}
|
||||
/// `rows` is optional — omit it and the band is read off `visibleRows`.
|
||||
/// `rows` is optional — omit it and the band is read off the array the
|
||||
/// views show (`feedRows.published`).
|
||||
function queueWindowReport(first, last, rows, visibleFirst) {
|
||||
windowDebounce.pendingFirst = first
|
||||
windowDebounce.pendingLast = last
|
||||
@@ -1250,6 +1307,11 @@ Rectangle {
|
||||
readonly property int barInset:
|
||||
(root.activeTab === "tracks" && root.tracksMultiSelect) ? 52 : 0
|
||||
|
||||
// A reload with a page already on screen (a bulk removal, a language
|
||||
// switch) keeps that page until the new document lands, so the rows
|
||||
// reconcile instead of the page blinking through the skeleton.
|
||||
readonly property bool hasPage: root.feed.length > 0
|
||||
|
||||
// Loading skeleton — a deliberate ADDITION: the Slint mounts a
|
||||
// bare centred 36px LoadingSpinner here (FavoritesView.slint:
|
||||
// 956/1012), which says "busy" but not "this is the shape of
|
||||
@@ -1269,6 +1331,7 @@ Rectangle {
|
||||
anchors.rightMargin: 32
|
||||
anchors.topMargin: 16
|
||||
visible: QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
|
||||
&& !content.hasPage
|
||||
// Which shape the tab is about to render.
|
||||
readonly property bool listShape:
|
||||
(root.activeTab === "all" && root.viewMode === "list")
|
||||
@@ -1339,7 +1402,7 @@ Rectangle {
|
||||
height: 32
|
||||
radius: 6
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
@@ -1360,7 +1423,8 @@ Rectangle {
|
||||
}
|
||||
|
||||
readonly property bool ready:
|
||||
!QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
|
||||
(!QbzLibrary.libraryLoading || content.hasPage)
|
||||
&& QbzLibrary.libraryError === ""
|
||||
onReadyChanged: Qt.callLater(root.reportSurfaceState)
|
||||
// Which surface owns the tab right now — one predicate per body,
|
||||
// so no two can mount at once.
|
||||
@@ -1391,12 +1455,10 @@ Rectangle {
|
||||
// being spent on the body that was not showing.
|
||||
//
|
||||
// Collapsing the height to 0 is what actually stops the refill.
|
||||
// The obvious alternative — `model: visible ? root.visibleRows :
|
||||
// []` — is the one thing this file spends a paragraph warning
|
||||
// against (see onLibraryFavoriteChanged): handing `model` a new
|
||||
// value goes through QQuickItemView::setModel(), which resets the
|
||||
// scroll offset to 0 and rebuilds every delegate. The height gate
|
||||
// leaves `model` untouched.
|
||||
// The obvious alternative — `model: visible ? feedRows : null` —
|
||||
// is a model swap: QQuickItemView::setModel() resets the scroll
|
||||
// offset to 0 and rebuilds every delegate. The height gate leaves
|
||||
// `model` untouched.
|
||||
GridView {
|
||||
id: grid
|
||||
anchors.left: parent.left
|
||||
@@ -1416,7 +1478,12 @@ Rectangle {
|
||||
reuseItems: true
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.visibleRows
|
||||
model: feedRows
|
||||
// A card leaving shrinks and fades while the rest slide over.
|
||||
add: QbzRowAdd { enabled: feedRows.animate; grow: 0.94 }
|
||||
remove: QbzRowRemove { enabled: feedRows.animate; shrink: 0.92 }
|
||||
move: QbzRowDisplaced { enabled: feedRows.animate }
|
||||
displaced: QbzRowDisplaced { enabled: feedRows.animate }
|
||||
|
||||
onContentYChanged: root.gridWindowReport(true)
|
||||
onModelChanged: root.gridWindowReport()
|
||||
@@ -1447,8 +1514,10 @@ Rectangle {
|
||||
Component.onCompleted: root.gridWindowReport()
|
||||
|
||||
delegate: FeedGridCell {
|
||||
required property var modelData
|
||||
required property string rowKey
|
||||
required property int rowRev
|
||||
required property int index
|
||||
readonly property var modelData: feedRows.row(rowKey, rowRev)
|
||||
view: root
|
||||
item: modelData
|
||||
// Viewport-relative so the skeleton's 48-instance cap
|
||||
@@ -1474,7 +1543,11 @@ Rectangle {
|
||||
reuseItems: true
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.visibleRows
|
||||
model: feedRows
|
||||
add: QbzRowAdd { enabled: feedRows.animate }
|
||||
remove: QbzRowRemove { enabled: feedRows.animate }
|
||||
move: QbzRowDisplaced { enabled: feedRows.animate }
|
||||
displaced: QbzRowDisplaced { enabled: feedRows.animate }
|
||||
onContentYChanged: root.listWindowReport()
|
||||
onModelChanged: root.listWindowReport()
|
||||
onVisibleChanged: root.listWindowReport()
|
||||
@@ -1484,8 +1557,10 @@ Rectangle {
|
||||
Component.onCompleted: root.listWindowReport()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
required property string rowKey
|
||||
required property int rowRev
|
||||
required property int index
|
||||
readonly property var modelData: feedRows.row(rowKey, rowRev)
|
||||
width: list.width
|
||||
height: modelData && modelData.kind === "group-header"
|
||||
? 34
|
||||
@@ -1583,7 +1658,12 @@ Rectangle {
|
||||
}
|
||||
onLoaded: activateLoadedRow()
|
||||
ListView.onPooled: releaseLoadedRow()
|
||||
ListView.onReused: activateLoadedRow()
|
||||
ListView.onReused: {
|
||||
// A transition cut short can pool the row half faded.
|
||||
opacity = 1
|
||||
scale = 1
|
||||
activateLoadedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,7 +1676,7 @@ Rectangle {
|
||||
anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 98 : 52) : 32
|
||||
anchors.topMargin: 16
|
||||
view: root
|
||||
rows: content.showAlbumsList ? root.visibleRows : []
|
||||
rowsModel: feedRows
|
||||
}
|
||||
|
||||
// ============ Playlists LIST mode ============================
|
||||
@@ -1605,7 +1685,11 @@ Rectangle {
|
||||
ListView {
|
||||
id: playlistsList
|
||||
visible: content.showPlaylistsList
|
||||
anchors.fill: parent
|
||||
// Height-gated like the grid: hidden, it builds nothing.
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
height: playlistsList.visible ? parent.height - 16 : 0
|
||||
anchors.leftMargin: 32
|
||||
anchors.rightMargin: 32
|
||||
anchors.topMargin: 16
|
||||
@@ -1614,21 +1698,26 @@ Rectangle {
|
||||
cacheBuffer: 60 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: content.showPlaylistsList ? root.visibleRows : []
|
||||
model: feedRows
|
||||
add: QbzRowAdd { enabled: feedRows.animate }
|
||||
remove: QbzRowRemove { enabled: feedRows.animate }
|
||||
move: QbzRowDisplaced { enabled: feedRows.animate }
|
||||
displaced: QbzRowDisplaced { enabled: feedRows.animate }
|
||||
|
||||
// Gated on visibility for the same reason gridWindowReport and
|
||||
// listWindowReport are: `artMap` is ONE map, a report PRUNES
|
||||
// outside its band, and this body still fires while hidden —
|
||||
// `model` flips to [] on every tab switch (onModelChanged) and
|
||||
// `anchors.fill` makes every window resize an onHeightChanged.
|
||||
// outside its band, and this body still fires while hidden.
|
||||
// Ungated, a resize on the Artists sidepanel evicted the rail's
|
||||
// avatars and requested a `visibleRows` band the rail is not
|
||||
// even showing (its order is A-Z, not feed order).
|
||||
// avatars and requested a feed band the rail is not even
|
||||
// showing (its order is A-Z, not feed order). Offsets count
|
||||
// from `originY`: a removal above the window moves the origin,
|
||||
// not the rows on screen.
|
||||
function report() {
|
||||
if (!playlistsList.visible) return
|
||||
var first = Math.max(0, Math.floor(playlistsList.contentY / 62) - 4)
|
||||
var last = Math.ceil((playlistsList.contentY + playlistsList.height) / 62) + 4
|
||||
root.queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
|
||||
var scrollY = playlistsList.contentY - playlistsList.originY
|
||||
var first = Math.max(0, Math.floor(scrollY / 62) - 4)
|
||||
var last = Math.ceil((scrollY + playlistsList.height) / 62) + 4
|
||||
root.queueWindowReport(first, Math.min(feedRows.published.length - 1, last))
|
||||
}
|
||||
onContentYChanged: playlistsList.report()
|
||||
onModelChanged: playlistsList.report()
|
||||
@@ -1637,8 +1726,11 @@ Rectangle {
|
||||
Component.onCompleted: playlistsList.report()
|
||||
|
||||
delegate: PlaylistListRow {
|
||||
required property var modelData
|
||||
required property string rowKey
|
||||
required property int rowRev
|
||||
required property int index
|
||||
readonly property var modelData: feedRows.row(rowKey, rowRev)
|
||||
ListView.onReused: { opacity = 1; scale = 1 }
|
||||
width: playlistsList.width
|
||||
item: modelData
|
||||
rowIndex: index
|
||||
@@ -1710,7 +1802,7 @@ Rectangle {
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: grid; scope: "library:" + root.activeTab }
|
||||
ScrollMemory { target: grid; scope: "library:" + root.activeTab; relativeToOrigin: true }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 80 : 34) : 4
|
||||
@@ -1722,7 +1814,7 @@ Rectangle {
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: list; scope: "library:" + root.activeTab }
|
||||
ScrollMemory { target: list; scope: "library:" + root.activeTab; relativeToOrigin: true }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 80 : 34) : 4
|
||||
@@ -1734,7 +1826,7 @@ Rectangle {
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: playlistsList; scope: "library:" + root.activeTab }
|
||||
ScrollMemory { target: playlistsList; scope: "library:" + root.activeTab; relativeToOrigin: true }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
@@ -1747,10 +1839,12 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Both run from onContentYChanged, i.e. from INSIDE QQuickItemView::
|
||||
// setModel() — see the visibleRows comment. Everything they touch on the
|
||||
// view (width/height/contentY/cellWidth/cellHeight) is a plain qreal on
|
||||
// the private; the array comes from root, never from the view.
|
||||
// Both run from onContentYChanged. Everything they touch on the view
|
||||
// (width/height/contentY/originY/cellWidth/cellHeight) is a plain qreal on
|
||||
// the private; the array comes from the keyed model's `published` — the
|
||||
// rows index for index as the view holds them — never from `.model` (see
|
||||
// the visibleRows comment). Offsets count from `originY`: rows leaving
|
||||
// above the window move the content origin, not the rows on screen.
|
||||
//
|
||||
// Both early-return while their body is hidden. `artMap` is ONE map and a
|
||||
// report PRUNES everything outside the reported band, so a hidden body
|
||||
@@ -1766,11 +1860,12 @@ Rectangle {
|
||||
function gridWindowReport(motionOnly) {
|
||||
if (!grid.visible) return
|
||||
var cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
|
||||
var visibleFirst = Math.max(0, Math.floor(grid.contentY / grid.cellHeight))
|
||||
var scrollY = grid.contentY - grid.originY
|
||||
var visibleFirst = Math.max(0, Math.floor(scrollY / grid.cellHeight))
|
||||
// Match the two rows of delegates already held by cacheBuffer.
|
||||
var firstRow = Math.max(0, visibleFirst - 2)
|
||||
var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 2
|
||||
var m = root.visibleRows
|
||||
var lastRow = Math.ceil((scrollY + grid.height) / grid.cellHeight) + 2
|
||||
var m = feedRows.published
|
||||
var band = firstRow + ":" + lastRow + ":" + visibleFirst + ":" + cols
|
||||
var now = Date.now()
|
||||
// Do not restart the bridge timer for every pixel in the same band.
|
||||
@@ -1796,12 +1891,12 @@ Rectangle {
|
||||
var first = list.indexAt(4, list.contentY + 1)
|
||||
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
||||
if (first < 0)
|
||||
first = Math.max(0, Math.floor(list.contentY / 50))
|
||||
first = Math.max(0, Math.floor((list.contentY - list.originY) / 50))
|
||||
if (last < 0)
|
||||
last = Math.ceil((list.contentY + list.height) / 50)
|
||||
last = Math.ceil((list.contentY - list.originY + list.height) / 50)
|
||||
first = Math.max(0, first - 4)
|
||||
last += 4
|
||||
queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
|
||||
queueWindowReport(first, Math.min(feedRows.published.length - 1, last))
|
||||
}
|
||||
|
||||
// ============================ overlay =================================
|
||||
|
||||
@@ -588,7 +588,7 @@ Rectangle {
|
||||
border.width: 1
|
||||
border.color: root.multiSelect ? theme.accent : theme.borderSubtle
|
||||
color: (root.multiSelect || selectArea.containsMouse)
|
||||
? theme.surfaceHover
|
||||
? theme.surfaceElevatedHover
|
||||
: theme.surfaceElevated
|
||||
QbzIcon {
|
||||
name: "square-check-big"
|
||||
|
||||
@@ -103,7 +103,7 @@ Rectangle {
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: theme.surfaceElevated
|
||||
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -75,6 +75,8 @@ Rectangle {
|
||||
// never reach Rust, everything else goes down as a JSON id array through
|
||||
// QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
|
||||
property bool multiSelect: false
|
||||
/// The rows a bulk "Remove from playlist" is waiting to confirm.
|
||||
property var pendingRemoveIds: []
|
||||
property var selected: ({})
|
||||
readonly property int selectedCount: Object.keys(root.selected).length
|
||||
readonly property bool multiSelectOn: root.multiSelect
|
||||
@@ -108,6 +110,13 @@ Rectangle {
|
||||
return
|
||||
}
|
||||
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
|
||||
if (action === "remove") {
|
||||
var picked = root.selectedIdsInOrder()
|
||||
if (picked.length === 0) return
|
||||
root.pendingRemoveIds = picked
|
||||
removeConfirm.open()
|
||||
return
|
||||
}
|
||||
var ids = root.selectedIdsInOrder()
|
||||
if (ids.length === 0) return
|
||||
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || ""))
|
||||
@@ -722,39 +731,14 @@ Rectangle {
|
||||
}
|
||||
|
||||
// In-playlist search.
|
||||
Rectangle {
|
||||
QbzSearchField {
|
||||
width: 220
|
||||
height: 30
|
||||
radius: 6
|
||||
sidePadding: 9
|
||||
glyphSize: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 9
|
||||
anchors.rightMargin: 9
|
||||
spacing: 6
|
||||
QbzIcon { name: "search"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
width: parent.width - 19
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: QbzBridge.playlistSetSearch(text)
|
||||
Text {
|
||||
visible: parent.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search tracks", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
placeholder: QbzSession.tr("Search tracks", QbzSession.trRev)
|
||||
onEdited: function (text) { QbzBridge.playlistSetSearch(text) }
|
||||
}
|
||||
// Sort dropdown.
|
||||
Rectangle {
|
||||
@@ -765,8 +749,8 @@ Rectangle {
|
||||
// PlaylistView.slint:855-859 — resting fill
|
||||
// translucent under the dynamic background.
|
||||
color: sortArea.containsMouse
|
||||
? theme.surfaceHover
|
||||
: (root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Row {
|
||||
@@ -898,8 +882,11 @@ Rectangle {
|
||||
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
||||
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
|
||||
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
|
||||
// Owner only, like the row menu's entry (2026-09-14): one
|
||||
// confirm, then one bulk remove routed per row.
|
||||
{ "id": "remove", "label": QbzSession.tr("Remove from playlist", QbzSession.trRev), "icon": "trash-2", "danger": true, "needsSelection": true, "hidden": !root.isOwner },
|
||||
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
|
||||
]
|
||||
].filter(function (a) { return a.hidden !== true })
|
||||
onAction: function (id) { root.bulkAction(id) }
|
||||
}
|
||||
|
||||
@@ -1100,4 +1087,22 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select "Remove from playlist" (2026-09-14): one confirm for the
|
||||
// whole selection, then one bulk remove — playlist_remove_tracks routes
|
||||
// each row the way the single remove does (local repo, sidecar, Qobuz).
|
||||
QbzConfirmModal {
|
||||
id: removeConfirm
|
||||
anchors.fill: parent
|
||||
title: QbzSession.tr("Remove from playlist", QbzSession.trRev)
|
||||
body: QbzSession.tr("{} tracks will be removed from this playlist.", QbzSession.trRev)
|
||||
.replace("{}", root.pendingRemoveIds.length)
|
||||
confirmLabel: QbzSession.tr("Remove", QbzSession.trRev)
|
||||
danger: true
|
||||
onConfirmed: {
|
||||
QbzBridge.playlistRemoveTracks(JSON.stringify(root.pendingRemoveIds))
|
||||
root.pendingRemoveIds = []
|
||||
root.selected = ({})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +400,7 @@ Rectangle {
|
||||
height: 32
|
||||
radius: 6
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
|
||||
@@ -355,7 +355,7 @@ Rectangle {
|
||||
required property var modelData
|
||||
width: Math.max(96, kioskTabLabel.implicitWidth + 24)
|
||||
height: 64
|
||||
color: root.tab === modelData.id ? theme.surfaceElevated : "transparent"
|
||||
color: root.tab === modelData.id ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) : "transparent"
|
||||
Text { id: kioskTabLabel; anchors.centerIn: parent; text: parent.modelData.label; color: theme.textPrimary; font.pixelSize: 18 }
|
||||
MouseArea { anchors.fill: parent; onClicked: QbzPurchases.setTab(parent.modelData.id) }
|
||||
}
|
||||
@@ -446,7 +446,7 @@ Rectangle {
|
||||
height: 32
|
||||
radius: 6
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
|
||||
@@ -40,6 +40,39 @@ Rectangle {
|
||||
&& root.doc.currentIndex < root.rows.length
|
||||
readonly property int actionRailWidth: kioskHost ? 72 : 52
|
||||
|
||||
// The list's model (2026-09-14): queue changes reach the ListView as
|
||||
// removes / inserts / moves instead of a new array, so removing a track
|
||||
// fades that row out while the rest slide up, "play next" slides the
|
||||
// queue open, a history row trimmed above the window leaves the rows on
|
||||
// screen where they are, and nothing is rebuilt from the top.
|
||||
//
|
||||
// Identity is the track (source + id); a repeat of the same track is its
|
||||
// next occurrence. Phase, section and phaseIndex are data, not identity:
|
||||
// when a track ends, the chronological order does not change, only which
|
||||
// row is current, so those rows update in place and nothing moves. A
|
||||
// search is a different list (the scope), replaced at once.
|
||||
QbzKeyedModel {
|
||||
id: rowsModel
|
||||
rows: root.rows
|
||||
keyOf: function (row) { return (row.source || "") + ":" + row.id }
|
||||
scope: root.doc.searchQuery || ""
|
||||
onReconciled: {
|
||||
// The follow and the covers read view indices: run them once the
|
||||
// view holds the new rows.
|
||||
root.scheduleVisibleCovers()
|
||||
if (root.hasPlayingRow && root.followPlaying)
|
||||
root.followPlayingRow()
|
||||
}
|
||||
}
|
||||
// A committed drop lands the dragged row where the pointer left it; only
|
||||
// its neighbours slide. The chevrons keep the move animation (a swap).
|
||||
property bool dropSettling: false
|
||||
Timer {
|
||||
id: dropSettle
|
||||
interval: 600
|
||||
onTriggered: root.dropSettling = false
|
||||
}
|
||||
|
||||
// Timing for the chronological projection currently on screen. History is
|
||||
// fully elapsed, the current row contributes the player's live position,
|
||||
// and upcoming rows are fully remaining. A filtered listen list therefore
|
||||
@@ -128,6 +161,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
rowsModel.views = [queueList]
|
||||
QbzQueue.queueExtendedOpened()
|
||||
root.scheduleVisibleCovers()
|
||||
}
|
||||
@@ -139,8 +173,6 @@ Rectangle {
|
||||
root.scheduleVisibleCovers()
|
||||
if (!root.hasPlayingRow)
|
||||
root.cancelPlayingFollow()
|
||||
else if (root.followPlaying)
|
||||
Qt.callLater(function () { root.followPlayingRow() })
|
||||
}
|
||||
onSearchActiveChanged: if (root.searchActive) root.cancelPlayingFollow()
|
||||
|
||||
@@ -170,20 +202,22 @@ Rectangle {
|
||||
}
|
||||
|
||||
function requestVisibleCovers() {
|
||||
if (root.rows.length === 0 || queueList.height <= 0)
|
||||
// View indices index the rows the model holds, not a newer `rows`.
|
||||
var shown = rowsModel.published
|
||||
if (shown.length === 0 || queueList.height <= 0)
|
||||
return
|
||||
var first = queueList.indexAt(4, queueList.contentY + 2)
|
||||
if (first < 0)
|
||||
first = 0
|
||||
var last = queueList.indexAt(4, queueList.contentY + queueList.height - 2)
|
||||
if (last < first)
|
||||
last = Math.min(root.rows.length - 1, first + 16)
|
||||
last = Math.min(shown.length - 1, first + 16)
|
||||
first = Math.max(0, first - 3)
|
||||
last = Math.min(root.rows.length - 1, last + 3)
|
||||
last = Math.min(shown.length - 1, last + 3)
|
||||
var urls = []
|
||||
var wanted = ({})
|
||||
for (var i = first; i <= last; i++) {
|
||||
var url = root.artUrl(root.rows[i])
|
||||
var url = root.artUrl(shown[i])
|
||||
if (url !== "" && !root.coverMap[url] && wanted[url] !== true) {
|
||||
wanted[url] = true
|
||||
urls.push(url)
|
||||
@@ -258,6 +292,41 @@ Rectangle {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Go to album / Go to artist, by SOURCE (2026-09-14). A catalog row routes
|
||||
// through its catalog ids (title and artist ride along as the
|
||||
// unavailable-page hint); a local row — file, Plex, Jellyfin, Subsonic —
|
||||
// through the queue's NAVIGATION KEY (local_playback.rs stamps the album
|
||||
// key the card itself opens, prefixed for media servers) and the artist
|
||||
// NAME into the Local Library Artists tab, the local surfaces' own routes
|
||||
// (local/LocalTrackRow.qml). A row without the key or the name shows no
|
||||
// entry.
|
||||
function rowCanGoAlbum(row) {
|
||||
return (row.albumId || "") !== ""
|
||||
}
|
||||
function rowCanGoArtist(row) {
|
||||
return row.isLocal === true ? (row.artist || "") !== "" : (row.artistId || "") !== ""
|
||||
}
|
||||
function goToAlbum(row) {
|
||||
if (!rowCanGoAlbum(row))
|
||||
return
|
||||
if (row.isLocal === true) {
|
||||
QbzLocal.openAlbum(row.albumId)
|
||||
QbzShell.navigateTo("localalbum")
|
||||
} else {
|
||||
QbzAlbum.openAlbumFrom(row.albumId, row.album || "", row.artist || "")
|
||||
}
|
||||
}
|
||||
function goToArtist(row) {
|
||||
if (!rowCanGoArtist(row))
|
||||
return
|
||||
if (row.isLocal === true) {
|
||||
QbzLocal.openArtistByName(row.artist)
|
||||
QbzShell.navigateTo("local")
|
||||
} else {
|
||||
QbzArtist.openArtist(row.artistId)
|
||||
}
|
||||
}
|
||||
|
||||
function queueMenu(row) {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
@@ -277,10 +346,17 @@ Rectangle {
|
||||
? t("Cancel stop after this", r) : t("Stop after this", r),
|
||||
"icon": "circle-stop", "action": "stop-after", "external": true })
|
||||
}
|
||||
if (root.rowCanGoAlbum(row))
|
||||
items.push({ "label": t("Go to album", r), "icon": "disc-3",
|
||||
"action": "go-album", "external": true })
|
||||
if (root.rowCanGoArtist(row))
|
||||
items.push({ "label": t("Go to artist", r), "icon": "user",
|
||||
"action": "go-artist", "external": true })
|
||||
if (row.isEphemeral !== true) {
|
||||
if (row.isLocal !== true)
|
||||
items.push({ "label": t("Add to playlist", r), "icon": "list-plus",
|
||||
"action": "add-to-playlist", "external": true })
|
||||
// Local rows too (2026-09-14): the queue resolves them to picker
|
||||
// refs by source (queue_qt::picker_payload_for).
|
||||
items.push({ "label": t("Add to playlist", r), "icon": "list-plus",
|
||||
"action": "add-to-playlist", "external": true })
|
||||
items.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
|
||||
if (row.isLocal !== true)
|
||||
items.push({ "label": row.isFavorite === true
|
||||
@@ -315,6 +391,10 @@ Rectangle {
|
||||
QbzQueue.queueToggleStopAfter(row.id)
|
||||
else if (action === "add-to-playlist")
|
||||
QbzPlaylistPicker.openForTrack(row.id)
|
||||
else if (action === "go-album")
|
||||
root.goToAlbum(row)
|
||||
else if (action === "go-artist")
|
||||
root.goToArtist(row)
|
||||
}
|
||||
|
||||
function historyVisualIndex(row) {
|
||||
@@ -447,9 +527,12 @@ Rectangle {
|
||||
root.dropSlot = -1
|
||||
root.dropHot = false
|
||||
QbzShell.dragInlineVisual = false
|
||||
if (shouldCommit)
|
||||
if (shouldCommit) {
|
||||
root.dropSettling = true
|
||||
dropSettle.restart()
|
||||
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId,
|
||||
targetPhase, slot)
|
||||
}
|
||||
}
|
||||
|
||||
function dragProxyY() {
|
||||
@@ -760,7 +843,11 @@ Rectangle {
|
||||
spacing: 3
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
reuseItems: true
|
||||
model: root.rows
|
||||
model: rowsModel
|
||||
add: QbzRowAdd { enabled: rowsModel.animate }
|
||||
remove: QbzRowRemove { enabled: rowsModel.animate }
|
||||
move: QbzRowDisplaced { enabled: rowsModel.animate && !root.dropSettling }
|
||||
displaced: QbzRowDisplaced { enabled: rowsModel.animate }
|
||||
onContentYChanged: root.scheduleVisibleCovers()
|
||||
onMovementStarted: {
|
||||
if (!root.programmaticListPosition)
|
||||
@@ -769,8 +856,10 @@ Rectangle {
|
||||
|
||||
delegate: Item {
|
||||
id: rowHost
|
||||
required property var modelData
|
||||
required property string rowKey
|
||||
required property int rowRev
|
||||
required property int index
|
||||
readonly property var modelData: rowsModel.row(rowKey, rowRev)
|
||||
|
||||
readonly property string heading: root.sectionText(index, modelData)
|
||||
readonly property var displayItem: Object.assign({}, modelData, {
|
||||
@@ -786,7 +875,12 @@ Rectangle {
|
||||
trackRow.recycleActive = false
|
||||
trackRow.releaseForReuse()
|
||||
}
|
||||
ListView.onReused: trackRow.recycleActive = true
|
||||
ListView.onReused: {
|
||||
// A transition cut short can pool the row half faded.
|
||||
rowHost.opacity = 1
|
||||
rowHost.scale = 1
|
||||
trackRow.recycleActive = true
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: rowHost.heading !== ""
|
||||
@@ -885,7 +979,7 @@ Rectangle {
|
||||
width: root.kioskHost ? 64 : 36
|
||||
height: root.kioskHost ? 64 : 36
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceCard
|
||||
color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard)
|
||||
border.width: 1
|
||||
border.color: root.followPlaying ? theme.accent
|
||||
: theme.borderSubtle
|
||||
@@ -908,7 +1002,7 @@ Rectangle {
|
||||
width: root.kioskHost ? 64 : 36
|
||||
height: root.kioskHost ? 64 : 36
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceCard
|
||||
color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard)
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
QbzIconButton {
|
||||
|
||||
@@ -689,7 +689,7 @@ Rectangle {
|
||||
height: 24
|
||||
radius: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: clrArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: clrArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
QbzIcon { name: "x"; width: 13; height: 13; anchors.centerIn: parent; tintName: clrArea.containsMouse ? "textPrimary" : "muted" }
|
||||
MouseArea {
|
||||
id: clrArea
|
||||
|
||||
@@ -63,7 +63,13 @@ Item {
|
||||
cell.scheduleMutableRestore()
|
||||
}
|
||||
GridView.onPooled: cell.releaseLoadedCard()
|
||||
GridView.onReused: cell.scheduleMutableRestore()
|
||||
GridView.onReused: {
|
||||
// A row transition cut short (QbzKeyedModel in LibraryView) can pool
|
||||
// the cell half faded or shrunk; the pool hands it back as it was.
|
||||
cell.opacity = 1
|
||||
cell.scale = 1
|
||||
cell.scheduleMutableRestore()
|
||||
}
|
||||
|
||||
width: 200
|
||||
height: 246
|
||||
|
||||
@@ -26,9 +26,11 @@ Item {
|
||||
|
||||
/// The LibraryView root (artMap, the selection map, the window reporter).
|
||||
property var view: null
|
||||
/// The derived album rows for this tab — `view.visibleRows`, passed in so
|
||||
/// this file never re-derives and both surfaces see the same array.
|
||||
property var rows: []
|
||||
/// The Library's keyed model (views/LibraryView.qml `feedRows`), shared by
|
||||
/// every body so a row leaving animates instead of rebuilding the list.
|
||||
property var rowsModel: null
|
||||
/// For the model's synchronous reset layout.
|
||||
readonly property alias listView: list
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -39,6 +41,8 @@ Item {
|
||||
function positionAt(index) {
|
||||
list.positionViewAtIndex(index, ListView.Beginning)
|
||||
}
|
||||
/// Re-report the artwork window (the host calls it after a reconcile).
|
||||
function report() { list.report() }
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
@@ -83,7 +87,10 @@ Item {
|
||||
anchors.topMargin: 4
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
// Height-gated like the host's grid: a hidden ListView with a
|
||||
// model keeps refilling its viewport, and this one shares the
|
||||
// Library's model with every other body.
|
||||
height: root.visible ? parent.height - header.height - 4 : 0
|
||||
// No strip inset here: the A-Z strip lives in LibraryView and
|
||||
// the HOST already widened this component's right margin for
|
||||
// it. Insetting again would double-count it.
|
||||
@@ -92,7 +99,11 @@ Item {
|
||||
cacheBuffer: 64 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.rows
|
||||
model: root.rowsModel
|
||||
add: QbzRowAdd { enabled: root.rowsModel !== null && root.rowsModel.animate }
|
||||
remove: QbzRowRemove { enabled: root.rowsModel !== null && root.rowsModel.animate }
|
||||
move: QbzRowDisplaced { enabled: root.rowsModel !== null && root.rowsModel.animate }
|
||||
displaced: QbzRowDisplaced { enabled: root.rowsModel !== null && root.rowsModel.animate }
|
||||
|
||||
// Windowed artwork, same contract as the grid: never read
|
||||
// `list.model` back off the view (views/LibraryView.qml
|
||||
@@ -101,18 +112,19 @@ Item {
|
||||
// Gated on visibility like the host's own bodies
|
||||
// (LibraryView.gridWindowReport / listWindowReport): `artMap`
|
||||
// is ONE map and a report PRUNES outside its band, while this
|
||||
// body keeps firing hidden — `rows` flips to [] on every tab
|
||||
// switch (onModelChanged) and `anchors.fill` turns any window
|
||||
// resize into onHeightChanged. Ungated it evicted whichever
|
||||
// body keeps firing hidden (`anchors.fill` turns any window
|
||||
// resize into onHeightChanged). Ungated it evicted whichever
|
||||
// body is actually on screen, the Artists sidepanel rail above
|
||||
// all, whose band is NOT a `visibleRows` band.
|
||||
// all, whose band is NOT a feed band. Offsets count from
|
||||
// `originY`, which rows leaving above the window move.
|
||||
function report() {
|
||||
if (!list.visible) return
|
||||
if (!list.visible || root.rowsModel === null) return
|
||||
var stride = root.rowH + root.rowGap
|
||||
var first = Math.max(0, Math.floor(list.contentY / stride) - 4)
|
||||
var last = Math.ceil((list.contentY + list.height) / stride) + 4
|
||||
var scrollY = list.contentY - list.originY
|
||||
var first = Math.max(0, Math.floor(scrollY / stride) - 4)
|
||||
var last = Math.ceil((scrollY + list.height) / stride) + 4
|
||||
root.view.queueWindowReport(first,
|
||||
Math.min(root.rows.length - 1, last))
|
||||
Math.min(root.rowsModel.published.length - 1, last))
|
||||
}
|
||||
onContentYChanged: list.report()
|
||||
onModelChanged: list.report()
|
||||
@@ -121,8 +133,11 @@ Item {
|
||||
Component.onCompleted: list.report()
|
||||
|
||||
delegate: AlbumListRow {
|
||||
required property var modelData
|
||||
required property string rowKey
|
||||
required property int rowRev
|
||||
required property int index
|
||||
readonly property var modelData: root.rowsModel.row(rowKey, rowRev)
|
||||
ListView.onReused: { opacity = 1; scale = 1 }
|
||||
width: list.width
|
||||
item: modelData
|
||||
rowIndex: index
|
||||
|
||||
@@ -95,7 +95,25 @@ Item {
|
||||
root._rebuilding = false
|
||||
root.report()
|
||||
}
|
||||
Component.onCompleted: { root.rebuild(); root.reportSoon() }
|
||||
// The rail's own keyed model (2026-09-14): an artist unfollowed from the
|
||||
// pane fades out of the rail while the rows below slide up, instead of
|
||||
// the rail being rebuilt from the top. Same page identity as the host's
|
||||
// bodies, so a search or a sort still replaces it at once.
|
||||
QbzKeyedModel {
|
||||
id: railModel
|
||||
rows: root.entries
|
||||
keyOf: function (entry) {
|
||||
return entry.t === 0 ? "letter:" + entry.label : "artist:" + entry.item.id
|
||||
}
|
||||
scope: root.view ? root.view.rowsScope : ""
|
||||
onReconciled: root.report()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
railModel.views = [railList]
|
||||
root.rebuild()
|
||||
root.reportSoon()
|
||||
}
|
||||
Connections {
|
||||
target: root.view
|
||||
function onVisibleRowsChanged() { root.rebuild() }
|
||||
@@ -212,7 +230,11 @@ Item {
|
||||
cacheBuffer: 56 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.entries
|
||||
model: railModel
|
||||
add: QbzRowAdd { enabled: railModel.animate }
|
||||
remove: QbzRowRemove { enabled: railModel.animate }
|
||||
move: QbzRowDisplaced { enabled: railModel.animate }
|
||||
displaced: QbzRowDisplaced { enabled: railModel.animate }
|
||||
|
||||
// The trigger set views/local/LocalArtistsTab.qml:161-165 already
|
||||
// uses for the same shape of rail: scroll, model swap, viewport
|
||||
@@ -225,7 +247,10 @@ Item {
|
||||
onVisibleChanged: if (visible) root.reportSoon()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
required property string rowKey
|
||||
required property int rowRev
|
||||
readonly property var modelData: railModel.row(rowKey, rowRev)
|
||||
ListView.onReused: { opacity = 1; scale = 1 }
|
||||
width: railList.width
|
||||
height: modelData.t === 0 ? root.headerH : root.rowH
|
||||
sourceComponent: modelData.t === 0 ? letterComp : artistComp
|
||||
|
||||
@@ -82,7 +82,7 @@ Item {
|
||||
width: 30
|
||||
height: 30
|
||||
radius: 6
|
||||
color: active ? theme.surfaceElevated
|
||||
color: active ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
: ttArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: parent.name
|
||||
@@ -113,8 +113,8 @@ Item {
|
||||
// rest fill and, when a genre is applied, the SAME accent OUTLINE —
|
||||
// not an accent fill — so the three toolbar controls read identically
|
||||
// in their modified state.
|
||||
color: gtbArea.containsMouse ? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
color: gtbArea.containsMouse ? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
border.width: gtb.active ? 1 : 0
|
||||
border.color: gtb.active ? theme.accent : "transparent"
|
||||
Row {
|
||||
@@ -234,7 +234,9 @@ Item {
|
||||
y: 25 - height / 2
|
||||
width: tabRow.width
|
||||
height: tabRow.height
|
||||
color: theme.surfaceElevated
|
||||
// Ambient-aware like the tab bar it hosts (the strip read opaque
|
||||
// under the ambient field while Discover's and Local's had alpha).
|
||||
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
radius: 6
|
||||
Row {
|
||||
id: tabRow
|
||||
|
||||
@@ -22,7 +22,7 @@ Rectangle {
|
||||
border.width: 1
|
||||
border.color: active ? theme.accent : theme.borderSubtle
|
||||
color: active ? theme.accent
|
||||
: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
|
||||
Text {
|
||||
id: lbl
|
||||
|
||||
@@ -26,7 +26,7 @@ Rectangle {
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: cardArea.containsMouse ? theme.accent : theme.borderSubtle
|
||||
color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: cardArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -120,8 +120,8 @@ Item {
|
||||
readonly property bool busy: QbzLocal.localDiscOpening
|
||||
|
||||
color: (!openBtn.busy && (openArea.containsMouse || openMenu.opened))
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
|
||||
Row {
|
||||
id: openRow
|
||||
|
||||
@@ -314,8 +314,8 @@ Item {
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: ripArea.containsMouse
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
Row {
|
||||
id: ripRow
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -25,7 +25,7 @@ Item {
|
||||
border.width: 1
|
||||
border.color: root.view && root.view.filterCount > 0
|
||||
? theme.accent : theme.borderSubtle
|
||||
color: area.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: area.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||||
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
|
||||
@@ -198,9 +198,8 @@ Item {
|
||||
height: 28
|
||||
radius: 6
|
||||
color: chipMouse.containsMouse
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50
|
||||
: theme.surfaceElevated)
|
||||
? theme.elevatedHoverFill
|
||||
: theme.elevatedFill
|
||||
border.width: 1
|
||||
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
|
||||
|
||||
@@ -312,7 +311,7 @@ Item {
|
||||
radius: 5
|
||||
color: theme.ambientOn
|
||||
? theme.surfaceElevatedA50
|
||||
: (caretArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
|
||||
: (caretArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated)
|
||||
border.width: 1
|
||||
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
|
||||
QbzIcon {
|
||||
|
||||
@@ -40,7 +40,7 @@ Item {
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: root.marked ? theme.accent : theme.borderSubtle
|
||||
color: btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: btnArea.containsMouse ? theme.elevatedHoverFill : theme.elevatedFill
|
||||
QbzIcon {
|
||||
name: root.iconName
|
||||
width: 15
|
||||
|
||||
@@ -35,7 +35,7 @@ Rectangle {
|
||||
|
||||
height: 26
|
||||
radius: 6
|
||||
color: selected ? theme.surfaceElevated
|
||||
color: selected ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
// Row body — declared FIRST so the chevron / checkbox win their clicks.
|
||||
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user