merge: pull smoke-2026-07-25 backend for the Library All POC phase
Brings the windowed Library All backend (qbz-library) and the latest Slint reference implementation into the POC base. POC branch only; pre-release untouched. km
No files matched your search
@@ -227,6 +227,11 @@ jobs:
|
|||||||
-t "dist/QBZ_${VERSION}_arm64.deb"
|
-t "dist/QBZ_${VERSION}_arm64.deb"
|
||||||
nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \
|
nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \
|
||||||
-t "dist/QBZ-${VERSION}-1.aarch64.rpm"
|
-t "dist/QBZ-${VERSION}-1.aarch64.rpm"
|
||||||
|
# Standalone qbzd packages (daemon-only, mirrors the amd64 job).
|
||||||
|
nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p deb \
|
||||||
|
-t "dist/qbzd_${VERSION}_arm64.deb"
|
||||||
|
nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p rpm \
|
||||||
|
-t "dist/qbzd-${VERSION}-1.aarch64.rpm"
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
@@ -328,3 +333,16 @@ jobs:
|
|||||||
repository: vicrodh/qbz-apt
|
repository: vicrodh/qbz-apt
|
||||||
event-type: new-release
|
event-type: new-release
|
||||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||||
|
|
||||||
|
# RPM repo (qbz-rpm) — same dual-dispatch rule as #648 above: the arm64
|
||||||
|
# .rpms land hours after x86_64's, and update-repo.yml is idempotent.
|
||||||
|
# continue-on-error until the token scope covers the new repo.
|
||||||
|
- name: Trigger RPM repo update
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
continue-on-error: true
|
||||||
|
uses: peter-evans/repository-dispatch@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.APT_REPO_TOKEN }}
|
||||||
|
repository: vicrodh/qbz-rpm
|
||||||
|
event-type: new-release
|
||||||
|
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||||
@@ -262,6 +262,12 @@ jobs:
|
|||||||
-t "dist/QBZ_${VERSION}_amd64.deb"
|
-t "dist/QBZ_${VERSION}_amd64.deb"
|
||||||
nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \
|
nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \
|
||||||
-t "dist/QBZ-${VERSION}-1.x86_64.rpm"
|
-t "dist/QBZ-${VERSION}-1.x86_64.rpm"
|
||||||
|
# Standalone qbzd packages (daemon-only — the independent deb/rpm
|
||||||
|
# the owner asked for; rides the same staged ./qbzd).
|
||||||
|
nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p deb \
|
||||||
|
-t "dist/qbzd_${VERSION}_amd64.deb"
|
||||||
|
nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p rpm \
|
||||||
|
-t "dist/qbzd-${VERSION}-1.x86_64.rpm"
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
@@ -372,3 +378,16 @@ jobs:
|
|||||||
repository: vicrodh/qbz-apt
|
repository: vicrodh/qbz-apt
|
||||||
event-type: new-release
|
event-type: new-release
|
||||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||||
|
|
||||||
|
# Our own RPM repo (qbz-rpm — mirrors qbz-apt's flow with createrepo).
|
||||||
|
# continue-on-error until the token scope covers the new repo (the PAT
|
||||||
|
# was minted for qbz-apt only).
|
||||||
|
- name: Trigger RPM repo update
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
continue-on-error: true
|
||||||
|
uses: peter-evans/repository-dispatch@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.APT_REPO_TOKEN }}
|
||||||
|
repository: vicrodh/qbz-rpm
|
||||||
|
event-type: new-release
|
||||||
|
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,481 @@
|
|||||||
|
// QBZ — Steam Deck (neptune) controller layout, seeded by qbz-add-to-steam.
|
||||||
|
//
|
||||||
|
// Based on the stock Steam "Gamepad" template (controller_neptune_gamepad_joystick.vdf,
|
||||||
|
// Steam client 2026-06) — PURE GAMEPAD: only virtual X360 pad output, NO keyboard
|
||||||
|
// or mouse emulation, so QBZ's own gilrs gamepad thread is the single input source
|
||||||
|
// (see qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md, PR 3/PR 5).
|
||||||
|
//
|
||||||
|
// The one deviation from the template: Y is bound to Steam's "Show Keyboard"
|
||||||
|
// system action (controller_action SHOW_KEYBOARD — token verified in
|
||||||
|
// steamclient.so) as a manual OSK fallback next to the steam://open/keyboard
|
||||||
|
// deeplink. Y therefore does NOT emit an XInput button.
|
||||||
|
//
|
||||||
|
// TODO(verify-on-hardware): this file is authored from the known exported
|
||||||
|
// config format, not yet exported/validated by the Deck UI. Do the one-time
|
||||||
|
// validation from the plan: load it on a real Deck in Game Mode, confirm the
|
||||||
|
// layout applies on first launch (configset_<serial>.vdf path) and that Y
|
||||||
|
// opens the OSK, then re-export and replace this file if the format drifted.
|
||||||
|
"controller_mappings"
|
||||||
|
{
|
||||||
|
"version" "3"
|
||||||
|
"title" "QBZ Kiosk (gamepad + Y=keyboard)"
|
||||||
|
"description" "Pure gamepad layout for the QBZ kiosk: D-pad/stick navigate, A activates, B goes back, Y shows the Steam keyboard. No keyboard/mouse emulation."
|
||||||
|
"controller_type" "controller_neptune"
|
||||||
|
"major_revision" "0"
|
||||||
|
"minor_revision" "0"
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "0"
|
||||||
|
"mode" "four_buttons"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"button_a"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button A, Activate, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_b"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button B, Back, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_x"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button X, , , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_y"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "controller_action SHOW_KEYBOARD, Show Keyboard, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "1"
|
||||||
|
"mode" "dpad"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"dpad_north"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button dpad_up, , , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_south"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button dpad_down, , , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_east"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button dpad_right, , , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_west"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button dpad_left, , , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "2"
|
||||||
|
"mode" "joystick_move"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Soft_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_RIGHT, , , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "3"
|
||||||
|
"mode" "joystick_move"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_LEFT, , , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"deadzone_inner_radius" "7199"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "4"
|
||||||
|
"mode" "trigger"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"output_trigger" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "5"
|
||||||
|
"mode" "trigger"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"output_trigger" "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "7"
|
||||||
|
"mode" "switches"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"button_escape"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button start, , , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_menu"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button select, , , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"left_bumper"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button shoulder_left, , , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"right_bumper"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button shoulder_right, , , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_back_left"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_back_right"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_back_left_upper"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_back_right_upper"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "8"
|
||||||
|
"mode" "joystick_move"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_RIGHT, , , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "9"
|
||||||
|
"mode" "dpad"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"dpad_north"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button DPAD_UP, , , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_south"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button DPAD_DOWN, , , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_east"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button DPAD_RIGHT, , , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_west"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button DPAD_LEFT, , , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"requires_click" "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"preset"
|
||||||
|
{
|
||||||
|
"id" "0"
|
||||||
|
"name" "Default"
|
||||||
|
"group_source_bindings"
|
||||||
|
{
|
||||||
|
"7" "switch active"
|
||||||
|
"0" "button_diamond active"
|
||||||
|
"1" "left_trackpad active"
|
||||||
|
"2" "right_trackpad active"
|
||||||
|
"3" "joystick active"
|
||||||
|
"4" "left_trigger active"
|
||||||
|
"5" "right_trigger active"
|
||||||
|
"8" "right_joystick active"
|
||||||
|
"9" "dpad active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"left_trackpad_mode" "0"
|
||||||
|
"right_trackpad_mode" "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -358,6 +358,7 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
|
"wl-clipboard-rs",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2469,6 +2470,12 @@ version = "0.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fixedbitset"
|
||||||
|
version = "0.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
@@ -2848,6 +2855,40 @@ dependencies = [
|
|||||||
"weezl",
|
"weezl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gilrs"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"gilrs-core",
|
||||||
|
"log",
|
||||||
|
"uuid 1.22.0",
|
||||||
|
"vec_map",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gilrs-core"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9"
|
||||||
|
dependencies = [
|
||||||
|
"inotify",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"libudev-sys",
|
||||||
|
"log",
|
||||||
|
"nix 0.31.3",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-kit",
|
||||||
|
"uuid 1.22.0",
|
||||||
|
"vec_map",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
"windows 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gl_generator"
|
name = "gl_generator"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -3802,6 +3843,26 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"inotify-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify-sys"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -4190,9 +4251,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.183"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libdbus-sys"
|
name = "libdbus-sys"
|
||||||
@@ -4748,6 +4809,18 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "no_std_io2"
|
name = "no_std_io2"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -5214,6 +5287,17 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"libc",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-surface"
|
name = "objc2-io-surface"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -5447,6 +5531,16 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "os_pipe"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owned_ttf_parser"
|
name = "owned_ttf_parser"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@@ -5614,6 +5708,17 @@ dependencies = [
|
|||||||
"pest",
|
"pest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "petgraph"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||||
|
dependencies = [
|
||||||
|
"fixedbitset 0.5.7",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -6053,6 +6158,7 @@ dependencies = [
|
|||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"gilrs",
|
||||||
"i-slint-backend-winit",
|
"i-slint-backend-winit",
|
||||||
"i-slint-renderer-femtovg",
|
"i-slint-renderer-femtovg",
|
||||||
"image",
|
"image",
|
||||||
@@ -8585,7 +8691,7 @@ dependencies = [
|
|||||||
"fancy-regex",
|
"fancy-regex",
|
||||||
"filedescriptor",
|
"filedescriptor",
|
||||||
"finl_unicode",
|
"finl_unicode",
|
||||||
"fixedbitset",
|
"fixedbitset 0.4.2",
|
||||||
"hex",
|
"hex",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -9049,6 +9155,17 @@ dependencies = [
|
|||||||
"strength_reduce",
|
"strength_reduce",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tree_magic_mini"
|
||||||
|
version = "3.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"nom 8.0.0",
|
||||||
|
"petgraph",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "try-lock"
|
name = "try-lock"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -9344,6 +9461,12 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vec_map"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -10673,6 +10796,24 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wl-clipboard-rs"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"os_pipe",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tree_magic_mini",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
"wayland-protocols-wlr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "write-fonts"
|
name = "write-fonts"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
|
pub mod memory_watchdog;
|
||||||
pub mod offline_mode;
|
pub mod offline_mode;
|
||||||
pub mod graphics_autoconfig;
|
pub mod graphics_autoconfig;
|
||||||
pub mod playback_context;
|
pub mod playback_context;
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! Memory-pressure watchdog.
|
||||||
|
//!
|
||||||
|
//! Every 15 s, reads the host's memory pressure from `qbz-core`'s
|
||||||
|
//! `system_capabilities` and reacts:
|
||||||
|
//!
|
||||||
|
//! * `is_critical` (< 5 % available) — evict the L1 in-memory audio cache
|
||||||
|
//! (the L2 disk files stay; disk is not the scarce resource) and latch
|
||||||
|
//! [`prefetch_halted`] so no new prefetch downloads start.
|
||||||
|
//! * `is_low` (< 15 % available) — pause HiRes prefetch for that tick via
|
||||||
|
//! [`hires_prefetch_paused`]; a normal-lossless prefetch is ~4x smaller.
|
||||||
|
//! * healthy again — clear both latches and log the recovery at debug.
|
||||||
|
//! There is deliberately no fancier unlatching logic.
|
||||||
|
//!
|
||||||
|
//! Both frontends (`qbz`, `qbzd`) spawn the loop once at startup with the
|
||||||
|
//! shared [`Player`] handle; the prefetch path in
|
||||||
|
//! [`crate::playback_driver`] consults the latches before starting work.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use qbz_player::Player;
|
||||||
|
|
||||||
|
/// How often the watchdog samples /proc/meminfo.
|
||||||
|
const TICK_SECS: u64 = 15;
|
||||||
|
|
||||||
|
/// Latched on critical pressure: no new prefetch downloads may start.
|
||||||
|
static PREFETCH_HALTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Set on low/critical pressure ticks: prefetch downgrades HiRes to
|
||||||
|
/// Lossless. Follows the latest tick rather than latching.
|
||||||
|
static HIRES_PREFETCH_PAUSED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// True while the watchdog has halted prefetch downloads (critical
|
||||||
|
/// pressure). Checked by the prefetch path before starting any download.
|
||||||
|
pub fn prefetch_halted() -> bool {
|
||||||
|
PREFETCH_HALTED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the latest watchdog tick saw low (or critical) memory
|
||||||
|
/// pressure — prefetch should stay at Lossless this round.
|
||||||
|
pub fn hires_prefetch_paused() -> bool {
|
||||||
|
HIRES_PREFETCH_PAUSED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the watchdog loop on the current tokio runtime. Runs for the
|
||||||
|
/// process lifetime; the returned handle lets a caller with an ordered
|
||||||
|
/// shutdown abort it explicitly.
|
||||||
|
pub fn spawn(player: Arc<Player>) -> tokio::task::JoinHandle<()> {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(TICK_SECS));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
// None on platforms without /proc/meminfo — nothing to do.
|
||||||
|
let Some(pressure) = qbz_core::system_capabilities::read_memory_pressure() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if pressure.is_critical {
|
||||||
|
log::warn!(
|
||||||
|
"[memwatch] critical memory pressure: {:.1}% available ({} MB of {} MB) — evicting L1 audio cache, halting prefetch",
|
||||||
|
pressure.available_pct,
|
||||||
|
pressure.mem_available_kb / 1024,
|
||||||
|
pressure.mem_total_kb / 1024,
|
||||||
|
);
|
||||||
|
player.evict_l1_audio_cache();
|
||||||
|
PREFETCH_HALTED.store(true, Ordering::Relaxed);
|
||||||
|
HIRES_PREFETCH_PAUSED.store(true, Ordering::Relaxed);
|
||||||
|
} else if pressure.is_low {
|
||||||
|
HIRES_PREFETCH_PAUSED.store(true, Ordering::Relaxed);
|
||||||
|
log::debug!(
|
||||||
|
"[memwatch] low memory pressure: {:.1}% available — hires prefetch paused this tick",
|
||||||
|
pressure.available_pct,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let was_halted = PREFETCH_HALTED.swap(false, Ordering::Relaxed);
|
||||||
|
let was_paused = HIRES_PREFETCH_PAUSED.swap(false, Ordering::Relaxed);
|
||||||
|
if was_halted || was_paused {
|
||||||
|
log::debug!(
|
||||||
|
"[memwatch] memory pressure recovered ({:.1}% available) — prefetch re-enabled",
|
||||||
|
pressure.available_pct,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -561,8 +561,22 @@ async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
|
|||||||
runtime: &AppRuntime<A>,
|
runtime: &AppRuntime<A>,
|
||||||
quality: Quality,
|
quality: Quality,
|
||||||
) {
|
) {
|
||||||
|
// Memory-pressure watchdog: critical pressure latched a halt — do not
|
||||||
|
// start a new download (issue #660).
|
||||||
|
if crate::memory_watchdog::prefetch_halted() {
|
||||||
|
log::debug!("[qbzd] driver: prefetch skipped — memory-pressure halt active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let profile = qbz_core::system_capabilities::memory_profile();
|
||||||
let core = runtime.core();
|
let core = runtime.core();
|
||||||
let upcoming = core.peek_upcoming(1).await;
|
// Depth honors the profile's prefetch_count cap. The daemon's
|
||||||
|
// historical depth is 1 successor per advance, which is within BOTH
|
||||||
|
// classes' caps (LowMemory 1, Normal 5), so this changes nothing today
|
||||||
|
// and clamps automatically if the depth ever grows. There is no
|
||||||
|
// concurrency loop here — the single prefetch below is awaited — so
|
||||||
|
// max_concurrent_prefetch (>= 1 in both classes) is trivially honored.
|
||||||
|
let depth = 1usize.min(profile.prefetch_count);
|
||||||
|
let upcoming = core.peek_upcoming(depth).await;
|
||||||
let Some(next) = upcoming.into_iter().next() else {
|
let Some(next) = upcoming.into_iter().next() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -578,11 +592,34 @@ async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
|
|||||||
let Some(client) = guard.as_ref() else {
|
let Some(client) = guard.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Err(e) = player.prefetch_into_cache(client, next.id, quality).await {
|
// Quality cap: the LowMemory profile disallows HiRes prefetch entirely
|
||||||
|
// (~60 MB/track → ~15 MB at Lossless); the watchdog additionally pauses
|
||||||
|
// HiRes prefetch on low-pressure ticks. This caps only the warm-ahead
|
||||||
|
// copy — playback of the user's selected quality is unaffected.
|
||||||
|
let allow_hires =
|
||||||
|
profile.allow_hires_prefetch && !crate::memory_watchdog::hires_prefetch_paused();
|
||||||
|
let prefetch_quality = cap_prefetch_quality(quality, allow_hires);
|
||||||
|
if let Err(e) = player
|
||||||
|
.prefetch_into_cache(client, next.id, prefetch_quality)
|
||||||
|
.await
|
||||||
|
{
|
||||||
log::debug!("[qbzd] driver: prefetch track {} failed: {e}", next.id);
|
log::debug!("[qbzd] driver: prefetch track {} failed: {e}", next.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure prefetch-quality cap: when HiRes prefetch is disallowed (LowMemory
|
||||||
|
/// profile or a low-pressure watchdog tick), HiRes/UltraHiRes warm-aheads
|
||||||
|
/// downgrade to Lossless; anything else passes through unchanged.
|
||||||
|
fn cap_prefetch_quality(requested: Quality, allow_hires: bool) -> Quality {
|
||||||
|
if allow_hires {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
match requested {
|
||||||
|
Quality::HiRes | Quality::UltraHiRes => Quality::Lossless,
|
||||||
|
q => q,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Project the live queue into the decision's [`QueueSnapshot`].
|
/// Project the live queue into the decision's [`QueueSnapshot`].
|
||||||
async fn queue_snapshot<A: FrontendAdapter + Send + Sync + 'static>(
|
async fn queue_snapshot<A: FrontendAdapter + Send + Sync + 'static>(
|
||||||
core: &QbzCore<A>,
|
core: &QbzCore<A>,
|
||||||
@@ -890,4 +927,30 @@ mod tests {
|
|||||||
assert!(!a.contains(&DriverAction::AdvanceAndPlay));
|
assert!(!a.contains(&DriverAction::AdvanceAndPlay));
|
||||||
assert!(a.contains(&DriverAction::ReportEdge)); // play-state edge
|
assert!(a.contains(&DriverAction::ReportEdge)); // play-state edge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefetch_quality_uncapped_when_hires_allowed() {
|
||||||
|
// Normal profile, healthy pressure: every tier passes through.
|
||||||
|
for q in [
|
||||||
|
Quality::Mp3,
|
||||||
|
Quality::Lossless,
|
||||||
|
Quality::HiRes,
|
||||||
|
Quality::UltraHiRes,
|
||||||
|
] {
|
||||||
|
assert_eq!(cap_prefetch_quality(q, true), q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefetch_quality_caps_hires_to_lossless_when_disallowed() {
|
||||||
|
// LowMemory profile / low-pressure tick: HiRes warm-aheads shrink.
|
||||||
|
assert_eq!(cap_prefetch_quality(Quality::HiRes, false), Quality::Lossless);
|
||||||
|
assert_eq!(
|
||||||
|
cap_prefetch_quality(Quality::UltraHiRes, false),
|
||||||
|
Quality::Lossless
|
||||||
|
);
|
||||||
|
// Lower tiers are untouched.
|
||||||
|
assert_eq!(cap_prefetch_quality(Quality::Lossless, false), Quality::Lossless);
|
||||||
|
assert_eq!(cap_prefetch_quality(Quality::Mp3, false), Quality::Mp3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +318,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn test_runtime() -> AppRuntime<NoOpAdapter> {
|
fn test_runtime() -> AppRuntime<NoOpAdapter> {
|
||||||
|
// QbzCore builds a reqwest client (MusicBrainz); since reqwest no
|
||||||
|
// longer pulls a rustls provider (#663), tests must install the
|
||||||
|
// process-level one explicitly, exactly like the real binaries do at
|
||||||
|
// startup — otherwise Client construction panics with "No provider set".
|
||||||
|
crate::ensure_crypto_provider();
|
||||||
AppRuntime::with_audio_settings(NoOpAdapter, None, AudioSettings::default(), None)
|
AppRuntime::with_audio_settings(NoOpAdapter, None, AudioSettings::default(), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -885,9 +885,18 @@ impl AlsaDirectStream {
|
|||||||
use alsa::mixer::SelemChannelId::*;
|
use alsa::mixer::SelemChannelId::*;
|
||||||
use alsa::mixer::{Mixer, SelemId};
|
use alsa::mixer::{Mixer, SelemId};
|
||||||
|
|
||||||
// Open mixer for device
|
// Mixer controls live on the CARD ctl device, not on the PCM alias the
|
||||||
let mixer = Mixer::new(&self.device_id, false)
|
// stream opened: `iec958:CARD=x,DEV=0` (HiFiBerry Digi, #331/#659),
|
||||||
.map_err(|e| format!("Failed to open mixer for {}: {}", self.device_id, e))?;
|
// `hdmi:`, `front:`… are PCM plugin ids the mixer can't attach to, and
|
||||||
|
// a `DEV`-qualified `hw:` id isn't a valid ctl name either. Open the
|
||||||
|
// mixer on the derived `hw:CARD=<name>` instead.
|
||||||
|
let ctl_name = mixer_ctl_name(&self.device_id);
|
||||||
|
let mixer = Mixer::new(&ctl_name, false).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to open mixer for {} (ctl {}): {}",
|
||||||
|
self.device_id, ctl_name, e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Try to find a volume control element
|
// Try to find a volume control element
|
||||||
// Common names: "Master", "PCM", "Speaker", "Headphone"
|
// Common names: "Master", "PCM", "Speaker", "Headphone"
|
||||||
@@ -970,3 +979,64 @@ impl AlsaDirectStream {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive the ALSA **card** ctl id from a PCM device id for mixer access.
|
||||||
|
///
|
||||||
|
/// Mixer elements are per-card; attaching a mixer to a PCM plugin alias
|
||||||
|
/// (`iec958:`, `hdmi:`, `front:`, `sysdefault:`…) or a `DEV`-qualified `hw:`
|
||||||
|
/// id fails. Maps every shape to `hw:CARD=<name>` when a `CARD=` argument is
|
||||||
|
/// present, to `hw:<n>` for the numeric `hw:N,M` / `plughw:N,M` forms, and
|
||||||
|
/// falls back to the raw id otherwise (e.g. `default` — which then fails with
|
||||||
|
/// the same "no mixer" outcome as before, no regression).
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
pub(crate) fn mixer_ctl_name(device_id: &str) -> String {
|
||||||
|
if let Some((_, args)) = device_id.split_once(':') {
|
||||||
|
for arg in args.split(',') {
|
||||||
|
if let Some(name) = arg.trim().strip_prefix("CARD=") {
|
||||||
|
if !name.is_empty() {
|
||||||
|
return format!("hw:CARD={name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Numeric form: `hw:1,0` / `plughw:1` → `hw:1` (card index only).
|
||||||
|
if let Some(first) = args.split(',').next() {
|
||||||
|
let first = first.trim();
|
||||||
|
if first.parse::<u32>().is_ok() {
|
||||||
|
return format!("hw:{first}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
device_id.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::mixer_ctl_name;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixer_ctl_name_maps_card_forms() {
|
||||||
|
// HiFiBerry Digi S/PDIF (#331/#659) and other plugin aliases.
|
||||||
|
assert_eq!(
|
||||||
|
mixer_ctl_name("iec958:CARD=sndrpihifiberry,DEV=0"),
|
||||||
|
"hw:CARD=sndrpihifiberry"
|
||||||
|
);
|
||||||
|
assert_eq!(mixer_ctl_name("hw:CARD=C20,DEV=0"), "hw:CARD=C20");
|
||||||
|
assert_eq!(mixer_ctl_name("front:CARD=PCH,DEV=0"), "hw:CARD=PCH");
|
||||||
|
assert_eq!(mixer_ctl_name("hdmi:CARD=NVidia,DEV=1"), "hw:CARD=NVidia");
|
||||||
|
assert_eq!(
|
||||||
|
mixer_ctl_name("sysdefault:CARD=DacMagic,DEV=0"),
|
||||||
|
"hw:CARD=DacMagic"
|
||||||
|
);
|
||||||
|
// CARD= in a non-first position.
|
||||||
|
assert_eq!(mixer_ctl_name("hw:DEV=0,CARD=DacMagic"), "hw:CARD=DacMagic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixer_ctl_name_numeric_and_fallback() {
|
||||||
|
assert_eq!(mixer_ctl_name("hw:1,0"), "hw:1");
|
||||||
|
assert_eq!(mixer_ctl_name("plughw:2"), "hw:2");
|
||||||
|
// No CARD= and no numeric arg → unchanged.
|
||||||
|
assert_eq!(mixer_ctl_name("default"), "default");
|
||||||
|
assert_eq!(mixer_ctl_name("pulse"), "pulse");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -251,6 +251,19 @@ impl AudioCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop every L1 in-memory entry WITHOUT touching the L2 disk cache or
|
||||||
|
/// the fetching/failed bookkeeping. The memory-pressure watchdog's
|
||||||
|
/// relief valve: RAM is the scarce resource under pressure, the disk
|
||||||
|
/// files cost nothing to keep and save a re-download on recovery.
|
||||||
|
pub fn evict_all_memory(&self) {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let dropped = state.tracks.len();
|
||||||
|
state.tracks.clear();
|
||||||
|
state.access_order.clear();
|
||||||
|
state.current_size = 0;
|
||||||
|
log::info!("L1 memory cache evicted ({dropped} tracks dropped, L2 disk cache kept)");
|
||||||
|
}
|
||||||
|
|
||||||
/// Get cache statistics
|
/// Get cache statistics
|
||||||
pub fn stats(&self) -> CacheStats {
|
pub fn stats(&self) -> CacheStats {
|
||||||
let state = self.state.lock().unwrap();
|
let state = self.state.lock().unwrap();
|
||||||
|
|||||||
@@ -244,6 +244,83 @@ impl PlaybackCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert a track whose bytes are streamed in by `fill` rather than
|
||||||
|
/// passed as an already-materialized slice. Used by the low-memory
|
||||||
|
/// oversized-track path, where a second full in-RAM copy of the track
|
||||||
|
/// just to hand `insert` a `&[u8]` is exactly what we're avoiding —
|
||||||
|
/// `fill` typically chunked-copies straight out of the playback buffer.
|
||||||
|
///
|
||||||
|
/// `size_hint` is the expected byte count: it drives the too-large
|
||||||
|
/// rejection and pre-write eviction; the entry records the actual bytes
|
||||||
|
/// written.
|
||||||
|
pub fn insert_from<F>(&self, track_id: u64, size_hint: u64, fill: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut fs::File) -> std::io::Result<usize>,
|
||||||
|
{
|
||||||
|
// Don't cache if larger than max size
|
||||||
|
if size_hint > self.max_size_bytes {
|
||||||
|
log::debug!(
|
||||||
|
"Track {} too large for playback cache ({} MB > {} MB)",
|
||||||
|
track_id,
|
||||||
|
size_hint / (1024 * 1024),
|
||||||
|
self.max_size_bytes / (1024 * 1024)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict old entries if needed
|
||||||
|
self.evict_if_needed(size_hint);
|
||||||
|
|
||||||
|
let path = self.track_path(track_id);
|
||||||
|
|
||||||
|
match fs::File::create(&path) {
|
||||||
|
Ok(mut file) => match fill(&mut file) {
|
||||||
|
Ok(written) => {
|
||||||
|
let size = written as u64;
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
|
||||||
|
// Remove old entry if exists
|
||||||
|
if let Some(old) = state.entries.remove(&track_id) {
|
||||||
|
state.current_size = state.current_size.saturating_sub(old.size_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.entries.insert(
|
||||||
|
track_id,
|
||||||
|
CacheEntry {
|
||||||
|
track_id,
|
||||||
|
size_bytes: size,
|
||||||
|
last_accessed: SystemTime::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.current_size += size;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Saved track {} to playback cache ({} KB). Total: {} MB / {} MB",
|
||||||
|
track_id,
|
||||||
|
size / 1024,
|
||||||
|
state.current_size / (1024 * 1024),
|
||||||
|
self.max_size_bytes / (1024 * 1024)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to stream playback cache file for track {}: {}",
|
||||||
|
track_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to create playback cache file for track {}: {}",
|
||||||
|
track_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Evict oldest entries to make room for new data
|
/// Evict oldest entries to make room for new data
|
||||||
fn evict_if_needed(&self, needed_bytes: u64) {
|
fn evict_if_needed(&self, needed_bytes: u64) {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
|
|||||||
@@ -68,43 +68,62 @@ impl DlnaDiscovery {
|
|||||||
|
|
||||||
if let Ok(stream) = discover {
|
if let Ok(stream) = discover {
|
||||||
let mut stream = std::pin::pin!(stream);
|
let mut stream = std::pin::pin!(stream);
|
||||||
while let Ok(Some(device)) = stream.try_next().await {
|
// Error-tolerant loop: a single device whose description
|
||||||
let id = device.udn().to_string();
|
// fetch/parse fails must NOT kill the whole window (the
|
||||||
let name = device.friendly_name().to_string();
|
// old `while let Ok(...)` broke on the first Err and
|
||||||
let manufacturer = device.manufacturer().to_string();
|
// silently dropped every other responder, forever — the
|
||||||
let model = device.model_name().to_string();
|
// #667 class of silent failure). Log and continue.
|
||||||
let url = device.url().to_string();
|
loop {
|
||||||
let ip = device.url().host().unwrap_or("unknown").to_string();
|
match stream.try_next().await {
|
||||||
|
Ok(Some(device)) => {
|
||||||
|
let id = device.udn().to_string();
|
||||||
|
let name = device.friendly_name().to_string();
|
||||||
|
let manufacturer = device.manufacturer().to_string();
|
||||||
|
let model = device.model_name().to_string();
|
||||||
|
let url = device.url().to_string();
|
||||||
|
let ip = device.url().host().unwrap_or("unknown").to_string();
|
||||||
|
|
||||||
let mut has_av_transport = false;
|
let mut has_av_transport = false;
|
||||||
let mut has_rendering_control = false;
|
let mut has_rendering_control = false;
|
||||||
|
|
||||||
for service in device.services_iter() {
|
for service in device.services_iter() {
|
||||||
// Version-agnostic match: a renderer advertising only
|
// Version-agnostic match: a renderer advertising only
|
||||||
// AVTransport:2/:3 (not :1) must still count as capable.
|
// AVTransport:2/:3 (not :1) must still count as capable.
|
||||||
let service_type = service.service_type().to_string();
|
let service_type = service.service_type().to_string();
|
||||||
if service_type.contains(":service:AVTransport:") {
|
if service_type.contains(":service:AVTransport:") {
|
||||||
has_av_transport = true;
|
has_av_transport = true;
|
||||||
|
}
|
||||||
|
if service_type.contains(":service:RenderingControl:") {
|
||||||
|
has_rendering_control = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[Cast] DLNA discovered: '{name}' ({manufacturer} {model}) at {ip} avt={has_av_transport} rc={has_rendering_control}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let discovered = DiscoveredDlnaDevice {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
ip,
|
||||||
|
url,
|
||||||
|
has_av_transport,
|
||||||
|
has_rendering_control,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(mut state) = state.lock() {
|
||||||
|
state.devices.insert(id, discovered);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if service_type.contains(":service:RenderingControl:") {
|
Ok(None) => break, // window elapsed
|
||||||
has_rendering_control = true;
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"[Cast] DLNA discovery: skipping a device (description fetch/parse failed): {e}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let discovered = DiscoveredDlnaDevice {
|
|
||||||
id: id.clone(),
|
|
||||||
name,
|
|
||||||
manufacturer,
|
|
||||||
model,
|
|
||||||
ip,
|
|
||||||
url,
|
|
||||||
has_av_transport,
|
|
||||||
has_rendering_control,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(mut state) = state.lock() {
|
|
||||||
state.devices.insert(id, discovered);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -582,6 +582,18 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a track at the END of the manual block ("Play later", #442):
|
||||||
|
/// after every play-next / play-later already queued, before the source
|
||||||
|
/// resumes. "Add to queue" stays untouched at the absolute end.
|
||||||
|
pub async fn add_track_later(&self, track: QueueTrack) {
|
||||||
|
let queue = self.queue.write().await;
|
||||||
|
queue.add_track_later(track);
|
||||||
|
self.emit(CoreEvent::QueueUpdated {
|
||||||
|
state: queue.get_state(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the entire queue (replaces existing)
|
/// Set the entire queue (replaces existing)
|
||||||
pub async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>) {
|
pub async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>) {
|
||||||
// Any queue replacement drops the offline-only-playlist stamp; the
|
// Any queue replacement drops the offline-only-playlist stamp; the
|
||||||
|
|||||||
@@ -78,6 +78,40 @@ fn has_redaction_candidate(lower: &str) -> bool {
|
|||||||
NEEDLES.iter().any(|n| lower.contains(n))
|
NEEDLES.iter().any(|n| lower.contains(n))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// IPv4 octet (0-255) — the range guard is what keeps version strings
|
||||||
|
/// ("1.16.1", "8.2.0") and timestamps out of the mask.
|
||||||
|
const OCTET: &str = r"(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
|
||||||
|
|
||||||
|
/// IP-masking patterns (owner 2026-07-24): ANY address — LAN or public — is
|
||||||
|
/// masked to its LAST segment only (`192.168.178.211` → `*.*.*.211`), so
|
||||||
|
/// uploaded logs stop leaking the user's network topology. The port survives.
|
||||||
|
///
|
||||||
|
/// IPv6 groups must START with a 4-hex-digit group — that is what keeps time
|
||||||
|
/// strings ("11:41:48.054") and MAC addresses out of the mask (regex has no
|
||||||
|
/// lookaround, so the width anchor does the disambiguation).
|
||||||
|
fn ip_patterns() -> &'static Vec<Regex> {
|
||||||
|
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
|
||||||
|
PATTERNS.get_or_init(|| {
|
||||||
|
[
|
||||||
|
// IPv4: mask the first three octets, keep the last (capture 1).
|
||||||
|
format!(r"\b(?:{OCTET}\.){{3}}({OCTET})\b"),
|
||||||
|
// IPv6 full form (first group 4 hex wide — see the doc above).
|
||||||
|
r"\b[0-9a-fA-F]{4}:(?:[0-9a-fA-F]{1,4}:){1,6}([0-9a-fA-F]{1,4})\b".to_string(),
|
||||||
|
// IPv6 `::`-compressed form (same 4-wide anchor).
|
||||||
|
r"\b[0-9a-fA-F]{4}(?::[0-9a-fA-F]{0,4})*::(?:[0-9a-fA-F]{1,4}:)*([0-9a-fA-F]{1,4})\b".to_string(),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| Regex::new(p).ok())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap pre-check for the IP layer: a dot or colon and a digit, O(n). (The
|
||||||
|
/// colon is required — IPv6 has no dots.)
|
||||||
|
fn has_ip_candidate(line: &str) -> bool {
|
||||||
|
(line.contains('.') || line.contains(':')) && line.bytes().any(|b| b.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
/// Redact secrets from a single log line. Literal live-secret layer first, then regex.
|
/// Redact secrets from a single log line. Literal live-secret layer first, then regex.
|
||||||
pub fn redact(line: &str) -> String {
|
pub fn redact(line: &str) -> String {
|
||||||
let mut out = line.to_string();
|
let mut out = line.to_string();
|
||||||
@@ -103,6 +137,16 @@ pub fn redact(line: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Layer 3 — IP masking (`*.*.*.<last>` for v4, `*:*:<last>` for v6).
|
||||||
|
if has_ip_candidate(&out) {
|
||||||
|
for (i, re) in ip_patterns().iter().enumerate() {
|
||||||
|
if re.is_match(&out) {
|
||||||
|
let replacement = if i == 0 { "*.*.*.${1}" } else { "*:*:${1}" };
|
||||||
|
out = re.replace_all(&out, replacement).into_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,4 +185,38 @@ mod tests {
|
|||||||
let r = redact("value abc here");
|
let r = redact("value abc here");
|
||||||
assert!(r.contains("abc"), "short value should not be scrubbed: {r}");
|
assert!(r.contains("abc"), "short value should not be scrubbed: {r}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn masks_ipv4_keep_last_octet() {
|
||||||
|
let r = redact("DLNA: Set URI to http://192.168.178.211:9876/audio/***/235314822");
|
||||||
|
assert_eq!(
|
||||||
|
r,
|
||||||
|
"DLNA: Set URI to http://*.*.*.211:9876/audio/***/235314822"
|
||||||
|
);
|
||||||
|
// Public addresses get the same treatment.
|
||||||
|
let r = redact("dial 203.0.113.9 failed");
|
||||||
|
assert!(r.contains("*.*.*.9"), "public IPv4 survived: {r}");
|
||||||
|
assert!(!r.contains("203.0.113"), "public IPv4 leaked: {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ip_mask_leaves_versions_and_timestamps_alone() {
|
||||||
|
for s in [
|
||||||
|
"2026-07-24 11:41:48.054 INFO qbz",
|
||||||
|
"bundle 8.2.0-b034",
|
||||||
|
"slint 1.16.1",
|
||||||
|
"kernel 6.17.0-40-generic",
|
||||||
|
"rate 44.1kHz 24-bit / 192 kHz",
|
||||||
|
] {
|
||||||
|
assert_eq!(redact(s), s, "non-IP was mangled: {s}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn masks_ipv6() {
|
||||||
|
let r = redact("bind fe80:0000:0000:0000:0224:a4ff:fe12:3456 up");
|
||||||
|
assert!(r.contains("*:*:3456"), "full IPv6 survived: {r}");
|
||||||
|
let r2 = redact("bind fe80::224:a4ff:fe12:3456 up");
|
||||||
|
assert!(r2.contains("*:*:3456"), "compressed IPv6 survived: {r2}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
mod types;
|
mod types;
|
||||||
pub use types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
|
pub use types::{xesam_url_for, MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
|
||||||
|
|
||||||
pub mod notify;
|
pub mod notify;
|
||||||
pub use notify::{show_track_notification, NotificationMeta};
|
pub use notify::{show_track_notification, NotificationMeta};
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ fn build_metadata(meta: &TrackMeta) -> Metadata {
|
|||||||
if let Some(url) = &meta.art_url {
|
if let Some(url) = &meta.art_url {
|
||||||
b = b.art_url(url.clone());
|
b = b.art_url(url.clone());
|
||||||
}
|
}
|
||||||
|
if let Some(url) = &meta.url {
|
||||||
|
b = b.url(url.clone());
|
||||||
|
}
|
||||||
b.build()
|
b.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,26 @@ pub struct TrackMeta {
|
|||||||
/// is NOT the application icon (that is resolved by GNOME from the MPRIS
|
/// is NOT the application icon (that is resolved by GNOME from the MPRIS
|
||||||
/// `DesktopEntry` property → the installed .desktop → `Icon=`).
|
/// `DesktopEntry` property → the installed .desktop → `Icon=`).
|
||||||
pub art_url: Option<String>,
|
pub art_url: Option<String>,
|
||||||
|
/// `xesam:url` (#658) — the track's PUBLIC web link so external scripts
|
||||||
|
/// (playerctl widgets, handoff automation, scrobblers) can identify the
|
||||||
|
/// exact song: `https://open.qobuz.com/track/<id>` for Qobuz catalog rows
|
||||||
|
/// (offline `qobuz_download` keeps its real Qobuz id, so it qualifies too).
|
||||||
|
/// None for local/Plex rows — QueueTrack carries no file path to map to a
|
||||||
|
/// file:// URI.
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `xesam:url` for a queue track (#658): the public Qobuz web link
|
||||||
|
/// (`https://open.qobuz.com/track/<id>`) for catalog tracks — offline
|
||||||
|
/// `qobuz_download` rows qualify too, their id IS the real Qobuz id. None for
|
||||||
|
/// `local` / `plex` rows: QueueTrack carries no file path to map to a file://
|
||||||
|
/// URI, and a synthetic local id would produce a bogus Qobuz link.
|
||||||
|
pub fn xesam_url_for(source: Option<&str>, track_id: u64) -> Option<String> {
|
||||||
|
match source.map(|s| s.to_ascii_lowercase()) {
|
||||||
|
Some(ref s) if s == "local" || s == "plex" => None,
|
||||||
|
_ if track_id > 0 => Some(format!("https://open.qobuz.com/track/{track_id}")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ pub struct QueueState {
|
|||||||
pub repeat: RepeatMode,
|
pub repeat: RepeatMode,
|
||||||
pub total_tracks: usize,
|
pub total_tracks: usize,
|
||||||
pub stop_after_track_id: Option<u64>,
|
pub stop_after_track_id: Option<u64>,
|
||||||
|
/// Manual-block size (#442): how many entries right after `current_index`
|
||||||
|
/// were hand-queued ("Play next" / "Play later") and play before the
|
||||||
|
/// source resumes.
|
||||||
|
pub manual_next_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Playback State ============
|
// ============ Playback State ============
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! Process-wide memory tuning for the player.
|
||||||
|
//!
|
||||||
|
//! `qbz-core`'s `system_capabilities` detects the host's memory profile
|
||||||
|
//! (Normal vs LowMemory), but `qbz-core` depends on `qbz-player` — the
|
||||||
|
//! player cannot read the profile back without a dependency cycle. So the
|
||||||
|
//! frontend binaries (`qbz`, `qbzd`) read the profile once at startup and
|
||||||
|
//! push the derived values down here, exactly like the pre-existing
|
||||||
|
//! [`super::streaming_source::set_max_initial_buffer_bytes`] cap.
|
||||||
|
//!
|
||||||
|
//! Every knob defaults to today's hardcoded Normal behavior, so a consumer
|
||||||
|
//! that never calls [`apply_memory_tuning`] (tests, auxiliary tools) is
|
||||||
|
//! byte-identical to before.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// Whether the host profile is LowMemory (< 2 GB RAM, Raspberry Pi-class).
|
||||||
|
/// Default false = Normal — behavioral changes gated on this flag must keep
|
||||||
|
/// Normal hosts unchanged.
|
||||||
|
static LOW_MEMORY_CLASS: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// L1 (in-memory) audio-cache budget in bytes. Defaults to the historical
|
||||||
|
/// hardcoded 400 MB; the LowMemory profile lowers it to 50 MB so the cache
|
||||||
|
/// cannot eat 40 % of a 1 GB host (issue #660).
|
||||||
|
static AUDIO_CACHE_L1_MAX_BYTES: AtomicUsize = AtomicUsize::new(400 * 1024 * 1024);
|
||||||
|
|
||||||
|
/// Push the host memory profile's derived values into the player. Called
|
||||||
|
/// once at process start by the frontend binaries; not designed for
|
||||||
|
/// mid-session mutation (the L1 cache reads its budget at construction).
|
||||||
|
pub fn apply_memory_tuning(
|
||||||
|
low_memory: bool,
|
||||||
|
l1_max_bytes: usize,
|
||||||
|
max_initial_buffer_bytes: usize,
|
||||||
|
) {
|
||||||
|
LOW_MEMORY_CLASS.store(low_memory, Ordering::Relaxed);
|
||||||
|
AUDIO_CACHE_L1_MAX_BYTES.store(l1_max_bytes, Ordering::Relaxed);
|
||||||
|
super::streaming_source::set_max_initial_buffer_bytes(max_initial_buffer_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the host was classified LowMemory at startup.
|
||||||
|
pub fn is_low_memory_class() -> bool {
|
||||||
|
LOW_MEMORY_CLASS.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configured L1 audio-cache budget in bytes.
|
||||||
|
pub fn audio_cache_l1_max_bytes() -> usize {
|
||||||
|
AUDIO_CACHE_L1_MAX_BYTES.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure predicate: a completed track of `len` bytes is "oversized" for an
|
||||||
|
/// L1 budget of `l1_max_bytes` when it exceeds a quarter of that budget.
|
||||||
|
///
|
||||||
|
/// Rationale for the /4: the streaming buffer already holds one full copy
|
||||||
|
/// of the track in RAM for playback, so promoting an oversized track into
|
||||||
|
/// L1 (plus the promotion clone itself) transiently triples its footprint.
|
||||||
|
/// Anything beyond a quarter of the L1 budget would also evict every other
|
||||||
|
/// cached track on insert, so the cache stop pays for itself only for
|
||||||
|
/// smaller tracks. With the LowMemory 50 MB budget this flags tracks over
|
||||||
|
/// 12.5 MB; the predicate is only consulted on LowMemory hosts.
|
||||||
|
pub fn oversized_for_l1(len: usize, l1_max_bytes: usize) -> bool {
|
||||||
|
len > l1_max_bytes / 4
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const LOW_L1: usize = 50 * 1024 * 1024; // LowMemory profile budget
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_boundary_is_quarter_of_budget() {
|
||||||
|
let threshold = LOW_L1 / 4;
|
||||||
|
assert!(!oversized_for_l1(threshold, LOW_L1)); // exactly at: still cached
|
||||||
|
assert!(oversized_for_l1(threshold + 1, LOW_L1));
|
||||||
|
assert!(!oversized_for_l1(0, LOW_L1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_budget_flags_only_very_large_tracks() {
|
||||||
|
// The predicate is gated on the LowMemory class at the call sites,
|
||||||
|
// but with the Normal 400 MB budget the threshold would be 100 MB.
|
||||||
|
let normal = 400 * 1024 * 1024;
|
||||||
|
assert!(!oversized_for_l1(60 * 1024 * 1024, normal)); // typical HiRes
|
||||||
|
assert!(oversized_for_l1(200 * 1024 * 1024, normal)); // issue #660 track
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_match_historical_hardcoded_behavior() {
|
||||||
|
// A process that never applied tuning: Normal class, 400 MB L1 —
|
||||||
|
// identical to the pre-wiring hardcoded values.
|
||||||
|
assert!(!is_low_memory_class());
|
||||||
|
assert_eq!(audio_cache_l1_max_bytes(), 400 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@
|
|||||||
mod playback_engine;
|
mod playback_engine;
|
||||||
mod streaming_source;
|
mod streaming_source;
|
||||||
|
|
||||||
|
pub mod memory_tuning;
|
||||||
|
|
||||||
|
pub use memory_tuning::{
|
||||||
|
apply_memory_tuning, audio_cache_l1_max_bytes, is_low_memory_class, oversized_for_l1,
|
||||||
|
};
|
||||||
pub use streaming_source::{
|
pub use streaming_source::{
|
||||||
max_initial_buffer_bytes, set_max_initial_buffer_bytes, BufferWriter, BufferedMediaSource,
|
max_initial_buffer_bytes, set_max_initial_buffer_bytes, BufferWriter, BufferedMediaSource,
|
||||||
InMemorySource, IncrementalStreamingSource, StreamingConfig,
|
InMemorySource, IncrementalStreamingSource, StreamingConfig,
|
||||||
@@ -1603,6 +1608,11 @@ impl Player {
|
|||||||
const PAUSE_SUSPEND_DELAY_MS: u64 = 2000;
|
const PAUSE_SUSPEND_DELAY_MS: u64 = 2000;
|
||||||
let mut pause_suspend_deadline: Option<Instant> = None;
|
let mut pause_suspend_deadline: Option<Instant> = None;
|
||||||
let mut last_empty_check = Instant::now();
|
let mut last_empty_check = Instant::now();
|
||||||
|
// Latch so the low-memory oversized-track promotion skip logs
|
||||||
|
// once per track instead of on every 500 ms idle tick. Reset
|
||||||
|
// whenever the streaming source is absent or still downloading
|
||||||
|
// (i.e. the next track re-arms the log).
|
||||||
|
let mut low_mem_promotion_skip_logged = false;
|
||||||
// Current track's normalization gain factor (stored for reuse on resume/seek)
|
// Current track's normalization gain factor (stored for reuse on resume/seek)
|
||||||
let mut current_normalization_gain: Option<f32> = None;
|
let mut current_normalization_gain: Option<f32> = None;
|
||||||
// Current track's dynamic gain atomic (shared with DynamicAmplify + LoudnessAnalyzer)
|
// Current track's dynamic gain atomic (shared with DynamicAmplify + LoudnessAnalyzer)
|
||||||
@@ -3721,18 +3731,59 @@ impl Player {
|
|||||||
if let Some(streaming_src) = current_streaming_source.as_ref() {
|
if let Some(streaming_src) = current_streaming_source.as_ref() {
|
||||||
if streaming_src.is_complete() {
|
if streaming_src.is_complete() {
|
||||||
if current_audio_data.is_none() {
|
if current_audio_data.is_none() {
|
||||||
if let Some(full_data) =
|
// Low-memory profile + oversized
|
||||||
streaming_src.take_complete_data()
|
// track: skip the promotion clone
|
||||||
{
|
// (take_complete_data copies the
|
||||||
log::info!(
|
// whole buffer — a persistent 2x
|
||||||
"Streaming promotion: full track buffered ({} bytes), enabling cached transition path",
|
// RSS for the rest of the track)
|
||||||
full_data.len()
|
// and KEEP the streaming source:
|
||||||
);
|
// seek/resume then read the
|
||||||
current_audio_data = Some(full_data);
|
// single buffered copy. The
|
||||||
|
// gapless pre-queue gate below
|
||||||
|
// requires the source cleared, so
|
||||||
|
// such a track transitions with a
|
||||||
|
// small gap instead of gapless —
|
||||||
|
// accepted trade vs. an OOM kill
|
||||||
|
// on 1 GB hosts (issue #660). The
|
||||||
|
// L2 disk copy is written by the
|
||||||
|
// CMAF feeder task, off this
|
||||||
|
// thread.
|
||||||
|
let skip_promotion =
|
||||||
|
memory_tuning::is_low_memory_class()
|
||||||
|
&& memory_tuning::oversized_for_l1(
|
||||||
|
streaming_src.buffer_size(),
|
||||||
|
memory_tuning::audio_cache_l1_max_bytes(),
|
||||||
|
);
|
||||||
|
if skip_promotion {
|
||||||
|
if !low_mem_promotion_skip_logged {
|
||||||
|
low_mem_promotion_skip_logged = true;
|
||||||
|
log::info!(
|
||||||
|
"Streaming promotion skipped (low-memory host): {} bytes exceeds the oversized threshold — keeping the single buffered copy",
|
||||||
|
streaming_src.buffer_size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(full_data) =
|
||||||
|
streaming_src.take_complete_data()
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
"Streaming promotion: full track buffered ({} bytes), enabling cached transition path",
|
||||||
|
full_data.len()
|
||||||
|
);
|
||||||
|
current_audio_data = Some(full_data);
|
||||||
|
}
|
||||||
|
clear_streaming_source = true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
clear_streaming_source = true;
|
||||||
}
|
}
|
||||||
clear_streaming_source = true;
|
} else {
|
||||||
|
// Still downloading: re-arm the
|
||||||
|
// skip log for the next track.
|
||||||
|
low_mem_promotion_skip_logged = false;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
low_mem_promotion_skip_logged = false;
|
||||||
}
|
}
|
||||||
if clear_streaming_source {
|
if clear_streaming_source {
|
||||||
current_streaming_source = None;
|
current_streaming_source = None;
|
||||||
@@ -3992,17 +4043,21 @@ impl Player {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Two-level playback cache: L1 in memory (~400 MB), L2 on disk
|
// Two-level playback cache: L1 in memory, L2 on disk (~800 MB). The
|
||||||
// (~800 MB). A disk-cache failure degrades to L1-only rather than
|
// L1 budget comes from the host memory profile pushed down via
|
||||||
// aborting player creation.
|
// `memory_tuning` at process start (400 MB Normal / 50 MB LowMemory)
|
||||||
|
// — on a 1 GB host the historical 400 MB hardcode alone could eat
|
||||||
|
// 40 % of RAM (issue #660). A disk-cache failure degrades to
|
||||||
|
// L1-only rather than aborting player creation.
|
||||||
|
let l1_max_bytes = memory_tuning::audio_cache_l1_max_bytes();
|
||||||
let audio_cache = match qbz_cache::PlaybackCache::new(800 * 1024 * 1024) {
|
let audio_cache = match qbz_cache::PlaybackCache::new(800 * 1024 * 1024) {
|
||||||
Ok(pc) => Arc::new(qbz_cache::AudioCache::with_playback_cache(
|
Ok(pc) => Arc::new(qbz_cache::AudioCache::with_playback_cache(
|
||||||
400 * 1024 * 1024,
|
l1_max_bytes,
|
||||||
Arc::new(pc),
|
Arc::new(pc),
|
||||||
)),
|
)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Playback disk cache unavailable: {e}; memory cache only");
|
log::warn!("Playback disk cache unavailable: {e}; memory cache only");
|
||||||
Arc::new(qbz_cache::AudioCache::new(400 * 1024 * 1024))
|
Arc::new(qbz_cache::AudioCache::new(l1_max_bytes))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4188,6 +4243,7 @@ impl Player {
|
|||||||
track_id,
|
track_id,
|
||||||
cache,
|
cache,
|
||||||
skip_cache,
|
skip_cache,
|
||||||
|
total_flac_size,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -4382,6 +4438,13 @@ impl Player {
|
|||||||
self.audio_cache.clear();
|
self.audio_cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop only the L1 in-memory entries, keeping the L2 disk cache. The
|
||||||
|
/// memory-pressure watchdog's relief valve (issue #660): freeing RAM is
|
||||||
|
/// what relieves the pressure; the disk files cost nothing to keep.
|
||||||
|
pub fn evict_l1_audio_cache(&self) {
|
||||||
|
self.audio_cache.evict_all_memory();
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch a track's audio bytes for a gapless handoff: L1 memory →
|
/// Fetch a track's audio bytes for a gapless handoff: L1 memory →
|
||||||
/// L2 disk → CMAF `download_full` (legacy full download as fallback).
|
/// L2 disk → CMAF `download_full` (legacy full download as fallback).
|
||||||
/// Does not start playback — the caller passes the bytes to
|
/// Does not start playback — the caller passes the bytes to
|
||||||
@@ -4572,6 +4635,13 @@ impl Player {
|
|||||||
/// then fetches each audio segment, decrypts encrypted frames, and pushes
|
/// then fetches each audio segment, decrypts encrypted frames, and pushes
|
||||||
/// the resulting FLAC frame data to the streaming buffer. The player
|
/// the resulting FLAC frame data to the streaming buffer. The player
|
||||||
/// starts playing as soon as enough data is buffered.
|
/// starts playing as soon as enough data is buffered.
|
||||||
|
///
|
||||||
|
/// `expected_total_bytes` is the assembled FLAC size known from the
|
||||||
|
/// segment table; on a LowMemory host it decides up front whether the
|
||||||
|
/// track is oversized for the L1 budget, in which case NO parallel RAM
|
||||||
|
/// accumulator is built and the finished track goes straight to the L2
|
||||||
|
/// disk cache, streamed out of the playback buffer in chunks (issue
|
||||||
|
/// #660).
|
||||||
async fn cmaf_stream_segments(
|
async fn cmaf_stream_segments(
|
||||||
url_template: &str,
|
url_template: &str,
|
||||||
n_segments: u8,
|
n_segments: u8,
|
||||||
@@ -4581,6 +4651,7 @@ impl Player {
|
|||||||
track_id: u64,
|
track_id: u64,
|
||||||
cache: Arc<qbz_cache::AudioCache>,
|
cache: Arc<qbz_cache::AudioCache>,
|
||||||
skip_cache: bool,
|
skip_cache: bool,
|
||||||
|
expected_total_bytes: u64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
struct FailGuard {
|
struct FailGuard {
|
||||||
writer: BufferWriter,
|
writer: BufferWriter,
|
||||||
@@ -4615,11 +4686,21 @@ impl Player {
|
|||||||
let mut total_written: u64 = flac_header.len() as u64;
|
let mut total_written: u64 = flac_header.len() as u64;
|
||||||
// Accumulate the assembled FLAC (header + decrypted frames) so the
|
// Accumulate the assembled FLAC (header + decrypted frames) so the
|
||||||
// finished track can be cached for instant replay. Empty when
|
// finished track can be cached for instant replay. Empty when
|
||||||
// `skip_cache` (streaming_only) is set.
|
// `skip_cache` (streaming_only) is set — and never built at all for
|
||||||
let mut cache_data: Vec<u8> = if skip_cache {
|
// an oversized track on a LowMemory host, where a second full copy
|
||||||
Vec::new()
|
// of the track in RAM next to the playback buffer is the issue #660
|
||||||
} else {
|
// OOM recipe; that track goes to the L2 disk cache at the end.
|
||||||
|
let low_mem_oversized = !skip_cache
|
||||||
|
&& memory_tuning::is_low_memory_class()
|
||||||
|
&& memory_tuning::oversized_for_l1(
|
||||||
|
expected_total_bytes as usize,
|
||||||
|
memory_tuning::audio_cache_l1_max_bytes(),
|
||||||
|
);
|
||||||
|
let accumulate_cache = !skip_cache && !low_mem_oversized;
|
||||||
|
let mut cache_data: Vec<u8> = if accumulate_cache {
|
||||||
flac_header.clone()
|
flac_header.clone()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
};
|
};
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
@@ -4655,7 +4736,7 @@ impl Player {
|
|||||||
let _ = writer.error(msg.clone());
|
let _ = writer.error(msg.clone());
|
||||||
return Err(msg);
|
return Err(msg);
|
||||||
}
|
}
|
||||||
if !skip_cache {
|
if accumulate_cache {
|
||||||
cache_data.extend_from_slice(&frame);
|
cache_data.extend_from_slice(&frame);
|
||||||
}
|
}
|
||||||
total_written += frame.len() as u64;
|
total_written += frame.len() as u64;
|
||||||
@@ -4670,7 +4751,7 @@ impl Player {
|
|||||||
let _ = writer.error(msg.clone());
|
let _ = writer.error(msg.clone());
|
||||||
return Err(msg);
|
return Err(msg);
|
||||||
}
|
}
|
||||||
if !skip_cache {
|
if accumulate_cache {
|
||||||
cache_data.extend_from_slice(trailing);
|
cache_data.extend_from_slice(trailing);
|
||||||
}
|
}
|
||||||
total_written += trailing.len() as u64;
|
total_written += trailing.len() as u64;
|
||||||
@@ -4716,10 +4797,32 @@ impl Player {
|
|||||||
|
|
||||||
// Cache the assembled FLAC (header + decrypted frames) for instant
|
// Cache the assembled FLAC (header + decrypted frames) for instant
|
||||||
// replay on the next play of this track.
|
// replay on the next play of this track.
|
||||||
if !skip_cache && !cache_data.is_empty() {
|
if accumulate_cache && !cache_data.is_empty() {
|
||||||
let bytes = cache_data.len();
|
let bytes = cache_data.len();
|
||||||
cache.insert(track_id, cache_data);
|
cache.insert(track_id, cache_data);
|
||||||
log::info!("[CMAF-STREAM] Track {} cached ({} bytes)", track_id, bytes);
|
log::info!("[CMAF-STREAM] Track {} cached ({} bytes)", track_id, bytes);
|
||||||
|
} else if low_mem_oversized {
|
||||||
|
// Oversized track on a low-memory host: no RAM accumulator was
|
||||||
|
// built. Persist straight to the L2 disk playback cache,
|
||||||
|
// streamed out of the playback buffer in chunks — no second
|
||||||
|
// full in-RAM copy, and the per-chunk lock keeps the audio
|
||||||
|
// reader starved for at most ~1 MB of writes at a time.
|
||||||
|
if let Some(playback_cache) = cache.get_playback_cache() {
|
||||||
|
playback_cache.insert_from(track_id, total_written, |file| {
|
||||||
|
writer.write_buffered_to(file)
|
||||||
|
});
|
||||||
|
log::info!(
|
||||||
|
"[CMAF-STREAM] Track {} persisted to L2 disk cache only ({} bytes, low-memory oversized)",
|
||||||
|
track_id,
|
||||||
|
total_written
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"[CMAF-STREAM] Track {} ({} bytes) oversized for low-memory L1 and no disk cache available — not cached",
|
||||||
|
track_id,
|
||||||
|
total_written
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -5130,8 +5233,9 @@ impl Player {
|
|||||||
// unlike `speed_mbps`, which is estimated from the tiny init fetch and
|
// unlike `speed_mbps`, which is estimated from the tiny init fetch and
|
||||||
// is latency-dominated — it lands on the slowest ladder rung for every
|
// is latency-dominated — it lands on the slowest ladder rung for every
|
||||||
// connection. Clamped to 256KB (format-detection minimum) .. 8MB (the
|
// connection. Clamped to 256KB (format-detection minimum) .. 8MB (the
|
||||||
// process-wide ladder cap has no desktop caller, so this is the
|
// process-wide ladder cap is set from the host memory profile at
|
||||||
// effective ceiling protecting low-memory hosts).
|
// process start, so on a LowMemory host `from_speed_mbps` already
|
||||||
|
// clamped lower; the 8MB ceiling here is the last line of defense).
|
||||||
let user_secs = self
|
let user_secs = self
|
||||||
.audio_settings
|
.audio_settings
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -169,6 +169,23 @@ pub fn max_initial_buffer_bytes() -> usize {
|
|||||||
MAX_INITIAL_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed)
|
MAX_INITIAL_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Absolute ceiling for the up-front buffer reservation: 1 GiB. Guards
|
||||||
|
/// against absurd Content-Length values reserving more RAM than any real
|
||||||
|
/// track needs.
|
||||||
|
const MAX_PREALLOC_BYTES: u64 = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Pure decision for the up-front reservation in [`BufferedMediaSource::new`]:
|
||||||
|
/// `Some(capacity)` when `total_size` is known and non-zero (capped at
|
||||||
|
/// [`MAX_PREALLOC_BYTES`]), `None` when the caller should fall back to the
|
||||||
|
/// config's initial-buffer capacity.
|
||||||
|
fn prealloc_capacity(total_size: Option<u64>) -> Option<usize> {
|
||||||
|
let total = total_size?;
|
||||||
|
if total == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(total.min(MAX_PREALLOC_BYTES) as usize)
|
||||||
|
}
|
||||||
|
|
||||||
/// Internal state shared between reader and writer
|
/// Internal state shared between reader and writer
|
||||||
struct BufferState {
|
struct BufferState {
|
||||||
/// Accumulated data from HTTP response
|
/// Accumulated data from HTTP response
|
||||||
@@ -199,9 +216,30 @@ impl BufferedMediaSource {
|
|||||||
/// Returns the source and a writer for pushing downloaded chunks.
|
/// Returns the source and a writer for pushing downloaded chunks.
|
||||||
/// The writer should be used from the async download task.
|
/// The writer should be used from the async download task.
|
||||||
pub fn new(config: StreamingConfig, total_size: Option<u64>) -> (Self, BufferWriter) {
|
pub fn new(config: StreamingConfig, total_size: Option<u64>) -> (Self, BufferWriter) {
|
||||||
|
// When the total track size is known, reserve it up front: the
|
||||||
|
// buffer ends up holding the whole track either way, so this skips
|
||||||
|
// the ~8 doubling reallocs (and their memcpy) a 60-400 MB download
|
||||||
|
// would otherwise pay. Falls back to the initial-buffer capacity
|
||||||
|
// when the reservation fails (e.g. a 424 MB track on 32-bit, where
|
||||||
|
// the contiguous range may not exist) or the size is unknown.
|
||||||
|
let data = match prealloc_capacity(total_size) {
|
||||||
|
Some(cap) => {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
if v.try_reserve_exact(cap).is_err() {
|
||||||
|
log::warn!(
|
||||||
|
"Stream buffer: could not reserve {} bytes up front; growing dynamically",
|
||||||
|
cap
|
||||||
|
);
|
||||||
|
v = Vec::with_capacity(config.initial_buffer_bytes);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
None => Vec::with_capacity(config.initial_buffer_bytes),
|
||||||
|
};
|
||||||
|
|
||||||
let state = Arc::new((
|
let state = Arc::new((
|
||||||
Mutex::new(BufferState {
|
Mutex::new(BufferState {
|
||||||
data: Vec::with_capacity(config.initial_buffer_bytes),
|
data,
|
||||||
download_complete: false,
|
download_complete: false,
|
||||||
download_error: None,
|
download_error: None,
|
||||||
total_size,
|
total_size,
|
||||||
@@ -550,6 +588,30 @@ impl BufferWriter {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy the buffered bytes into `out` in bounded chunks, re-taking the
|
||||||
|
/// state lock per chunk instead of holding it for the whole transfer,
|
||||||
|
/// so live readers are starved for at most one chunk's write instead of
|
||||||
|
/// a full-track copy. Used by the low-memory oversized-track path to
|
||||||
|
/// persist the finished track to the L2 disk cache straight from the
|
||||||
|
/// playback buffer — no second full in-RAM copy. Returns bytes written.
|
||||||
|
pub fn write_buffered_to<W: std::io::Write>(&self, out: &mut W) -> IoResult<usize> {
|
||||||
|
const CHUNK: usize = 1024 * 1024;
|
||||||
|
let (lock, _) = &*self.state;
|
||||||
|
let mut offset = 0usize;
|
||||||
|
loop {
|
||||||
|
let state = lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
|
||||||
|
if offset >= state.data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let end = (offset + CHUNK).min(state.data.len());
|
||||||
|
out.write_all(&state.data[offset..end])?;
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
Ok(offset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1100,6 +1162,67 @@ mod tests {
|
|||||||
assert_eq!(cfg.max_buffer_bytes, 100 * 1024 * 1024);
|
assert_eq!(cfg.max_buffer_bytes, 100 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prealloc_capacity_decision() {
|
||||||
|
// Unknown or zero size: no reservation, caller uses the initial
|
||||||
|
// buffer capacity instead.
|
||||||
|
assert_eq!(prealloc_capacity(None), None);
|
||||||
|
assert_eq!(prealloc_capacity(Some(0)), None);
|
||||||
|
// Known size reserves exactly that (no doubling slack).
|
||||||
|
assert_eq!(prealloc_capacity(Some(60 * 1024 * 1024)), Some(60 * 1024 * 1024));
|
||||||
|
assert_eq!(prealloc_capacity(Some(1)), Some(1));
|
||||||
|
// Absurd sizes are capped at 1 GiB.
|
||||||
|
assert_eq!(
|
||||||
|
prealloc_capacity(Some(8 * 1024 * 1024 * 1024)),
|
||||||
|
Some(MAX_PREALLOC_BYTES as usize)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_source_preallocates_known_total_size() {
|
||||||
|
let config = StreamingConfig {
|
||||||
|
initial_buffer_bytes: 16,
|
||||||
|
max_buffer_bytes: 100,
|
||||||
|
};
|
||||||
|
let (_source, writer) = BufferedMediaSource::new(config, Some(4096));
|
||||||
|
// The writer's buffer should have reserved the full track up front.
|
||||||
|
// (No public capacity accessor — push the full size and ensure the
|
||||||
|
// data lands intact, which is the observable contract.)
|
||||||
|
let payload = vec![0xABu8; 4096];
|
||||||
|
writer.push_chunk(&payload).unwrap();
|
||||||
|
writer.complete().unwrap();
|
||||||
|
assert_eq!(writer.buffer_size(), 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_buffered_to_copies_full_contents() {
|
||||||
|
let config = StreamingConfig {
|
||||||
|
initial_buffer_bytes: 4,
|
||||||
|
max_buffer_bytes: 100,
|
||||||
|
};
|
||||||
|
let (_source, writer) = BufferedMediaSource::new(config, None);
|
||||||
|
writer.push_chunk(b"Hello, ").unwrap();
|
||||||
|
writer.push_chunk(b"world!").unwrap();
|
||||||
|
writer.complete().unwrap();
|
||||||
|
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
let written = writer.write_buffered_to(&mut out).unwrap();
|
||||||
|
assert_eq!(written, 13);
|
||||||
|
assert_eq!(&out, b"Hello, world!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_buffered_to_handles_empty_buffer() {
|
||||||
|
let config = StreamingConfig {
|
||||||
|
initial_buffer_bytes: 4,
|
||||||
|
max_buffer_bytes: 100,
|
||||||
|
};
|
||||||
|
let (_source, writer) = BufferedMediaSource::new(config, None);
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
assert_eq!(writer.write_buffered_to(&mut out).unwrap(), 0);
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_basic_read_write() {
|
fn test_basic_read_write() {
|
||||||
let config = StreamingConfig {
|
let config = StreamingConfig {
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ struct InternalState {
|
|||||||
history: VecDeque<usize>,
|
history: VecDeque<usize>,
|
||||||
/// Track ID to stop after (optional)
|
/// Track ID to stop after (optional)
|
||||||
stop_after_track_id: Option<u64>,
|
stop_after_track_id: Option<u64>,
|
||||||
|
/// Manual-block size (#442): how many entries right after `current_index`
|
||||||
|
/// were added by hand ("Play next" / "Play later"). The block always
|
||||||
|
/// plays before the source (album/playlist) resumes; "Add to queue" does
|
||||||
|
/// NOT extend it (it appends to the absolute end, untouched).
|
||||||
|
manual_next_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue manager for handling playback queue
|
/// Queue manager for handling playback queue
|
||||||
@@ -61,6 +66,7 @@ impl QueueManager {
|
|||||||
repeat: RepeatMode::Off,
|
repeat: RepeatMode::Off,
|
||||||
history: VecDeque::with_capacity(50),
|
history: VecDeque::with_capacity(50),
|
||||||
stop_after_track_id: None,
|
stop_after_track_id: None,
|
||||||
|
manual_next_count: 0,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +153,8 @@ impl QueueManager {
|
|||||||
} else {
|
} else {
|
||||||
state.tracks.insert(insert_index, track);
|
state.tracks.insert(insert_index, track);
|
||||||
}
|
}
|
||||||
|
// The track joins the manual block (#442).
|
||||||
|
state.manual_next_count += 1;
|
||||||
|
|
||||||
if state.shuffle {
|
if state.shuffle {
|
||||||
for idx in state.shuffle_order.iter_mut() {
|
for idx in state.shuffle_order.iter_mut() {
|
||||||
@@ -170,10 +178,51 @@ impl QueueManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a track at the END of the manual block (#442 "Play later"): after
|
||||||
|
/// every play-next / play-later already queued, before the source
|
||||||
|
/// (album/playlist) resumes. "Add to queue" stays untouched at the
|
||||||
|
/// absolute end. With shuffle on, degrades to play-next — the block
|
||||||
|
/// concept is meaningless once the order is reshuffled.
|
||||||
|
pub fn add_track_later(&self, track: QueueTrack) {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let insert_index = if state.shuffle {
|
||||||
|
state.current_index.map(|idx| idx + 1).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.current_index
|
||||||
|
.map(|idx| idx + 1 + state.manual_next_count)
|
||||||
|
.unwrap_or(state.manual_next_count)
|
||||||
|
};
|
||||||
|
let insert_index = insert_index.min(state.tracks.len());
|
||||||
|
state.tracks.insert(insert_index, track);
|
||||||
|
state.manual_next_count += 1;
|
||||||
|
|
||||||
|
if state.shuffle {
|
||||||
|
for idx in state.shuffle_order.iter_mut() {
|
||||||
|
if *idx >= insert_index {
|
||||||
|
*idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let new_idx = insert_index;
|
||||||
|
let next_pos = if state.current_index.is_some() {
|
||||||
|
state.shuffle_position + 1
|
||||||
|
} else {
|
||||||
|
state.shuffle_order.len()
|
||||||
|
};
|
||||||
|
if next_pos >= state.shuffle_order.len() {
|
||||||
|
state.shuffle_order.push(new_idx);
|
||||||
|
} else {
|
||||||
|
state.shuffle_order.insert(next_pos, new_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the entire queue (replaces existing)
|
/// Set the entire queue (replaces existing)
|
||||||
pub fn set_queue(&self, new_tracks: Vec<QueueTrack>, start_index: Option<usize>) {
|
pub fn set_queue(&self, new_tracks: Vec<QueueTrack>, start_index: Option<usize>) {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.stop_after_track_id = None;
|
state.stop_after_track_id = None;
|
||||||
|
// A full replacement is a new source: the manual block dissolves (#442).
|
||||||
|
state.manual_next_count = 0;
|
||||||
// Remap history by track id BEFORE replacing tracks so that legitimate
|
// Remap history by track id BEFORE replacing tracks so that legitimate
|
||||||
// plays survive queue version bumps / reorders. Entries whose track is
|
// plays survive queue version bumps / reorders. Entries whose track is
|
||||||
// no longer present are dropped. See bug #316.
|
// no longer present are dropped. See bug #316.
|
||||||
@@ -259,6 +308,7 @@ impl QueueManager {
|
|||||||
pub fn clear(&self, keep_current: bool) {
|
pub fn clear(&self, keep_current: bool) {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.stop_after_track_id = None;
|
state.stop_after_track_id = None;
|
||||||
|
state.manual_next_count = 0;
|
||||||
|
|
||||||
if keep_current {
|
if keep_current {
|
||||||
// Keep the track at `current_index`, not always `tracks[0]`.
|
// Keep the track at `current_index`, not always `tracks[0]`.
|
||||||
@@ -307,6 +357,13 @@ impl QueueManager {
|
|||||||
|
|
||||||
let removed = state.tracks.remove(index);
|
let removed = state.tracks.remove(index);
|
||||||
|
|
||||||
|
// Removing an entry inside the manual block shrinks it (#442).
|
||||||
|
if let Some(curr_idx) = state.current_index {
|
||||||
|
if index > curr_idx && index <= curr_idx + state.manual_next_count {
|
||||||
|
state.manual_next_count = state.manual_next_count.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate marker if the removed track matches
|
// Invalidate marker if the removed track matches
|
||||||
if state.stop_after_track_id == Some(removed.id) {
|
if state.stop_after_track_id == Some(removed.id) {
|
||||||
state.stop_after_track_id = None;
|
state.stop_after_track_id = None;
|
||||||
@@ -722,6 +779,14 @@ impl QueueManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
state.current_index = next_idx;
|
state.current_index = next_idx;
|
||||||
|
// Manual-block bookkeeping (#442): advancing past a manual entry
|
||||||
|
// shrinks the block; exhausting or wrapping the queue dissolves it.
|
||||||
|
match next_idx {
|
||||||
|
Some(0) | None => state.manual_next_count = 0,
|
||||||
|
Some(_) => {
|
||||||
|
state.manual_next_count = state.manual_next_count.saturating_sub(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
next_idx.and_then(|idx| state.tracks.get(idx).cloned())
|
next_idx.and_then(|idx| state.tracks.get(idx).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,6 +935,8 @@ impl QueueManager {
|
|||||||
state.shuffle = enabled;
|
state.shuffle = enabled;
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
|
// The manual block is meaningless once the order is reshuffled (#442).
|
||||||
|
state.manual_next_count = 0;
|
||||||
Self::regenerate_shuffle_order_internal(&mut state);
|
Self::regenerate_shuffle_order_internal(&mut state);
|
||||||
|
|
||||||
// Enabling shuffle during active playback must keep current track
|
// Enabling shuffle during active playback must keep current track
|
||||||
@@ -1019,6 +1086,7 @@ impl QueueManager {
|
|||||||
repeat: state.repeat,
|
repeat: state.repeat,
|
||||||
total_tracks: state.tracks.len(),
|
total_tracks: state.tracks.len(),
|
||||||
stop_after_track_id: state.stop_after_track_id,
|
stop_after_track_id: state.stop_after_track_id,
|
||||||
|
manual_next_count: state.manual_next_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1078,6 +1146,7 @@ impl QueueManager {
|
|||||||
repeat: state.repeat,
|
repeat: state.repeat,
|
||||||
total_tracks: state.tracks.len(),
|
total_tracks: state.tracks.len(),
|
||||||
stop_after_track_id: state.stop_after_track_id,
|
stop_after_track_id: state.stop_after_track_id,
|
||||||
|
manual_next_count: state.manual_next_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4813,3 +4813,57 @@ msgstr "Künstler aus Empfehlungen abgelehnt"
|
|||||||
|
|
||||||
msgid "{} restored to Recommendations"
|
msgid "{} restored to Recommendations"
|
||||||
msgstr "{} wieder in Empfehlungen aufgenommen"
|
msgstr "{} wieder in Empfehlungen aufgenommen"
|
||||||
|
|
||||||
|
msgid "LIBRARY › ALL"
|
||||||
|
msgstr "BIBLIOTHEK › ALLE"
|
||||||
|
|
||||||
|
msgid "Show local files & Plex"
|
||||||
|
msgstr "Lokale Dateien & Plex anzeigen"
|
||||||
|
|
||||||
|
msgid "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
msgstr "Zeigt deine favorisierten lokalen Dateien und Plex-Elemente im Bibliotheksfeed Alle an."
|
||||||
|
|
||||||
|
msgid "Play indicator animation"
|
||||||
|
msgstr "Wiedergabeindikator-Animation"
|
||||||
|
|
||||||
|
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
msgstr "Animiert die gerade spielende Zeile mit Equalizer-Balken. Aus (Standard) zeigt ein statisches Pause-Symbol mit einer Akzentmarkierung am Rand — schont die CPU."
|
||||||
|
|
||||||
|
msgid "Invert swipe navigation direction"
|
||||||
|
msgstr "Wischrichtung für Navigation umkehren"
|
||||||
|
|
||||||
|
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
msgstr "Vertauscht den Zwei-Finger-Wisch auf dem Touchpad: links geht zurück, rechts geht vorwärts."
|
||||||
|
|
||||||
|
msgid "Local items in Library All"
|
||||||
|
msgstr "Lokale Elemente in Bibliothek › Alle"
|
||||||
|
|
||||||
|
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
msgstr "Nur Favoriten: was du markiert hast. Gesamte lokale Bibliothek: jedes Album, jeder Künstler und jeder Titel aus Ordnern und Plex."
|
||||||
|
|
||||||
|
msgid "Favorited items only"
|
||||||
|
msgstr "Nur Favoriten"
|
||||||
|
|
||||||
|
msgid "Entire local library"
|
||||||
|
msgstr "Gesamte lokale Bibliothek"
|
||||||
|
|
||||||
|
msgid "Select multiple albums"
|
||||||
|
msgstr "Mehrere Alben auswählen"
|
||||||
|
|
||||||
|
msgid "Play later"
|
||||||
|
msgstr "Später abspielen"
|
||||||
|
|
||||||
|
msgid "Added to play later"
|
||||||
|
msgstr "Für später hinzugefügt"
|
||||||
|
|
||||||
|
msgid "Next in queue"
|
||||||
|
msgstr "Als Nächstes in der Warteschlange"
|
||||||
|
|
||||||
|
msgid "Next up"
|
||||||
|
msgstr "Als Nächstes"
|
||||||
|
|
||||||
|
msgid "Removed {} tracks from favorites"
|
||||||
|
msgstr "{} Titel aus Favoriten entfernt"
|
||||||
|
|
||||||
|
msgid "Removing {} tracks from favorites…"
|
||||||
|
msgstr "{} Titel werden aus Favoriten entfernt…"
|
||||||
@@ -609,3 +609,57 @@ msgstr "Artist dismissed from Recommendations"
|
|||||||
|
|
||||||
msgid "{} restored to Recommendations"
|
msgid "{} restored to Recommendations"
|
||||||
msgstr "{} restored to Recommendations"
|
msgstr "{} restored to Recommendations"
|
||||||
|
|
||||||
|
msgid "LIBRARY › ALL"
|
||||||
|
msgstr "LIBRARY › ALL"
|
||||||
|
|
||||||
|
msgid "Show local files & Plex"
|
||||||
|
msgstr "Show local files & Plex"
|
||||||
|
|
||||||
|
msgid "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
msgstr "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
|
||||||
|
msgid "Play indicator animation"
|
||||||
|
msgstr "Play indicator animation"
|
||||||
|
|
||||||
|
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
msgstr "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
|
||||||
|
msgid "Invert swipe navigation direction"
|
||||||
|
msgstr "Invert swipe navigation direction"
|
||||||
|
|
||||||
|
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
msgstr "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
|
||||||
|
msgid "Local items in Library All"
|
||||||
|
msgstr "Local items in Library All"
|
||||||
|
|
||||||
|
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
msgstr "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
|
||||||
|
msgid "Favorited items only"
|
||||||
|
msgstr "Favorited items only"
|
||||||
|
|
||||||
|
msgid "Entire local library"
|
||||||
|
msgstr "Entire local library"
|
||||||
|
|
||||||
|
msgid "Select multiple albums"
|
||||||
|
msgstr "Select multiple albums"
|
||||||
|
|
||||||
|
msgid "Play later"
|
||||||
|
msgstr "Play later"
|
||||||
|
|
||||||
|
msgid "Added to play later"
|
||||||
|
msgstr "Added to play later"
|
||||||
|
|
||||||
|
msgid "Next in queue"
|
||||||
|
msgstr "Next in queue"
|
||||||
|
|
||||||
|
msgid "Next up"
|
||||||
|
msgstr "Next up"
|
||||||
|
|
||||||
|
msgid "Removed {} tracks from favorites"
|
||||||
|
msgstr "Removed {} tracks from favorites"
|
||||||
|
|
||||||
|
msgid "Removing {} tracks from favorites…"
|
||||||
|
msgstr "Removing {} tracks from favorites…"
|
||||||
@@ -4843,3 +4843,57 @@ msgstr "Artista descartado de Recomendaciones"
|
|||||||
|
|
||||||
msgid "{} restored to Recommendations"
|
msgid "{} restored to Recommendations"
|
||||||
msgstr "{} restaurado en Recomendaciones"
|
msgstr "{} restaurado en Recomendaciones"
|
||||||
|
|
||||||
|
msgid "LIBRARY › ALL"
|
||||||
|
msgstr "BIBLIOTECA › TODOS"
|
||||||
|
|
||||||
|
msgid "Show local files & Plex"
|
||||||
|
msgstr "Mostrar archivos locales y Plex"
|
||||||
|
|
||||||
|
msgid "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
msgstr "Incluye tus archivos locales y elementos de Plex favoritos en el feed Todos de la biblioteca."
|
||||||
|
|
||||||
|
msgid "Play indicator animation"
|
||||||
|
msgstr "Animación del indicador de reproducción"
|
||||||
|
|
||||||
|
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
msgstr "Anima la fila en reproducción con barras de ecualizador. Apagado (default) muestra un ícono de pausa estático con una marca de acento en el borde — más ligero en CPU."
|
||||||
|
|
||||||
|
msgid "Invert swipe navigation direction"
|
||||||
|
msgstr "Invertir la dirección del gesto de navegación"
|
||||||
|
|
||||||
|
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
msgstr "Invierte el deslizamiento con dos dedos del trackpad: izquierda va atrás, derecha va adelante."
|
||||||
|
|
||||||
|
msgid "Local items in Library All"
|
||||||
|
msgstr "Elementos locales en Biblioteca Todo"
|
||||||
|
|
||||||
|
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
msgstr "Solo favoritos: lo que marcaste con corazón. Toda la biblioteca local: cada álbum, artista y track de carpetas y Plex."
|
||||||
|
|
||||||
|
msgid "Favorited items only"
|
||||||
|
msgstr "Solo favoritos"
|
||||||
|
|
||||||
|
msgid "Entire local library"
|
||||||
|
msgstr "Toda la biblioteca local"
|
||||||
|
|
||||||
|
msgid "Select multiple albums"
|
||||||
|
msgstr "Seleccionar varios álbumes"
|
||||||
|
|
||||||
|
msgid "Play later"
|
||||||
|
msgstr "Reproducir después"
|
||||||
|
|
||||||
|
msgid "Added to play later"
|
||||||
|
msgstr "Agregado para después"
|
||||||
|
|
||||||
|
msgid "Next in queue"
|
||||||
|
msgstr "Siguiente en la cola"
|
||||||
|
|
||||||
|
msgid "Next up"
|
||||||
|
msgstr "A continuación"
|
||||||
|
|
||||||
|
msgid "Removed {} tracks from favorites"
|
||||||
|
msgstr "Se quitaron {} tracks de favoritos"
|
||||||
|
|
||||||
|
msgid "Removing {} tracks from favorites…"
|
||||||
|
msgstr "Quitando {} tracks de favoritos…"
|
||||||
@@ -4813,3 +4813,57 @@ msgstr "Artiste écarté des Recommandations"
|
|||||||
|
|
||||||
msgid "{} restored to Recommendations"
|
msgid "{} restored to Recommendations"
|
||||||
msgstr "{} rétabli dans Recommandations"
|
msgstr "{} rétabli dans Recommandations"
|
||||||
|
|
||||||
|
msgid "LIBRARY › ALL"
|
||||||
|
msgstr "BIBLIOTHÈQUE › TOUT"
|
||||||
|
|
||||||
|
msgid "Show local files & Plex"
|
||||||
|
msgstr "Afficher les fichiers locaux et Plex"
|
||||||
|
|
||||||
|
msgid "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
msgstr "Inclut vos fichiers locaux et éléments Plex favoris dans le flux Tout de la bibliothèque."
|
||||||
|
|
||||||
|
msgid "Play indicator animation"
|
||||||
|
msgstr "Animation de l'indicateur de lecture"
|
||||||
|
|
||||||
|
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
msgstr "Anime la ligne en cours de lecture avec des barres d'égaliseur. Désactivée (défaut), une icône de pause statique avec une marque d'accent sur le bord s'affiche — plus léger pour le CPU."
|
||||||
|
|
||||||
|
msgid "Invert swipe navigation direction"
|
||||||
|
msgstr "Inverser la direction du geste de navigation"
|
||||||
|
|
||||||
|
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
msgstr "Inverse le balayage à deux doigts du pavé tactile : gauche recule, droite avance."
|
||||||
|
|
||||||
|
msgid "Local items in Library All"
|
||||||
|
msgstr "Éléments locaux dans Bibliothèque › Tout"
|
||||||
|
|
||||||
|
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
msgstr "Favoris uniquement : ce que vous avez marqué. Toute la bibliothèque locale : chaque album, artiste et piste des dossiers et de Plex."
|
||||||
|
|
||||||
|
msgid "Favorited items only"
|
||||||
|
msgstr "Favoris uniquement"
|
||||||
|
|
||||||
|
msgid "Entire local library"
|
||||||
|
msgstr "Toute la bibliothèque locale"
|
||||||
|
|
||||||
|
msgid "Select multiple albums"
|
||||||
|
msgstr "Sélectionner plusieurs albums"
|
||||||
|
|
||||||
|
msgid "Play later"
|
||||||
|
msgstr "Lire plus tard"
|
||||||
|
|
||||||
|
msgid "Added to play later"
|
||||||
|
msgstr "Ajouté pour plus tard"
|
||||||
|
|
||||||
|
msgid "Next in queue"
|
||||||
|
msgstr "Suivant dans la file d'attente"
|
||||||
|
|
||||||
|
msgid "Next up"
|
||||||
|
msgstr "À suivre"
|
||||||
|
|
||||||
|
msgid "Removed {} tracks from favorites"
|
||||||
|
msgstr "{} pistes retirées des favoris"
|
||||||
|
|
||||||
|
msgid "Removing {} tracks from favorites…"
|
||||||
|
msgstr "Suppression de {} pistes des favoris…"
|
||||||
@@ -4801,3 +4801,57 @@ msgstr "アーティストをおすすめから除外しました"
|
|||||||
|
|
||||||
msgid "{} restored to Recommendations"
|
msgid "{} restored to Recommendations"
|
||||||
msgstr "{} をおすすめに復元しました"
|
msgstr "{} をおすすめに復元しました"
|
||||||
|
|
||||||
|
msgid "LIBRARY › ALL"
|
||||||
|
msgstr "ライブラリ › すべて"
|
||||||
|
|
||||||
|
msgid "Show local files & Plex"
|
||||||
|
msgstr "ローカルファイルと Plex を表示"
|
||||||
|
|
||||||
|
msgid "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
msgstr "お気に入りのローカルファイルと Plex のアイテムをライブラリの「すべて」フィードに含めます。"
|
||||||
|
|
||||||
|
msgid "Play indicator animation"
|
||||||
|
msgstr "再生インジケーターのアニメーション"
|
||||||
|
|
||||||
|
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
msgstr "再生中の行をイコライザーバーでアニメーション表示します。オフ(デフォルト)では静的な一時停止アイコンと行の端にアクセントマークを表示し、CPU負荷を抑えます。"
|
||||||
|
|
||||||
|
msgid "Invert swipe navigation direction"
|
||||||
|
msgstr "スワイプナビゲーションの方向を反転"
|
||||||
|
|
||||||
|
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
msgstr "トラックパッドの2本指スワイプを入れ替えます:左で戻る、右で進む。"
|
||||||
|
|
||||||
|
msgid "Local items in Library All"
|
||||||
|
msgstr "ライブラリ「すべて」のローカル項目"
|
||||||
|
|
||||||
|
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
msgstr "お気に入りのみ:ハートを付けた項目。ローカルライブラリ全体:フォルダと Plex のすべてのアルバム・アーティスト・トラック。"
|
||||||
|
|
||||||
|
msgid "Favorited items only"
|
||||||
|
msgstr "お気に入りのみ"
|
||||||
|
|
||||||
|
msgid "Entire local library"
|
||||||
|
msgstr "ローカルライブラリ全体"
|
||||||
|
|
||||||
|
msgid "Select multiple albums"
|
||||||
|
msgstr "複数のアルバムを選択"
|
||||||
|
|
||||||
|
msgid "Play later"
|
||||||
|
msgstr "後で再生"
|
||||||
|
|
||||||
|
msgid "Added to play later"
|
||||||
|
msgstr "「後で再生」に追加しました"
|
||||||
|
|
||||||
|
msgid "Next in queue"
|
||||||
|
msgstr "キューの次"
|
||||||
|
|
||||||
|
msgid "Next up"
|
||||||
|
msgstr "次に再生"
|
||||||
|
|
||||||
|
msgid "Removed {} tracks from favorites"
|
||||||
|
msgstr "{} トラックをお気に入りから削除しました"
|
||||||
|
|
||||||
|
msgid "Removing {} tracks from favorites…"
|
||||||
|
msgstr "{} トラックをお気に入りから削除中…"
|
||||||
@@ -4825,3 +4825,57 @@ msgstr "Artiest afgewezen voor Aanbevelingen"
|
|||||||
|
|
||||||
msgid "{} restored to Recommendations"
|
msgid "{} restored to Recommendations"
|
||||||
msgstr "{} hersteld in Aanbevelingen"
|
msgstr "{} hersteld in Aanbevelingen"
|
||||||
|
|
||||||
|
msgid "LIBRARY › ALL"
|
||||||
|
msgstr "BIBLIOTHEEK › ALLES"
|
||||||
|
|
||||||
|
msgid "Show local files & Plex"
|
||||||
|
msgstr "Lokale bestanden en Plex tonen"
|
||||||
|
|
||||||
|
msgid "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
msgstr "Toon je favoriete lokale bestanden en Plex-items in de Bibliotheek-feed Alles."
|
||||||
|
|
||||||
|
msgid "Play indicator animation"
|
||||||
|
msgstr "Afspeelindicator-animatie"
|
||||||
|
|
||||||
|
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
msgstr "Animeert de nu-spelende rij met equalizerbalken. Uit (standaard) toont een statisch pauze-icoon met een accentmarkering op de rand — lichter voor de CPU."
|
||||||
|
|
||||||
|
msgid "Invert swipe navigation direction"
|
||||||
|
msgstr "Veegnavigatierichting omkeren"
|
||||||
|
|
||||||
|
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
msgstr "Wisselt de tweevingerveeg op het touchpad om: links gaat terug, rechts gaat vooruit."
|
||||||
|
|
||||||
|
msgid "Local items in Library All"
|
||||||
|
msgstr "Lokale items in Bibliotheek › Alles"
|
||||||
|
|
||||||
|
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
msgstr "Alleen favorieten: wat je hebt gemarkeerd. Hele lokale bibliotheek: elk album, artiest en nummer uit mappen en Plex."
|
||||||
|
|
||||||
|
msgid "Favorited items only"
|
||||||
|
msgstr "Alleen favorieten"
|
||||||
|
|
||||||
|
msgid "Entire local library"
|
||||||
|
msgstr "Hele lokale bibliotheek"
|
||||||
|
|
||||||
|
msgid "Select multiple albums"
|
||||||
|
msgstr "Meerdere albums selecteren"
|
||||||
|
|
||||||
|
msgid "Play later"
|
||||||
|
msgstr "Later afspelen"
|
||||||
|
|
||||||
|
msgid "Added to play later"
|
||||||
|
msgstr "Toegevoegd voor later"
|
||||||
|
|
||||||
|
msgid "Next in queue"
|
||||||
|
msgstr "Volgende in de wachtrij"
|
||||||
|
|
||||||
|
msgid "Next up"
|
||||||
|
msgstr "Hierna"
|
||||||
|
|
||||||
|
msgid "Removed {} tracks from favorites"
|
||||||
|
msgstr "{} nummers verwijderd uit favorieten"
|
||||||
|
|
||||||
|
msgid "Removing {} tracks from favorites…"
|
||||||
|
msgstr "{} nummers worden verwijderd uit favorieten…"
|
||||||
@@ -4813,3 +4813,57 @@ msgstr "Artista dispensado das Recomendações"
|
|||||||
|
|
||||||
msgid "{} restored to Recommendations"
|
msgid "{} restored to Recommendations"
|
||||||
msgstr "{} restaurado em Recomendações"
|
msgstr "{} restaurado em Recomendações"
|
||||||
|
|
||||||
|
msgid "LIBRARY › ALL"
|
||||||
|
msgstr "BIBLIOTECA › TUDO"
|
||||||
|
|
||||||
|
msgid "Show local files & Plex"
|
||||||
|
msgstr "Mostrar ficheiros locais e Plex"
|
||||||
|
|
||||||
|
msgid "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
msgstr "Inclui os seus ficheiros locais e itens do Plex favoritos no feed Tudo da biblioteca."
|
||||||
|
|
||||||
|
msgid "Play indicator animation"
|
||||||
|
msgstr "Animação do indicador de reprodução"
|
||||||
|
|
||||||
|
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
msgstr "Anima a linha em reprodução com barras de equalizador. Desligado (padrão) mostra um ícone de pausa estático com uma marca de destaque na borda — mais leve para a CPU."
|
||||||
|
|
||||||
|
msgid "Invert swipe navigation direction"
|
||||||
|
msgstr "Inverter a direção do gesto de navegação"
|
||||||
|
|
||||||
|
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
msgstr "Troca o deslizar com dois dedos no touchpad: esquerda volta, direita avança."
|
||||||
|
|
||||||
|
msgid "Local items in Library All"
|
||||||
|
msgstr "Itens locais na Biblioteca Tudo"
|
||||||
|
|
||||||
|
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
msgstr "Somente favoritos: o que você marcou. Toda a biblioteca local: cada álbum, artista e faixa das pastas e do Plex."
|
||||||
|
|
||||||
|
msgid "Favorited items only"
|
||||||
|
msgstr "Somente favoritos"
|
||||||
|
|
||||||
|
msgid "Entire local library"
|
||||||
|
msgstr "Toda a biblioteca local"
|
||||||
|
|
||||||
|
msgid "Select multiple albums"
|
||||||
|
msgstr "Selecionar vários álbuns"
|
||||||
|
|
||||||
|
msgid "Play later"
|
||||||
|
msgstr "Tocar depois"
|
||||||
|
|
||||||
|
msgid "Added to play later"
|
||||||
|
msgstr "Adicionado para depois"
|
||||||
|
|
||||||
|
msgid "Next in queue"
|
||||||
|
msgstr "A seguir na fila"
|
||||||
|
|
||||||
|
msgid "Next up"
|
||||||
|
msgstr "A seguir"
|
||||||
|
|
||||||
|
msgid "Removed {} tracks from favorites"
|
||||||
|
msgstr "{} faixas removidas dos favoritos"
|
||||||
|
|
||||||
|
msgid "Removing {} tracks from favorites…"
|
||||||
|
msgstr "Removendo {} faixas dos favoritos…"
|
||||||
@@ -4827,3 +4827,57 @@ msgstr "Исполнитель отклонён из Рекомендаций"
|
|||||||
|
|
||||||
msgid "{} restored to Recommendations"
|
msgid "{} restored to Recommendations"
|
||||||
msgstr "{} возвращён в Рекомендации"
|
msgstr "{} возвращён в Рекомендации"
|
||||||
|
|
||||||
|
msgid "LIBRARY › ALL"
|
||||||
|
msgstr "БИБЛИОТЕКА › ВСЁ"
|
||||||
|
|
||||||
|
msgid "Show local files & Plex"
|
||||||
|
msgstr "Показывать локальные файлы и Plex"
|
||||||
|
|
||||||
|
msgid "Include your favorited local files and Plex items in the Library All feed."
|
||||||
|
msgstr "Показывать избранные локальные файлы и элементы Plex в ленте «Всё» библиотеки."
|
||||||
|
|
||||||
|
msgid "Play indicator animation"
|
||||||
|
msgstr "Анимация индикатора воспроизведения"
|
||||||
|
|
||||||
|
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
|
||||||
|
msgstr "Анимирует строку воспроизведения полосами эквалайзера. Выкл. (по умолчанию) — статичный значок паузы с акцентной меткой на краю строки, легче для CPU."
|
||||||
|
|
||||||
|
msgid "Invert swipe navigation direction"
|
||||||
|
msgstr "Инвертировать направление жеста навигации"
|
||||||
|
|
||||||
|
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
|
||||||
|
msgstr "Меняет местами свайп двумя пальцами по тачпаду: влево — назад, вправо — вперёд."
|
||||||
|
|
||||||
|
msgid "Local items in Library All"
|
||||||
|
msgstr "Локальные элементы в «Всё» библиотеки"
|
||||||
|
|
||||||
|
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
|
||||||
|
msgstr "Только избранное: отмеченное вами. Вся локальная библиотека: каждый альбом, исполнитель и трек из папок и Plex."
|
||||||
|
|
||||||
|
msgid "Favorited items only"
|
||||||
|
msgstr "Только избранное"
|
||||||
|
|
||||||
|
msgid "Entire local library"
|
||||||
|
msgstr "Вся локальная библиотека"
|
||||||
|
|
||||||
|
msgid "Select multiple albums"
|
||||||
|
msgstr "Выбрать несколько альбомов"
|
||||||
|
|
||||||
|
msgid "Play later"
|
||||||
|
msgstr "Слушать позже"
|
||||||
|
|
||||||
|
msgid "Added to play later"
|
||||||
|
msgstr "Добавлено «на позже»"
|
||||||
|
|
||||||
|
msgid "Next in queue"
|
||||||
|
msgstr "Далее в очереди"
|
||||||
|
|
||||||
|
msgid "Next up"
|
||||||
|
msgstr "Далее"
|
||||||
|
|
||||||
|
msgid "Removed {} tracks from favorites"
|
||||||
|
msgstr "{} треков удалено из избранного"
|
||||||
|
|
||||||
|
msgid "Removing {} tracks from favorites…"
|
||||||
|
msgstr "Удаление {} треков из избранного…"
|
||||||
@@ -793,7 +793,8 @@ export component AlbumPageView inherits Rectangle {
|
|||||||
selected-count: AlbumState.selected-count;
|
selected-count: AlbumState.selected-count;
|
||||||
actions: [
|
actions: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-favorites", label: @tr("Add to Library"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-favorites", label: @tr("Add to Library"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ export component ArtistPageView inherits Rectangle {
|
|||||||
close-policy: close-on-click;
|
close-policy: close-on-click;
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play all next");
|
label: @tr("Play all next");
|
||||||
clicked => {
|
clicked => {
|
||||||
ArtistActions.top-tracks-menu-action("next-all");
|
ArtistActions.top-tracks-menu-action("next-all");
|
||||||
@@ -816,7 +816,8 @@ export component ArtistPageView inherits Rectangle {
|
|||||||
selected-count: ArtistState.top-tracks-selected-count;
|
selected-count: ArtistState.top-tracks-selected-count;
|
||||||
actions: [
|
actions: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12.409 5.824c-.702.792-1.15 1.496-1.415 2.166l2.153 2.156a.5.5 0 0 1 0 .707l-2.293 2.293a.5.5 0 0 0 0 .707L12 15" />
|
||||||
|
<path d="M13.508 20.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.677.6.6 0 0 0 .818.001A5.5 5.5 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 5H11" />
|
||||||
|
<path d="M21 12H11" />
|
||||||
|
<path d="M21 19H11" />
|
||||||
|
<path d="m3 8 4 4-4 4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 286 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 5h6" />
|
||||||
|
<path d="M3 12h13" />
|
||||||
|
<path d="M3 19h13" />
|
||||||
|
<path d="m16 8-3-3 3-3" />
|
||||||
|
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 324 B |
@@ -382,8 +382,8 @@ export component AlbumCard inherits Rectangle {
|
|||||||
x: more-btn.absolute-position.x + more-btn.width / 2 - artwork-box.absolute-position.x;
|
x: more-btn.absolute-position.x + more-btn.width / 2 - artwork-box.absolute-position.x;
|
||||||
y: more-btn.absolute-position.y + more-btn.height / 2 - artwork-box.absolute-position.y;
|
y: more-btn.absolute-position.y + more-btn.height / 2 - artwork-box.absolute-position.y;
|
||||||
width: 196px;
|
width: 196px;
|
||||||
// 6 items max (Open/Play/Play-next/Queue + favorite + block).
|
// 7 items max (Open/Play/Play-next/Play-later/Queue + favorite + block).
|
||||||
height: 6 * 33px + 10px;
|
height: 7 * 33px + 10px;
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/library-big.svg");
|
icon: @image-url("../assets/icons/library-big.svg");
|
||||||
@@ -400,12 +400,19 @@ export component AlbumCard inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("album", root.album.id, "play-next");
|
root.media-action("album", root.album.id, "play-next");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("album", root.album.id, "play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ component OverlayButton inherits Rectangle {
|
|||||||
export component PlaylistCard inherits Rectangle {
|
export component PlaylistCard inherits Rectangle {
|
||||||
in property <SearchPlaylistItem> playlist;
|
in property <SearchPlaylistItem> playlist;
|
||||||
// kind ("playlist"), playlist id, action ("play" | "play-next" |
|
// kind ("playlist"), playlist id, action ("play" | "play-next" |
|
||||||
// "queue" | "favorite" | "open").
|
// "play-later" | "queue" | "favorite" | "open").
|
||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
// When true, a click on the card BODY opens the playlist ("open")
|
// When true, a click on the card BODY opens the playlist ("open")
|
||||||
// instead of playing it — the Favorites grid wants body=open, the play
|
// instead of playing it — the Favorites grid wants body=open, the play
|
||||||
@@ -188,8 +188,8 @@ export component PlaylistCard inherits Rectangle {
|
|||||||
// + 33px/row) so the panel never clips or over-reserves.
|
// + 33px/row) so the panel never clips or over-reserves.
|
||||||
playlist-menu := PopupWindow {
|
playlist-menu := PopupWindow {
|
||||||
property <int> item-count: root.playlist.is-owned
|
property <int> item-count: root.playlist.is-owned
|
||||||
? 4
|
? 5
|
||||||
: (root.playlist.is-copied ? 4 : 5);
|
: (root.playlist.is-copied ? 5 : 6);
|
||||||
x: 26px;
|
x: 26px;
|
||||||
y: 56px;
|
y: 56px;
|
||||||
width: 196px;
|
width: 196px;
|
||||||
@@ -203,12 +203,19 @@ export component PlaylistCard inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("playlist", root.playlist.id, "play-next");
|
root.media-action("playlist", root.playlist.id, "play-next");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("playlist", root.playlist.id, "play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export component TrackCard inherits Rectangle {
|
|||||||
animate opacity { duration: 250ms; }
|
animate opacity { duration: 250ms; }
|
||||||
callback clicked(string);
|
callback clicked(string);
|
||||||
// kind ("track"), track id, action ("play" | "queue" | "play-next" |
|
// kind ("track"), track id, action ("play" | "queue" | "play-next" |
|
||||||
// "favorite" | "open").
|
// "play-later" | "favorite" | "open").
|
||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
|
|
||||||
in property <length> card-height: 246px;
|
in property <length> card-height: 246px;
|
||||||
@@ -202,8 +202,8 @@ export component TrackCard inherits Rectangle {
|
|||||||
x: 26px;
|
x: 26px;
|
||||||
y: 56px;
|
y: 56px;
|
||||||
width: 196px;
|
width: 196px;
|
||||||
// Play / Play-next / Queue / Go-to-artist / Go-to-album / favorite.
|
// Play / Play-next / Play-later / Queue / Go-to-artist / Go-to-album / favorite.
|
||||||
height: 6 * 33px + 10px;
|
height: 7 * 33px + 10px;
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/play.svg");
|
icon: @image-url("../assets/icons/play.svg");
|
||||||
@@ -213,12 +213,19 @@ export component TrackCard inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("track", root.track.id, "play-next");
|
root.media-action("track", root.track.id, "play-next");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("track", root.track.id, "play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
|
|||||||
@@ -68,6 +68,104 @@ component FavTabMenu inherits SegmentedTabBar {
|
|||||||
selected(id) => { FavoritesActions.select-tab(id); }
|
selected(id) => { FavoritesActions.select-tab(id); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Viewport windowing engine for the Library "All" grid + list — POST-LAYOUT
|
||||||
|
// SNAPSHOT SAMPLING, the SAME class-kill as AlbumGrid's (see the long comment
|
||||||
|
// in AlbumCollectionView.slint; the "Recursion detected" panic class).
|
||||||
|
//
|
||||||
|
// Rules copied from there, do not break them:
|
||||||
|
// - `visible-top` / `visible-height` / `content-offset` are POLLED, never
|
||||||
|
// observed: read ONLY from the sampler Timer below. Wiring them to live
|
||||||
|
// layout expressions (-flick.viewport-y / flick.height) is safe BECAUSE
|
||||||
|
// nothing here evaluates them from a binding or `changed` handler.
|
||||||
|
// - The band state is PLAIN properties written ONLY by the Timer. No
|
||||||
|
// `changed` handlers, no init-time reads of the inputs.
|
||||||
|
// Consumers gate each slot on `first-item`/`last-item` (plain, Timer-written)
|
||||||
|
// and report through `window-changed` so Rust dispatches covers for the band.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
component ViewportWindow inherits Rectangle {
|
||||||
|
in property <length> visible-top: 0px;
|
||||||
|
in property <length> visible-height: 0px;
|
||||||
|
// This content's approximate y inside the Flickable's CONTENT coordinates;
|
||||||
|
// small errors are absorbed by the one-viewport margin.
|
||||||
|
in property <length> content-offset: 0px;
|
||||||
|
in property <length> row-stride: 1px;
|
||||||
|
in property <int> columns: 1;
|
||||||
|
callback window-changed(int /* first item index */, int /* last item index */);
|
||||||
|
|
||||||
|
// Snapshot band — plain values, sampler-written. The pre-seed [0, 3]
|
||||||
|
// keeps roughly the first viewport of slots mounted for the <=80ms
|
||||||
|
// between mount and the first tick (placeholder tiles cover the rest).
|
||||||
|
property <int> target-first-row: 0;
|
||||||
|
property <int> target-last-row: 3;
|
||||||
|
// Mounted band = target band + contract hysteresis. Expansion follows the
|
||||||
|
// target immediately; contraction lags by `hysteresis-rows` so a few-px
|
||||||
|
// oscillation across a row boundary cannot mount/unmount a whole row on
|
||||||
|
// every direction change.
|
||||||
|
property <int> hysteresis-rows: 1;
|
||||||
|
property <int> first-window-row: 0;
|
||||||
|
property <int> last-window-row: 3;
|
||||||
|
// Item-index form of the mounted band — the slot gates read these.
|
||||||
|
out property <int> first-item: 0;
|
||||||
|
out property <int> last-item: 3 * Math.max(1, root.columns) - 1;
|
||||||
|
// Last column count reported through window-changed (columns shift on
|
||||||
|
// resize, which re-keys the band's ITEM indices).
|
||||||
|
property <int> notified-columns: 0;
|
||||||
|
pure function desired-first-row() -> int {
|
||||||
|
if (root.target-first-row < root.first-window-row) {
|
||||||
|
return root.target-first-row;
|
||||||
|
}
|
||||||
|
if (root.target-first-row > root.first-window-row + root.hysteresis-rows) {
|
||||||
|
return root.target-first-row - root.hysteresis-rows;
|
||||||
|
}
|
||||||
|
return root.first-window-row;
|
||||||
|
}
|
||||||
|
pure function desired-last-row() -> int {
|
||||||
|
if (root.target-last-row > root.last-window-row) {
|
||||||
|
return root.target-last-row;
|
||||||
|
}
|
||||||
|
if (root.target-last-row < root.last-window-row - root.hysteresis-rows) {
|
||||||
|
return root.target-last-row + root.hysteresis-rows;
|
||||||
|
}
|
||||||
|
return root.last-window-row;
|
||||||
|
}
|
||||||
|
function apply-window() {
|
||||||
|
if (root.desired-first-row() != root.first-window-row
|
||||||
|
|| root.desired-last-row() != root.last-window-row) {
|
||||||
|
root.first-window-row = root.desired-first-row();
|
||||||
|
root.last-window-row = root.desired-last-row();
|
||||||
|
root.publish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The sampler: reads the live inputs (safe here — event-loop context),
|
||||||
|
// recomputes the target band, applies hysteresis, and notifies on change.
|
||||||
|
// ~12.5 Hz is enough: the band carries a full-viewport margin, and the
|
||||||
|
// placeholder tiles are the designed fast-scroll fallback.
|
||||||
|
function sample-window() {
|
||||||
|
root.target-first-row = Math.max(0, Math.floor(
|
||||||
|
((root.visible-top - root.content-offset) - root.visible-height)
|
||||||
|
/ root.row-stride));
|
||||||
|
root.target-last-row = Math.max(0, Math.floor(
|
||||||
|
((root.visible-top - root.content-offset) + 2 * root.visible-height)
|
||||||
|
/ root.row-stride));
|
||||||
|
root.apply-window();
|
||||||
|
if (root.columns != root.notified-columns) {
|
||||||
|
root.notified-columns = root.columns;
|
||||||
|
root.publish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timer {
|
||||||
|
interval: 80ms;
|
||||||
|
running: true;
|
||||||
|
triggered => { root.sample-window(); }
|
||||||
|
}
|
||||||
|
function publish() {
|
||||||
|
root.first-item = root.first-window-row * Math.max(1, root.columns);
|
||||||
|
root.last-item = (root.last-window-row + 1) * Math.max(1, root.columns) - 1;
|
||||||
|
root.window-changed(root.first-item, root.last-item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One underline sub-tab for the Playlists tab (Library / Following).
|
// One underline sub-tab for the Playlists tab (Library / Following).
|
||||||
// Underline style, NOT a pill (ADR-008). Mirrors the FavTab pattern.
|
// Underline style, NOT a pill (ADR-008). Mirrors the FavTab pattern.
|
||||||
component PlaylistSubTab inherits Rectangle {
|
component PlaylistSubTab inherits Rectangle {
|
||||||
@@ -411,6 +509,18 @@ export component FavoritesView inherits Rectangle {
|
|||||||
clicked => { FavoritesActions.albums-shuffle(); }
|
clicked => { FavoritesActions.albums-shuffle(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Multi-select — LIST mode only (owner 2026-07-24): bulk
|
||||||
|
// un-favorite / play-next / queue on the checked rows.
|
||||||
|
if FavoritesState.albums-view-mode == "list": VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
CircleAction {
|
||||||
|
icon: @image-url("../assets/icons/square-check-big.svg");
|
||||||
|
active: FavoritesState.albums-multi-select;
|
||||||
|
on-surface: true;
|
||||||
|
tooltip: @tr("Select multiple albums");
|
||||||
|
clicked => { FavoritesActions.albums-toggle-multi-select(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if FavoritesState.active-tab == "artists"
|
if FavoritesState.active-tab == "artists"
|
||||||
&& FavoritesState.artists-visible.length > 0: HorizontalLayout {
|
&& FavoritesState.artists-visible.length > 0: HorizontalLayout {
|
||||||
@@ -693,9 +803,15 @@ export component FavoritesView inherits Rectangle {
|
|||||||
sm: true;
|
sm: true;
|
||||||
query <=> LibraryAllState.search;
|
query <=> LibraryAllState.search;
|
||||||
edited(q) => {
|
edited(q) => {
|
||||||
LibraryAllActions.search(q);
|
all-search-debounce.restart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Derive once per typing pause, not per keystroke — with
|
||||||
|
// thousands of items each derive swaps the visible model.
|
||||||
|
all-search-debounce := Timer {
|
||||||
|
interval: 250ms;
|
||||||
|
triggered => { LibraryAllActions.search(LibraryAllState.search); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Source switches — Purchases / Favorites / Following. Tooltips
|
// Source switches — Purchases / Favorites / Following. Tooltips
|
||||||
// spell out each cryptic icon (owner request).
|
// spell out each cryptic icon (owner request).
|
||||||
@@ -729,7 +845,9 @@ export component FavoritesView inherits Rectangle {
|
|||||||
clicked => { LibraryAllActions.toggle-source("following"); }
|
clicked => { LibraryAllActions.toggle-source("following"); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Show local files + Plex (local-favorites layer).
|
// Show local files + Plex (filter lives IN the view; the
|
||||||
|
// Settings row only picks the SCOPE: favorites vs whole
|
||||||
|
// local library).
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
ToggleButton {
|
ToggleButton {
|
||||||
@@ -932,10 +1050,12 @@ export component FavoritesView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Grid — non-windowed flow (columns from width), one card per
|
// Grid — WINDOWED flow (columns from width), one card-slot per
|
||||||
// item keyed off `kind`, reusing the existing card components.
|
// item keyed off `kind`. Only slots near the viewport mount a
|
||||||
|
// real card; the rest are same-footprint placeholder tiles
|
||||||
|
// (ViewportWindow engine — the AlbumGrid class-kill pattern).
|
||||||
if !LibraryAllState.loading && LibraryAllState.load-error == ""
|
if !LibraryAllState.loading && LibraryAllState.load-error == ""
|
||||||
&& LibraryAllState.view-mode != "list": Flickable {
|
&& LibraryAllState.view-mode != "list": all-grid-flick := Flickable {
|
||||||
viewport-height: all-grid.height + 32px;
|
viewport-height: all-grid.height + 32px;
|
||||||
all-grid := Rectangle {
|
all-grid := Rectangle {
|
||||||
property <length> cw: 200px;
|
property <length> cw: 200px;
|
||||||
@@ -950,13 +1070,30 @@ export component FavoritesView inherits Rectangle {
|
|||||||
width: parent.width - 64px;
|
width: parent.width - 64px;
|
||||||
height: self.rows > 0 ? self.rows * (self.ch + self.gap) : 0px;
|
height: self.rows > 0 ? self.rows * (self.ch + self.gap) : 0px;
|
||||||
|
|
||||||
for item[i] in LibraryAllState.items-visible: Rectangle {
|
all-grid-win := ViewportWindow {
|
||||||
|
visible-top: -all-grid-flick.viewport-y;
|
||||||
|
visible-height: all-grid-flick.height;
|
||||||
|
content-offset: 16px;
|
||||||
|
row-stride: all-grid.ch + all-grid.gap;
|
||||||
|
columns: all-grid.columns;
|
||||||
|
window-changed(first, last) => {
|
||||||
|
LibraryAllActions.window-changed(first, last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for item[i] in LibraryAllState.items-visible: card-slot := Rectangle {
|
||||||
x: Math.mod(i, all-grid.columns) * (all-grid.cw + all-grid.gap);
|
x: Math.mod(i, all-grid.columns) * (all-grid.cw + all-grid.gap);
|
||||||
y: Math.floor(i / all-grid.columns) * (all-grid.ch + all-grid.gap);
|
y: Math.floor(i / all-grid.columns) * (all-grid.ch + all-grid.gap);
|
||||||
width: all-grid.cw;
|
width: all-grid.cw;
|
||||||
height: all-grid.ch;
|
height: all-grid.ch;
|
||||||
|
property <bool> in-window: i >= all-grid-win.first-item
|
||||||
|
&& i <= all-grid-win.last-item;
|
||||||
|
// Placeholder tile while the real card is unmounted
|
||||||
|
// (fast scroll).
|
||||||
|
border-radius: 8px;
|
||||||
|
background: card-slot.in-window ? transparent : Theme.surface-elevated;
|
||||||
|
|
||||||
if item.kind == "track": TrackCard {
|
if card-slot.in-window && item.kind == "track": TrackCard {
|
||||||
show-source-badge: LibraryAllState.show-local;
|
show-source-badge: LibraryAllState.show-local;
|
||||||
track: {
|
track: {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -981,7 +1118,7 @@ export component FavoritesView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item.kind == "album": AlbumCard {
|
if card-slot.in-window && item.kind == "album": AlbumCard {
|
||||||
show-source-badge: LibraryAllState.show-local;
|
show-source-badge: LibraryAllState.show-local;
|
||||||
album: {
|
album: {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -992,6 +1129,7 @@ export component FavoritesView inherits Rectangle {
|
|||||||
quality-tier: item.quality-tier,
|
quality-tier: item.quality-tier,
|
||||||
quality-label: item.quality-detail,
|
quality-label: item.quality-detail,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
|
is-favorite: item.is-favorite,
|
||||||
};
|
};
|
||||||
clicked(id) => { FavoritesActions.open-album(id); }
|
clicked(id) => { FavoritesActions.open-album(id); }
|
||||||
media-action(kind, id, action) => {
|
media-action(kind, id, action) => {
|
||||||
@@ -1002,16 +1140,21 @@ export component FavoritesView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item.kind == "artist": ArtistGridCard {
|
if card-slot.in-window && item.kind == "artist": ArtistGridCard {
|
||||||
artist: {
|
artist: {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
artwork: item.image,
|
artwork: item.image,
|
||||||
|
following: item.is-favorite,
|
||||||
};
|
};
|
||||||
follow-mode: "none";
|
|
||||||
clicked(id) => { FavoritesActions.open-artist(id); }
|
clicked(id) => { FavoritesActions.open-artist(id); }
|
||||||
|
media-action(kind, id, action) => {
|
||||||
|
if (action == "follow") {
|
||||||
|
FavoritesActions.artist-action(id, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if item.kind == "playlist": PlaylistCard {
|
if card-slot.in-window && item.kind == "playlist": PlaylistCard {
|
||||||
playlist: {
|
playlist: {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
@@ -1032,7 +1175,7 @@ export component FavoritesView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item.kind == "label": LabelCard {
|
if card-slot.in-window && item.kind == "label": LabelCard {
|
||||||
label: {
|
label: {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
@@ -1044,10 +1187,23 @@ export component FavoritesView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// List view — compact mixed rows (cover + title + subtitle).
|
// List view — WINDOWED compact mixed rows (cover + title +
|
||||||
|
// subtitle). Only rows near the viewport mount their content
|
||||||
|
// (cells + context menu); off-band rows are same-footprint
|
||||||
|
// placeholder tiles (ViewportWindow engine, AlbumGrid pattern).
|
||||||
if !LibraryAllState.loading && LibraryAllState.load-error == ""
|
if !LibraryAllState.loading && LibraryAllState.load-error == ""
|
||||||
&& LibraryAllState.view-mode == "list": Flickable {
|
&& LibraryAllState.view-mode == "list": all-list-flick := Flickable {
|
||||||
viewport-height: all-list.preferred-height + 32px;
|
viewport-height: all-list.preferred-height + 32px;
|
||||||
|
all-list-win := ViewportWindow {
|
||||||
|
visible-top: -all-list-flick.viewport-y;
|
||||||
|
visible-height: all-list-flick.height;
|
||||||
|
content-offset: 16px;
|
||||||
|
row-stride: 58px; // 56px row + 2px spacing
|
||||||
|
columns: 1;
|
||||||
|
window-changed(first, last) => {
|
||||||
|
LibraryAllActions.window-changed(first, last);
|
||||||
|
}
|
||||||
|
}
|
||||||
all-list := VerticalLayout {
|
all-list := VerticalLayout {
|
||||||
x: 32px;
|
x: 32px;
|
||||||
y: 16px;
|
y: 16px;
|
||||||
@@ -1055,9 +1211,12 @@ export component FavoritesView inherits Rectangle {
|
|||||||
spacing: 2px;
|
spacing: 2px;
|
||||||
for item[i] in LibraryAllState.items-visible: row-rect := Rectangle {
|
for item[i] in LibraryAllState.items-visible: row-rect := Rectangle {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
|
property <bool> in-window: i >= all-list-win.first-item
|
||||||
|
&& i <= all-list-win.last-item;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: row-ta.has-hover || menu-btn-ta.has-hover
|
// Placeholder tile while the row content is
|
||||||
? Theme.surface-hover : transparent;
|
// unmounted (fast scroll).
|
||||||
|
background: row-rect.in-window ? transparent : Theme.surface-elevated;
|
||||||
// Context-menu opener position (row-local px). Defaults
|
// Context-menu opener position (row-local px). Defaults
|
||||||
// by the ⋯ button; a right-click anywhere on the row
|
// by the ⋯ button; a right-click anywhere on the row
|
||||||
// overrides it to the cursor (window pointer minus the
|
// overrides it to the cursor (window pointer minus the
|
||||||
@@ -1065,315 +1224,329 @@ export component FavoritesView inherits Rectangle {
|
|||||||
// the right-click handler so the whole row is sensitive.
|
// the right-click handler so the whole row is sensitive.
|
||||||
property <length> menu-x: row-rect.width - 206px;
|
property <length> menu-x: row-rect.width - 206px;
|
||||||
property <length> menu-y: 48px;
|
property <length> menu-y: 48px;
|
||||||
// Row body — click plays/opens by kind; right-click opens
|
if row-rect.in-window: Rectangle {
|
||||||
// the menu at the cursor. Behind the cell TouchAreas.
|
border-radius: 6px;
|
||||||
row-ta := TouchArea {
|
background: row-ta.has-hover || menu-btn-ta.has-hover
|
||||||
mouse-cursor: pointer;
|
? Theme.surface-hover : transparent;
|
||||||
clicked => {
|
// Row body — click plays/opens by kind; right-click
|
||||||
if (item.kind == "track") {
|
// opens the menu at the cursor. Behind the cells.
|
||||||
FavoritesActions.track-action(item.id, "play");
|
row-ta := TouchArea {
|
||||||
} else if (item.kind == "album") {
|
mouse-cursor: pointer;
|
||||||
FavoritesActions.open-album(item.id);
|
clicked => {
|
||||||
} else if (item.kind == "artist") {
|
if (item.kind == "track") {
|
||||||
FavoritesActions.open-artist(item.id);
|
FavoritesActions.track-action(item.id, "play");
|
||||||
} else if (item.kind == "playlist") {
|
} else if (item.kind == "album") {
|
||||||
FavoritesActions.open-playlist(item.id);
|
FavoritesActions.open-album(item.id);
|
||||||
} else if (item.kind == "label") {
|
} else if (item.kind == "artist") {
|
||||||
FavoritesActions.open-label(item.id, item.title);
|
FavoritesActions.open-artist(item.id);
|
||||||
}
|
} else if (item.kind == "playlist") {
|
||||||
}
|
FavoritesActions.open-playlist(item.id);
|
||||||
pointer-event(e) => {
|
} else if (item.kind == "label") {
|
||||||
if (e.kind == PointerEventKind.down
|
FavoritesActions.open-label(item.id, item.title);
|
||||||
&& e.button == PointerEventButton.right) {
|
|
||||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
|
||||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
|
||||||
row-menu.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalLayout {
|
|
||||||
padding-left: 12px;
|
|
||||||
padding-right: 12px;
|
|
||||||
spacing: 12px;
|
|
||||||
// Col — artwork (round for artists) + hover play.
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Rectangle {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: item.kind == "artist" ? 22px : 6px;
|
|
||||||
background: Theme.surface-elevated;
|
|
||||||
clip: true;
|
|
||||||
Image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
source: item.image;
|
|
||||||
image-fit: item.kind == "label" ? contain : cover;
|
|
||||||
}
|
}
|
||||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle {
|
}
|
||||||
width: 100%;
|
pointer-event(e) => {
|
||||||
height: 100%;
|
if (e.kind == PointerEventKind.down
|
||||||
background: #000000a6;
|
&& e.button == PointerEventButton.right) {
|
||||||
opacity: play-ta.has-hover ? 1.0 : 0.0;
|
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||||
animate opacity { duration: 150ms; }
|
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||||
QbzIcon {
|
row-menu.show();
|
||||||
source: @image-url("../assets/icons/play-fill.svg");
|
}
|
||||||
width: 16px;
|
}
|
||||||
height: 16px;
|
}
|
||||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
HorizontalLayout {
|
||||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
padding-left: 12px;
|
||||||
tint: #ffffff;
|
padding-right: 12px;
|
||||||
|
spacing: 12px;
|
||||||
|
// Col — artwork (round for artists) + hover play.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: item.kind == "artist" ? 22px : 6px;
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
clip: true;
|
||||||
|
Image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
source: item.image;
|
||||||
|
image-fit: item.kind == "label" ? contain : cover;
|
||||||
}
|
}
|
||||||
play-ta := TouchArea {
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle {
|
||||||
mouse-cursor: pointer;
|
width: 100%;
|
||||||
clicked => {
|
height: 100%;
|
||||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
background: #000000a6;
|
||||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
opacity: play-ta.has-hover ? 1.0 : 0.0;
|
||||||
else { FavoritesActions.playlist-action(item.id, "play"); }
|
animate opacity { duration: 150ms; }
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/play-fill.svg");
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||||
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||||
|
tint: #ffffff;
|
||||||
}
|
}
|
||||||
pointer-event(e) => {
|
play-ta := TouchArea {
|
||||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
mouse-cursor: pointer;
|
||||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
clicked => {
|
||||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||||
row-menu.show();
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
||||||
|
else { FavoritesActions.playlist-action(item.id, "play"); }
|
||||||
|
}
|
||||||
|
pointer-event(e) => {
|
||||||
|
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||||
|
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||||
|
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||||
|
row-menu.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Col — title + subtitle (artist link for track/album).
|
||||||
// Col — title + subtitle (artist link for track/album).
|
VerticalLayout {
|
||||||
VerticalLayout {
|
alignment: center;
|
||||||
alignment: center;
|
horizontal-stretch: 1;
|
||||||
horizontal-stretch: 1;
|
spacing: 2px;
|
||||||
spacing: 2px;
|
title-ta := TouchArea {
|
||||||
title-ta := TouchArea {
|
height: 18px;
|
||||||
height: 18px;
|
|
||||||
mouse-cursor: pointer;
|
|
||||||
clicked => {
|
|
||||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
|
||||||
else if (item.kind == "album") { FavoritesActions.open-album(item.id); }
|
|
||||||
else if (item.kind == "artist") { FavoritesActions.open-artist(item.id); }
|
|
||||||
else if (item.kind == "playlist") { FavoritesActions.open-playlist(item.id); }
|
|
||||||
else { FavoritesActions.open-label(item.id, item.title); }
|
|
||||||
}
|
|
||||||
pointer-event(e) => {
|
|
||||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
|
||||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
|
||||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
|
||||||
row-menu.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
width: 100%;
|
|
||||||
text: item.title;
|
|
||||||
color: title-ta.has-hover ? Theme.accent : Theme.text-primary;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: Typography.medium;
|
|
||||||
overflow: elide;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if item.subtitle != "": sub-ta := TouchArea {
|
|
||||||
height: 16px;
|
|
||||||
mouse-cursor: (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) ? pointer : default;
|
|
||||||
clicked => {
|
|
||||||
if (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) {
|
|
||||||
FavoritesActions.open-artist(item.artist-id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pointer-event(e) => {
|
|
||||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
|
||||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
|
||||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
|
||||||
row-menu.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
width: 100%;
|
|
||||||
text: item.subtitle;
|
|
||||||
color: (item.artist-id != "" && (item.kind == "track" || item.kind == "album") && sub-ta.has-hover)
|
|
||||||
? Theme.accent : Theme.text-muted;
|
|
||||||
font-size: 12px;
|
|
||||||
overflow: elide;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Col — type (icon + label).
|
|
||||||
Rectangle {
|
|
||||||
width: 116px;
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 6px;
|
|
||||||
alignment: start;
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
QbzIcon {
|
|
||||||
source: item.kind == "track" ? @image-url("../assets/icons/music.svg")
|
|
||||||
: item.kind == "playlist" ? @image-url("../assets/icons/list-music.svg")
|
|
||||||
: item.kind == "artist" ? @image-url("../assets/icons/user.svg")
|
|
||||||
: item.kind == "label" ? @image-url("../assets/icons/disc-3.svg")
|
|
||||||
: @image-url("../assets/icons/disc.svg");
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
tint: Theme.text-muted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: item.kind == "track" ? @tr("Track")
|
|
||||||
: item.kind == "album" ? @tr("Album")
|
|
||||||
: item.kind == "artist" ? @tr("Artist")
|
|
||||||
: item.kind == "playlist" ? @tr("Playlist")
|
|
||||||
: @tr("Label");
|
|
||||||
color: Theme.text-muted;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: Typography.semibold;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Col — source glyph. Only when local/Plex can
|
|
||||||
// appear (show-local on); otherwise the whole feed
|
|
||||||
// is Qobuz, so the column is hidden rather than
|
|
||||||
// painting the Qobuz mark on every row.
|
|
||||||
if LibraryAllState.show-local: Rectangle {
|
|
||||||
width: 44px;
|
|
||||||
SourceGlyph {
|
|
||||||
kind: item.source;
|
|
||||||
x: 0;
|
|
||||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Col — quality (albums + tracks; the row badge).
|
|
||||||
Rectangle {
|
|
||||||
width: 150px;
|
|
||||||
if (item.kind == "album" || item.kind == "track") && item.quality-tier != "": QualityBadgeFull {
|
|
||||||
tier: item.quality-tier;
|
|
||||||
detail: item.quality-detail;
|
|
||||||
x: 0;
|
|
||||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Col — favorite (heart) / follow (person) indicator.
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
QbzIcon {
|
|
||||||
source: item.kind == "artist"
|
|
||||||
? @image-url("../assets/icons/user-plus.svg")
|
|
||||||
: (item.is-favorite
|
|
||||||
? @image-url("../assets/icons/heart-filled.svg")
|
|
||||||
: @image-url("../assets/icons/heart.svg"));
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
tint: Theme.accent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Col — context-menu button.
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
menu-btn := Rectangle {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: menu-btn-ta.has-hover ? Theme.surface-elevated : transparent;
|
|
||||||
QbzIcon {
|
|
||||||
source: @image-url("../assets/icons/ellipsis.svg");
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
||||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
||||||
tint: Theme.text-secondary;
|
|
||||||
}
|
|
||||||
menu-btn-ta := TouchArea {
|
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
clicked => {
|
clicked => {
|
||||||
// The ⋯ opener: anchor the menu by
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||||
// the button (right-click uses the
|
else if (item.kind == "album") { FavoritesActions.open-album(item.id); }
|
||||||
// cursor instead).
|
else if (item.kind == "artist") { FavoritesActions.open-artist(item.id); }
|
||||||
row-rect.menu-x = row-rect.width - 206px;
|
else if (item.kind == "playlist") { FavoritesActions.open-playlist(item.id); }
|
||||||
row-rect.menu-y = 48px;
|
else { FavoritesActions.open-label(item.id, item.title); }
|
||||||
row-menu.show();
|
}
|
||||||
|
pointer-event(e) => {
|
||||||
|
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||||
|
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||||
|
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||||
|
row-menu.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: 100%;
|
||||||
|
text: item.title;
|
||||||
|
color: title-ta.has-hover ? Theme.accent : Theme.text-primary;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
overflow: elide;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.subtitle != "": sub-ta := TouchArea {
|
||||||
|
height: 16px;
|
||||||
|
mouse-cursor: (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) ? pointer : default;
|
||||||
|
clicked => {
|
||||||
|
if (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) {
|
||||||
|
FavoritesActions.open-artist(item.artist-id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pointer-event(e) => {
|
||||||
|
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||||
|
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||||
|
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||||
|
row-menu.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: 100%;
|
||||||
|
text: item.subtitle;
|
||||||
|
color: (item.artist-id != "" && (item.kind == "track" || item.kind == "album") && sub-ta.has-hover)
|
||||||
|
? Theme.accent : Theme.text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: elide;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Col — type (icon + label).
|
||||||
|
Rectangle {
|
||||||
|
width: 116px;
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 6px;
|
||||||
|
alignment: start;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: item.kind == "track" ? @image-url("../assets/icons/music.svg")
|
||||||
|
: item.kind == "playlist" ? @image-url("../assets/icons/list-music.svg")
|
||||||
|
: item.kind == "artist" ? @image-url("../assets/icons/user.svg")
|
||||||
|
: item.kind == "label" ? @image-url("../assets/icons/disc-3.svg")
|
||||||
|
: @image-url("../assets/icons/disc.svg");
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
tint: Theme.text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: item.kind == "track" ? @tr("Track")
|
||||||
|
: item.kind == "album" ? @tr("Album")
|
||||||
|
: item.kind == "artist" ? @tr("Artist")
|
||||||
|
: item.kind == "playlist" ? @tr("Playlist")
|
||||||
|
: @tr("Label");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Col — source glyph. Only when local/Plex can
|
||||||
|
// appear (show-local on); otherwise the whole feed
|
||||||
|
// is Qobuz, so the column is hidden rather than
|
||||||
|
// painting the Qobuz mark on every row.
|
||||||
|
if LibraryAllState.show-local: Rectangle {
|
||||||
|
width: 44px;
|
||||||
|
SourceGlyph {
|
||||||
|
kind: item.source;
|
||||||
|
x: 0;
|
||||||
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Col — quality (albums + tracks; the row badge).
|
||||||
|
Rectangle {
|
||||||
|
width: 150px;
|
||||||
|
if (item.kind == "album" || item.kind == "track") && item.quality-tier != "": QualityBadgeFull {
|
||||||
|
tier: item.quality-tier;
|
||||||
|
detail: item.quality-detail;
|
||||||
|
x: 0;
|
||||||
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Col — favorite (heart) / follow (person) indicator.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: item.kind == "artist"
|
||||||
|
? @image-url("../assets/icons/user-plus.svg")
|
||||||
|
: (item.is-favorite
|
||||||
|
? @image-url("../assets/icons/heart-filled.svg")
|
||||||
|
: @image-url("../assets/icons/heart.svg"));
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
tint: Theme.accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Col — context-menu button.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
menu-btn := Rectangle {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: menu-btn-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/ellipsis.svg");
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||||
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||||
|
tint: Theme.text-secondary;
|
||||||
|
}
|
||||||
|
menu-btn-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
// The ⋯ opener: anchor the menu by
|
||||||
|
// the button (right-click uses the
|
||||||
|
// cursor instead).
|
||||||
|
row-rect.menu-x = row-rect.width - 206px;
|
||||||
|
row-rect.menu-y = 48px;
|
||||||
|
row-menu.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Per-kind context menu (⋯ button + right-click).
|
||||||
// Per-kind context menu (⋯ button + right-click).
|
row-menu := PopupWindow {
|
||||||
row-menu := PopupWindow {
|
x: row-rect.menu-x;
|
||||||
x: row-rect.menu-x;
|
y: row-rect.menu-y;
|
||||||
y: row-rect.menu-y;
|
width: 196px;
|
||||||
width: 196px;
|
height: (item.kind == "track" ? 7
|
||||||
height: (item.kind == "track" ? 6
|
: item.kind == "album" ? 7
|
||||||
: item.kind == "album" ? 6
|
: item.kind == "playlist" ? 5
|
||||||
: item.kind == "playlist" ? 4
|
: 1) * 33px + 10px;
|
||||||
: 1) * 33px + 10px;
|
ContextMenu {
|
||||||
ContextMenu {
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
icon: @image-url("../assets/icons/play.svg");
|
||||||
icon: @image-url("../assets/icons/play.svg");
|
label: @tr("Play");
|
||||||
label: @tr("Play");
|
clicked => {
|
||||||
clicked => {
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
||||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
else { FavoritesActions.playlist-action(item.id, "play"); }
|
||||||
else { FavoritesActions.playlist-action(item.id, "play"); }
|
}
|
||||||
}
|
}
|
||||||
}
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
label: @tr("Play next");
|
||||||
label: @tr("Play next");
|
clicked => {
|
||||||
clicked => {
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); }
|
||||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); }
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-next"); }
|
||||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-next"); }
|
else { FavoritesActions.playlist-action(item.id, "play-next"); }
|
||||||
else { FavoritesActions.playlist-action(item.id, "play-next"); }
|
}
|
||||||
}
|
}
|
||||||
}
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
label: @tr("Play later");
|
||||||
label: @tr("Add to queue");
|
clicked => {
|
||||||
clicked => {
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-later"); }
|
||||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); }
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-later"); }
|
||||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); }
|
else { FavoritesActions.playlist-action(item.id, "play-later"); }
|
||||||
else { FavoritesActions.playlist-action(item.id, "queue"); }
|
}
|
||||||
}
|
}
|
||||||
}
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||||
if item.kind == "album": ContextMenuItem {
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
icon: @image-url("../assets/icons/library-big.svg");
|
label: @tr("Add to queue");
|
||||||
label: @tr("Open album");
|
clicked => {
|
||||||
clicked => { FavoritesActions.open-album(item.id); }
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); }
|
||||||
}
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); }
|
||||||
if item.kind == "playlist": ContextMenuItem {
|
else { FavoritesActions.playlist-action(item.id, "queue"); }
|
||||||
icon: @image-url("../assets/icons/library-big.svg");
|
}
|
||||||
label: @tr("Open playlist");
|
}
|
||||||
clicked => { FavoritesActions.open-playlist(item.id); }
|
if item.kind == "album": ContextMenuItem {
|
||||||
}
|
icon: @image-url("../assets/icons/library-big.svg");
|
||||||
if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem {
|
label: @tr("Open album");
|
||||||
icon: @image-url("../assets/icons/user.svg");
|
clicked => { FavoritesActions.open-album(item.id); }
|
||||||
label: @tr("Go to artist");
|
}
|
||||||
clicked => { FavoritesActions.open-artist(item.artist-id); }
|
if item.kind == "playlist": ContextMenuItem {
|
||||||
}
|
icon: @image-url("../assets/icons/library-big.svg");
|
||||||
if item.kind == "track" && item.album-id != "": ContextMenuItem {
|
label: @tr("Open playlist");
|
||||||
icon: @image-url("../assets/icons/disc.svg");
|
clicked => { FavoritesActions.open-playlist(item.id); }
|
||||||
label: @tr("Go to album");
|
}
|
||||||
clicked => { FavoritesActions.open-album(item.album-id); }
|
if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem {
|
||||||
}
|
icon: @image-url("../assets/icons/user.svg");
|
||||||
if item.kind == "artist": ContextMenuItem {
|
label: @tr("Go to artist");
|
||||||
icon: @image-url("../assets/icons/user.svg");
|
clicked => { FavoritesActions.open-artist(item.artist-id); }
|
||||||
label: @tr("Go to artist");
|
}
|
||||||
clicked => { FavoritesActions.open-artist(item.id); }
|
if item.kind == "track" && item.album-id != "": ContextMenuItem {
|
||||||
}
|
icon: @image-url("../assets/icons/disc.svg");
|
||||||
if item.kind == "label": ContextMenuItem {
|
label: @tr("Go to album");
|
||||||
icon: @image-url("../assets/icons/disc-3.svg");
|
clicked => { FavoritesActions.open-album(item.album-id); }
|
||||||
label: @tr("Go to label");
|
}
|
||||||
clicked => { FavoritesActions.open-label(item.id, item.title); }
|
if item.kind == "artist": ContextMenuItem {
|
||||||
}
|
icon: @image-url("../assets/icons/user.svg");
|
||||||
if item.kind == "track" || item.kind == "album": ContextMenuItem {
|
label: @tr("Go to artist");
|
||||||
icon: item.is-favorite
|
clicked => { FavoritesActions.open-artist(item.id); }
|
||||||
? @image-url("../assets/icons/heart-filled.svg")
|
}
|
||||||
: @image-url("../assets/icons/heart.svg");
|
if item.kind == "label": ContextMenuItem {
|
||||||
label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library");
|
icon: @image-url("../assets/icons/disc-3.svg");
|
||||||
clicked => {
|
label: @tr("Go to label");
|
||||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); }
|
clicked => { FavoritesActions.open-label(item.id, item.title); }
|
||||||
else { FavoritesActions.album-action(item.id, "favorite"); }
|
}
|
||||||
|
if item.kind == "track" || item.kind == "album": ContextMenuItem {
|
||||||
|
icon: item.is-favorite
|
||||||
|
? @image-url("../assets/icons/heart-filled.svg")
|
||||||
|
: @image-url("../assets/icons/heart.svg");
|
||||||
|
label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library");
|
||||||
|
clicked => {
|
||||||
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); }
|
||||||
|
else { FavoritesActions.album-action(item.id, "favorite"); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1398,12 +1571,13 @@ export component FavoritesView inherits Rectangle {
|
|||||||
selected-count: FavoritesState.tracks-selected-count;
|
selected-count: FavoritesState.tracks-selected-count;
|
||||||
actions: [
|
actions: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.svg"), danger: false, needs-selection: true },
|
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
|
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true },
|
||||||
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
||||||
];
|
];
|
||||||
action(id) => {
|
action(id) => {
|
||||||
@@ -1555,6 +1729,25 @@ export component FavoritesView inherits Rectangle {
|
|||||||
// --- Albums tab ---
|
// --- Albums tab ---
|
||||||
if FavoritesState.active-tab == "albums": VerticalLayout {
|
if FavoritesState.active-tab == "albums": VerticalLayout {
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
// Bulk action bar — visible in multi-select edit
|
||||||
|
// mode (list view only).
|
||||||
|
if FavoritesState.albums-multi-select
|
||||||
|
&& FavoritesState.albums-view-mode == "list": MultiSelectBar {
|
||||||
|
selected-count: FavoritesState.albums-selected-count;
|
||||||
|
actions: [
|
||||||
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true },
|
||||||
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
||||||
|
];
|
||||||
|
action(id) => {
|
||||||
|
FavoritesActions.albums-bulk-action(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if FavoritesState.albums-multi-select
|
||||||
|
&& FavoritesState.albums-view-mode == "list": Rectangle { height: 8px; }
|
||||||
if FavoritesState.albums.length == 0: Text {
|
if FavoritesState.albums.length == 0: Text {
|
||||||
text: @tr("No albums in your Library yet.");
|
text: @tr("No albums in your Library yet.");
|
||||||
color: Theme.text-muted;
|
color: Theme.text-muted;
|
||||||
@@ -1572,6 +1765,7 @@ export component FavoritesView inherits Rectangle {
|
|||||||
view-mode: FavoritesState.albums-view-mode;
|
view-mode: FavoritesState.albums-view-mode;
|
||||||
is-grouped: FavoritesState.albums-group-mode != "off";
|
is-grouped: FavoritesState.albums-group-mode != "off";
|
||||||
show-source: false;
|
show-source: false;
|
||||||
|
select-mode: FavoritesState.albums-multi-select;
|
||||||
// Viewport windowing (Phase 1): only cards near the
|
// Viewport windowing (Phase 1): only cards near the
|
||||||
// visible band mount + get artwork dispatched. The
|
// visible band mount + get artwork dispatched. The
|
||||||
// offset is the page's padding-top (16px); the grid's
|
// offset is the page's padding-top (16px); the grid's
|
||||||
|
|||||||
@@ -11,4 +11,12 @@
|
|||||||
// the user is typing (mirrors the Tauri `isInputTarget` check).
|
// the user is typing (mirrors the Tauri `isInputTarget` check).
|
||||||
export global UiFocusState {
|
export global UiFocusState {
|
||||||
in-out property <bool> text-input-focused: false;
|
in-out property <bool> text-input-focused: false;
|
||||||
|
// Steam Deck OSK (Steam plan PR 4): fired on every focus transition so
|
||||||
|
// Rust (steam_osk) can open/close the Steam OSK — every text field rides
|
||||||
|
// it with zero per-field wiring. Mode: 0 = single-line (all fields today).
|
||||||
|
in-out property <int> osk-mode: 0;
|
||||||
|
callback osk(bool /* focused */, int /* mode */);
|
||||||
|
changed text-input-focused => {
|
||||||
|
root.osk(root.text-input-focused, root.osk-mode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ component SuggestionCardView inherits Rectangle {
|
|||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
OverlayButton {
|
OverlayButton {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
clicked => { SuggestionsActions.queue-playlist(root.card.playlist-id); }
|
clicked => { SuggestionsActions.queue-playlist(root.card.playlist-id); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,9 +201,9 @@ export component LabelPageView inherits Rectangle {
|
|||||||
root.media-action("label", LabelState.id, "shuffle");
|
root.media-action("label", LabelState.id, "shuffle");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ⋯ Overflow — Play next / Add to queue / Share (the
|
// ⋯ Overflow — Play next / Play later / Add to queue /
|
||||||
// Qobuz web label menu). Share is Qobuz-link only:
|
// Share (the Qobuz web label menu). Share is Qobuz-link
|
||||||
// Song.link/Album.link have no label equivalent.
|
// only: Song.link/Album.link have no label equivalent.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -222,16 +222,23 @@ export component LabelPageView inherits Rectangle {
|
|||||||
x: 0px;
|
x: 0px;
|
||||||
y: 38px;
|
y: 38px;
|
||||||
width: 224px;
|
width: 224px;
|
||||||
height: 3 * 33px + 10px;
|
height: 4 * 33px + 10px;
|
||||||
close-policy: close-on-click;
|
close-policy: close-on-click;
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("label", "", "top-play-next");
|
root.media-action("label", "", "top-play-next");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("label", "", "top-play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
@@ -363,16 +370,23 @@ export component LabelPageView inherits Rectangle {
|
|||||||
x: 32px - self.width;
|
x: 32px - self.width;
|
||||||
y: 38px;
|
y: 38px;
|
||||||
width: 224px;
|
width: 224px;
|
||||||
height: 2 * 33px + 10px;
|
height: 3 * 33px + 10px;
|
||||||
close-policy: close-on-click;
|
close-policy: close-on-click;
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("label", "", "top-play-next");
|
root.media-action("label", "", "top-play-next");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("label", "", "top-play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
@@ -395,7 +409,8 @@ export component LabelPageView inherits Rectangle {
|
|||||||
selected-count: LabelState.selected-count;
|
selected-count: LabelState.selected-count;
|
||||||
actions: [
|
actions: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
||||||
|
|||||||
@@ -1241,7 +1241,8 @@ export component LocalLibraryView inherits Rectangle {
|
|||||||
actions: [
|
actions: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
||||||
@@ -1407,7 +1408,9 @@ export component LocalLibraryView inherits Rectangle {
|
|||||||
actions: [
|
actions: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "remove-favorites", label: @tr("Remove from favorites"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true },
|
||||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
||||||
@@ -1802,11 +1805,19 @@ export component LocalLibraryView inherits Rectangle {
|
|||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
RailBulkBtn {
|
RailBulkBtn {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
tooltip: @tr("Play next");
|
tooltip: @tr("Play next");
|
||||||
clicked => { LocalLibraryActions.folders-bulk-action("play-next"); }
|
clicked => { LocalLibraryActions.folders-bulk-action("play-next"); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
RailBulkBtn {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
tooltip: @tr("Play later");
|
||||||
|
clicked => { LocalLibraryActions.folders-bulk-action("play-later"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
RailBulkBtn {
|
RailBulkBtn {
|
||||||
|
|||||||
@@ -219,7 +219,8 @@ export component MixView inherits Rectangle {
|
|||||||
selected-count: MixState.selected-count;
|
selected-count: MixState.selected-count;
|
||||||
actions: [
|
actions: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
||||||
|
|||||||
@@ -440,9 +440,9 @@ component DetailRow inherits Rectangle {
|
|||||||
width: 184px;
|
width: 184px;
|
||||||
// Explicit height: without it the PopupWindow's box does not
|
// Explicit height: without it the PopupWindow's box does not
|
||||||
// resolve and the in-flow rows below paint over it (the only
|
// resolve and the in-flow rows below paint over it (the only
|
||||||
// PopupWindow in the codebase that omitted this). 4 items
|
// PopupWindow in the codebase that omitted this). 5 items
|
||||||
// (32px) + 1 separator (1px) + padding (8px) + spacing (4px).
|
// (32px) + 1 separator (1px) + padding (8px) + spacing (5px).
|
||||||
height: 145px;
|
height: 178px;
|
||||||
// Act-and-dismiss menu — match the app idiom (close on any
|
// Act-and-dismiss menu — match the app idiom (close on any
|
||||||
// click; items also act before the implicit close).
|
// click; items also act before the implicit close).
|
||||||
close-policy: PopupClosePolicy.close-on-click;
|
close-policy: PopupClosePolicy.close-on-click;
|
||||||
@@ -462,12 +462,17 @@ component DetailRow inherits Rectangle {
|
|||||||
chosen => { MyQbzDetailActions.play-item(root.item.source-item-id); }
|
chosen => { MyQbzDetailActions.play-item(root.item.source-item-id); }
|
||||||
}
|
}
|
||||||
RowMenuItem {
|
RowMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-music.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-next"); }
|
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-next"); }
|
||||||
}
|
}
|
||||||
RowMenuItem {
|
RowMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-music.svg");
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-later"); }
|
||||||
|
}
|
||||||
|
RowMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "add-to-queue"); }
|
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "add-to-queue"); }
|
||||||
}
|
}
|
||||||
@@ -1233,10 +1238,11 @@ export component MixtapeDetailView inherits Rectangle {
|
|||||||
// Spec 12 §13: a bottom-docked bar that appears while items are selected
|
// Spec 12 §13: a bottom-docked bar that appears while items are selected
|
||||||
// (placement='bottom' default). Mirrors the local-library MultiSelectBar
|
// (placement='bottom' default). Mirrors the local-library MultiSelectBar
|
||||||
// chrome (a flat [BulkAction] row — the §13.1 split-queue dropdown isn't
|
// chrome (a flat [BulkAction] row — the §13.1 split-queue dropdown isn't
|
||||||
// expressible in MultiSelectBar, so its two actions are flattened to the
|
// expressible in MultiSelectBar, so its queue actions are flattened to
|
||||||
// adjacent "Add to queue" + "Play next" buttons, same idiom the Local
|
// adjacent buttons, same idiom the Local Library tracks bar uses). The
|
||||||
// Library tracks bar uses). The §13.1 action set, in order: Add to queue
|
// action set, in order: Add to queue, Play later (#442 — the manual-block
|
||||||
// (play-later), Play next, Add to playlist, Add to Mixtape/Collection,
|
// tail; §13.1's own "play-later" label mapped to plain append before
|
||||||
|
// #442 existed), Play next, Add to playlist, Add to Mixtape/Collection,
|
||||||
// Remove from this collection (danger), Clear (exit-select). The bar emits
|
// Remove from this collection (danger), Clear (exit-select). The bar emits
|
||||||
// the id; the Rust side routes it.
|
// the id; the Rust side routes it.
|
||||||
if MyQbzDetailState.select-mode && MyQbzDetailState.selected-count > 0: Rectangle {
|
if MyQbzDetailState.select-mode && MyQbzDetailState.selected-count > 0: Rectangle {
|
||||||
@@ -1251,7 +1257,8 @@ export component MixtapeDetailView inherits Rectangle {
|
|||||||
selected-count: MyQbzDetailState.selected-count;
|
selected-count: MyQbzDetailState.selected-count;
|
||||||
actions: [
|
actions: [
|
||||||
{ id: "add-to-queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
|
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ component PmSearch inherits Rectangle {
|
|||||||
component PmMenuItem inherits Rectangle {
|
component PmMenuItem inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <bool> selected: false;
|
in property <bool> selected: false;
|
||||||
|
// Sort rows: "up" | "down" draws the direction chevron in place of the
|
||||||
|
// plain check on the active row (#657 caret).
|
||||||
|
in property <string> trailing-icon: "";
|
||||||
// Collapse property — `for x: if c: El` is disallowed in Slint 1.16, so
|
// Collapse property — `for x: if c: El` is disallowed in Slint 1.16, so
|
||||||
// a hidden item collapses to zero height instead (the sidebar pattern).
|
// a hidden item collapses to zero height instead (the sidebar pattern).
|
||||||
in property <bool> shown: true;
|
in property <bool> shown: true;
|
||||||
@@ -137,7 +140,9 @@ component PmMenuItem inherits Rectangle {
|
|||||||
if root.selected: VerticalLayout {
|
if root.selected: VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
QbzIcon {
|
QbzIcon {
|
||||||
source: @image-url("../assets/icons/check.svg");
|
source: root.trailing-icon == "up" ? @image-url("../assets/icons/chevron-up.svg")
|
||||||
|
: root.trailing-icon == "down" ? @image-url("../assets/icons/chevron-down.svg")
|
||||||
|
: @image-url("../assets/icons/check.svg");
|
||||||
width: 13px;
|
width: 13px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
tint: Theme.accent;
|
tint: Theme.accent;
|
||||||
@@ -1193,26 +1198,36 @@ export component PlaylistManagerView inherits Rectangle {
|
|||||||
PmMenuItem {
|
PmMenuItem {
|
||||||
label: @tr("Name A-Z");
|
label: @tr("Name A-Z");
|
||||||
selected: PlaylistManagerState.sort == "name";
|
selected: PlaylistManagerState.sort == "name";
|
||||||
|
trailing-icon: PlaylistManagerState.sort == "name"
|
||||||
|
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { PlaylistManagerActions.set-sort("name"); }
|
activated => { PlaylistManagerActions.set-sort("name"); }
|
||||||
}
|
}
|
||||||
PmMenuItem {
|
PmMenuItem {
|
||||||
label: @tr("Recent");
|
label: @tr("Recent");
|
||||||
selected: PlaylistManagerState.sort == "recent";
|
selected: PlaylistManagerState.sort == "recent";
|
||||||
|
trailing-icon: PlaylistManagerState.sort == "recent"
|
||||||
|
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { PlaylistManagerActions.set-sort("recent"); }
|
activated => { PlaylistManagerActions.set-sort("recent"); }
|
||||||
}
|
}
|
||||||
PmMenuItem {
|
PmMenuItem {
|
||||||
label: @tr("Play Count");
|
label: @tr("Play Count");
|
||||||
selected: PlaylistManagerState.sort == "playcount";
|
selected: PlaylistManagerState.sort == "playcount";
|
||||||
|
trailing-icon: PlaylistManagerState.sort == "playcount"
|
||||||
|
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { PlaylistManagerActions.set-sort("playcount"); }
|
activated => { PlaylistManagerActions.set-sort("playcount"); }
|
||||||
}
|
}
|
||||||
PmMenuItem {
|
PmMenuItem {
|
||||||
label: @tr("Track Count");
|
label: @tr("Track Count");
|
||||||
selected: PlaylistManagerState.sort == "tracks";
|
selected: PlaylistManagerState.sort == "tracks";
|
||||||
|
trailing-icon: PlaylistManagerState.sort == "tracks"
|
||||||
|
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { PlaylistManagerActions.set-sort("tracks"); }
|
activated => { PlaylistManagerActions.set-sort("tracks"); }
|
||||||
}
|
}
|
||||||
PmMenuItem {
|
PmMenuItem {
|
||||||
label: @tr("Custom");
|
label: @tr("Custom");
|
||||||
selected: PlaylistManagerState.sort == "custom";
|
selected: PlaylistManagerState.sort == "custom";
|
||||||
|
trailing-icon: PlaylistManagerState.sort == "custom"
|
||||||
|
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { PlaylistManagerActions.set-sort("custom"); }
|
activated => { PlaylistManagerActions.set-sort("custom"); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,19 +310,22 @@ export component PlaylistView inherits Rectangle {
|
|||||||
property <bool> show-upload-confirm: false;
|
property <bool> show-upload-confirm: false;
|
||||||
|
|
||||||
// Bulk-bar action sets (typed properties — a ternary over bare array
|
// Bulk-bar action sets (typed properties — a ternary over bare array
|
||||||
// literals can't coerce to [BulkAction]). Tauri parity (spec §1.5):
|
// literals can't coerce to [BulkAction]). Tauri parity (spec §1.5) + #442:
|
||||||
// Play next / Add to queue / Add to playlist over the selection,
|
// Play next / Play later / Add to queue / Add to playlist over the
|
||||||
// Remove owner-only — followed playlists keep the queueing actions.
|
// selection, Remove owner-only — followed playlists keep the queueing
|
||||||
|
// actions.
|
||||||
property <[BulkAction]> bulk-actions-owner: [
|
property <[BulkAction]> bulk-actions-owner: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-later-selected", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
|
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
|
||||||
];
|
];
|
||||||
property <[BulkAction]> bulk-actions-follower: [
|
property <[BulkAction]> bulk-actions-follower: [
|
||||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||||
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
||||||
|
{ id: "play-later-selected", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||||
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
// Item -> media-action("album", <album-id>, <action>) mapping:
|
// Item -> media-action("album", <album-id>, <action>) mapping:
|
||||||
// Play next -> "play-next"
|
// Play next -> "play-next"
|
||||||
|
// Play later -> "play-later"
|
||||||
// Add to queue -> "queue"
|
// Add to queue -> "queue"
|
||||||
// --
|
// --
|
||||||
// Add to playlist -> "add-to-playlist"
|
// Add to playlist -> "add-to-playlist"
|
||||||
@@ -39,11 +40,11 @@ export component AlbumContextMenu inherits PopupWindow {
|
|||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
|
|
||||||
width: 224px;
|
width: 224px;
|
||||||
// 9 rows x 33px + 4 separators x 1px + ContextMenu's 5/5 padding. The
|
// 10 rows x 33px + 4 separators x 1px + ContextMenu's 5/5 padding. The
|
||||||
// offline row swaps label/action on `fully-cached` (count constant); the
|
// offline row swaps label/action on `fully-cached` (count constant); the
|
||||||
// block row swaps label/action on `is-blocked` (count constant); the pin
|
// block row swaps label/action on `is-blocked` (count constant); the pin
|
||||||
// row swaps label/action on `is-pinned` (count constant).
|
// row swaps label/action on `is-pinned` (count constant).
|
||||||
height: 9 * 33px + 4 * 1px + 10px;
|
height: 10 * 33px + 4 * 1px + 10px;
|
||||||
// Default close-policy is close-on-click, which races the row's TouchArea:
|
// Default close-policy is close-on-click, which races the row's TouchArea:
|
||||||
// the popup can dismiss on the press before the item's `clicked` fires, so
|
// the popup can dismiss on the press before the item's `clicked` fires, so
|
||||||
// some rows (e.g. Share Qobuz link) needed a second open+click to register.
|
// some rows (e.g. Share Qobuz link) needed a second open+click to register.
|
||||||
@@ -53,13 +54,23 @@ export component AlbumContextMenu inherits PopupWindow {
|
|||||||
|
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("album", root.album-id, "play-next");
|
root.media-action("album", root.album-id, "play-next");
|
||||||
root.close();
|
root.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #442 "Play later" — the END of the manual block (after every
|
||||||
|
// play-next / play-later already queued, before the source resumes).
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("album", root.album-id, "play-later");
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export component AlbumListRow inherits Rectangle {
|
|||||||
|
|
||||||
callback clicked(string);
|
callback clicked(string);
|
||||||
// kind ("album" | "artist"), id, action ("play" | "queue" |
|
// kind ("album" | "artist"), id, action ("play" | "queue" |
|
||||||
// "play-next" | "favorite" | "open").
|
// "play-next" | "play-later" | "favorite" | "open").
|
||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
|
|
||||||
// Hover spans the row body + the art / artist / overflow affordances so
|
// Hover spans the row body + the art / artist / overflow affordances so
|
||||||
@@ -382,8 +382,8 @@ export component AlbumListRow inherits Rectangle {
|
|||||||
x: -170px;
|
x: -170px;
|
||||||
y: 34px;
|
y: 34px;
|
||||||
width: 196px;
|
width: 196px;
|
||||||
// 6 items max (Open/Play/Play-next/Queue/favorite + block).
|
// 7 items max (Open/Play/Play-next/Play-later/Queue/favorite + block).
|
||||||
height: 6 * 33px + 10px;
|
height: 7 * 33px + 10px;
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/library-big.svg");
|
icon: @image-url("../assets/icons/library-big.svg");
|
||||||
@@ -400,12 +400,19 @@ export component AlbumListRow inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("album", root.album.id, "play-next");
|
root.media-action("album", root.album.id, "play-next");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("album", root.album.id, "play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
// Shuffle -> disc-action(disc, "shuffle")
|
// Shuffle -> disc-action(disc, "shuffle")
|
||||||
// --
|
// --
|
||||||
// Add to queue -> disc-action(disc, "queue")
|
// Add to queue -> disc-action(disc, "queue")
|
||||||
|
// Play later -> disc-action(disc, "play-later")
|
||||||
// Play next -> disc-action(disc, "play-next")
|
// Play next -> disc-action(disc, "play-next")
|
||||||
|
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
@@ -22,8 +23,8 @@ export component DiscContextMenu inherits PopupWindow {
|
|||||||
callback disc-action(int, string);
|
callback disc-action(int, string);
|
||||||
|
|
||||||
width: 200px;
|
width: 200px;
|
||||||
// 4 rows x 33px + 1 separator x 1px + ContextMenu's 5/5 padding.
|
// 5 rows x 33px + 1 separator x 1px + ContextMenu's 5/5 padding.
|
||||||
height: 4 * 33px + 1 * 1px + 10px;
|
height: 5 * 33px + 1 * 1px + 10px;
|
||||||
|
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
@@ -50,6 +51,13 @@ export component DiscContextMenu inherits PopupWindow {
|
|||||||
}
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.disc-action(root.disc, "play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.disc-action(root.disc, "play-next");
|
root.disc-action(root.disc, "play-next");
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { DiscContextMenu } from "DiscContextMenu.slint";
|
|||||||
|
|
||||||
export component DiscHeaderMenu inherits Rectangle {
|
export component DiscHeaderMenu inherits Rectangle {
|
||||||
in property <int> disc;
|
in property <int> disc;
|
||||||
// (disc, action) — action: "play" | "shuffle" | "queue" | "play-next".
|
// (disc, action) — action: "play" | "shuffle" | "queue" | "play-later" | "play-next".
|
||||||
callback disc-action(int, string);
|
callback disc-action(int, string);
|
||||||
|
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|||||||
@@ -110,14 +110,15 @@ export component PlaylistDuplicateConfirmModal inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add only new tracks — skips the duplicates
|
// Add only new tracks — skips the duplicates
|
||||||
// (executeAddTracks(true)). Disabled when there is
|
// (executeAddTracks(true)). Always the primary continue
|
||||||
// nothing left to add (all dupes).
|
// action (#655): with all-dupes it adds zero and closes
|
||||||
|
// (the Rust arm handles the empty set), so it must not
|
||||||
|
// disable — the mixed-case primary disappearing under
|
||||||
|
// the user's finger was the bug.
|
||||||
QbzPrimaryButton {
|
QbzPrimaryButton {
|
||||||
label: DuplicateConfirmState.busy ? @tr("Adding...") : @tr("Add only new tracks");
|
label: DuplicateConfirmState.busy ? @tr("Adding...") : @tr("Add only new tracks");
|
||||||
height: 34px;
|
height: 34px;
|
||||||
enabled: !DuplicateConfirmState.busy
|
enabled: !DuplicateConfirmState.busy;
|
||||||
&& DuplicateConfirmState.duplicate-count
|
|
||||||
< DuplicateConfirmState.total-count;
|
|
||||||
clicked => {
|
clicked => {
|
||||||
DuplicateConfirmActions.add-new-only();
|
DuplicateConfirmActions.add-new-only();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export component PlaylistListRow inherits Rectangle {
|
|||||||
x: -166px;
|
x: -166px;
|
||||||
y: 32px;
|
y: 32px;
|
||||||
width: 196px;
|
width: 196px;
|
||||||
height: 150px;
|
// 5 rows x 33px + ContextMenu's 5/5 padding.
|
||||||
|
height: 5 * 33px + 10px;
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/play.svg");
|
icon: @image-url("../assets/icons/play.svg");
|
||||||
@@ -125,12 +126,19 @@ export component PlaylistListRow inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("playlist", root.playlist.id, "play-next");
|
root.media-action("playlist", root.playlist.id, "play-next");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("playlist", root.playlist.id, "play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { ContextMenu } from "ContextMenu.slint";
|
import { ContextMenu } from "ContextMenu.slint";
|
||||||
import { ContextMenuItem } from "ContextMenuItem.slint";
|
import { ContextMenuItem } from "ContextMenuItem.slint";
|
||||||
|
import { QueueState } from "../state.slint";
|
||||||
|
|
||||||
export component TrackContextMenu inherits PopupWindow {
|
export component TrackContextMenu inherits PopupWindow {
|
||||||
in property <string> track-id;
|
in property <string> track-id;
|
||||||
@@ -61,14 +62,15 @@ export component TrackContextMenu inherits PopupWindow {
|
|||||||
|
|
||||||
width: 224px;
|
width: 224px;
|
||||||
// Nav-only (blacklisted): only Go to album / Go to artist / Track info.
|
// Nav-only (blacklisted): only Go to album / Go to artist / Track info.
|
||||||
// Otherwise: 3 transient + optional favorite/mixtape/add-to-playlist/remove
|
// Otherwise: 4 transient (play/next/later/queue) + optional
|
||||||
// + optional go-to pair + the Qobuz block (4 fixed: QBZ radio, Qobuz
|
// favorite/mixtape/add-to-playlist/remove + optional go-to pair + the
|
||||||
// radio, 2 shares; + 1-or-2 offline entries + optional track info).
|
// Qobuz block (4 fixed: QBZ radio, Qobuz radio, 2 shares; + 1-or-2
|
||||||
|
// offline entries + optional track info).
|
||||||
height: (root.nav-only
|
height: (root.nav-only
|
||||||
? ((root.go-album-action ? 1 : 0)
|
? ((root.go-album-action ? 1 : 0)
|
||||||
+ (root.go-artist-action ? 1 : 0)
|
+ (root.go-artist-action ? 1 : 0)
|
||||||
+ (root.show-track-info ? 1 : 0))
|
+ (root.show-track-info ? 1 : 0))
|
||||||
: (3
|
: (4
|
||||||
+ (root.favorite-action ? 1 : 0)
|
+ (root.favorite-action ? 1 : 0)
|
||||||
+ (root.mixtape-action ? 1 : 0)
|
+ (root.mixtape-action ? 1 : 0)
|
||||||
+ (root.playlist-action ? 1 : 0)
|
+ (root.playlist-action ? 1 : 0)
|
||||||
@@ -88,12 +90,21 @@ export component TrackContextMenu inherits PopupWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !root.nav-only: ContextMenuItem {
|
if !root.nav-only: ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("track", root.track-id, "play-next");
|
root.media-action("track", root.track-id, "play-next");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #442 "Play later" — the end of the manual block (after every
|
||||||
|
// play-next / play-later already queued, before the source resumes).
|
||||||
|
if !root.nav-only: ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("track", root.track-id, "play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
if !root.nav-only: ContextMenuItem {
|
if !root.nav-only: ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-end.svg");
|
icon: @image-url("../assets/icons/list-end.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { NowPlayingState, ShellState } from "../state.slint";
|
import { NowPlayingState, ShellState, AppearanceState } from "../state.slint";
|
||||||
import { QbzIcon } from "QbzIcon.slint";
|
import { QbzIcon } from "QbzIcon.slint";
|
||||||
|
|
||||||
// One equalizer bar — oscillates continuously while it is shown (a 1s
|
// One equalizer bar — oscillates continuously while it is shown (a 1s
|
||||||
@@ -91,9 +91,21 @@ export component TrackPlayCell inherits Rectangle {
|
|||||||
// and it clears automatically when the flag clears.
|
// and it clears automatically when the flag clears.
|
||||||
property <bool> is-loading: root.is-active && NowPlayingState.loading;
|
property <bool> is-loading: root.is-active && NowPlayingState.loading;
|
||||||
property <bool> show-overlay: root.row-hovered || root.is-active;
|
property <bool> show-overlay: root.row-hovered || root.is-active;
|
||||||
// The animated bars show only while playing, the row is at rest, and the
|
// The animated bars show only while playing, the row is at rest, the
|
||||||
// track isn't still fetching (the spinner wins during the fetch window).
|
// track isn't still fetching (the spinner wins during the fetch window),
|
||||||
property <bool> show-bars: root.is-playing && !root.row-hovered && !root.is-loading;
|
// AND the user opted into the animation (Appearance settings). With the
|
||||||
|
// pref OFF the cell falls through to the static glyph below (the owner's
|
||||||
|
// 2026-07-24 spec: rest = play inside an accent ring, hover = pause).
|
||||||
|
property <bool> show-bars: root.is-playing && !root.row-hovered && !root.is-loading
|
||||||
|
&& AppearanceState.play-indicator-animation;
|
||||||
|
// The glyph shows PAUSE only when the playing row is hovered (the click
|
||||||
|
// pauses). At rest the playing row shows PLAY — an affordance, not a
|
||||||
|
// state badge: the accent ring + the row's edge mark carry the state.
|
||||||
|
property <bool> show-pause: root.is-playing && root.row-hovered;
|
||||||
|
// Static-mode ring on the playing row (pref OFF): transparent fill,
|
||||||
|
// accent circumference + accent glyph. Anything else keeps the original
|
||||||
|
// alpha backing + text-primary glyph (legibility on every theme, #638).
|
||||||
|
property <bool> accent-ring: !AppearanceState.play-indicator-animation && root.is-playing;
|
||||||
|
|
||||||
width: root.show-artwork ? root.art-size : root.number-width;
|
width: root.show-artwork ? root.art-size : root.number-width;
|
||||||
height: root.show-artwork ? root.art-size : 40px;
|
height: root.show-artwork ? root.art-size : 40px;
|
||||||
@@ -146,7 +158,7 @@ export component TrackPlayCell inherits Rectangle {
|
|||||||
: animation-tick() / 1ms) * 0.36) * 1deg;
|
: animation-tick() / 1ms) * 0.36) * 1deg;
|
||||||
}
|
}
|
||||||
if root.show-overlay && !root.show-bars && !root.is-loading: QbzIcon {
|
if root.show-overlay && !root.show-bars && !root.is-loading: QbzIcon {
|
||||||
source: (root.is-playing && root.row-hovered)
|
source: root.show-pause
|
||||||
? @image-url("../assets/icons/pause.svg")
|
? @image-url("../assets/icons/pause.svg")
|
||||||
: @image-url("../assets/icons/play-fill.svg");
|
: @image-url("../assets/icons/play-fill.svg");
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -191,26 +203,30 @@ export component TrackPlayCell inherits Rectangle {
|
|||||||
// no scrim like the artwork variant has). Fill/border/shadow use the
|
// no scrim like the artwork variant has). Fill/border/shadow use the
|
||||||
// polarity-baked alpha ramp (black-alpha on light, white-alpha on dark)
|
// polarity-baked alpha ramp (black-alpha on light, white-alpha on dark)
|
||||||
// and the glyph uses text-primary = the theme's max-contrast color. #638.
|
// and the glyph uses text-primary = the theme's max-contrast color. #638.
|
||||||
|
// On the playing row with the animation pref OFF the circle switches to
|
||||||
|
// the static ring: transparent fill, accent circumference + glyph.
|
||||||
if root.show-overlay && !root.show-bars && !root.is-loading: Rectangle {
|
if root.show-overlay && !root.show-bars && !root.is-loading: Rectangle {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||||
background: Theme.alpha-10;
|
background: root.accent-ring ? transparent : Theme.alpha-10;
|
||||||
border-width: 1px;
|
// 1.5px like CircleAction's header rings — a 1px stroked circle
|
||||||
border-color: Theme.alpha-15;
|
// aliases badly in femtovg (owner screenshot 2026-07-26).
|
||||||
|
border-width: 1.5px;
|
||||||
|
border-color: root.accent-ring ? Theme.accent : Theme.alpha-15;
|
||||||
drop-shadow-blur: 6px;
|
drop-shadow-blur: 6px;
|
||||||
drop-shadow-color: Theme.card-shadow;
|
drop-shadow-color: Theme.card-shadow;
|
||||||
QbzIcon {
|
QbzIcon {
|
||||||
source: (root.is-playing && root.row-hovered)
|
source: root.show-pause
|
||||||
? @image-url("../assets/icons/pause.svg")
|
? @image-url("../assets/icons/pause.svg")
|
||||||
: @image-url("../assets/icons/play-fill.svg");
|
: @image-url("../assets/icons/play-fill.svg");
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
tint: Theme.text-primary;
|
tint: root.accent-ring ? Theme.accent : Theme.text-primary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { TrackItem, DragActions, NowPlayingState, NavState, TooltipState, ShellState, TrackMenuState } from "../state.slint";
|
import { TrackItem, DragActions, NowPlayingState, NavState, TooltipState, ShellState, TrackMenuState, AppearanceState } from "../state.slint";
|
||||||
import { QbzIcon } from "QbzIcon.slint";
|
import { QbzIcon } from "QbzIcon.slint";
|
||||||
import { TrackPlayCell } from "TrackPlayCell.slint";
|
import { TrackPlayCell } from "TrackPlayCell.slint";
|
||||||
import { QualityBadgeFull } from "QualityBadgeFull.slint";
|
import { QualityBadgeFull } from "QualityBadgeFull.slint";
|
||||||
@@ -110,6 +110,12 @@ export component TrackRow inherits Rectangle {
|
|||||||
|
|
||||||
height: 50px;
|
height: 50px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
// This row IS the now-playing track (mirrors TrackPlayCell's is-active —
|
||||||
|
// offline-cache rows publish the catalog id in track-id but the local row
|
||||||
|
// id in local-track-id). Drives the static playing affordance.
|
||||||
|
property <bool> is-active: NowPlayingState.has-track
|
||||||
|
&& (NowPlayingState.track-id == root.track.id
|
||||||
|
|| (NowPlayingState.local-track-id != "" && NowPlayingState.local-track-id == root.track.id));
|
||||||
// Hover uses the polarity-baked alpha ramp so the hover state is visible on
|
// Hover uses the polarity-baked alpha ramp so the hover state is visible on
|
||||||
// light themes too (the old #ffffff16 was invisible white-on-white there;
|
// light themes too (the old #ffffff16 was invisible white-on-white there;
|
||||||
// near-identical on dark). Zebra kept as-is to avoid a visible dark-theme
|
// near-identical on dark). Zebra kept as-is to avoid a visible dark-theme
|
||||||
@@ -118,6 +124,18 @@ export component TrackRow inherits Rectangle {
|
|||||||
? Theme.alpha-8
|
? Theme.alpha-8
|
||||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||||
|
|
||||||
|
// Static now-playing mark (animation pref OFF, owner mockup 2026-07-24):
|
||||||
|
// an accent pill on the row's left edge. With the pref ON the animated
|
||||||
|
// eq bars in the play cell carry the state instead.
|
||||||
|
if !AppearanceState.play-indicator-animation && root.is-active: Rectangle {
|
||||||
|
x: 2px;
|
||||||
|
y: 7px;
|
||||||
|
width: 3px;
|
||||||
|
height: parent.height - 14px;
|
||||||
|
border-radius: 1.5px;
|
||||||
|
background: Theme.accent;
|
||||||
|
}
|
||||||
|
|
||||||
// Background TouchArea — also the drag source. Pressing the row
|
// Background TouchArea — also the drag source. Pressing the row
|
||||||
// body (not the play/fav/more controls, which sit on top) and
|
// body (not the play/fav/more controls, which sit on top) and
|
||||||
// moving past a threshold begins a track drag onto the sidebar
|
// moving past a threshold begins a track drag onto the sidebar
|
||||||
|
|||||||
@@ -616,6 +616,28 @@ export component AppearanceSettings inherits VerticalLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingRow {
|
||||||
|
label: @tr("Play indicator animation");
|
||||||
|
description: @tr("Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU.");
|
||||||
|
QbzToggle {
|
||||||
|
checked: AppearanceState.play-indicator-animation;
|
||||||
|
toggled(v) => {
|
||||||
|
AppearanceState.play-indicator-animation = v;
|
||||||
|
AppearanceState.appearance-bool("play-indicator-animation", v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingRow {
|
||||||
|
label: @tr("Invert swipe navigation direction");
|
||||||
|
description: @tr("Swap the two-finger touchpad swipe: left goes back, right goes forward.");
|
||||||
|
QbzToggle {
|
||||||
|
checked: AppearanceState.invert-swipe-navigation;
|
||||||
|
toggled(v) => {
|
||||||
|
AppearanceState.invert-swipe-navigation = v;
|
||||||
|
AppearanceState.appearance-bool("invert-swipe-navigation", v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle { height: 12px; }
|
Rectangle { height: 12px; }
|
||||||
Divider { }
|
Divider { }
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState, LocalLibraryState, LocalLibraryActions } from "../state.slint";
|
import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState, LocalLibraryState, LocalLibraryActions, LibraryAllState, LibraryAllActions } from "../state.slint";
|
||||||
import { SettingRow } from "SettingRow.slint";
|
import { SettingRow } from "SettingRow.slint";
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
|
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
|
||||||
@@ -448,6 +448,33 @@ export component LocalLibrarySettings inherits VerticalLayout {
|
|||||||
|
|
||||||
Rectangle { height: 22px; }
|
Rectangle { height: 22px; }
|
||||||
|
|
||||||
|
// --- Library › All feed -------------------------------------------------
|
||||||
|
// The All view's toolbar hard-drive toggle filters local items in/out;
|
||||||
|
// THIS row picks what "local" means in the feed (owner 2026-07-24):
|
||||||
|
// hearted items only, or the entire local library.
|
||||||
|
GroupHeader { text: @tr("LIBRARY › ALL"); }
|
||||||
|
SettingRow {
|
||||||
|
label: @tr("Local items in Library All");
|
||||||
|
description: @tr("Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track.");
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzSelect {
|
||||||
|
menu-width: 210px;
|
||||||
|
sm: true;
|
||||||
|
options: [
|
||||||
|
@tr("Favorited items only"),
|
||||||
|
@tr("Entire local library"),
|
||||||
|
];
|
||||||
|
current-index: LibraryAllState.local-scope == "all" ? 1 : 0;
|
||||||
|
selected(i) => {
|
||||||
|
LibraryAllActions.set-local-scope(i == 1 ? "all" : "favorites");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 22px; }
|
||||||
|
|
||||||
// --- Maintenance -----------------------------------------------------
|
// --- Maintenance -----------------------------------------------------
|
||||||
GroupHeader { text: @tr("MAINTENANCE"); }
|
GroupHeader { text: @tr("MAINTENANCE"); }
|
||||||
SettingRow {
|
SettingRow {
|
||||||
|
|||||||
@@ -189,9 +189,15 @@ export component AppShell inherits Rectangle {
|
|||||||
// ~4.5% CPU with a playlist view open on a 165 Hz monitor. When the
|
// ~4.5% CPU with a playlist view open on a 165 Hz monitor. When the
|
||||||
// playing row is not on screen nothing reads the property, so the tick
|
// playing row is not on screen nothing reads the property, so the tick
|
||||||
// dirties no binding and costs nothing.
|
// dirties no binding and costs nothing.
|
||||||
|
//
|
||||||
|
// The playing arm is gated on AppearanceState.play-indicator-animation:
|
||||||
|
// with the pref OFF (default) the eq bars never mount, so the clock stays
|
||||||
|
// off while playing too — the static pause glyph + accent edge mark cost
|
||||||
|
// zero ticks (the CPU complaint the pref answers, 2026-07-24).
|
||||||
Timer {
|
Timer {
|
||||||
interval: 125ms;
|
interval: 125ms;
|
||||||
running: ShellState.reduce-motion || NowPlayingState.playing;
|
running: ShellState.reduce-motion
|
||||||
|
|| (NowPlayingState.playing && AppearanceState.play-indicator-animation);
|
||||||
triggered => {
|
triggered => {
|
||||||
ShellState.coarse-tick-ms = Math.mod(ShellState.coarse-tick-ms + 125, 86400000);
|
ShellState.coarse-tick-ms = Math.mod(ShellState.coarse-tick-ms + 125, 86400000);
|
||||||
}
|
}
|
||||||
@@ -294,12 +300,14 @@ export component AppShell inherits Rectangle {
|
|||||||
// libinput natural scrolling ON (GNOME default) fingers
|
// libinput natural scrolling ON (GNOME default) fingers
|
||||||
// right yields NEGATIVE delta-x, hence the signs below.
|
// right yields NEGATIVE delta-x, hence the signs below.
|
||||||
// Under non-natural scrolling the mapping feels inverted
|
// Under non-natural scrolling the mapping feels inverted
|
||||||
// — accepted trade-off (distro scroll-direction setting
|
// — the AppearanceState.invert-swipe-navigation pref
|
||||||
// is not visible to the app).
|
// (#653 follow-up) flips the mapping for those users.
|
||||||
if (swipe-accum < 0 && NavState.can-back) {
|
if ((AppearanceState.invert-swipe-navigation ? -swipe-accum : swipe-accum) < 0
|
||||||
|
&& NavState.can-back) {
|
||||||
NavState.request-back();
|
NavState.request-back();
|
||||||
}
|
}
|
||||||
if (swipe-accum > 0 && NavState.can-forward) {
|
if ((AppearanceState.invert-swipe-navigation ? -swipe-accum : swipe-accum) > 0
|
||||||
|
&& NavState.can-forward) {
|
||||||
NavState.request-forward();
|
NavState.request-forward();
|
||||||
}
|
}
|
||||||
swipe-fired = true;
|
swipe-fired = true;
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
// (same as the desktop AlbumPageView): a header (big cover + title + artist +
|
// (same as the desktop AlbumPageView): a header (big cover + title + artist +
|
||||||
// quality + Play/Shuffle) and a simple track list (number + title + duration).
|
// quality + Play/Shuffle) and a simple track list (number + title + duration).
|
||||||
// Album track rows carry no artwork, so we show the track number.
|
// Album track rows carry no artwork, so we show the track number.
|
||||||
|
//
|
||||||
|
// Keyboard/gamepad nav (PR 2): the track list is the nav model (1-column
|
||||||
|
// list, no tabs — index == track index). Enter plays the focused track. The
|
||||||
|
// header buttons (Play / Shuffle / artist link) stay touch-only in v1.
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { AlbumState, NowPlayingState } from "../state.slint";
|
import { AlbumState, NowPlayingState, KioskNavState } from "../state.slint";
|
||||||
|
|
||||||
component PillButton inherits Rectangle {
|
component PillButton inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
@@ -37,7 +41,32 @@ export component KioskAlbum inherits Rectangle {
|
|||||||
|
|
||||||
property <length> pad: 22px;
|
property <length> pad: 22px;
|
||||||
|
|
||||||
Flickable {
|
// ---- Nav geometry publish (PR 2): no tabs, one index per track.
|
||||||
|
function publish-nav() {
|
||||||
|
KioskNavState.tabs = 0;
|
||||||
|
KioskNavState.shelves = false;
|
||||||
|
KioskNavState.columns = 1;
|
||||||
|
KioskNavState.count = AlbumState.tracks.length;
|
||||||
|
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||||
|
}
|
||||||
|
init => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-len-probe: AlbumState.tracks.length;
|
||||||
|
changed nav-len-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter pulse → play the focused track (the row TouchArea's own action).
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||||
|
&& KioskNavState.index >= 0 && KioskNavState.index < AlbumState.tracks.length) {
|
||||||
|
root.media-action("track", AlbumState.tracks[KioskNavState.index].id, "play");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page-flick := Flickable {
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: page.preferred-height;
|
viewport-height: page.preferred-height;
|
||||||
|
|
||||||
@@ -122,9 +151,30 @@ export component KioskAlbum inherits Rectangle {
|
|||||||
border-radius: Radius.sm;
|
border-radius: Radius.sm;
|
||||||
// Same local-track-id alias as TrackPlayCell (offline-cache tracks).
|
// Same local-track-id alias as TrackPlayCell (offline-cache tracks).
|
||||||
property <bool> current: NowPlayingState.track-id == track.id || (NowPlayingState.local-track-id != "" && NowPlayingState.local-track-id == track.id);
|
property <bool> current: NowPlayingState.track-id == track.id || (NowPlayingState.local-track-id != "" && NowPlayingState.local-track-id == track.id);
|
||||||
background: trow.has-hover
|
// PR 2 — focus ring + scroll-into-view. Page coords are
|
||||||
|
// fixed arithmetic: 22px pad + ~180px header + 22px
|
||||||
|
// spacing, then a 54px pitch (52px row + 2px spacing).
|
||||||
|
// viewport-y is written from a callback on a settled
|
||||||
|
// layout ("Recursion detected" class).
|
||||||
|
property <bool> row-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content" && KioskNavState.index == i;
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.row-focused) {
|
||||||
|
if (224px + i * 54px < -page-flick.viewport-y) {
|
||||||
|
page-flick.viewport-y = -(224px + i * 54px - 8px);
|
||||||
|
} else if (224px + i * 54px + 52px > -page-flick.viewport-y + page-flick.height) {
|
||||||
|
page-flick.viewport-y = -(224px + i * 54px + 52px - page-flick.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
background: self.row-focused
|
||||||
|
? Theme.accent.with-alpha(0.18)
|
||||||
|
: trow.has-hover
|
||||||
? Theme.alpha-6
|
? Theme.alpha-6
|
||||||
: self.current ? Theme.accent.with-alpha(0.10) : transparent;
|
: self.current ? Theme.accent.with-alpha(0.10) : transparent;
|
||||||
|
border-width: self.row-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// plain full grid mounted every card at once and spiked CPU on entry — a Pi
|
// plain full grid mounted every card at once and spiked CPU on entry — a Pi
|
||||||
// can't afford that. Binds `[AlbumCardItem]` so artwork pushes land on the cards.
|
// can't afford that. Binds `[AlbumCardItem]` so artwork pushes land on the cards.
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { AlbumCardItem } from "../state.slint";
|
import { AlbumCardItem, KioskNavState } from "../state.slint";
|
||||||
import { KioskCard } from "KioskCard.slint";
|
import { KioskCard } from "KioskCard.slint";
|
||||||
|
|
||||||
export component KioskAlbumGrid inherits Flickable {
|
export component KioskAlbumGrid inherits Flickable {
|
||||||
@@ -25,6 +25,40 @@ export component KioskAlbumGrid inherits Flickable {
|
|||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: root.rows * (root.card-h + root.gap) + root.pad;
|
viewport-height: root.rows * (root.card-h + root.gap) + root.pad;
|
||||||
|
|
||||||
|
// ---- Keyboard/gamepad nav (PR 2). The HOSTING VIEW publishes the grid
|
||||||
|
// geometry (tabs / columns / count) into KioskNavState; the grid only
|
||||||
|
// mirrors the focus index (ring + scroll-into-view) and answers the
|
||||||
|
// Enter pulse. The content index space skips `tabs` leading tab entries.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& root.focused-item >= 0
|
||||||
|
&& root.focused-item < root.albums.length;
|
||||||
|
|
||||||
|
// Scroll the focused card's row into view. viewport-y is WRITTEN from
|
||||||
|
// this callback (plain imperative assignment on a settled layout), never
|
||||||
|
// read by a binding that feeds layout — the AlbumCollectionView
|
||||||
|
// "Recursion detected" panic class.
|
||||||
|
property <length> focus-top: root.pad + Math.floor(root.focused-item / root.columns) * (root.card-h + root.gap);
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (root.item-focused) {
|
||||||
|
if (root.focus-top < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(root.focus-top - 8px);
|
||||||
|
} else if (root.focus-top + root.card-h > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(root.focus-top + root.card-h - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter pulse → open the focused album.
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (root.item-focused) {
|
||||||
|
root.open(root.albums[root.focused-item].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
for album[i] in root.albums: slot := Rectangle {
|
for album[i] in root.albums: slot := Rectangle {
|
||||||
x: root.pad + Math.mod(i, root.columns) * (root.card-w + root.gap);
|
x: root.pad + Math.mod(i, root.columns) * (root.card-w + root.gap);
|
||||||
@@ -39,6 +73,7 @@ export component KioskAlbumGrid inherits Flickable {
|
|||||||
width: root.card-w;
|
width: root.card-w;
|
||||||
art-size: root.card-w;
|
art-size: root.card-w;
|
||||||
album: album;
|
album: album;
|
||||||
|
nav-focused: root.item-focused && root.focused-item == i;
|
||||||
clicked(id) => {
|
clicked(id) => {
|
||||||
root.open(id);
|
root.open(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
// ArtistState (same as the desktop ArtistPageView): a header (round portrait +
|
// ArtistState (same as the desktop ArtistPageView): a header (round portrait +
|
||||||
// name + Play/Radio) and the discography as simple KioskCard shelves per release
|
// name + Play/Radio) and the discography as simple KioskCard shelves per release
|
||||||
// section (the junk "other" bucket is skipped).
|
// section (the junk "other" bucket is skipped).
|
||||||
|
//
|
||||||
|
// Keyboard/gamepad nav (PR 2): SHELF mode, same model as KioskDiscover — one
|
||||||
|
// nav index per shelf (columns == 1, no tabs), Left/Right pulses `hmove`
|
||||||
|
// consumed by the focused shelf's local cursor, skipped shelves ("other"
|
||||||
|
// bucket / empty) are stepped over in the last travel direction. The header
|
||||||
|
// buttons (Play / Radio) stay touch-only in v1.
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { ArtistState } from "../state.slint";
|
import { ArtistState, KioskNavState } from "../state.slint";
|
||||||
import { KioskCard } from "KioskCard.slint";
|
import { KioskCard } from "KioskCard.slint";
|
||||||
|
|
||||||
export component KioskArtist inherits Rectangle {
|
export component KioskArtist inherits Rectangle {
|
||||||
@@ -13,7 +19,23 @@ export component KioskArtist inherits Rectangle {
|
|||||||
|
|
||||||
property <length> pad: 22px;
|
property <length> pad: 22px;
|
||||||
|
|
||||||
Flickable {
|
// ---- Nav geometry publish (PR 2): no tab strip, one index per shelf.
|
||||||
|
function publish-nav() {
|
||||||
|
KioskNavState.tabs = 0;
|
||||||
|
KioskNavState.shelves = true;
|
||||||
|
KioskNavState.columns = 1;
|
||||||
|
KioskNavState.count = ArtistState.release-sections.length;
|
||||||
|
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||||
|
}
|
||||||
|
init => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-count-probe: ArtistState.release-sections.length;
|
||||||
|
changed nav-count-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
|
||||||
|
page-flick := Flickable {
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: page.preferred-height;
|
viewport-height: page.preferred-height;
|
||||||
|
|
||||||
@@ -93,24 +115,101 @@ export component KioskArtist inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for section in ArtistState.release-sections: VerticalLayout {
|
for section[s] in ArtistState.release-sections: shelf-vl := VerticalLayout {
|
||||||
|
// PR 2 — per-shelf nav state (same idiom as KioskDiscover).
|
||||||
|
property <int> cursor: 0;
|
||||||
|
property <bool> shelf-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& KioskNavState.index - KioskNavState.tabs == s;
|
||||||
|
|
||||||
|
// Skip the shelves this view doesn't render ("other" bucket,
|
||||||
|
// empty sections) in the last travel direction.
|
||||||
|
property <int> nav-skip-probe: KioskNavState.index;
|
||||||
|
changed nav-skip-probe => {
|
||||||
|
if (KioskNavState.zone == "content" && KioskNavState.nav-active
|
||||||
|
&& KioskNavState.index - KioskNavState.tabs == s
|
||||||
|
&& (section.release-type == "other" || section.albums.length == 0)
|
||||||
|
&& KioskNavState.dir != 0) {
|
||||||
|
KioskNavState.index = Math.max(KioskNavState.tabs,
|
||||||
|
Math.min(KioskNavState.count - 1, KioskNavState.index + KioskNavState.dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left/Right shelf move: consume the shell's `hmove` pulse.
|
||||||
|
property <int> hmove-probe: KioskNavState.hmove;
|
||||||
|
changed hmove-probe => {
|
||||||
|
if (self.shelf-focused && KioskNavState.hmove != 0
|
||||||
|
&& section.albums.length > 0) {
|
||||||
|
self.cursor = Math.clamp(self.cursor + KioskNavState.hmove,
|
||||||
|
0, section.albums.length - 1);
|
||||||
|
KioskNavState.hmove = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter pulse → open the cursor album.
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.shelf-focused && section.albums.length > 0
|
||||||
|
&& self.cursor < section.albums.length) {
|
||||||
|
root.open-album(section.albums[self.cursor].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the focused shelf's strip inside the page viewport.
|
||||||
|
// Fixed page arithmetic: 22px pad + 140px header + 22px
|
||||||
|
// spacing, then a 240px pitch per shelf block (24px title +
|
||||||
|
// 12px + 182px strip + 22px spacing). viewport-y is written
|
||||||
|
// from a callback on a settled layout ("Recursion detected"
|
||||||
|
// class — never a layout-feeding binding).
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.shelf-focused && section.albums.length > 0) {
|
||||||
|
if (184px + s * 240px < -page-flick.viewport-y) {
|
||||||
|
page-flick.viewport-y = -(184px + s * 240px - 8px);
|
||||||
|
} else if (184px + s * 240px + 218px > -page-flick.viewport-y + page-flick.height) {
|
||||||
|
page-flick.viewport-y = -(184px + s * 240px + 218px - page-flick.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if section.release-type != "other" && section.albums.length > 0: VerticalLayout {
|
if section.release-type != "other" && section.albums.length > 0: VerticalLayout {
|
||||||
spacing: 12px;
|
spacing: 12px;
|
||||||
Text {
|
// Fixed-height title wrapper — keeps the shelf block a
|
||||||
text: section.title;
|
// constant 218px for the scroll arithmetic above.
|
||||||
color: Theme.text-primary;
|
Rectangle {
|
||||||
font-size: 18px;
|
height: 24px;
|
||||||
font-weight: 600;
|
Text {
|
||||||
|
text: section.title;
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Flickable {
|
strip-flick := Flickable {
|
||||||
height: 182px;
|
height: 182px;
|
||||||
viewport-width: strip.preferred-width;
|
viewport-width: strip.preferred-width;
|
||||||
viewport-height: self.height;
|
viewport-height: self.height;
|
||||||
|
|
||||||
|
// Keep the cursor card inside the strip's horizontal
|
||||||
|
// viewport (card pitch = 128px art + 14px spacing).
|
||||||
|
property <int> cursor-probe: shelf-vl.cursor;
|
||||||
|
changed cursor-probe => {
|
||||||
|
if (shelf-vl.shelf-focused) {
|
||||||
|
if (shelf-vl.cursor * 142px < -self.viewport-x) {
|
||||||
|
self.viewport-x = -(shelf-vl.cursor * 142px - 8px);
|
||||||
|
} else if (shelf-vl.cursor * 142px + 128px > -self.viewport-x + self.width) {
|
||||||
|
self.viewport-x = -(shelf-vl.cursor * 142px + 128px - self.width + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
strip := HorizontalLayout {
|
strip := HorizontalLayout {
|
||||||
spacing: 14px;
|
spacing: 14px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
for album in section.albums: KioskCard {
|
for album[i] in section.albums: KioskCard {
|
||||||
album: album;
|
album: album;
|
||||||
|
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
|
||||||
clicked(id) => {
|
clicked(id) => {
|
||||||
root.open-album(id);
|
root.open-album(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,22 @@
|
|||||||
// SearchState.artists (SlimItem: id/title/artwork) and FavoritesState.artists
|
// SearchState.artists (SlimItem: id/title/artwork) and FavoritesState.artists
|
||||||
// (FavoriteArtistItem: id/name/image).
|
// (FavoriteArtistItem: id/name/image).
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
|
import { Radius } from "../foundation/radius.slint";
|
||||||
|
|
||||||
export component KioskArtistCard inherits Rectangle {
|
export component KioskArtistCard inherits Rectangle {
|
||||||
in property <string> id;
|
in property <string> id;
|
||||||
in property <string> name;
|
in property <string> name;
|
||||||
in property <image> avatar;
|
in property <image> avatar;
|
||||||
in property <length> size: 120px;
|
in property <length> size: 120px;
|
||||||
|
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback clicked(string);
|
callback clicked(string);
|
||||||
|
|
||||||
width: root.size;
|
width: root.size;
|
||||||
|
border-width: root.nav-focused ? 3px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
|
|||||||
@@ -13,9 +13,17 @@ export component KioskCard inherits Rectangle {
|
|||||||
in property <AlbumCardItem> album;
|
in property <AlbumCardItem> album;
|
||||||
in property <length> art-size: 128px;
|
in property <length> art-size: 128px;
|
||||||
in property <bool> round: false;
|
in property <bool> round: false;
|
||||||
|
// Keyboard/gamepad nav (PR 2): the hosting view sets this when the shell
|
||||||
|
// focus index lands on this card. A 3px accent ring + tint, readable at
|
||||||
|
// 10-foot distance.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback clicked(string);
|
callback clicked(string);
|
||||||
|
|
||||||
width: root.art-size;
|
width: root.art-size;
|
||||||
|
border-width: root.nav-focused ? 3px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
|
|||||||
@@ -6,19 +6,32 @@
|
|||||||
// stack — lighter for a Pi. v1 covers the album-carousel rows (newReleases,
|
// stack — lighter for a Pi. v1 covers the album-carousel rows (newReleases,
|
||||||
// qobuzissimes, editorPicks, pressAwards, idealDiscography, mostStreamed); the
|
// qobuzissimes, editorPicks, pressAwards, idealDiscography, mostStreamed); the
|
||||||
// non-album rows (playlists, mixes, slim rails) are skipped for now.
|
// non-album rows (playlists, mixes, slim rails) are skipped for now.
|
||||||
|
//
|
||||||
|
// Keyboard/gamepad nav (PR 2): SHELF mode. The nav index space is the 3 tab
|
||||||
|
// pills (indices 0-2) followed by one index per shelf (columns == 1, so
|
||||||
|
// Up/Down walk shelves). Left/Right can't move the global index (that would
|
||||||
|
// jump shelves), so the shell emits an `hmove` pulse that the focused shelf
|
||||||
|
// consumes into its LOCAL card cursor. Sections with no albums render
|
||||||
|
// nothing (the playlist/mix/slim rows above) — the focused shelf skips over
|
||||||
|
// them in the last travel direction (`KioskNavState.dir`), since Slint has
|
||||||
|
// no cross-loop accumulator to pre-compact the index space.
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { DiscoverState, HomeState, HomeActions } from "../state.slint";
|
import { DiscoverState, HomeState, HomeActions, KioskNavState } from "../state.slint";
|
||||||
import { KioskCard } from "KioskCard.slint";
|
import { KioskCard } from "KioskCard.slint";
|
||||||
|
|
||||||
component TabPill inherits Rectangle {
|
component TabPill inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <bool> active;
|
in property <bool> active;
|
||||||
|
// Keyboard/gamepad nav (PR 2): this pill holds the shell focus ring.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback clicked;
|
callback clicked;
|
||||||
horizontal-stretch: 0;
|
horizontal-stretch: 0;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: Radius.sm;
|
border-radius: Radius.sm;
|
||||||
background: root.active ? Theme.accent : ta.has-hover ? Theme.alpha-6 : transparent;
|
background: root.active ? Theme.accent : ta.has-hover ? Theme.alpha-6 : transparent;
|
||||||
|
border-width: root.nav-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
@@ -43,7 +56,48 @@ export component KioskDiscover inherits Rectangle {
|
|||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
callback discover-view-all(string, string);
|
callback discover-view-all(string, string);
|
||||||
|
|
||||||
Flickable {
|
// ---- Nav geometry publish (PR 2): 3 tab pills + one index per shelf.
|
||||||
|
// Re-published on tab switch and whenever the section set changes.
|
||||||
|
property <int> nav-section-count: (DiscoverState.active-tab == "editorPicks"
|
||||||
|
? DiscoverState.editor-sections
|
||||||
|
: DiscoverState.active-tab == "forYou"
|
||||||
|
? DiscoverState.foryou-sections
|
||||||
|
: DiscoverState.home-sections).length;
|
||||||
|
function publish-nav() {
|
||||||
|
KioskNavState.tabs = 3;
|
||||||
|
KioskNavState.shelves = true;
|
||||||
|
KioskNavState.columns = 1;
|
||||||
|
KioskNavState.count = 3 + root.nav-section-count;
|
||||||
|
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||||
|
}
|
||||||
|
init => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-count-probe: root.nav-section-count;
|
||||||
|
changed nav-count-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <string> nav-tab-probe: DiscoverState.active-tab;
|
||||||
|
changed nav-tab-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a tab pill → the same tab switch the pills' TouchAreas drive.
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||||
|
&& KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
if (KioskNavState.index == 0) {
|
||||||
|
HomeActions.select-tab("home");
|
||||||
|
} else if (KioskNavState.index == 1) {
|
||||||
|
HomeActions.select-tab("editorPicks");
|
||||||
|
} else if (KioskNavState.index == 2) {
|
||||||
|
HomeActions.select-tab("forYou");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page-flick := Flickable {
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: page.preferred-height;
|
viewport-height: page.preferred-height;
|
||||||
|
|
||||||
@@ -68,16 +122,19 @@ export component KioskDiscover inherits Rectangle {
|
|||||||
TabPill {
|
TabPill {
|
||||||
label: @tr("Home");
|
label: @tr("Home");
|
||||||
active: DiscoverState.active-tab == "home";
|
active: DiscoverState.active-tab == "home";
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
|
||||||
clicked => { HomeActions.select-tab("home"); }
|
clicked => { HomeActions.select-tab("home"); }
|
||||||
}
|
}
|
||||||
TabPill {
|
TabPill {
|
||||||
label: @tr("Editor's Picks");
|
label: @tr("Editor's Picks");
|
||||||
active: DiscoverState.active-tab == "editorPicks";
|
active: DiscoverState.active-tab == "editorPicks";
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
|
||||||
clicked => { HomeActions.select-tab("editorPicks"); }
|
clicked => { HomeActions.select-tab("editorPicks"); }
|
||||||
}
|
}
|
||||||
TabPill {
|
TabPill {
|
||||||
label: @tr("For You");
|
label: @tr("For You");
|
||||||
active: DiscoverState.active-tab == "forYou";
|
active: DiscoverState.active-tab == "forYou";
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2;
|
||||||
clicked => { HomeActions.select-tab("forYou"); }
|
clicked => { HomeActions.select-tab("forYou"); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,30 +143,111 @@ export component KioskDiscover inherits Rectangle {
|
|||||||
|
|
||||||
// Album shelves for the active tab. Only rows carrying album data
|
// Album shelves for the active tab. Only rows carrying album data
|
||||||
// render (length > 0); playlist/mix/slim rows are skipped in v1.
|
// render (length > 0); playlist/mix/slim rows are skipped in v1.
|
||||||
for desc in (DiscoverState.active-tab == "editorPicks"
|
for desc[s] in (DiscoverState.active-tab == "editorPicks"
|
||||||
? DiscoverState.editor-sections
|
? DiscoverState.editor-sections
|
||||||
: DiscoverState.active-tab == "forYou"
|
: DiscoverState.active-tab == "forYou"
|
||||||
? DiscoverState.foryou-sections
|
? DiscoverState.foryou-sections
|
||||||
: DiscoverState.home-sections): VerticalLayout {
|
: DiscoverState.home-sections): shelf-vl := VerticalLayout {
|
||||||
|
// PR 2 — per-shelf nav state. `cursor` is the focused card
|
||||||
|
// inside this shelf; `shelf-focused` mirrors the global index.
|
||||||
|
property <int> cursor: 0;
|
||||||
|
property <bool> shelf-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& KioskNavState.index - KioskNavState.tabs == s;
|
||||||
|
|
||||||
|
// Skip over shelves that render nothing (empty / non-album
|
||||||
|
// sections): nudge the index one more step in the last travel
|
||||||
|
// direction. The write re-fires the probes of the next shelf,
|
||||||
|
// so the cascade walks contiguous gaps and converges.
|
||||||
|
property <int> nav-skip-probe: KioskNavState.index;
|
||||||
|
changed nav-skip-probe => {
|
||||||
|
if (KioskNavState.zone == "content" && KioskNavState.nav-active
|
||||||
|
&& KioskNavState.index - KioskNavState.tabs == s
|
||||||
|
&& desc.section.albums.length == 0 && KioskNavState.dir != 0) {
|
||||||
|
KioskNavState.index = Math.max(KioskNavState.tabs,
|
||||||
|
Math.min(KioskNavState.count - 1, KioskNavState.index + KioskNavState.dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left/Right shelf move: the shell pulses `hmove`; the focused
|
||||||
|
// shelf consumes it into its cursor (and resets the pulse).
|
||||||
|
property <int> hmove-probe: KioskNavState.hmove;
|
||||||
|
changed hmove-probe => {
|
||||||
|
if (self.shelf-focused && KioskNavState.hmove != 0
|
||||||
|
&& desc.section.albums.length > 0) {
|
||||||
|
self.cursor = Math.clamp(self.cursor + KioskNavState.hmove,
|
||||||
|
0, desc.section.albums.length - 1);
|
||||||
|
KioskNavState.hmove = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter pulse → open the cursor album.
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.shelf-focused && desc.section.albums.length > 0
|
||||||
|
&& self.cursor < desc.section.albums.length) {
|
||||||
|
root.album-clicked(desc.section.albums[self.cursor].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the focused shelf's strip inside the page viewport.
|
||||||
|
// Page coords are fixed arithmetic: 16px pad + 50px tab row +
|
||||||
|
// 22px spacing, then a 240px pitch per shelf block (24px
|
||||||
|
// title + 12px + 182px strip + 22px spacing). viewport-y is
|
||||||
|
// WRITTEN from this callback on a settled layout, never read
|
||||||
|
// by a layout-feeding binding ("Recursion detected" class).
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.shelf-focused && desc.section.albums.length > 0) {
|
||||||
|
if (88px + s * 240px < -page-flick.viewport-y) {
|
||||||
|
page-flick.viewport-y = -(88px + s * 240px - 8px);
|
||||||
|
} else if (88px + s * 240px + 218px > -page-flick.viewport-y + page-flick.height) {
|
||||||
|
page-flick.viewport-y = -(88px + s * 240px + 218px - page-flick.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if desc.section.albums.length > 0: VerticalLayout {
|
if desc.section.albums.length > 0: VerticalLayout {
|
||||||
spacing: 12px;
|
spacing: 12px;
|
||||||
|
|
||||||
Text {
|
// Fixed-height title wrapper (was a bare Text): makes the
|
||||||
text: desc.section.title;
|
// shelf block a constant 218px so the scroll-into-view
|
||||||
color: Theme.text-primary;
|
// arithmetic above stays exact.
|
||||||
font-size: 18px;
|
Rectangle {
|
||||||
font-weight: 600;
|
height: 24px;
|
||||||
|
Text {
|
||||||
|
text: desc.section.title;
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Flickable {
|
strip-flick := Flickable {
|
||||||
height: 182px;
|
height: 182px;
|
||||||
viewport-width: strip.preferred-width;
|
viewport-width: strip.preferred-width;
|
||||||
viewport-height: self.height;
|
viewport-height: self.height;
|
||||||
|
|
||||||
|
// Keep the cursor card inside the strip's horizontal
|
||||||
|
// viewport (card pitch = 128px art + 14px spacing).
|
||||||
|
property <int> cursor-probe: shelf-vl.cursor;
|
||||||
|
changed cursor-probe => {
|
||||||
|
if (shelf-vl.shelf-focused) {
|
||||||
|
if (shelf-vl.cursor * 142px < -self.viewport-x) {
|
||||||
|
self.viewport-x = -(shelf-vl.cursor * 142px - 8px);
|
||||||
|
} else if (shelf-vl.cursor * 142px + 128px > -self.viewport-x + self.width) {
|
||||||
|
self.viewport-x = -(shelf-vl.cursor * 142px + 128px - self.width + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
strip := HorizontalLayout {
|
strip := HorizontalLayout {
|
||||||
spacing: 14px;
|
spacing: 14px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
for album in desc.section.albums: KioskCard {
|
for album[i] in desc.section.albums: KioskCard {
|
||||||
album: album;
|
album: album;
|
||||||
|
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
|
||||||
clicked(id) => {
|
clicked(id) => {
|
||||||
root.album-clicked(id);
|
root.album-clicked(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,18 @@
|
|||||||
// FavoritesActions.select-tab (Rust lazy-loads each tab). Covers are push-only
|
// FavoritesActions.select-tab (Rust lazy-loads each tab). Covers are push-only
|
||||||
// except album covers (windowed — v1 shows the first ~60; the window hook is a
|
// except album covers (windowed — v1 shows the first ~60; the window hook is a
|
||||||
// follow-up).
|
// follow-up).
|
||||||
|
//
|
||||||
|
// Keyboard/gamepad nav (PR 2): the nav index space is the 5 tabs (indices
|
||||||
|
// 0-4) followed by the active tab's items as a uniform grid (tracks = a
|
||||||
|
// 1-column list). The view publishes that geometry into KioskNavState; each
|
||||||
|
// container mirrors the focus index (ring + scroll-into-view) and answers
|
||||||
|
// the Enter pulse. viewport-y is only ever WRITTEN from callbacks on a
|
||||||
|
// settled layout, never read by layout-feeding bindings (the
|
||||||
|
// AlbumCollectionView "Recursion detected" panic class).
|
||||||
import { ListView } from "std-widgets.slint";
|
import { ListView } from "std-widgets.slint";
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { FavoritesState, FavoritesActions } from "../state.slint";
|
import { FavoritesState, FavoritesActions, KioskNavState } from "../state.slint";
|
||||||
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
||||||
import { KioskArtistCard } from "KioskArtistCard.slint";
|
import { KioskArtistCard } from "KioskArtistCard.slint";
|
||||||
import { KioskTrackRow } from "KioskTrackRow.slint";
|
import { KioskTrackRow } from "KioskTrackRow.slint";
|
||||||
@@ -16,9 +24,14 @@ import { KioskTrackRow } from "KioskTrackRow.slint";
|
|||||||
component LibTab inherits Rectangle {
|
component LibTab inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <string> id;
|
in property <string> id;
|
||||||
|
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback picked(string);
|
callback picked(string);
|
||||||
property <bool> active: FavoritesState.active-tab == root.id;
|
property <bool> active: FavoritesState.active-tab == root.id;
|
||||||
horizontal-stretch: 0;
|
horizontal-stretch: 0;
|
||||||
|
border-width: root.nav-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
@@ -50,6 +63,64 @@ export component KioskLibrary inherits Rectangle {
|
|||||||
|
|
||||||
property <length> pad: 16px;
|
property <length> pad: 16px;
|
||||||
|
|
||||||
|
// ---- Nav geometry publish (PR 2): 5 tabs + the active tab's items.
|
||||||
|
function publish-nav() {
|
||||||
|
KioskNavState.tabs = 5;
|
||||||
|
KioskNavState.shelves = false;
|
||||||
|
if (FavoritesState.active-tab == "tracks") {
|
||||||
|
KioskNavState.columns = 1;
|
||||||
|
KioskNavState.count = 5 + FavoritesState.tracks-visible.length;
|
||||||
|
} else if (FavoritesState.active-tab == "albums") {
|
||||||
|
KioskNavState.columns = 6;
|
||||||
|
KioskNavState.count = 5 + FavoritesState.albums-visible.length;
|
||||||
|
} else if (FavoritesState.active-tab == "artists") {
|
||||||
|
KioskNavState.columns = 7;
|
||||||
|
KioskNavState.count = 5 + FavoritesState.artists-visible.length;
|
||||||
|
} else if (FavoritesState.active-tab == "playlists") {
|
||||||
|
KioskNavState.columns = 6;
|
||||||
|
KioskNavState.count = 5 + FavoritesState.playlists-visible.length;
|
||||||
|
} else {
|
||||||
|
KioskNavState.columns = 7;
|
||||||
|
KioskNavState.count = 5 + FavoritesState.labels-visible.length;
|
||||||
|
}
|
||||||
|
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||||
|
}
|
||||||
|
init => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <string> nav-tab-probe: FavoritesState.active-tab;
|
||||||
|
changed nav-tab-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-len-probe: FavoritesState.tracks-visible.length
|
||||||
|
+ FavoritesState.albums-visible.length
|
||||||
|
+ FavoritesState.artists-visible.length
|
||||||
|
+ FavoritesState.playlists-visible.length
|
||||||
|
+ FavoritesState.labels-visible.length;
|
||||||
|
changed nav-len-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a tab → the same switch the tab TouchAreas drive. Items are
|
||||||
|
// activated by their own container (each watches the same pulse).
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||||
|
&& KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
if (KioskNavState.index == 0) {
|
||||||
|
FavoritesActions.select-tab("tracks");
|
||||||
|
} else if (KioskNavState.index == 1) {
|
||||||
|
FavoritesActions.select-tab("albums");
|
||||||
|
} else if (KioskNavState.index == 2) {
|
||||||
|
FavoritesActions.select-tab("artists");
|
||||||
|
} else if (KioskNavState.index == 3) {
|
||||||
|
FavoritesActions.select-tab("playlists");
|
||||||
|
} else if (KioskNavState.index == 4) {
|
||||||
|
FavoritesActions.select-tab("labels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
|
||||||
@@ -61,11 +132,11 @@ export component KioskLibrary inherits Rectangle {
|
|||||||
padding-right: root.pad;
|
padding-right: root.pad;
|
||||||
spacing: 22px;
|
spacing: 22px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
LibTab { label: @tr("Tracks"); id: "tracks"; picked(t) => { FavoritesActions.select-tab(t); } }
|
LibTab { label: @tr("Tracks"); id: "tracks"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||||
LibTab { label: @tr("Albums"); id: "albums"; picked(t) => { FavoritesActions.select-tab(t); } }
|
LibTab { label: @tr("Albums"); id: "albums"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||||
LibTab { label: @tr("Artists"); id: "artists"; picked(t) => { FavoritesActions.select-tab(t); } }
|
LibTab { label: @tr("Artists"); id: "artists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||||
LibTab { label: @tr("Playlists"); id: "playlists"; picked(t) => { FavoritesActions.select-tab(t); } }
|
LibTab { label: @tr("Playlists"); id: "playlists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||||
LibTab { label: @tr("Labels"); id: "labels"; picked(t) => { FavoritesActions.select-tab(t); } }
|
LibTab { label: @tr("Labels"); id: "labels"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 4; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
y: parent.height - 1px;
|
y: parent.height - 1px;
|
||||||
@@ -81,9 +152,33 @@ export component KioskLibrary inherits Rectangle {
|
|||||||
// ListView windows the rows (only the visible band is mounted) — a
|
// ListView windows the rows (only the visible band is mounted) — a
|
||||||
// plain VerticalLayout of every track spiked CPU on entry, which a Pi
|
// plain VerticalLayout of every track spiked CPU on entry, which a Pi
|
||||||
// can't afford.
|
// can't afford.
|
||||||
if FavoritesState.active-tab == "tracks": ListView {
|
if FavoritesState.active-tab == "tracks": tracks-list := ListView {
|
||||||
for track in FavoritesState.tracks-visible: KioskTrackRow {
|
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < FavoritesState.tracks-visible.length;
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focused-item * 62px < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focused-item * 62px - 8px);
|
||||||
|
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
FavoritesActions.play-track(FavoritesState.tracks-visible[self.focused-item].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for track[i] in FavoritesState.tracks-visible: KioskTrackRow {
|
||||||
track: track;
|
track: track;
|
||||||
|
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
|
||||||
play(id) => {
|
play(id) => {
|
||||||
FavoritesActions.play-track(id);
|
FavoritesActions.play-track(id);
|
||||||
}
|
}
|
||||||
@@ -103,6 +198,31 @@ export component KioskLibrary inherits Rectangle {
|
|||||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: Math.ceil(FavoritesState.artists-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
viewport-height: Math.ceil(FavoritesState.artists-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
||||||
|
|
||||||
|
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < FavoritesState.artists-visible.length;
|
||||||
|
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focus-top < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focus-top - 8px);
|
||||||
|
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
root.open-artist(FavoritesState.artists-visible[self.focused-item].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
for artist[i] in FavoritesState.artists-visible: KioskArtistCard {
|
for artist[i] in FavoritesState.artists-visible: KioskArtistCard {
|
||||||
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
|
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
|
||||||
@@ -111,6 +231,7 @@ export component KioskLibrary inherits Rectangle {
|
|||||||
id: artist.id;
|
id: artist.id;
|
||||||
name: artist.name;
|
name: artist.name;
|
||||||
avatar: artist.image;
|
avatar: artist.image;
|
||||||
|
nav-focused: arts.item-focused && arts.focused-item == i;
|
||||||
clicked(id) => {
|
clicked(id) => {
|
||||||
root.open-artist(id);
|
root.open-artist(id);
|
||||||
}
|
}
|
||||||
@@ -124,6 +245,33 @@ export component KioskLibrary inherits Rectangle {
|
|||||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: Math.ceil(FavoritesState.labels-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
viewport-height: Math.ceil(FavoritesState.labels-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
||||||
|
|
||||||
|
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < FavoritesState.labels-visible.length;
|
||||||
|
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focus-top < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focus-top - 8px);
|
||||||
|
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
FavoritesActions.open-label(
|
||||||
|
FavoritesState.labels-visible[self.focused-item].id,
|
||||||
|
FavoritesState.labels-visible[self.focused-item].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
for label[i] in FavoritesState.labels-visible: KioskArtistCard {
|
for label[i] in FavoritesState.labels-visible: KioskArtistCard {
|
||||||
x: root.pad + Math.mod(i, labs.cols) * (labs.cell + labs.gap);
|
x: root.pad + Math.mod(i, labs.cols) * (labs.cell + labs.gap);
|
||||||
@@ -132,6 +280,7 @@ export component KioskLibrary inherits Rectangle {
|
|||||||
id: label.id;
|
id: label.id;
|
||||||
name: label.name;
|
name: label.name;
|
||||||
avatar: label.image;
|
avatar: label.image;
|
||||||
|
nav-focused: labs.item-focused && labs.focused-item == i;
|
||||||
clicked(id) => {
|
clicked(id) => {
|
||||||
FavoritesActions.open-label(label.id, label.name);
|
FavoritesActions.open-label(label.id, label.name);
|
||||||
}
|
}
|
||||||
@@ -145,11 +294,40 @@ export component KioskLibrary inherits Rectangle {
|
|||||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: Math.ceil(FavoritesState.playlists-visible.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
|
viewport-height: Math.ceil(FavoritesState.playlists-visible.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
|
||||||
|
|
||||||
|
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < FavoritesState.playlists-visible.length;
|
||||||
|
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 42px + self.gap);
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focus-top < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focus-top - 8px);
|
||||||
|
} else if (self.focus-top + self.cell + 42px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focus-top + self.cell + 42px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
FavoritesActions.open-playlist(FavoritesState.playlists-visible[self.focused-item].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
for pl[i] in FavoritesState.playlists-visible: Rectangle {
|
for pl[i] in FavoritesState.playlists-visible: Rectangle {
|
||||||
x: root.pad + Math.mod(i, pls.cols) * (pls.cell + pls.gap);
|
x: root.pad + Math.mod(i, pls.cols) * (pls.cell + pls.gap);
|
||||||
y: root.pad + Math.floor(i / pls.cols) * (pls.cell + 42px + pls.gap);
|
y: root.pad + Math.floor(i / pls.cols) * (pls.cell + 42px + pls.gap);
|
||||||
width: pls.cell;
|
width: pls.cell;
|
||||||
|
border-width: pls.item-focused && pls.focused-item == i ? 3px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: pls.item-focused && pls.focused-item == i ? Theme.accent.with-alpha(0.12) : transparent;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
spacing: 7px;
|
spacing: 7px;
|
||||||
|
|||||||
@@ -5,10 +5,15 @@
|
|||||||
// (open-album routes to ContentView.local-album in Rust). v1 simplifications:
|
// (open-album routes to ContentView.local-album in Rust). v1 simplifications:
|
||||||
// no album-window / tracks-load-more hooks yet (first band loads), folders as a
|
// no album-window / tracks-load-more hooks yet (first band loads), folders as a
|
||||||
// flat grid.
|
// flat grid.
|
||||||
|
//
|
||||||
|
// Keyboard/gamepad nav (PR 2): same model as KioskLibrary — the index space
|
||||||
|
// is the 4 tabs followed by the active tab's items as a uniform grid
|
||||||
|
// (tracks = a 1-column list). The view publishes the geometry; containers
|
||||||
|
// mirror the focus index and answer the Enter pulse.
|
||||||
import { ListView } from "std-widgets.slint";
|
import { ListView } from "std-widgets.slint";
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { LocalLibraryState, LocalLibraryActions } from "../state.slint";
|
import { LocalLibraryState, LocalLibraryActions, KioskNavState } from "../state.slint";
|
||||||
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
||||||
import { KioskArtistCard } from "KioskArtistCard.slint";
|
import { KioskArtistCard } from "KioskArtistCard.slint";
|
||||||
import { KioskTrackRow } from "KioskTrackRow.slint";
|
import { KioskTrackRow } from "KioskTrackRow.slint";
|
||||||
@@ -16,9 +21,14 @@ import { KioskTrackRow } from "KioskTrackRow.slint";
|
|||||||
component LLTab inherits Rectangle {
|
component LLTab inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <string> id;
|
in property <string> id;
|
||||||
|
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback picked(string);
|
callback picked(string);
|
||||||
property <bool> active: LocalLibraryState.active-tab == root.id;
|
property <bool> active: LocalLibraryState.active-tab == root.id;
|
||||||
horizontal-stretch: 0;
|
horizontal-stretch: 0;
|
||||||
|
border-width: root.nav-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
@@ -47,6 +57,57 @@ component LLTab inherits Rectangle {
|
|||||||
export component KioskLocalLibrary inherits Rectangle {
|
export component KioskLocalLibrary inherits Rectangle {
|
||||||
property <length> pad: 16px;
|
property <length> pad: 16px;
|
||||||
|
|
||||||
|
// ---- Nav geometry publish (PR 2): 4 tabs + the active tab's items.
|
||||||
|
function publish-nav() {
|
||||||
|
KioskNavState.tabs = 4;
|
||||||
|
KioskNavState.shelves = false;
|
||||||
|
if (LocalLibraryState.active-tab == "albums") {
|
||||||
|
KioskNavState.columns = 6;
|
||||||
|
KioskNavState.count = 4 + LocalLibraryState.albums-visible.length;
|
||||||
|
} else if (LocalLibraryState.active-tab == "artists") {
|
||||||
|
KioskNavState.columns = 7;
|
||||||
|
KioskNavState.count = 4 + LocalLibraryState.artists.length;
|
||||||
|
} else if (LocalLibraryState.active-tab == "folders") {
|
||||||
|
KioskNavState.columns = 6;
|
||||||
|
KioskNavState.count = 4 + LocalLibraryState.folders-visible.length;
|
||||||
|
} else {
|
||||||
|
KioskNavState.columns = 1;
|
||||||
|
KioskNavState.count = 4 + LocalLibraryState.tracks-visible.length;
|
||||||
|
}
|
||||||
|
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||||
|
}
|
||||||
|
init => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <string> nav-tab-probe: LocalLibraryState.active-tab;
|
||||||
|
changed nav-tab-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-len-probe: LocalLibraryState.albums-visible.length
|
||||||
|
+ LocalLibraryState.artists.length
|
||||||
|
+ LocalLibraryState.folders-visible.length
|
||||||
|
+ LocalLibraryState.tracks-visible.length;
|
||||||
|
changed nav-len-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a tab → the same switch the tab TouchAreas drive.
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||||
|
&& KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
if (KioskNavState.index == 0) {
|
||||||
|
LocalLibraryActions.select-tab("albums");
|
||||||
|
} else if (KioskNavState.index == 1) {
|
||||||
|
LocalLibraryActions.select-tab("artists");
|
||||||
|
} else if (KioskNavState.index == 2) {
|
||||||
|
LocalLibraryActions.select-tab("folders");
|
||||||
|
} else if (KioskNavState.index == 3) {
|
||||||
|
LocalLibraryActions.select-tab("tracks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
|
||||||
@@ -58,10 +119,10 @@ export component KioskLocalLibrary inherits Rectangle {
|
|||||||
padding-right: root.pad;
|
padding-right: root.pad;
|
||||||
spacing: 22px;
|
spacing: 22px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
LLTab { label: @tr("Albums"); id: "albums"; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
LLTab { label: @tr("Albums"); id: "albums"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||||
LLTab { label: @tr("Artists"); id: "artists"; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
LLTab { label: @tr("Artists"); id: "artists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||||
LLTab { label: @tr("Folders"); id: "folders"; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
LLTab { label: @tr("Folders"); id: "folders"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||||
LLTab { label: @tr("Tracks"); id: "tracks"; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
LLTab { label: @tr("Tracks"); id: "tracks"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
y: parent.height - 1px;
|
y: parent.height - 1px;
|
||||||
@@ -94,6 +155,32 @@ export component KioskLocalLibrary inherits Rectangle {
|
|||||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: Math.ceil(LocalLibraryState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
viewport-height: Math.ceil(LocalLibraryState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
||||||
|
|
||||||
|
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < LocalLibraryState.artists.length;
|
||||||
|
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focus-top < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focus-top - 8px);
|
||||||
|
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
// The TouchArea path passes the artist NAME as id.
|
||||||
|
LocalLibraryActions.open-artist(LocalLibraryState.artists[self.focused-item].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
for artist[i] in LocalLibraryState.artists: KioskArtistCard {
|
for artist[i] in LocalLibraryState.artists: KioskArtistCard {
|
||||||
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
|
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
|
||||||
@@ -102,6 +189,7 @@ export component KioskLocalLibrary inherits Rectangle {
|
|||||||
id: artist.name;
|
id: artist.name;
|
||||||
name: artist.display-name;
|
name: artist.display-name;
|
||||||
avatar: artist.image;
|
avatar: artist.image;
|
||||||
|
nav-focused: arts.item-focused && arts.focused-item == i;
|
||||||
clicked(id) => {
|
clicked(id) => {
|
||||||
LocalLibraryActions.open-artist(id);
|
LocalLibraryActions.open-artist(id);
|
||||||
}
|
}
|
||||||
@@ -109,9 +197,33 @@ export component KioskLocalLibrary inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if LocalLibraryState.active-tab == "tracks": ListView {
|
if LocalLibraryState.active-tab == "tracks": tracks-list := ListView {
|
||||||
for track in LocalLibraryState.tracks-visible: KioskTrackRow {
|
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < LocalLibraryState.tracks-visible.length;
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focused-item * 62px < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focused-item * 62px - 8px);
|
||||||
|
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
LocalLibraryActions.track-action(LocalLibraryState.tracks-visible[self.focused-item].id, "play");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for track[i] in LocalLibraryState.tracks-visible: KioskTrackRow {
|
||||||
track: track;
|
track: track;
|
||||||
|
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
|
||||||
play(id) => {
|
play(id) => {
|
||||||
LocalLibraryActions.track-action(id, "play");
|
LocalLibraryActions.track-action(id, "play");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,24 @@
|
|||||||
// Collections (separate ContentView routes); simple cover cards, tap opens the
|
// Collections (separate ContentView routes); simple cover cards, tap opens the
|
||||||
// detail. Covers are push-only. Mounted for both ContentView.mixtapes and
|
// detail. Covers are push-only. Mounted for both ContentView.mixtapes and
|
||||||
// ContentView.collections.
|
// ContentView.collections.
|
||||||
|
//
|
||||||
|
// Keyboard/gamepad nav (PR 2): the index space is the 2 tabs followed by the
|
||||||
|
// active list as a 6-column grid. Enter on a tab routes through the same
|
||||||
|
// header-menu navigation the tab TouchAreas use.
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState, ContentView } from "../state.slint";
|
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState, ContentView, KioskNavState } from "../state.slint";
|
||||||
|
|
||||||
component MyQbzTab inherits Rectangle {
|
component MyQbzTab inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <bool> active;
|
in property <bool> active;
|
||||||
|
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback picked;
|
callback picked;
|
||||||
horizontal-stretch: 0;
|
horizontal-stretch: 0;
|
||||||
|
border-width: root.nav-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
@@ -40,8 +49,14 @@ component MyQbzTab inherits Rectangle {
|
|||||||
component MyQbzCardLite inherits Rectangle {
|
component MyQbzCardLite inherits Rectangle {
|
||||||
in property <MixtapeCardItem> item;
|
in property <MixtapeCardItem> item;
|
||||||
in property <length> size: 150px;
|
in property <length> size: 150px;
|
||||||
|
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback clicked(string);
|
callback clicked(string);
|
||||||
width: root.size;
|
width: root.size;
|
||||||
|
border-width: root.nav-focused ? 3px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
spacing: 7px;
|
spacing: 7px;
|
||||||
@@ -94,6 +109,37 @@ export component KioskMyQBZ inherits Rectangle {
|
|||||||
property <length> pad: 16px;
|
property <length> pad: 16px;
|
||||||
property <bool> on-collections: NavState.view == ContentView.collections;
|
property <bool> on-collections: NavState.view == ContentView.collections;
|
||||||
|
|
||||||
|
// ---- Nav geometry publish (PR 2): 2 tabs + the active list.
|
||||||
|
function publish-nav() {
|
||||||
|
KioskNavState.tabs = 2;
|
||||||
|
KioskNavState.shelves = false;
|
||||||
|
KioskNavState.columns = 6;
|
||||||
|
KioskNavState.count = 2 + (root.on-collections
|
||||||
|
? MyQbzState.collections.length
|
||||||
|
: MyQbzState.mixtapes.length);
|
||||||
|
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||||
|
}
|
||||||
|
init => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-len-probe: MyQbzState.collections.length + MyQbzState.mixtapes.length;
|
||||||
|
changed nav-len-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a tab → the same route the tab TouchAreas navigate to.
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||||
|
&& KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
if (KioskNavState.index == 0) {
|
||||||
|
root.navigate("myqbz-mixtapes");
|
||||||
|
} else if (KioskNavState.index == 1) {
|
||||||
|
root.navigate("myqbz-collections");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
|
||||||
@@ -108,6 +154,7 @@ export component KioskMyQBZ inherits Rectangle {
|
|||||||
MyQbzTab {
|
MyQbzTab {
|
||||||
label: @tr("Mixtapes");
|
label: @tr("Mixtapes");
|
||||||
active: !root.on-collections;
|
active: !root.on-collections;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
|
||||||
picked => {
|
picked => {
|
||||||
root.navigate("myqbz-mixtapes");
|
root.navigate("myqbz-mixtapes");
|
||||||
}
|
}
|
||||||
@@ -115,6 +162,7 @@ export component KioskMyQBZ inherits Rectangle {
|
|||||||
MyQbzTab {
|
MyQbzTab {
|
||||||
label: @tr("Collections");
|
label: @tr("Collections");
|
||||||
active: root.on-collections;
|
active: root.on-collections;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
|
||||||
picked => {
|
picked => {
|
||||||
root.navigate("myqbz-collections");
|
root.navigate("myqbz-collections");
|
||||||
}
|
}
|
||||||
@@ -136,12 +184,44 @@ export component KioskMyQBZ inherits Rectangle {
|
|||||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: Math.ceil((root.on-collections ? MyQbzState.collections.length : MyQbzState.mixtapes.length) / self.cols) * (self.cell + 44px + self.gap) + root.pad;
|
viewport-height: Math.ceil((root.on-collections ? MyQbzState.collections.length : MyQbzState.mixtapes.length) / self.cols) * (self.cell + 44px + self.gap) + root.pad;
|
||||||
|
|
||||||
|
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < (root.on-collections
|
||||||
|
? MyQbzState.collections.length
|
||||||
|
: MyQbzState.mixtapes.length);
|
||||||
|
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 44px + self.gap);
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focus-top < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focus-top - 8px);
|
||||||
|
} else if (self.focus-top + self.cell + 44px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focus-top + self.cell + 44px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (root.on-collections) {
|
||||||
|
MyQbzActions.open-card(MyQbzState.collections[self.focused-item].id);
|
||||||
|
} else {
|
||||||
|
MyQbzActions.open-card(MyQbzState.mixtapes[self.focused-item].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
for item[i] in (root.on-collections ? MyQbzState.collections : MyQbzState.mixtapes): MyQbzCardLite {
|
for item[i] in (root.on-collections ? MyQbzState.collections : MyQbzState.mixtapes): MyQbzCardLite {
|
||||||
x: root.pad + Math.mod(i, grid.cols) * (grid.cell + grid.gap);
|
x: root.pad + Math.mod(i, grid.cols) * (grid.cell + grid.gap);
|
||||||
y: root.pad + Math.floor(i / grid.cols) * (grid.cell + 44px + grid.gap);
|
y: root.pad + Math.floor(i / grid.cols) * (grid.cell + 44px + grid.gap);
|
||||||
size: grid.cell;
|
size: grid.cell;
|
||||||
item: item;
|
item: item;
|
||||||
|
nav-focused: grid.item-focused && grid.focused-item == i;
|
||||||
clicked(id) => {
|
clicked(id) => {
|
||||||
MyQbzActions.open-card(id);
|
MyQbzActions.open-card(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
// QueueState.panel-opened() on init so the paginated queue slice is fresh.
|
// QueueState.panel-opened() on init so the paginated queue slice is fresh.
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { NowPlayingState, QueueState, QueueItem, LyricsState, ShellState } from "../state.slint";
|
import { NowPlayingState, QueueState, QueueItem, LyricsState, ShellState, KioskNavState } from "../state.slint";
|
||||||
import { LyricsLinesView } from "LyricsLinesView.slint";
|
import { LyricsLinesView } from "LyricsLinesView.slint";
|
||||||
|
|
||||||
component QueueRowLite inherits Rectangle {
|
component QueueRowLite inherits Rectangle {
|
||||||
in property <QueueItem> item;
|
in property <QueueItem> item;
|
||||||
|
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskTrackRow.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback play;
|
callback play;
|
||||||
height: 58px;
|
height: 58px;
|
||||||
border-radius: Radius.sm;
|
border-radius: Radius.sm;
|
||||||
background: qrow.has-hover ? Theme.alpha-6 : transparent;
|
background: root.nav-focused ? Theme.accent.with-alpha(0.18) : qrow.has-hover ? Theme.alpha-6 : transparent;
|
||||||
|
border-width: root.nav-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
@@ -74,8 +78,15 @@ component QueueRowLite inherits Rectangle {
|
|||||||
component QueueTab inherits Rectangle {
|
component QueueTab inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <bool> active;
|
in property <bool> active;
|
||||||
|
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||||
|
// Only the right-panel Queue/History pair joins the nav model; the
|
||||||
|
// left Cover/Lyrics pair leaves this false.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback picked;
|
callback picked;
|
||||||
horizontal-stretch: 0;
|
horizontal-stretch: 0;
|
||||||
|
border-width: root.nav-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
@@ -115,11 +126,52 @@ export component KioskNowPlaying inherits Rectangle {
|
|||||||
// lightweight lyrics surface (no immersive visualizer needed, Pi-friendly).
|
// lightweight lyrics surface (no immersive visualizer needed, Pi-friendly).
|
||||||
property <bool> show-lyrics: false;
|
property <bool> show-lyrics: false;
|
||||||
|
|
||||||
|
// ---- Keyboard/gamepad nav (PR 2). The nav model is the right panel:
|
||||||
|
// 2 queue tabs (Up Next / History) followed by the active tab's rows as
|
||||||
|
// a 1-column list. Enter on a row plays it; Enter on a tab switches.
|
||||||
|
// The left panel (cover/lyrics toggle, artist link, seek) stays
|
||||||
|
// touch-only in v1; the transport row joins the "player" zone (ring
|
||||||
|
// below; Enter = play/pause handled by the shell).
|
||||||
|
function publish-nav() {
|
||||||
|
KioskNavState.tabs = 2;
|
||||||
|
KioskNavState.shelves = false;
|
||||||
|
KioskNavState.columns = 1;
|
||||||
|
KioskNavState.count = 2 + (QueueState.tab == 0
|
||||||
|
? QueueState.upcoming-page.length
|
||||||
|
: QueueState.history.length);
|
||||||
|
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||||
|
}
|
||||||
|
property <int> nav-tab-probe: QueueState.tab;
|
||||||
|
changed nav-tab-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-len-probe: QueueState.upcoming-page.length + QueueState.history.length;
|
||||||
|
changed nav-len-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter pulse → queue tab switch / play the focused queue row.
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (KioskNavState.nav-active && KioskNavState.zone == "content") {
|
||||||
|
if (KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
QueueState.set-tab(KioskNavState.index);
|
||||||
|
} else {
|
||||||
|
if (QueueState.tab == 0 && KioskNavState.index - 2 < QueueState.upcoming-page.length) {
|
||||||
|
QueueState.play-upcoming(KioskNavState.index - 2);
|
||||||
|
} else if (QueueState.tab == 1 && KioskNavState.index - 2 < QueueState.history.length) {
|
||||||
|
QueueState.play-history(KioskNavState.index - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init => {
|
init => {
|
||||||
QueueState.panel-opened();
|
QueueState.panel-opened();
|
||||||
// Mount in Cover mode — make sure a stale follow flag from a prior visit
|
// Mount in Cover mode — make sure a stale follow flag from a prior visit
|
||||||
// doesn't keep the sync engine running.
|
// doesn't keep the sync engine running.
|
||||||
ShellState.kiosk-lyrics-follow = false;
|
ShellState.kiosk-lyrics-follow = false;
|
||||||
|
root.publish-nav();
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
@@ -278,6 +330,12 @@ export component KioskNowPlaying inherits Rectangle {
|
|||||||
// space on short windows).
|
// space on short windows).
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: 46px;
|
height: 46px;
|
||||||
|
// PR 2 — the "player" zone ring (PlayerBarSmall is hidden
|
||||||
|
// on this view, so the in-page transport carries it).
|
||||||
|
border-width: KioskNavState.nav-active && KioskNavState.zone == "player" ? 3px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: KioskNavState.nav-active && KioskNavState.zone == "player" ? Theme.accent.with-alpha(0.08) : transparent;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
TransportControls {
|
TransportControls {
|
||||||
@@ -321,6 +379,7 @@ export component KioskNowPlaying inherits Rectangle {
|
|||||||
QueueTab {
|
QueueTab {
|
||||||
label: @tr("Up Next");
|
label: @tr("Up Next");
|
||||||
active: QueueState.tab == 0;
|
active: QueueState.tab == 0;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
|
||||||
picked => {
|
picked => {
|
||||||
QueueState.set-tab(0);
|
QueueState.set-tab(0);
|
||||||
}
|
}
|
||||||
@@ -328,6 +387,7 @@ export component KioskNowPlaying inherits Rectangle {
|
|||||||
QueueTab {
|
QueueTab {
|
||||||
label: @tr("History");
|
label: @tr("History");
|
||||||
active: QueueState.tab == 1;
|
active: QueueState.tab == 1;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
|
||||||
picked => {
|
picked => {
|
||||||
QueueState.set-tab(1);
|
QueueState.set-tab(1);
|
||||||
}
|
}
|
||||||
@@ -341,26 +401,66 @@ export component KioskNowPlaying inherits Rectangle {
|
|||||||
// the page is cheap.)
|
// the page is cheap.)
|
||||||
Rectangle {
|
Rectangle {
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
if QueueState.tab == 0: Flickable {
|
if QueueState.tab == 0: up-flick := Flickable {
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: up-body.preferred-height;
|
viewport-height: up-body.preferred-height;
|
||||||
|
|
||||||
|
// PR 2 — scroll the focused row into view (60px
|
||||||
|
// pitch: 58px row + 2px spacing). Written from a
|
||||||
|
// callback on a settled layout.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < QueueState.upcoming-page.length;
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focused-item * 60px < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focused-item * 60px - 8px);
|
||||||
|
} else if (self.focused-item * 60px + 58px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focused-item * 60px + 58px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
up-body := VerticalLayout {
|
up-body := VerticalLayout {
|
||||||
spacing: 2px;
|
spacing: 2px;
|
||||||
for item[i] in QueueState.upcoming-page: QueueRowLite {
|
for item[i] in QueueState.upcoming-page: QueueRowLite {
|
||||||
item: item;
|
item: item;
|
||||||
|
nav-focused: up-flick.item-focused && up-flick.focused-item == i;
|
||||||
play => {
|
play => {
|
||||||
QueueState.play-upcoming(i);
|
QueueState.play-upcoming(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if QueueState.tab == 1: Flickable {
|
if QueueState.tab == 1: hist-flick := Flickable {
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: hist-body.preferred-height;
|
viewport-height: hist-body.preferred-height;
|
||||||
|
|
||||||
|
// PR 2 — same scroll-into-view as the Up Next list.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < QueueState.history.length;
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focused-item * 60px < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focused-item * 60px - 8px);
|
||||||
|
} else if (self.focused-item * 60px + 58px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focused-item * 60px + 58px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hist-body := VerticalLayout {
|
hist-body := VerticalLayout {
|
||||||
spacing: 2px;
|
spacing: 2px;
|
||||||
for item[i] in QueueState.history: QueueRowLite {
|
for item[i] in QueueState.history: QueueRowLite {
|
||||||
item: item;
|
item: item;
|
||||||
|
nav-focused: hist-flick.item-focused && hist-flick.focused-item == i;
|
||||||
play => {
|
play => {
|
||||||
QueueState.play-history(i);
|
QueueState.play-history(i);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { ListView } from "std-widgets.slint";
|
import { ListView } from "std-widgets.slint";
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { SearchState, SearchActions } from "../state.slint";
|
import { SearchState, SearchActions, KioskNavState } from "../state.slint";
|
||||||
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
||||||
import { KioskArtistCard } from "KioskArtistCard.slint";
|
import { KioskArtistCard } from "KioskArtistCard.slint";
|
||||||
import { KioskTrackRow } from "KioskTrackRow.slint";
|
import { KioskTrackRow } from "KioskTrackRow.slint";
|
||||||
@@ -17,9 +17,14 @@ import { KioskCard } from "KioskCard.slint";
|
|||||||
component SearchTab inherits Rectangle {
|
component SearchTab inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <int> index;
|
in property <int> index;
|
||||||
|
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback picked(int);
|
callback picked(int);
|
||||||
property <bool> active: SearchState.tab == root.index;
|
property <bool> active: SearchState.tab == root.index;
|
||||||
horizontal-stretch: 0;
|
horizontal-stretch: 0;
|
||||||
|
border-width: root.nav-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
@@ -89,6 +94,55 @@ export component KioskSearch inherits Rectangle {
|
|||||||
|
|
||||||
property <length> pad: 16px;
|
property <length> pad: 16px;
|
||||||
|
|
||||||
|
// ---- Keyboard/gamepad nav (PR 2). The index space is the 5 tabs
|
||||||
|
// (All/Albums/Tracks/Artists/Playlists) followed by the active tab's
|
||||||
|
// items as a uniform grid. The All dashboard publishes NO items in v1
|
||||||
|
// (tabs only — its hero/preview mosaic isn't a uniform grid); the tab
|
||||||
|
// strips remain fully reachable. Containers mirror the focus index and
|
||||||
|
// answer the Enter pulse; the albums grid wires itself (KioskAlbumGrid).
|
||||||
|
function publish-nav() {
|
||||||
|
KioskNavState.tabs = 5;
|
||||||
|
KioskNavState.shelves = false;
|
||||||
|
if (SearchState.tab == 1) {
|
||||||
|
KioskNavState.columns = 6;
|
||||||
|
KioskNavState.count = 5 + SearchState.albums.length;
|
||||||
|
} else if (SearchState.tab == 2) {
|
||||||
|
KioskNavState.columns = 1;
|
||||||
|
KioskNavState.count = 5 + SearchState.tracks.length;
|
||||||
|
} else if (SearchState.tab == 3) {
|
||||||
|
KioskNavState.columns = 7;
|
||||||
|
KioskNavState.count = 5 + SearchState.artists.length;
|
||||||
|
} else if (SearchState.tab == 4) {
|
||||||
|
KioskNavState.columns = 6;
|
||||||
|
KioskNavState.count = 5 + SearchState.playlists.length;
|
||||||
|
} else {
|
||||||
|
KioskNavState.columns = 1;
|
||||||
|
KioskNavState.count = 5;
|
||||||
|
}
|
||||||
|
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||||
|
}
|
||||||
|
init => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-tab-probe: SearchState.tab;
|
||||||
|
changed nav-tab-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
property <int> nav-len-probe: SearchState.albums.length + SearchState.tracks.length
|
||||||
|
+ SearchState.artists.length + SearchState.playlists.length;
|
||||||
|
changed nav-len-probe => {
|
||||||
|
root.publish-nav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a tab → the same switch the tab TouchAreas drive.
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||||
|
&& KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
SearchActions.tab-changed(KioskNavState.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
|
||||||
@@ -101,11 +155,11 @@ export component KioskSearch inherits Rectangle {
|
|||||||
padding-right: root.pad;
|
padding-right: root.pad;
|
||||||
spacing: 22px;
|
spacing: 22px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
SearchTab { label: @tr("All"); index: 0; picked(i) => { SearchActions.tab-changed(i); } }
|
SearchTab { label: @tr("All"); index: 0; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(i) => { SearchActions.tab-changed(i); } }
|
||||||
SearchTab { label: @tr("Albums"); index: 1; picked(i) => { SearchActions.tab-changed(i); } }
|
SearchTab { label: @tr("Albums"); index: 1; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(i) => { SearchActions.tab-changed(i); } }
|
||||||
SearchTab { label: @tr("Tracks"); index: 2; picked(i) => { SearchActions.tab-changed(i); } }
|
SearchTab { label: @tr("Tracks"); index: 2; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(i) => { SearchActions.tab-changed(i); } }
|
||||||
SearchTab { label: @tr("Artists"); index: 3; picked(i) => { SearchActions.tab-changed(i); } }
|
SearchTab { label: @tr("Artists"); index: 3; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(i) => { SearchActions.tab-changed(i); } }
|
||||||
SearchTab { label: @tr("Playlists"); index: 4; picked(i) => { SearchActions.tab-changed(i); } }
|
SearchTab { label: @tr("Playlists"); index: 4; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 4; picked(i) => { SearchActions.tab-changed(i); } }
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
y: parent.height - 1px;
|
y: parent.height - 1px;
|
||||||
@@ -365,9 +419,33 @@ export component KioskSearch inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tracks — windowed list (ListView mounts only the visible band).
|
// Tracks — windowed list (ListView mounts only the visible band).
|
||||||
if SearchState.tab == 2: ListView {
|
if SearchState.tab == 2: tracks-list := ListView {
|
||||||
for track in SearchState.tracks: KioskTrackRow {
|
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < SearchState.tracks.length;
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focused-item * 62px < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focused-item * 62px - 8px);
|
||||||
|
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
root.media-action("track", SearchState.tracks[self.focused-item].id, "play");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for track[i] in SearchState.tracks: KioskTrackRow {
|
||||||
track: track;
|
track: track;
|
||||||
|
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
|
||||||
play(id) => {
|
play(id) => {
|
||||||
root.media-action("track", id, "play");
|
root.media-action("track", id, "play");
|
||||||
}
|
}
|
||||||
@@ -381,6 +459,31 @@ export component KioskSearch inherits Rectangle {
|
|||||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: Math.ceil(SearchState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
viewport-height: Math.ceil(SearchState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
||||||
|
|
||||||
|
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < SearchState.artists.length;
|
||||||
|
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focus-top < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focus-top - 8px);
|
||||||
|
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
root.open-artist(SearchState.artists[self.focused-item].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
for artist[i] in SearchState.artists: KioskArtistCard {
|
for artist[i] in SearchState.artists: KioskArtistCard {
|
||||||
x: root.pad + Math.mod(i, agrid.cols) * (agrid.cell + agrid.gap);
|
x: root.pad + Math.mod(i, agrid.cols) * (agrid.cell + agrid.gap);
|
||||||
@@ -389,6 +492,7 @@ export component KioskSearch inherits Rectangle {
|
|||||||
id: artist.id;
|
id: artist.id;
|
||||||
name: artist.title;
|
name: artist.title;
|
||||||
avatar: artist.artwork;
|
avatar: artist.artwork;
|
||||||
|
nav-focused: agrid.item-focused && agrid.focused-item == i;
|
||||||
clicked(id) => {
|
clicked(id) => {
|
||||||
root.open-artist(id);
|
root.open-artist(id);
|
||||||
}
|
}
|
||||||
@@ -403,11 +507,40 @@ export component KioskSearch inherits Rectangle {
|
|||||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
viewport-height: Math.ceil(SearchState.playlists.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
|
viewport-height: Math.ceil(SearchState.playlists.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
|
||||||
|
|
||||||
|
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||||
|
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||||
|
property <bool> item-focused: KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "content"
|
||||||
|
&& self.focused-item >= 0
|
||||||
|
&& self.focused-item < SearchState.playlists.length;
|
||||||
|
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 42px + self.gap);
|
||||||
|
property <int> nav-index-probe: KioskNavState.index;
|
||||||
|
changed nav-index-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
if (self.focus-top < -self.viewport-y) {
|
||||||
|
self.viewport-y = -(self.focus-top - 8px);
|
||||||
|
} else if (self.focus-top + self.cell + 42px > -self.viewport-y + self.height) {
|
||||||
|
self.viewport-y = -(self.focus-top + self.cell + 42px - self.height + 8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property <int> act-probe: KioskNavState.activate-seq;
|
||||||
|
changed act-probe => {
|
||||||
|
if (self.item-focused) {
|
||||||
|
root.media-action("playlist", SearchState.playlists[self.focused-item].id, "open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
for pl[i] in SearchState.playlists: Rectangle {
|
for pl[i] in SearchState.playlists: Rectangle {
|
||||||
x: root.pad + Math.mod(i, pgrid.cols) * (pgrid.cell + pgrid.gap);
|
x: root.pad + Math.mod(i, pgrid.cols) * (pgrid.cell + pgrid.gap);
|
||||||
y: root.pad + Math.floor(i / pgrid.cols) * (pgrid.cell + 42px + pgrid.gap);
|
y: root.pad + Math.floor(i / pgrid.cols) * (pgrid.cell + 42px + pgrid.gap);
|
||||||
width: pgrid.cell;
|
width: pgrid.cell;
|
||||||
|
border-width: pgrid.item-focused && pgrid.focused-item == i ? 3px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: pgrid.item-focused && pgrid.focused-item == i ? Theme.accent.with-alpha(0.12) : transparent;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
spacing: 7px;
|
spacing: 7px;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { LineEdit } from "std-widgets.slint";
|
|||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Layout } from "../foundation/layout.slint";
|
import { Layout } from "../foundation/layout.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState } from "../state.slint";
|
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState, NowPlayingState, KioskNavState } from "../state.slint";
|
||||||
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
|
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
|
||||||
import { PlayerBarSmall } from "PlayerBarSmall.slint";
|
import { PlayerBarSmall } from "PlayerBarSmall.slint";
|
||||||
import { NavRail } from "NavRail.slint";
|
import { NavRail } from "NavRail.slint";
|
||||||
@@ -73,6 +73,131 @@ export component KioskShell inherits Rectangle {
|
|||||||
callback logout();
|
callback logout();
|
||||||
callback close-app();
|
callback close-app();
|
||||||
callback recovery-login();
|
callback recovery-login();
|
||||||
|
// Keyboard/gamepad nav (PR 2): emitted by the root FocusScope on Enter.
|
||||||
|
// The internal dispatch runs through nav-activate() below; this public
|
||||||
|
// seam exists so an outer layer (or a future gamepad driver) can observe
|
||||||
|
// or trigger the same "activate the focused item" request.
|
||||||
|
callback activate-focused();
|
||||||
|
|
||||||
|
// ---- Kiosk keyboard navigation (Steam Deck Game Mode plan, PR 2) ----
|
||||||
|
// One root FocusScope drives the whole kiosk: arrows move a focus ring
|
||||||
|
// through three zones — rail (bottom NavRail) → content (the mounted
|
||||||
|
// view's items) → player (the transport bar) — Enter activates the
|
||||||
|
// focused item, Esc goes back. The zone model lives in KioskNavState
|
||||||
|
// (state.slint); the mounted content view publishes its geometry there
|
||||||
|
// (tabs / columns / count / shelves) and mirrors `index` onto its focus
|
||||||
|
// rings. Pure Slint: PR 3's gamepad feeds this same path by injecting
|
||||||
|
// synthetic key events, so nothing here is keyboard-specific.
|
||||||
|
//
|
||||||
|
// Zone cycle (Down): rail → content → player → rail; Up reverses. The
|
||||||
|
// content index space is `tabs` leading tab-strip entries followed by
|
||||||
|
// the item rows (a `columns`-wide grid; columns == 1 = list/shelves).
|
||||||
|
function nav-left() {
|
||||||
|
KioskNavState.nav-active = true;
|
||||||
|
KioskNavState.dir = 0;
|
||||||
|
if (KioskNavState.zone == "rail") {
|
||||||
|
KioskNavState.index = Math.max(0, KioskNavState.index - 1);
|
||||||
|
} else if (KioskNavState.zone == "content") {
|
||||||
|
if (KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
KioskNavState.index = Math.max(0, KioskNavState.index - 1);
|
||||||
|
} else if (KioskNavState.shelves) {
|
||||||
|
KioskNavState.hmove = -1;
|
||||||
|
} else if (Math.mod(KioskNavState.index - KioskNavState.tabs, KioskNavState.columns) > 0) {
|
||||||
|
KioskNavState.index = KioskNavState.index - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nav-right() {
|
||||||
|
KioskNavState.nav-active = true;
|
||||||
|
KioskNavState.dir = 0;
|
||||||
|
if (KioskNavState.zone == "rail") {
|
||||||
|
KioskNavState.index = Math.min(rail.tile-count - 1, KioskNavState.index + 1);
|
||||||
|
} else if (KioskNavState.zone == "content") {
|
||||||
|
if (KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
KioskNavState.index = Math.min(KioskNavState.tabs - 1, KioskNavState.index + 1);
|
||||||
|
} else if (KioskNavState.shelves) {
|
||||||
|
KioskNavState.hmove = 1;
|
||||||
|
} else if (Math.mod(KioskNavState.index - KioskNavState.tabs, KioskNavState.columns) < KioskNavState.columns - 1
|
||||||
|
&& KioskNavState.index + 1 < KioskNavState.count) {
|
||||||
|
KioskNavState.index = KioskNavState.index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nav-up() {
|
||||||
|
KioskNavState.nav-active = true;
|
||||||
|
KioskNavState.dir = -1;
|
||||||
|
if (KioskNavState.zone == "content") {
|
||||||
|
if (KioskNavState.index >= KioskNavState.tabs + KioskNavState.columns) {
|
||||||
|
KioskNavState.index = KioskNavState.index - KioskNavState.columns;
|
||||||
|
} else if (KioskNavState.index >= KioskNavState.tabs) {
|
||||||
|
// Grid row 0 → the tab strip above it (column → tab).
|
||||||
|
KioskNavState.index = Math.min(KioskNavState.tabs - 1, KioskNavState.index - KioskNavState.tabs);
|
||||||
|
} else {
|
||||||
|
// Tab strip (top of the cycle) → wrap down to the rail.
|
||||||
|
KioskNavState.zone = "rail";
|
||||||
|
KioskNavState.index = 0;
|
||||||
|
}
|
||||||
|
} else if (KioskNavState.zone == "rail") {
|
||||||
|
KioskNavState.zone = "player";
|
||||||
|
KioskNavState.index = 0;
|
||||||
|
} else {
|
||||||
|
KioskNavState.zone = "content";
|
||||||
|
KioskNavState.index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nav-down() {
|
||||||
|
KioskNavState.nav-active = true;
|
||||||
|
KioskNavState.dir = 1;
|
||||||
|
if (KioskNavState.zone == "content") {
|
||||||
|
if (KioskNavState.index < KioskNavState.tabs) {
|
||||||
|
// Tab strip → the first item row (or the player bar when the
|
||||||
|
// view carries no items).
|
||||||
|
if (KioskNavState.count > KioskNavState.tabs) {
|
||||||
|
KioskNavState.index = KioskNavState.tabs;
|
||||||
|
} else {
|
||||||
|
KioskNavState.zone = "player";
|
||||||
|
KioskNavState.index = 0;
|
||||||
|
}
|
||||||
|
} else if (KioskNavState.index + KioskNavState.columns < KioskNavState.count) {
|
||||||
|
KioskNavState.index = KioskNavState.index + KioskNavState.columns;
|
||||||
|
} else {
|
||||||
|
KioskNavState.zone = "player";
|
||||||
|
KioskNavState.index = 0;
|
||||||
|
}
|
||||||
|
} else if (KioskNavState.zone == "player") {
|
||||||
|
KioskNavState.zone = "rail";
|
||||||
|
KioskNavState.index = 0;
|
||||||
|
} else {
|
||||||
|
KioskNavState.zone = "content";
|
||||||
|
KioskNavState.index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nav-activate() {
|
||||||
|
KioskNavState.nav-active = true;
|
||||||
|
if (KioskNavState.zone == "player") {
|
||||||
|
// The transport is ONE focusable unit: Enter = play/pause.
|
||||||
|
if (NowPlayingState.has-track) {
|
||||||
|
NowPlayingState.toggle-play();
|
||||||
|
}
|
||||||
|
} else if (KioskNavState.zone == "rail") {
|
||||||
|
rail.activate-index(KioskNavState.index);
|
||||||
|
} else {
|
||||||
|
// Content views own their activation. They are conditionally
|
||||||
|
// mounted (can't be addressed from here), so the request goes
|
||||||
|
// out as a pulse the mounted view answers via a changed probe.
|
||||||
|
KioskNavState.activate-seq = KioskNavState.activate-seq + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the nav model on every view switch: focus lands on the new
|
||||||
|
// view's content, parked at the first item.
|
||||||
|
property <ContentView> nav-view-probe: NavState.view;
|
||||||
|
changed nav-view-probe => {
|
||||||
|
KioskNavState.zone = "content";
|
||||||
|
KioskNavState.index = 0;
|
||||||
|
KioskNavState.dir = 0;
|
||||||
|
KioskNavState.hmove = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Kiosk chrome heights. The back bar + transport are one header-row tall
|
// Kiosk chrome heights. The back bar + transport are one header-row tall
|
||||||
// each; the NavRail is a fat touch target.
|
// each; the NavRail is a fat touch target.
|
||||||
@@ -180,6 +305,9 @@ export component KioskShell inherits Rectangle {
|
|||||||
// on-screen keyboard (squeekboard) closes. Otherwise the
|
// on-screen keyboard (squeekboard) closes. Otherwise the
|
||||||
// search field keeps focus forever and the OSK never hides.
|
// search field keeps focus forever and the OSK never hides.
|
||||||
search-field.clear-focus();
|
search-field.clear-focus();
|
||||||
|
// Hand keyboard focus back to the nav scope so arrows
|
||||||
|
// keep working after a search (PR 2).
|
||||||
|
keys.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +331,11 @@ export component KioskShell inherits Rectangle {
|
|||||||
// BEHIND the views (which handle their own taps); it only catches
|
// BEHIND the views (which handle their own taps); it only catches
|
||||||
// empty-area taps.
|
// empty-area taps.
|
||||||
dismiss-osk-ta := TouchArea {
|
dismiss-osk-ta := TouchArea {
|
||||||
clicked => { search-field.clear-focus(); }
|
clicked => {
|
||||||
|
search-field.clear-focus();
|
||||||
|
// Same hand-back as the accepted path (PR 2).
|
||||||
|
keys.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
@@ -500,7 +632,7 @@ export component KioskShell inherits Rectangle {
|
|||||||
|
|
||||||
// --- Bottom NavRail (net-new). Each tile maps to the real route the
|
// --- Bottom NavRail (net-new). Each tile maps to the real route the
|
||||||
// Sidebar/Rust dispatcher already uses. ------------------------------
|
// Sidebar/Rust dispatcher already uses. ------------------------------
|
||||||
NavRail {
|
rail := NavRail {
|
||||||
height: root.navrail-height;
|
height: root.navrail-height;
|
||||||
discover => {
|
discover => {
|
||||||
root.header-menu-navigate("discover-home");
|
root.header-menu-navigate("discover-home");
|
||||||
@@ -526,6 +658,82 @@ export component KioskShell inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Player-zone focus ring (PR 2). The transport bar is ONE focusable
|
||||||
|
// unit (Enter toggles play/pause, handled in nav-activate). Drawn as an
|
||||||
|
// overlay on the bar's exact slot (above the NavRail); the Now Playing
|
||||||
|
// view hides PlayerBarSmall and draws its own ring on its in-page
|
||||||
|
// transport row instead.
|
||||||
|
if NavState.view != ContentView.now-playing
|
||||||
|
&& KioskNavState.nav-active
|
||||||
|
&& KioskNavState.zone == "player": Rectangle {
|
||||||
|
x: 0;
|
||||||
|
y: root.height - root.navrail-height - Layout.header-height;
|
||||||
|
width: root.width;
|
||||||
|
height: Layout.header-height;
|
||||||
|
border-width: 3px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: Theme.accent.with-alpha(0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Root nav FocusScope (PR 2). Holds keyboard focus for the whole
|
||||||
|
// kiosk; TouchAreas never take focus, so it survives card/tile taps and
|
||||||
|
// only yields to the search LineEdit (which then sets
|
||||||
|
// UiFocusState.text-input-focused and every key is rejected here — typing
|
||||||
|
// must never navigate). Rejected keys keep their normal path.
|
||||||
|
keys := FocusScope {
|
||||||
|
key-pressed(event) => {
|
||||||
|
if (UiFocusState.text-input-focused) {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
// The immersive visualizer overlay owns its own keys while open.
|
||||||
|
if (ImmersiveState.open) {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
if (event.text == Key.LeftArrow) {
|
||||||
|
root.nav-left();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (event.text == Key.RightArrow) {
|
||||||
|
root.nav-right();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (event.text == Key.UpArrow) {
|
||||||
|
root.nav-up();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (event.text == Key.DownArrow) {
|
||||||
|
root.nav-down();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (event.text == Key.Return) {
|
||||||
|
root.activate-focused();
|
||||||
|
root.nav-activate();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (event.text == Key.Escape) {
|
||||||
|
if (NavState.can-back) {
|
||||||
|
NavState.request-back();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deferred mount focus — synchronous focus during the initial layout
|
||||||
|
// races it; a one-shot timer grabs it a tick later (the desktop modal
|
||||||
|
// idiom, e.g. KeyboardShortcutsModal).
|
||||||
|
focus-timer := Timer {
|
||||||
|
interval: 30ms;
|
||||||
|
running: true;
|
||||||
|
triggered => {
|
||||||
|
self.running = false;
|
||||||
|
keys.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Immersive / visualizer overlay — the same app-level view the desktop
|
// Immersive / visualizer overlay — the same app-level view the desktop
|
||||||
// mounts, but the desktop hangs it inside AppShell, so the kiosk shell must
|
// mounts, but the desktop hangs it inside AppShell, so the kiosk shell must
|
||||||
// mount its own copy. Reached from the NavRail visualizer tile (which fires
|
// mount its own copy. Reached from the NavRail visualizer tile (which fires
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { TrackItem } from "../state.slint";
|
|||||||
|
|
||||||
export component KioskTrackRow inherits Rectangle {
|
export component KioskTrackRow inherits Rectangle {
|
||||||
in property <TrackItem> track;
|
in property <TrackItem> track;
|
||||||
|
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback play(string);
|
callback play(string);
|
||||||
|
|
||||||
height: 62px;
|
height: 62px;
|
||||||
border-radius: Radius.sm;
|
border-radius: Radius.sm;
|
||||||
background: ta.has-hover ? Theme.alpha-6 : transparent;
|
background: root.nav-focused ? Theme.accent.with-alpha(0.18) : ta.has-hover ? Theme.alpha-6 : transparent;
|
||||||
|
border-width: root.nav-focused ? 2px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
|||||||
@@ -8,20 +8,24 @@
|
|||||||
// panel).
|
// panel).
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { NavState, ContentView, ImmersiveState, MyQbzBrandingState } from "../state.slint";
|
import { NavState, ContentView, ImmersiveState, MyQbzBrandingState, KioskNavState } from "../state.slint";
|
||||||
|
|
||||||
component NavTile inherits Rectangle {
|
component NavTile inherits Rectangle {
|
||||||
in property <image> icon;
|
in property <image> icon;
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <bool> active;
|
in property <bool> active;
|
||||||
|
// Keyboard/gamepad nav (PR 2): this tile holds the shell focus ring.
|
||||||
|
in property <bool> nav-focused: false;
|
||||||
callback clicked;
|
callback clicked;
|
||||||
|
|
||||||
border-radius: Radius.sm;
|
border-radius: Radius.sm;
|
||||||
background: root.active
|
background: root.nav-focused
|
||||||
|
? Theme.accent.with-alpha(0.22)
|
||||||
|
: root.active
|
||||||
? Theme.accent.with-alpha(0.12)
|
? Theme.accent.with-alpha(0.12)
|
||||||
: ta.has-hover ? Theme.alpha-6 : transparent;
|
: ta.has-hover ? Theme.alpha-6 : transparent;
|
||||||
border-width: root.active ? 1px : 0px;
|
border-width: root.nav-focused ? 2px : root.active ? 1px : 0px;
|
||||||
border-color: Theme.accent.with-alpha(0.30);
|
border-color: root.nav-focused ? Theme.accent : Theme.accent.with-alpha(0.30);
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
@@ -65,6 +69,20 @@ export component NavRail inherits Rectangle {
|
|||||||
|
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
|
|
||||||
|
// Keyboard/gamepad nav (PR 2): tile count for the shell's rail zone and
|
||||||
|
// the activation seam for Enter (the rail is always mounted, so the
|
||||||
|
// shell calls this directly instead of using the activate-seq pulse).
|
||||||
|
out property <int> tile-count: 7;
|
||||||
|
public function activate-index(i: int) {
|
||||||
|
if (i == 0) { root.discover(); }
|
||||||
|
else if (i == 1) { root.library(); }
|
||||||
|
else if (i == 2) { root.local-library(); }
|
||||||
|
else if (i == 3) { root.myqbz(); }
|
||||||
|
else if (i == 4) { root.playing(); }
|
||||||
|
else if (i == 5) { root.visualizer(); }
|
||||||
|
else if (i == 6) { root.menu(); }
|
||||||
|
}
|
||||||
|
|
||||||
// 7 tiles, 8px outer padding, 6px gaps (6 gaps). Search is intentionally
|
// 7 tiles, 8px outer padding, 6px gaps (6 gaps). Search is intentionally
|
||||||
// absent — the back-bar carries a persistent search field, so a footer
|
// absent — the back-bar carries a persistent search field, so a footer
|
||||||
// Search tile is redundant.
|
// Search tile is redundant.
|
||||||
@@ -87,6 +105,7 @@ export component NavRail inherits Rectangle {
|
|||||||
icon: @image-url("../assets/icons/compass.svg");
|
icon: @image-url("../assets/icons/compass.svg");
|
||||||
label: @tr("Discover");
|
label: @tr("Discover");
|
||||||
active: NavState.view == ContentView.home;
|
active: NavState.view == ContentView.home;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 0;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.discover();
|
root.discover();
|
||||||
}
|
}
|
||||||
@@ -99,6 +118,7 @@ export component NavRail inherits Rectangle {
|
|||||||
icon: @image-url("../assets/icons/library-big.svg");
|
icon: @image-url("../assets/icons/library-big.svg");
|
||||||
label: @tr("Library");
|
label: @tr("Library");
|
||||||
active: NavState.view == ContentView.favorites;
|
active: NavState.view == ContentView.favorites;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 1;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.library();
|
root.library();
|
||||||
}
|
}
|
||||||
@@ -111,6 +131,7 @@ export component NavRail inherits Rectangle {
|
|||||||
icon: @image-url("../assets/icons/hard-drive.svg");
|
icon: @image-url("../assets/icons/hard-drive.svg");
|
||||||
label: @tr("Local Library");
|
label: @tr("Local Library");
|
||||||
active: NavState.view == ContentView.local-library || NavState.view == ContentView.local-album;
|
active: NavState.view == ContentView.local-library || NavState.view == ContentView.local-album;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 2;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.local-library();
|
root.local-library();
|
||||||
}
|
}
|
||||||
@@ -125,6 +146,7 @@ export component NavRail inherits Rectangle {
|
|||||||
active: NavState.view == ContentView.mixtapes
|
active: NavState.view == ContentView.mixtapes
|
||||||
|| NavState.view == ContentView.collections
|
|| NavState.view == ContentView.collections
|
||||||
|| NavState.view == ContentView.mixtape-detail;
|
|| NavState.view == ContentView.mixtape-detail;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 3;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.myqbz();
|
root.myqbz();
|
||||||
}
|
}
|
||||||
@@ -137,6 +159,7 @@ export component NavRail inherits Rectangle {
|
|||||||
icon: @image-url("../assets/icons/play-fill.svg");
|
icon: @image-url("../assets/icons/play-fill.svg");
|
||||||
label: @tr("Now Playing");
|
label: @tr("Now Playing");
|
||||||
active: NavState.view == ContentView.now-playing;
|
active: NavState.view == ContentView.now-playing;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 4;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.playing();
|
root.playing();
|
||||||
}
|
}
|
||||||
@@ -149,6 +172,7 @@ export component NavRail inherits Rectangle {
|
|||||||
icon: @image-url("../assets/icons/sliders-horizontal.svg");
|
icon: @image-url("../assets/icons/sliders-horizontal.svg");
|
||||||
label: @tr("Visualizer");
|
label: @tr("Visualizer");
|
||||||
active: false;
|
active: false;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 5;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.visualizer();
|
root.visualizer();
|
||||||
}
|
}
|
||||||
@@ -161,6 +185,7 @@ export component NavRail inherits Rectangle {
|
|||||||
icon: @image-url("../assets/icons/settings.svg");
|
icon: @image-url("../assets/icons/settings.svg");
|
||||||
label: @tr("Settings");
|
label: @tr("Settings");
|
||||||
active: NavState.view == ContentView.settings;
|
active: NavState.view == ContentView.settings;
|
||||||
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 6;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.menu();
|
root.menu();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,9 +382,21 @@ export component QueueSidebar inherits Rectangle {
|
|||||||
// Pointer window-Y -> insertion slot [0..N]. Rows are a fixed 44px stacked
|
// Pointer window-Y -> insertion slot [0..N]. Rows are a fixed 44px stacked
|
||||||
// with 0 spacing; the +0.5 row rounds the boundary to row centers (top half
|
// with 0 spacing; the +0.5 row rounds the boundary to row centers (top half
|
||||||
// of row k -> slot k, bottom half -> slot k+1).
|
// of row k -> slot k, bottom half -> slot k+1).
|
||||||
|
// #442: section headers (22px) above row/slot index i — every drag
|
||||||
|
// computation below adds them to the uniform 44px strides.
|
||||||
|
pure function headers-above(i: int) -> int {
|
||||||
|
return (QueueState.page-manual-header && i >= 1 ? 1 : 0)
|
||||||
|
+ (QueueState.page-manual-pos >= 0 && i > QueueState.page-manual-pos ? 1 : 0);
|
||||||
|
}
|
||||||
pure function slot-from-pointer(win-y: length) -> int {
|
pure function slot-from-pointer(win-y: length) -> int {
|
||||||
|
// One header-adjustment pass: with at most 2 headers and 44px rows,
|
||||||
|
// adjusting by the headers below the RAW slot lands on the right gap
|
||||||
|
// (boundary cases round to a row gap either way).
|
||||||
return Math.max(0, Math.min(QueueState.upcoming-page.length,
|
return Math.max(0, Math.min(QueueState.upcoming-page.length,
|
||||||
Math.floor((win-y - root.reorder-list-top) / 44px + 0.5)));
|
Math.floor((win-y - root.reorder-list-top
|
||||||
|
- root.headers-above(
|
||||||
|
Math.floor((win-y - root.reorder-list-top) / 44px + 0.5)) * 22px)
|
||||||
|
/ 44px + 0.5)));
|
||||||
}
|
}
|
||||||
function commit-reorder() {
|
function commit-reorder() {
|
||||||
root.reorder-active = false;
|
root.reorder-active = false;
|
||||||
@@ -597,14 +609,45 @@ export component QueueSidebar inherits Rectangle {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
// Page of upcoming rows + drop-indicator overlay. Explicit
|
// Page of upcoming rows + drop-indicator overlay. Explicit
|
||||||
// height (rows are a fixed 44px, spacing 0) so the wrapping
|
// height (rows are a fixed 44px, spacing 0; #442 section
|
||||||
// Rectangle does not collapse — a VerticalLayout does not
|
// headers are 22px each) so the wrapping Rectangle does
|
||||||
// auto-size its parent Rectangle.
|
// not collapse — a VerticalLayout does not auto-size its
|
||||||
|
// parent Rectangle.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: QueueState.upcoming-page.length * 44px;
|
height: QueueState.upcoming-page.length * 44px
|
||||||
|
+ QueueState.page-section-count * 22px;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
for item[idx] in QueueState.upcoming-page: QueueRow {
|
for item[idx] in QueueState.upcoming-page: VerticalLayout {
|
||||||
|
spacing: 0px;
|
||||||
|
// #442 section header above the row (only on
|
||||||
|
// marked rows; "" everywhere else). The
|
||||||
|
// indent icon marks the manual block's
|
||||||
|
// insertion point (list-indent-increase).
|
||||||
|
if item.section != "": HorizontalLayout {
|
||||||
|
spacing: 6px;
|
||||||
|
height: 22px;
|
||||||
|
if item.section == "next-in-queue": VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/list-indent-increase.svg");
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
tint: Theme.accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: item.section == "next-in-queue"
|
||||||
|
? @tr("Next in queue")
|
||||||
|
: @tr("Next up");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QueueRow {
|
||||||
item: item;
|
item: item;
|
||||||
index: QueueState.page-start - 1 + idx;
|
index: QueueState.page-start - 1 + idx;
|
||||||
page-local-index: idx;
|
page-local-index: idx;
|
||||||
@@ -627,7 +670,8 @@ export component QueueSidebar inherits Rectangle {
|
|||||||
root.reorder-active = true;
|
root.reorder-active = true;
|
||||||
root.reorder-from = local-idx;
|
root.reorder-from = local-idx;
|
||||||
root.reorder-over = local-idx;
|
root.reorder-over = local-idx;
|
||||||
root.reorder-list-top = row-top - local-idx * 44px;
|
root.reorder-list-top = row-top
|
||||||
|
- (local-idx * 44px + root.headers-above(local-idx) * 22px);
|
||||||
}
|
}
|
||||||
drag-update(pointer-y) => {
|
drag-update(pointer-y) => {
|
||||||
root.reorder-over = root.slot-from-pointer(pointer-y);
|
root.reorder-over = root.slot-from-pointer(pointer-y);
|
||||||
@@ -638,13 +682,15 @@ export component QueueSidebar inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Drop indicator: a 2px accent line at the insertion
|
// Drop indicator: a 2px accent line at the insertion
|
||||||
// slot, hidden for the two no-op slots (from, from+1).
|
// slot, hidden for the two no-op slots (from, from+1).
|
||||||
if root.reorder-active
|
if root.reorder-active
|
||||||
&& root.reorder-over != root.reorder-from
|
&& root.reorder-over != root.reorder-from
|
||||||
&& root.reorder-over != root.reorder-from + 1: Rectangle {
|
&& root.reorder-over != root.reorder-from + 1: Rectangle {
|
||||||
x: 0;
|
x: 0;
|
||||||
y: root.reorder-over * 44px - 1px;
|
y: root.reorder-over * 44px
|
||||||
|
+ root.headers-above(root.reorder-over) * 22px - 1px;
|
||||||
width: parent.width;
|
width: parent.width;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: Theme.accent;
|
background: Theme.accent;
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ component MenuItem inherits Rectangle {
|
|||||||
in property <bool> selected: false;
|
in property <bool> selected: false;
|
||||||
in property <image> icon;
|
in property <image> icon;
|
||||||
in property <bool> has-icon: false;
|
in property <bool> has-icon: false;
|
||||||
|
// Sort rows: "up" | "down" draws the direction chevron in place of the
|
||||||
|
// plain check on the active row (#657 caret).
|
||||||
|
in property <string> trailing-icon: "";
|
||||||
// When false the row is muted and ignores clicks (Import while
|
// When false the row is muted and ignores clicks (Import while
|
||||||
// offline). Same pattern as ContextMenuItem's enabled.
|
// offline). Same pattern as ContextMenuItem's enabled.
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
@@ -56,11 +59,13 @@ component MenuItem inherits Rectangle {
|
|||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
// Selected check (sort options).
|
// Selected check — or the direction chevron on a sort row.
|
||||||
if root.selected: VerticalLayout {
|
if root.selected: VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
QbzIcon {
|
QbzIcon {
|
||||||
source: @image-url("../assets/icons/check.svg");
|
source: root.trailing-icon == "up" ? @image-url("../assets/icons/chevron-up.svg")
|
||||||
|
: root.trailing-icon == "down" ? @image-url("../assets/icons/chevron-down.svg")
|
||||||
|
: @image-url("../assets/icons/check.svg");
|
||||||
width: 13px;
|
width: 13px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
tint: Theme.accent;
|
tint: Theme.accent;
|
||||||
@@ -946,26 +951,36 @@ export component Sidebar inherits Rectangle {
|
|||||||
MenuItem {
|
MenuItem {
|
||||||
label: @tr("Name (A-Z)");
|
label: @tr("Name (A-Z)");
|
||||||
selected: SidebarState.sort-option == "name";
|
selected: SidebarState.sort-option == "name";
|
||||||
|
trailing-icon: SidebarState.sort-option == "name"
|
||||||
|
? (SidebarState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { SidebarActions.set-sort("name"); }
|
activated => { SidebarActions.set-sort("name"); }
|
||||||
}
|
}
|
||||||
MenuItem {
|
MenuItem {
|
||||||
label: @tr("Recent");
|
label: @tr("Recent");
|
||||||
selected: SidebarState.sort-option == "recent";
|
selected: SidebarState.sort-option == "recent";
|
||||||
|
trailing-icon: SidebarState.sort-option == "recent"
|
||||||
|
? (SidebarState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { SidebarActions.set-sort("recent"); }
|
activated => { SidebarActions.set-sort("recent"); }
|
||||||
}
|
}
|
||||||
MenuItem {
|
MenuItem {
|
||||||
label: @tr("# of tracks");
|
label: @tr("# of tracks");
|
||||||
selected: SidebarState.sort-option == "tracks";
|
selected: SidebarState.sort-option == "tracks";
|
||||||
|
trailing-icon: SidebarState.sort-option == "tracks"
|
||||||
|
? (SidebarState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { SidebarActions.set-sort("tracks"); }
|
activated => { SidebarActions.set-sort("tracks"); }
|
||||||
}
|
}
|
||||||
MenuItem {
|
MenuItem {
|
||||||
label: @tr("Play Count");
|
label: @tr("Play Count");
|
||||||
selected: SidebarState.sort-option == "playcount";
|
selected: SidebarState.sort-option == "playcount";
|
||||||
|
trailing-icon: SidebarState.sort-option == "playcount"
|
||||||
|
? (SidebarState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { SidebarActions.set-sort("playcount"); }
|
activated => { SidebarActions.set-sort("playcount"); }
|
||||||
}
|
}
|
||||||
MenuItem {
|
MenuItem {
|
||||||
label: @tr("Custom");
|
label: @tr("Custom");
|
||||||
selected: SidebarState.sort-option == "custom";
|
selected: SidebarState.sort-option == "custom";
|
||||||
|
trailing-icon: SidebarState.sort-option == "custom"
|
||||||
|
? (SidebarState.sort-asc ? "up" : "down") : "";
|
||||||
activated => { SidebarActions.set-sort("custom"); }
|
activated => { SidebarActions.set-sort("custom"); }
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ export component TransportControls inherits HorizontalLayout {
|
|||||||
}
|
}
|
||||||
add-menu := PopupWindow {
|
add-menu := PopupWindow {
|
||||||
x: 0px;
|
x: 0px;
|
||||||
y: -222px;
|
y: -255px;
|
||||||
width: 232px;
|
width: 232px;
|
||||||
height: 214px;
|
height: 247px;
|
||||||
close-policy: close-on-click;
|
close-policy: close-on-click;
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
@@ -157,6 +157,13 @@ export component TransportControls inherits HorizontalLayout {
|
|||||||
}
|
}
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-plus.svg");
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
label: @tr("Play later");
|
||||||
|
clicked => {
|
||||||
|
root.media-action("track", NowPlayingState.track-id, "play-later");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/list-start.svg");
|
||||||
label: @tr("Play next");
|
label: @tr("Play next");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("track", NowPlayingState.track-id, "play-next");
|
root.media-action("track", NowPlayingState.track-id, "play-next");
|
||||||
|
|||||||
@@ -1029,12 +1029,12 @@ export global AlbumActions {
|
|||||||
// Toggle multi-select (track selection) mode on the album track list.
|
// Toggle multi-select (track selection) mode on the album track list.
|
||||||
callback toggle-multi-select();
|
callback toggle-multi-select();
|
||||||
// Bulk-bar action over the selected rows (id = "select-all" | "play-next"
|
// Bulk-bar action over the selected rows (id = "select-all" | "play-next"
|
||||||
// | "queue" | "add-to-playlist" | "add-to-favorites" | "make-offline" |
|
// | "play-later" | "queue" | "add-to-playlist" | "add-to-favorites" |
|
||||||
// "clear").
|
// "make-offline" | "clear").
|
||||||
callback bulk-action(string);
|
callback bulk-action(string);
|
||||||
// Per-disc context-menu action on a multi-disc album's "Disc N" header —
|
// Per-disc context-menu action on a multi-disc album's "Disc N" header —
|
||||||
// scoped to that disc's tracks only (action = "play" | "shuffle" | "queue"
|
// scoped to that disc's tracks only (action = "play" | "shuffle" | "queue"
|
||||||
// | "play-next"). The whole-album header buttons are unaffected.
|
// | "play-later" | "play-next"). The whole-album header buttons are unaffected.
|
||||||
callback disc-action(int /* disc number */, string /* action */);
|
callback disc-action(int /* disc number */, string /* action */);
|
||||||
// Open an external link (Last.fm / Discogs / MusicBrainz) in the system
|
// Open an external link (Last.fm / Discogs / MusicBrainz) in the system
|
||||||
// browser. The url is prebuilt by the controller (AlbumState.*-url).
|
// browser. The url is prebuilt by the controller (AlbumState.*-url).
|
||||||
@@ -1365,13 +1365,13 @@ export global LocalLibraryActions {
|
|||||||
callback open-album(string /* album id */);
|
callback open-album(string /* album id */);
|
||||||
// Open an artist (lands with the Artists slice).
|
// Open an artist (lands with the Artists slice).
|
||||||
callback open-artist(string /* artist id */);
|
callback open-artist(string /* artist id */);
|
||||||
// Per-card action (play / queue / play-next / toggle-select). The Albums
|
// Per-card action (play / queue / play-next / play-later / toggle-select).
|
||||||
// grid honors the shared AlbumCollectionView contract.
|
// The Albums grid honors the shared AlbumCollectionView contract.
|
||||||
callback album-action(string /* album id */, string /* action */);
|
callback album-action(string /* album id */, string /* action */);
|
||||||
// Toggle the Albums-grid multi-select mode.
|
// Toggle the Albums-grid multi-select mode.
|
||||||
callback albums-toggle-multi-select();
|
callback albums-toggle-multi-select();
|
||||||
// Albums bulk bar action: select-all | clear | queue | play-next |
|
// Albums bulk bar action: select-all | clear | queue | play-next |
|
||||||
// add-to-playlist | add-to-mixtape.
|
// play-later | add-to-playlist | add-to-mixtape.
|
||||||
callback albums-bulk-action(string /* action */);
|
callback albums-bulk-action(string /* action */);
|
||||||
// Windowed grid: the visible row band changed (item indices into
|
// Windowed grid: the visible row band changed (item indices into
|
||||||
// albums-visible, prefetch margin included) — dispatch artwork for it.
|
// albums-visible, prefetch margin included) — dispatch artwork for it.
|
||||||
@@ -1381,7 +1381,7 @@ export global LocalLibraryActions {
|
|||||||
callback tracks-search(string /* query */);
|
callback tracks-search(string /* query */);
|
||||||
callback tracks-load-more();
|
callback tracks-load-more();
|
||||||
callback tracks-retry();
|
callback tracks-retry();
|
||||||
// Per-row play/queue/toggle-select (action: play|play-next|queue|toggle-select).
|
// Per-row play/queue/toggle-select (action: play|play-next|play-later|queue|toggle-select).
|
||||||
callback track-action(string /* track id */, string /* action */);
|
callback track-action(string /* track id */, string /* action */);
|
||||||
// Group-by mode (off|album|artist|name), persisted.
|
// Group-by mode (off|album|artist|name), persisted.
|
||||||
callback tracks-set-group(string /* mode */);
|
callback tracks-set-group(string /* mode */);
|
||||||
@@ -1389,7 +1389,7 @@ export global LocalLibraryActions {
|
|||||||
callback tracks-set-sort(string /* key */);
|
callback tracks-set-sort(string /* key */);
|
||||||
// Toggle the multi-select checkbox column.
|
// Toggle the multi-select checkbox column.
|
||||||
callback tracks-toggle-multi-select();
|
callback tracks-toggle-multi-select();
|
||||||
// Bulk bar action: select-all | clear | queue | play-next | add-to-playlist.
|
// Bulk bar action: select-all | clear | queue | play-next | play-later | add-to-playlist.
|
||||||
callback tracks-bulk-action(string /* action */);
|
callback tracks-bulk-action(string /* action */);
|
||||||
|
|
||||||
// --- Folders tab ---
|
// --- Folders tab ---
|
||||||
@@ -1416,7 +1416,7 @@ export global LocalLibraryActions {
|
|||||||
callback folders-toggle-select-mode();
|
callback folders-toggle-select-mode();
|
||||||
callback folders-toggle-folder-select(string /* folder path */);
|
callback folders-toggle-folder-select(string /* folder path */);
|
||||||
callback folders-toggle-track-select(string /* track path */);
|
callback folders-toggle-track-select(string /* track path */);
|
||||||
callback folders-bulk-action(string /* select-all|clear|queue|play-next|add-to-playlist */);
|
callback folders-bulk-action(string /* select-all|clear|queue|play-next|play-later|add-to-playlist */);
|
||||||
|
|
||||||
// --- Ephemeral folder (ad-hoc play) ---
|
// --- Ephemeral folder (ad-hoc play) ---
|
||||||
// Open the native folder picker → scan → show the ephemeral pane.
|
// Open the native folder picker → scan → show the ephemeral pane.
|
||||||
@@ -1501,8 +1501,8 @@ export global LocalAlbumActions {
|
|||||||
callback add-to-playlist();
|
callback add-to-playlist();
|
||||||
callback add-to-mixtape();
|
callback add-to-mixtape();
|
||||||
callback play-track(string /* track id */);
|
callback play-track(string /* track id */);
|
||||||
// Per-row context-menu actions beyond "play" (play-next / queue /
|
// Per-row context-menu actions beyond "play" (play-next / play-later /
|
||||||
// add-to-playlist) — resolved against the open version's track cache.
|
// queue / add-to-playlist) — resolved against the open version's track cache.
|
||||||
callback track-menu-action(string /* track id */, string /* action */);
|
callback track-menu-action(string /* track id */, string /* action */);
|
||||||
callback select-version(int);
|
callback select-version(int);
|
||||||
// Client-side track filter (mirrors AlbumActions.search), applied over the
|
// Client-side track filter (mirrors AlbumActions.search), applied over the
|
||||||
@@ -1510,7 +1510,8 @@ export global LocalAlbumActions {
|
|||||||
callback search(string /* query */);
|
callback search(string /* query */);
|
||||||
// Per-disc context-menu action on a multi-disc local album's "Disc N"
|
// Per-disc context-menu action on a multi-disc local album's "Disc N"
|
||||||
// header — scoped to that disc's tracks only (action = "play" | "shuffle" |
|
// header — scoped to that disc's tracks only (action = "play" | "shuffle" |
|
||||||
// "queue" | "play-next"). The whole-album header buttons are unaffected.
|
// "queue" | "play-later" | "play-next"). The whole-album header buttons are
|
||||||
|
// unaffected.
|
||||||
callback disc-action(int /* disc number */, string /* action */);
|
callback disc-action(int /* disc number */, string /* action */);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1933,6 +1934,10 @@ export global SidebarState {
|
|||||||
// Active sort option: "name" | "recent" | "tracks" | "playcount"
|
// Active sort option: "name" | "recent" | "tracks" | "playcount"
|
||||||
// | "custom" (mirrors Tauri's SortOption). Persisted by crate::sidebar.
|
// | "custom" (mirrors Tauri's SortOption). Persisted by crate::sidebar.
|
||||||
in-out property <string> sort-option: "name";
|
in-out property <string> sort-option: "name";
|
||||||
|
// Direction of the active sort (re-selecting the option in the menu
|
||||||
|
// flips it). true = the option's natural direction (name A→Z, recent
|
||||||
|
// newest-first, tracks most-first, custom position asc).
|
||||||
|
in-out property <bool> sort-asc: true;
|
||||||
// Micro-collage of track covers on playlist rows. Opt-out (ON by
|
// Micro-collage of track covers on playlist rows. Opt-out (ON by
|
||||||
// default) to match Tauri's `sidebarPlaylistCollage`. The Settings >
|
// default) to match Tauri's `sidebarPlaylistCollage`. The Settings >
|
||||||
// Appearance toggle wires into this in a later step.
|
// Appearance toggle wires into this in a later step.
|
||||||
@@ -2315,6 +2320,10 @@ export global PlaylistManagerState {
|
|||||||
in-out property <string> filter: "all";
|
in-out property <string> filter: "all";
|
||||||
// "name" | "recent" | "playcount" | "tracks" | "custom".
|
// "name" | "recent" | "playcount" | "tracks" | "custom".
|
||||||
in-out property <string> sort: "name";
|
in-out property <string> sort: "name";
|
||||||
|
// Direction of the active sort (re-selecting the menu option flips it).
|
||||||
|
// true = natural direction (name A→Z, recent newest-first, playcount /
|
||||||
|
// tracks most-first, custom position asc).
|
||||||
|
in-out property <bool> sort-asc: true;
|
||||||
// "grid" | "list" | "tree".
|
// "grid" | "list" | "tree".
|
||||||
in-out property <string> view-mode: "grid";
|
in-out property <string> view-mode: "grid";
|
||||||
// Folder mode on (folders shown + folder filtering) vs flat.
|
// Folder mode on (folders shown + folder filtering) vs flat.
|
||||||
@@ -3236,6 +3245,12 @@ export global AppearanceState {
|
|||||||
in-out property <bool> intelligent-search: true;
|
in-out property <bool> intelligent-search: true;
|
||||||
// Player volume +/- stepper buttons — opt-in, hidden by default.
|
// Player volume +/- stepper buttons — opt-in, hidden by default.
|
||||||
in-out property <bool> show-volume-steppers: false;
|
in-out property <bool> show-volume-steppers: false;
|
||||||
|
// Animate the now-playing row's equalizer bars. Default OFF — a static
|
||||||
|
// pause glyph + accent left-edge mark instead (CPU on some machines).
|
||||||
|
in-out property <bool> play-indicator-animation: false;
|
||||||
|
// Invert the two-finger touchpad swipe navigation (#653). Default OFF =
|
||||||
|
// natural-scrolling convention (fingers right = back, left = forward).
|
||||||
|
in-out property <bool> invert-swipe-navigation: false;
|
||||||
|
|
||||||
// --- NOTIFICATIONS -----------------------------------------------------
|
// --- NOTIFICATIONS -----------------------------------------------------
|
||||||
in-out property <bool> in-app-toasts: true;
|
in-out property <bool> in-app-toasts: true;
|
||||||
@@ -3476,6 +3491,50 @@ export global NavState {
|
|||||||
in-out property <length> scroll-restore: 0;
|
in-out property <length> scroll-restore: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kiosk keyboard/gamepad navigation model (Steam Deck Game Mode plan, PR 2 —
|
||||||
|
// feature/internal/kiosk-key-nav). Pure Slint: the root FocusScope in
|
||||||
|
// KioskShell drives these properties from arrow/Enter/Esc keys; PR 3's
|
||||||
|
// gamepad layer injects the same synthetic key events, so nothing here is
|
||||||
|
// keyboard-specific. Additive with safe defaults — the desktop AppShell never
|
||||||
|
// touches this global.
|
||||||
|
//
|
||||||
|
// Zone flow: "rail" (bottom NavRail tiles) → "content" (the mounted view's
|
||||||
|
// navigable items) → "player" (the transport bar, one focusable unit).
|
||||||
|
// Down advances rail→content→player→rail; Up reverses. `index` is the
|
||||||
|
// focused item inside the zone. For content, the index space is
|
||||||
|
// `tabs` leading tab-strip entries followed by the item rows laid out as a
|
||||||
|
// `columns`-wide grid (`columns == 1` = vertical list or card shelves);
|
||||||
|
// the mounted view publishes `tabs` / `columns` / `count` / `shelves`
|
||||||
|
// whenever its tab or data changes. `nav-active` latches true on the first
|
||||||
|
// nav key and gates every focus-ring rendering (touch-only sessions never
|
||||||
|
// show a ring).
|
||||||
|
export global KioskNavState {
|
||||||
|
in-out property <string> zone: "content"; // "rail" | "content" | "player"
|
||||||
|
in-out property <int> index: 0;
|
||||||
|
in-out property <int> columns: 1;
|
||||||
|
in-out property <int> count: 0; // tabs + item count of the mounted content view
|
||||||
|
in-out property <bool> nav-active: false;
|
||||||
|
// Leading tab-strip entries inside the content index space (0 = the view
|
||||||
|
// has no tabs). Owned by the mounted view, never by shared grids.
|
||||||
|
in-out property <int> tabs: 0;
|
||||||
|
// Shelf mode (Discover / Artist): each content row is a horizontal card
|
||||||
|
// strip with its own cursor, so Left/Right can't move `index` (that
|
||||||
|
// would jump shelves). The shell emits an `hmove` pulse instead and the
|
||||||
|
// focused shelf consumes it into its local cursor.
|
||||||
|
in-out property <bool> shelves: false;
|
||||||
|
in-out property <int> hmove: 0; // pending shelf move: -1 | +1 (consumer resets to 0)
|
||||||
|
// Last vertical move direction (-1 up / +1 down / 0 none). Shelf views
|
||||||
|
// skip non-navigable rows (e.g. playlist/mix sections that render no
|
||||||
|
// album strip) by nudging `index` one more step this way.
|
||||||
|
in-out property <int> dir: 0;
|
||||||
|
// Enter pulse: the shell increments this instead of calling into views
|
||||||
|
// (conditionally-mounted children can't be addressed). The mounted zone
|
||||||
|
// owner (view, grid or list) reacts via a `changed` probe, checks the
|
||||||
|
// zone, and activates `index`. NavRail is always mounted, so the shell
|
||||||
|
// calls its `activate-index()` directly instead.
|
||||||
|
in-out property <int> activate-seq: 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Artist detail screen state.
|
// Artist detail screen state.
|
||||||
// One label row in the artist network sidebar's Labels section.
|
// One label row in the artist network sidebar's Labels section.
|
||||||
export struct LabelEntry {
|
export struct LabelEntry {
|
||||||
@@ -3601,8 +3660,8 @@ export global ArtistActions {
|
|||||||
// Toggle Popular Tracks multi-select mode.
|
// Toggle Popular Tracks multi-select mode.
|
||||||
callback toggle-top-tracks-select();
|
callback toggle-top-tracks-select();
|
||||||
// Bulk-bar action over the selected Popular Tracks (id = "select-all" |
|
// Bulk-bar action over the selected Popular Tracks (id = "select-all" |
|
||||||
// "play-next" | "queue" | "add-to-playlist" | "add-to-favorites" |
|
// "play-next" | "play-later" | "queue" | "add-to-playlist" |
|
||||||
// "clear").
|
// "add-to-favorites" | "clear").
|
||||||
callback top-tracks-bulk-action(string);
|
callback top-tracks-bulk-action(string);
|
||||||
// Popular Tracks section "more" menu (id = "next-all" | "queue-all" |
|
// Popular Tracks section "more" menu (id = "next-all" | "queue-all" |
|
||||||
// "shuffle-all" | "playlist-all").
|
// "shuffle-all" | "playlist-all").
|
||||||
@@ -4145,6 +4204,9 @@ export struct QueueItem {
|
|||||||
explicit: bool,
|
explicit: bool,
|
||||||
// From an ephemeral folder — gates persistence actions in the row menu.
|
// From an ephemeral folder — gates persistence actions in the row menu.
|
||||||
is-ephemeral: bool,
|
is-ephemeral: bool,
|
||||||
|
// #442 section header drawn ABOVE this row: "" | "next-in-queue" (first
|
||||||
|
// hand-queued row) | "next-up" (first row after the manual block).
|
||||||
|
section: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImmersiveView audio visualizers. Driven at ~30fps by the Rust drain
|
// ImmersiveView audio visualizers. Driven at ~30fps by the Rust drain
|
||||||
@@ -4772,6 +4834,19 @@ export global QueueState {
|
|||||||
in property <int> page-start: 0;
|
in property <int> page-start: 0;
|
||||||
// Index of the last upcoming entry shown, 1-based, for the counter.
|
// Index of the last upcoming entry shown, 1-based, for the counter.
|
||||||
in property <int> page-end: 0;
|
in property <int> page-end: 0;
|
||||||
|
// #442 section headers present in the current page (0-2: "Next in queue"
|
||||||
|
// and/or "Next up") — the sidebar adds their height to the rows block.
|
||||||
|
in property <int> page-section-count: 0;
|
||||||
|
// #442 drag-math aids: the "Next in queue" header sits above the page's
|
||||||
|
// first row, and the page-local index of the "Next up" row (-1 = not in
|
||||||
|
// this page).
|
||||||
|
in property <bool> page-manual-header: false;
|
||||||
|
in property <int> page-manual-pos: -1;
|
||||||
|
// #442 menu-hint numbers (TrackContextMenu "where does it land" suffix):
|
||||||
|
// total queue length, current index (-1 = none), manual-block size.
|
||||||
|
in property <int> queue-length: 0;
|
||||||
|
in property <int> queue-current-index: -1;
|
||||||
|
in property <int> manual-count: 0;
|
||||||
|
|
||||||
// --- History tab -----------------------------------------------------
|
// --- History tab -----------------------------------------------------
|
||||||
in property <[QueueItem]> history: [];
|
in property <[QueueItem]> history: [];
|
||||||
@@ -5072,6 +5147,11 @@ export global FavoritesState {
|
|||||||
in property <[AlbumCardItem]> albums-visible: []; // after search + sort (flat render)
|
in property <[AlbumCardItem]> albums-visible: []; // after search + sort (flat render)
|
||||||
in property <[DiscoverSection]> albums-grouped: []; // group-by sections
|
in property <[DiscoverSection]> albums-grouped: []; // group-by sections
|
||||||
in-out property <string> albums-view-mode: "grid"; // "grid" | "list"
|
in-out property <string> albums-view-mode: "grid"; // "grid" | "list"
|
||||||
|
// Albums LIST-mode multi-select (owner 2026-07-24): checkbox column on
|
||||||
|
// the rows + the bulk bar (un-favorite / play-next / play-later / queue).
|
||||||
|
// List only — switching to grid forces it off.
|
||||||
|
in-out property <bool> albums-multi-select: false;
|
||||||
|
in property <int> albums-selected-count: 0;
|
||||||
in-out property <string> albums-search: "";
|
in-out property <string> albums-search: "";
|
||||||
in-out property <string> albums-sort-by: "default"; // default|title-asc|title-desc|artist-asc
|
in-out property <string> albums-sort-by: "default"; // default|title-asc|title-desc|artist-asc
|
||||||
in-out property <string> albums-group-mode: "off"; // off|alpha|artist
|
in-out property <string> albums-group-mode: "off"; // off|alpha|artist
|
||||||
@@ -5132,7 +5212,15 @@ export global FavoritesActions {
|
|||||||
callback shuffle-tracks();
|
callback shuffle-tracks();
|
||||||
callback toggle-multi-select();
|
callback toggle-multi-select();
|
||||||
callback bulk-action(string /* select-all | remove-selected | clear */);
|
callback bulk-action(string /* select-all | remove-selected | clear */);
|
||||||
|
// Albums list-mode multi-select: toggle the edit mode + the bulk bar
|
||||||
|
// actions (select-all | play-next | play-later | queue | remove-selected | clear).
|
||||||
|
callback albums-toggle-multi-select();
|
||||||
|
callback albums-bulk-action(string);
|
||||||
callback album-action(string /* album id */, string /* action */);
|
callback album-action(string /* album id */, string /* action */);
|
||||||
|
// Generic artist action route (the All feed's ArtistGridCard follow
|
||||||
|
// button) — forwarded to the shared media-action ("artist", id, action)
|
||||||
|
// arm, which owns the optimistic flip + rollback.
|
||||||
|
callback artist-action(string /* artist id */, string /* action */);
|
||||||
callback albums-search(string /* query */);
|
callback albums-search(string /* query */);
|
||||||
callback search-labels(string /* query */);
|
callback search-labels(string /* query */);
|
||||||
callback playlists-set-sub-tab(string /* favorites | following */);
|
callback playlists-set-sub-tab(string /* favorites | following */);
|
||||||
@@ -5140,7 +5228,7 @@ export global FavoritesActions {
|
|||||||
callback labels-random();
|
callback labels-random();
|
||||||
callback search-playlists(string /* query */);
|
callback search-playlists(string /* query */);
|
||||||
callback playlists-set-view(string /* grid | list */);
|
callback playlists-set-view(string /* grid | list */);
|
||||||
callback playlist-action(string /* playlist id */, string /* play | play-next | queue | share | favorite | follow | unfollow | copy */);
|
callback playlist-action(string /* playlist id */, string /* play | play-next | play-later | queue | share | favorite | follow | unfollow | copy */);
|
||||||
callback search-artists(string /* query */);
|
callback search-artists(string /* query */);
|
||||||
callback artists-set-view(string /* grid | sidepanel */);
|
callback artists-set-view(string /* grid | sidepanel */);
|
||||||
callback artists-set-group(string /* off | alpha */);
|
callback artists-set-group(string /* off | alpha */);
|
||||||
@@ -5209,9 +5297,14 @@ export global LibraryAllState {
|
|||||||
in-out property <bool> show-purchases: true;
|
in-out property <bool> show-purchases: true;
|
||||||
in-out property <bool> show-favorites: true;
|
in-out property <bool> show-favorites: true;
|
||||||
in-out property <bool> show-following: true;
|
in-out property <bool> show-following: true;
|
||||||
// "show local" — includes local files + Plex (Phase 2). Off until the
|
// "show local" — includes local files + Plex (the local-favorites layer).
|
||||||
// local-favorites layer lands.
|
// ON by default (users expect their local items in All); toggled from
|
||||||
in-out property <bool> show-local: false;
|
// Settings > Local Library, persisted in favorites_ui.json.
|
||||||
|
in-out property <bool> show-local: true;
|
||||||
|
// What "local" means in the feed: "favorites" (hearted items only) |
|
||||||
|
// "all" (the whole local library). Display state for the Settings
|
||||||
|
// selector; the loader reads the persisted pref directly.
|
||||||
|
in-out property <string> local-scope: "favorites";
|
||||||
in property <int> total: 0; // badge count (deduped union)
|
in property <int> total: 0; // badge count (deduped union)
|
||||||
in property <bool> loading: false;
|
in property <bool> loading: false;
|
||||||
in property <string> load-error: "";
|
in property <string> load-error: "";
|
||||||
@@ -5223,6 +5316,14 @@ export global LibraryAllActions {
|
|||||||
callback set-sort(string /* date | title | artist */);
|
callback set-sort(string /* date | title | artist */);
|
||||||
callback set-view(string /* grid | list */);
|
callback set-view(string /* grid | list */);
|
||||||
callback toggle-source(string /* purchases | favorites | following | local */);
|
callback toggle-source(string /* purchases | favorites | following | local */);
|
||||||
|
// Settings > Local Library: what "local" means in the feed —
|
||||||
|
// "favorites" (hearted items only) | "all" (the whole local library).
|
||||||
|
// Reloads the feed.
|
||||||
|
callback set-local-scope(string);
|
||||||
|
// Viewport windowing: the grid/list report the mounted row band (item
|
||||||
|
// indices, prefetch margin included) so Rust dispatches covers only for
|
||||||
|
// the visible window and evicts far outside it.
|
||||||
|
callback window-changed(int /* first index */, int /* last index */);
|
||||||
callback open-item(string /* kind */, string /* id */);
|
callback open-item(string /* kind */, string /* id */);
|
||||||
callback media-action(string /* kind */, string /* id */, string /* action */);
|
callback media-action(string /* kind */, string /* id */, string /* action */);
|
||||||
callback retry();
|
callback retry();
|
||||||
|
|||||||
@@ -58,7 +58,14 @@ reqwest = { workspace = true }
|
|||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
open = "5"
|
open = "5"
|
||||||
arboard = "3"
|
# Steam Deck plan PR 3: native gamepad nav (D-pad/stick/A/B → synthetic Slint
|
||||||
|
# keys). Pure Rust, evdev — no C deps.
|
||||||
|
gilrs = "0.11.2"
|
||||||
|
# wayland-data-control: Wayland-native clipboard via the data-control
|
||||||
|
# protocols (GNOME/KDE/sway/niri/Hyprland), falling back to X11 when Wayland
|
||||||
|
# is absent (#514 — without it arboard is X11-only and every copy fails on
|
||||||
|
# Wayland-only sessions).
|
||||||
|
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||||
# async-std (rfd's name for the async-io/ashpd backend), NOT tokio: zbus 5's
|
# async-std (rfd's name for the async-io/ashpd backend), NOT tokio: zbus 5's
|
||||||
# "tokio" feature unifies graph-wide and breaks accesskit/atspi/ksni threads
|
# "tokio" feature unifies graph-wide and breaks accesskit/atspi/ksni threads
|
||||||
# (see the ashpd comment in qbz-credentials/Cargo.toml).
|
# (see the ashpd comment in qbz-credentials/Cargo.toml).
|
||||||
|
|||||||
@@ -137,9 +137,11 @@ pub enum ArtworkTarget {
|
|||||||
/// main-column Playlists carousel). Single cover (slot 0), like
|
/// main-column Playlists carousel). Single cover (slot 0), like
|
||||||
/// `LabelPlaylistCover`.
|
/// `LabelPlaylistCover`.
|
||||||
ArtistPlaylistCover { index: usize },
|
ArtistPlaylistCover { index: usize },
|
||||||
/// A card in the Library "All" mixed feed (`LibraryAllState.items-visible[index]`).
|
/// A card in the Library "All" mixed feed, keyed by `kind:id` (Qobuz
|
||||||
/// Dispatched against the VISIBLE model, re-dispatched on each derive.
|
/// numeric ids overlap across entity types). Id-keyed + generation-guarded
|
||||||
LibraryAllCover { index: usize },
|
/// so a derive re-sort between dispatch and apply cannot land a cover on
|
||||||
|
/// the wrong card; applied onto BOTH `items` and `items-visible`.
|
||||||
|
LibraryAllById { key: String, gen: u64 },
|
||||||
/// A row in `ArtistState.library-tracks[index]` — the ArtistPage
|
/// A row in `ArtistState.library-tracks[index]` — the ArtistPage
|
||||||
/// "In library" track list (library_by_artist seed). Row thumbnail.
|
/// "In library" track list (library_by_artist seed). Row thumbnail.
|
||||||
ArtistLibraryTrack { index: usize },
|
ArtistLibraryTrack { index: usize },
|
||||||
@@ -1264,12 +1266,16 @@ fn apply_artwork(
|
|||||||
model.set_row_data(index, item);
|
model.set_row_data(index, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ArtworkTarget::LibraryAllCover { index } => {
|
ArtworkTarget::LibraryAllById { key, gen } => {
|
||||||
let model = window.global::<crate::LibraryAllState>().get_items_visible();
|
// The job is done either way — free its in-flight slot so the
|
||||||
if let Some(mut item) = model.row_data(index) {
|
// window dispatcher can re-request it after an eviction.
|
||||||
item.image = image;
|
crate::library_all::artwork_job_done(&key);
|
||||||
model.set_row_data(index, item);
|
// Drop the cover if a reload superseded the set it belongs to.
|
||||||
|
if !crate::library_all::library_all_gen_current(gen) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Set by key onto the full set + visible rows.
|
||||||
|
crate::library_all::set_library_all_artwork(window, &key, image);
|
||||||
}
|
}
|
||||||
ArtworkTarget::ArtistLibraryTrack { index } => {
|
ArtworkTarget::ArtistLibraryTrack { index } => {
|
||||||
let model = window.global::<ArtistState>().get_library_tracks();
|
let model = window.global::<ArtistState>().get_library_tracks();
|
||||||
|
|||||||
@@ -1611,6 +1611,127 @@ pub fn selected_tracks(window: &AppWindow) -> Vec<Track> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Albums LIST-mode multi-select (mirrors the tracks section) ---------
|
||||||
|
// Selection lives on AlbumCardItem.selected in `albums-visible` (flat) and
|
||||||
|
// each `albums-grouped` section model (grouped list renders the same rows).
|
||||||
|
|
||||||
|
/// Enter/leave albums multi-select; leaving clears the selection.
|
||||||
|
pub fn set_albums_multi_select(window: &AppWindow, on: bool) {
|
||||||
|
window.global::<FavoritesState>().set_albums_multi_select(on);
|
||||||
|
crate::selection::clear_anchor();
|
||||||
|
if !on {
|
||||||
|
clear_albums_selection(window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flip one album's `selected` across the flat + grouped models.
|
||||||
|
pub fn toggle_select_album(window: &AppWindow, id: &str) {
|
||||||
|
let st = window.global::<FavoritesState>();
|
||||||
|
let flip = |model: &slint::ModelRc<AlbumCardItem>| {
|
||||||
|
for i in 0..model.row_count() {
|
||||||
|
if let Some(mut item) = model.row_data(i) {
|
||||||
|
if item.id.as_str() == id {
|
||||||
|
item.selected = !item.selected;
|
||||||
|
model.set_row_data(i, item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
flip(&st.get_albums_visible());
|
||||||
|
let grouped = st.get_albums_grouped();
|
||||||
|
for s in 0..grouped.row_count() {
|
||||||
|
if let Some(section) = grouped.row_data(s) {
|
||||||
|
flip(§ion.albums);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recount_albums_selected(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recount selected album rows into `albums-selected-count`.
|
||||||
|
pub fn recount_albums_selected(window: &AppWindow) {
|
||||||
|
let st = window.global::<FavoritesState>();
|
||||||
|
let visible = st.get_albums_visible();
|
||||||
|
let mut count = (0..visible.row_count())
|
||||||
|
.filter(|&i| visible.row_data(i).map(|a| a.selected).unwrap_or(false))
|
||||||
|
.count();
|
||||||
|
let grouped = st.get_albums_grouped();
|
||||||
|
for s in 0..grouped.row_count() {
|
||||||
|
if let Some(section) = grouped.row_data(s) {
|
||||||
|
let albums = section.albums;
|
||||||
|
count += (0..albums.row_count())
|
||||||
|
.filter(|&i| albums.row_data(i).map(|a| a.selected).unwrap_or(false))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.set_albums_selected_count(count as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select-all toggle on the flat visible set (or clear when all selected).
|
||||||
|
pub fn select_all_albums(window: &AppWindow) {
|
||||||
|
let model = window.global::<FavoritesState>().get_albums_visible();
|
||||||
|
let total = model.row_count();
|
||||||
|
let selected = (0..total)
|
||||||
|
.filter(|&i| model.row_data(i).map(|a| a.selected).unwrap_or(false))
|
||||||
|
.count();
|
||||||
|
let target = selected != total;
|
||||||
|
for i in 0..total {
|
||||||
|
if let Some(mut item) = model.row_data(i) {
|
||||||
|
if item.selected != target {
|
||||||
|
item.selected = target;
|
||||||
|
model.set_row_data(i, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recount_albums_selected(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deselect every album row (flat + grouped).
|
||||||
|
pub fn clear_albums_selection(window: &AppWindow) {
|
||||||
|
let st = window.global::<FavoritesState>();
|
||||||
|
let clear = |model: &slint::ModelRc<AlbumCardItem>| {
|
||||||
|
for i in 0..model.row_count() {
|
||||||
|
if let Some(mut item) = model.row_data(i) {
|
||||||
|
if item.selected {
|
||||||
|
item.selected = false;
|
||||||
|
model.set_row_data(i, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
clear(&st.get_albums_visible());
|
||||||
|
let grouped = st.get_albums_grouped();
|
||||||
|
for s in 0..grouped.row_count() {
|
||||||
|
if let Some(section) = grouped.row_data(s) {
|
||||||
|
clear(§ion.albums);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.set_albums_selected_count(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ids of the currently-selected album rows (flat + grouped).
|
||||||
|
pub fn selected_album_ids(window: &AppWindow) -> Vec<String> {
|
||||||
|
let st = window.global::<FavoritesState>();
|
||||||
|
let mut ids: Vec<String> = (0..st.get_albums_visible().row_count())
|
||||||
|
.filter_map(|i| st.get_albums_visible().row_data(i))
|
||||||
|
.filter(|a| a.selected)
|
||||||
|
.map(|a| a.id.to_string())
|
||||||
|
.collect();
|
||||||
|
let grouped = st.get_albums_grouped();
|
||||||
|
for s in 0..grouped.row_count() {
|
||||||
|
if let Some(section) = grouped.row_data(s) {
|
||||||
|
ids.extend(
|
||||||
|
(0..section.albums.row_count())
|
||||||
|
.filter_map(|i| section.albums.row_data(i))
|
||||||
|
.filter(|a| a.selected)
|
||||||
|
.map(|a| a.id.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn reset_loading(window: &AppWindow) {
|
pub fn reset_loading(window: &AppWindow) {
|
||||||
let state = window.global::<FavoritesState>();
|
let state = window.global::<FavoritesState>();
|
||||||
state.set_loading(true);
|
state.set_loading(true);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::path::PathBuf;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use slint::ComponentHandle;
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
use crate::{AppWindow, FavoritesState};
|
use crate::{AppWindow, FavoritesState, LibraryAllState};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct Prefs {
|
struct Prefs {
|
||||||
@@ -26,6 +26,17 @@ struct Prefs {
|
|||||||
artists_group: bool,
|
artists_group: bool,
|
||||||
#[serde(default = "d_grid")]
|
#[serde(default = "d_grid")]
|
||||||
artists_view: String,
|
artists_view: String,
|
||||||
|
// Library "All": include local files + Plex in the feed (the toolbar's
|
||||||
|
// hard-drive filter). Default ON — users expect their local items there
|
||||||
|
// (issuers' report).
|
||||||
|
#[serde(default = "d_true")]
|
||||||
|
all_show_local: bool,
|
||||||
|
// Library "All": what "local" means in the feed — "favorites" (only
|
||||||
|
// hearted local items, webplayer parity) or "all" (the entire local
|
||||||
|
// library: every folder + Plex album/artist/track). Settings > Local
|
||||||
|
// Library selector (owner 2026-07-24).
|
||||||
|
#[serde(default = "d_favorites")]
|
||||||
|
all_local_scope: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Prefs {
|
impl Default for Prefs {
|
||||||
@@ -38,6 +49,8 @@ impl Default for Prefs {
|
|||||||
playlists_view: d_grid(),
|
playlists_view: d_grid(),
|
||||||
artists_group: false,
|
artists_group: false,
|
||||||
artists_view: d_grid(),
|
artists_view: d_grid(),
|
||||||
|
all_show_local: true,
|
||||||
|
all_local_scope: d_favorites(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +64,12 @@ fn d_default() -> String {
|
|||||||
fn d_off() -> String {
|
fn d_off() -> String {
|
||||||
"off".to_string()
|
"off".to_string()
|
||||||
}
|
}
|
||||||
|
fn d_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn d_favorites() -> String {
|
||||||
|
"favorites".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn store_path() -> Option<PathBuf> {
|
fn store_path() -> Option<PathBuf> {
|
||||||
Some(dirs::data_dir()?.join("qbz").join("favorites_ui.json"))
|
Some(dirs::data_dir()?.join("qbz").join("favorites_ui.json"))
|
||||||
@@ -77,9 +96,17 @@ pub fn load(window: &AppWindow) {
|
|||||||
st.set_playlists_view_mode(p.playlists_view.into());
|
st.set_playlists_view_mode(p.playlists_view.into());
|
||||||
st.set_artists_group_enabled(p.artists_group);
|
st.set_artists_group_enabled(p.artists_group);
|
||||||
st.set_artists_view_mode(p.artists_view.into());
|
st.set_artists_view_mode(p.artists_view.into());
|
||||||
|
window
|
||||||
|
.global::<LibraryAllState>()
|
||||||
|
.set_show_local(p.all_show_local);
|
||||||
|
window
|
||||||
|
.global::<LibraryAllState>()
|
||||||
|
.set_local_scope(p.all_local_scope.clone().into());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the current toolbar choices read from `FavoritesState`.
|
/// Persist the current toolbar choices read from `FavoritesState`. Preserves
|
||||||
|
/// the Library-All local SCOPE (read-modify-write — it lives only here, not
|
||||||
|
/// in any Slint global).
|
||||||
pub fn save(window: &AppWindow) {
|
pub fn save(window: &AppWindow) {
|
||||||
let Some(path) = store_path() else {
|
let Some(path) = store_path() else {
|
||||||
return;
|
return;
|
||||||
@@ -93,6 +120,28 @@ pub fn save(window: &AppWindow) {
|
|||||||
playlists_view: st.get_playlists_view_mode().into(),
|
playlists_view: st.get_playlists_view_mode().into(),
|
||||||
artists_group: st.get_artists_group_enabled(),
|
artists_group: st.get_artists_group_enabled(),
|
||||||
artists_view: st.get_artists_view_mode().into(),
|
artists_view: st.get_artists_view_mode().into(),
|
||||||
|
all_show_local: window.global::<LibraryAllState>().get_show_local(),
|
||||||
|
all_local_scope: read().all_local_scope,
|
||||||
|
};
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_vec_pretty(&p) {
|
||||||
|
let _ = std::fs::write(&path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted Library-All local scope: "favorites" (default) | "all".
|
||||||
|
pub fn local_scope() -> String {
|
||||||
|
read().all_local_scope
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a new Library-All local scope (read-modify-write).
|
||||||
|
pub fn set_local_scope(scope: &str) {
|
||||||
|
let mut p = read();
|
||||||
|
p.all_local_scope = scope.to_string();
|
||||||
|
let Some(path) = store_path() else {
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Native gamepad input — Steam Deck plan PR 3
|
||||||
|
//! (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5).
|
||||||
|
//!
|
||||||
|
//! Steam Input's default template for non-Steam shortcuts exposes a VIRTUAL
|
||||||
|
//! Xbox 360 pad (uinput), not keyboard events — and QBZ reads neither by
|
||||||
|
//! default. `gilrs` (pure Rust, evdev) reads that pad and translates
|
||||||
|
//! D-pad / left stick / A / B into synthetic Slint key events through the
|
||||||
|
//! same `dispatch_event` pipeline as the winit synthetic-key hook
|
||||||
|
//! (main.rs install_browser_mouse_nav). PR 2's kiosk FocusScope consumes
|
||||||
|
//! them as ordinary arrow/Enter/Escape presses; any USB/BT pad on a HTPC
|
||||||
|
//! gets the same ride.
|
||||||
|
//!
|
||||||
|
//! The thread is spawned only when the kiosk profile is active or
|
||||||
|
//! `QBZ_GAMEPAD=1` forces it (see the call site in main.rs).
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use gilrs::{Axis, Button, EventType, Gilrs};
|
||||||
|
use slint::platform::{Key, WindowEvent};
|
||||||
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
|
use crate::AppWindow;
|
||||||
|
|
||||||
|
/// Axis→direction hysteresis: engage when |axis| crosses the high threshold,
|
||||||
|
/// release below the low one (classic 10-foot anti-jitter; stick drift near
|
||||||
|
/// center must cause zero ghost navigation).
|
||||||
|
const AXIS_ENGAGE: f32 = 0.6;
|
||||||
|
const AXIS_RELEASE: f32 = 0.4;
|
||||||
|
/// Key repeat while a direction is held: initial delay, then interval.
|
||||||
|
const REPEAT_DELAY: Duration = Duration::from_millis(250);
|
||||||
|
const REPEAT_INTERVAL: Duration = Duration::from_millis(80);
|
||||||
|
/// Double-input guard for a user-selected keyboard-EMULATING Steam layout:
|
||||||
|
/// the same nav action can then arrive from Steam's kbd hook AND from gilrs —
|
||||||
|
/// collapse repeats of the same key inside this window.
|
||||||
|
const DEBOUNCE: Duration = Duration::from_millis(30);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum Dir {
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dir {
|
||||||
|
fn key(self) -> Key {
|
||||||
|
match self {
|
||||||
|
Dir::Up => Key::UpArrow,
|
||||||
|
Dir::Down => Key::DownArrow,
|
||||||
|
Dir::Left => Key::LeftArrow,
|
||||||
|
Dir::Right => Key::RightArrow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the gamepad listener thread (no-op log on failure — gamepad input
|
||||||
|
/// is a convenience, never fatal).
|
||||||
|
pub fn spawn(weak: slint::Weak<AppWindow>) {
|
||||||
|
if let Err(e) = std::thread::Builder::new()
|
||||||
|
.name("qbz-gamepad".into())
|
||||||
|
.spawn(move || run(weak))
|
||||||
|
{
|
||||||
|
log::warn!("[gamepad] could not spawn thread: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Press+release `key` as one synthetic tap on the UI thread, debounced.
|
||||||
|
/// A Return/Escape tap is a single action; arrows are also emitted by the
|
||||||
|
/// repeat engine. While a text field is focused, Escape is SUPPRESSED (B
|
||||||
|
/// on the pad must close Steam's OSK without also navigating back — plan
|
||||||
|
/// §4.5) and Return passes through (the OSK's own accept).
|
||||||
|
fn emit_tap(weak: &slint::Weak<AppWindow>, key: Key, last: &mut Option<(Key, Instant)>) {
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Some((prev, at)) = last {
|
||||||
|
if *prev == key && now.duration_since(*at) < DEBOUNCE {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*last = Some((key, now));
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
if key == Key::Escape
|
||||||
|
&& w.global::<crate::UiFocusState>().get_text_input_focused()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text: slint::SharedString = key.into();
|
||||||
|
w.window().dispatch_event(WindowEvent::KeyPressed { text: text.clone() });
|
||||||
|
w.window().dispatch_event(WindowEvent::KeyReleased { text });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(weak: slint::Weak<AppWindow>) {
|
||||||
|
let mut gilrs = match Gilrs::new() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[gamepad] gilrs init failed (no evdev access?): {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pads = gilrs.gamepads().count();
|
||||||
|
log::info!("[gamepad] listening ({pads} pad(s) connected)");
|
||||||
|
|
||||||
|
// Held direction for repeat: (dir, engaged_at, last_emit). One at a
|
||||||
|
// time — the newest direction wins, like any 10-foot UI.
|
||||||
|
let mut held: Option<(Dir, Instant, Instant)> = None;
|
||||||
|
let mut last_emit: Option<(Key, Instant)> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Block until the next pad event, waking at most by the repeat tick
|
||||||
|
// so a held direction keeps firing with no pad traffic.
|
||||||
|
let wake = held.map(|(_, _, last_emit_at)| {
|
||||||
|
REPEAT_INTERVAL.saturating_sub(last_emit_at.elapsed())
|
||||||
|
});
|
||||||
|
let event = match wake {
|
||||||
|
Some(t) => gilrs.next_event_blocking(Some(t)),
|
||||||
|
None => gilrs.next_event_blocking(None),
|
||||||
|
};
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
match event.map(|e| e.event) {
|
||||||
|
Some(EventType::ButtonPressed(Button::South, _)) => {
|
||||||
|
emit_tap(&weak, Key::Return, &mut last_emit);
|
||||||
|
}
|
||||||
|
Some(EventType::ButtonPressed(Button::East, _)) => {
|
||||||
|
emit_tap(&weak, Key::Escape, &mut last_emit);
|
||||||
|
}
|
||||||
|
Some(EventType::ButtonPressed(btn, _)) => {
|
||||||
|
if let Some(dir) = dpad_dir(btn) {
|
||||||
|
emit_tap(&weak, dir.key(), &mut last_emit);
|
||||||
|
held = Some((dir, now, now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(EventType::ButtonReleased(btn, _)) => {
|
||||||
|
if let Some(dir) = dpad_dir(btn) {
|
||||||
|
if held.map(|(d, _, _)| d) == Some(dir) {
|
||||||
|
held = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(EventType::AxisChanged(axis, value, _)) => {
|
||||||
|
let (neg, pos) = match axis {
|
||||||
|
Axis::LeftStickX => (Dir::Left, Dir::Right),
|
||||||
|
Axis::LeftStickY => (Dir::Up, Dir::Down),
|
||||||
|
_ => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if value.abs() >= AXIS_ENGAGE {
|
||||||
|
let dir = if value < 0.0 { neg } else { pos };
|
||||||
|
if held.map(|(d, _, _)| d) != Some(dir) {
|
||||||
|
emit_tap(&weak, dir.key(), &mut last_emit);
|
||||||
|
held = Some((dir, now, now));
|
||||||
|
}
|
||||||
|
} else if value.abs() < AXIS_RELEASE {
|
||||||
|
// Only disengage when the held direction lives on THIS axis.
|
||||||
|
if let Some((d, _, _)) = held {
|
||||||
|
if d == neg || d == pos {
|
||||||
|
held = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
// Timeout wake (or a spurious None): fire the repeat engine.
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repeat: a held direction re-emits after the initial delay, then on
|
||||||
|
// every interval.
|
||||||
|
if let Some((dir, engaged_at, last_emit_at)) = held.as_mut() {
|
||||||
|
if engaged_at.elapsed() >= REPEAT_DELAY
|
||||||
|
&& last_emit_at.elapsed() >= REPEAT_INTERVAL
|
||||||
|
{
|
||||||
|
let key = dir.key();
|
||||||
|
emit_tap(&weak, key, &mut last_emit);
|
||||||
|
*last_emit_at = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dpad_dir(btn: Button) -> Option<Dir> {
|
||||||
|
match btn {
|
||||||
|
Button::DPadUp => Some(Dir::Up),
|
||||||
|
Button::DPadDown => Some(Dir::Down),
|
||||||
|
Button::DPadLeft => Some(Dir::Left),
|
||||||
|
Button::DPadRight => Some(Dir::Right),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -545,6 +545,11 @@ fn toggle_immersive(window: &AppWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn toggle_miniplayer() {
|
fn toggle_miniplayer() {
|
||||||
|
// Suppressed under gamescope (Steam Deck Game Mode PR 1): an extra
|
||||||
|
// mapped window can steal the compositor's focused-window pick.
|
||||||
|
if crate::in_gamescope() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if crate::miniplayer::is_open() {
|
if crate::miniplayer::is_open() {
|
||||||
crate::miniplayer::exit();
|
crate::miniplayer::exit();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@
|
|||||||
//! into `LibraryAllState`. Search / sort / source-switch filtering all run in Rust
|
//! into `LibraryAllState`. Search / sort / source-switch filtering all run in Rust
|
||||||
//! (`derive`) — Slint renders the pre-computed `items-visible`.
|
//! (`derive`) — Slint renders the pre-computed `items-visible`.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use qbz_app::shell::AppRuntime;
|
use qbz_app::shell::AppRuntime;
|
||||||
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||||
|
|
||||||
use crate::adapter::SlintAdapter;
|
use crate::adapter::SlintAdapter;
|
||||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
|
||||||
use crate::favorites::{self, FavData, FavTab};
|
use crate::favorites::{self, FavData, FavTab};
|
||||||
use crate::{AppWindow, LibraryAllState, LibraryFeedItem};
|
use crate::{AppWindow, LibraryAllState, LibraryFeedItem};
|
||||||
|
|
||||||
@@ -295,9 +296,16 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Local + Plex favorites (source "local"/"plex"; gated by show-local
|
// --- Local + Plex (source "local"/"plex"; gated by show-local in
|
||||||
// in derive). group "local" — bypasses the Qobuz source switches. ---
|
// derive). group "local" — bypasses the Qobuz source switches. The
|
||||||
{
|
// Settings scope picks the CONTENT: "favorites" (hearted items only,
|
||||||
|
// webplayer parity) or "all" (the entire local library). ---
|
||||||
|
if crate::favorites_prefs::local_scope() == "all" {
|
||||||
|
match tokio::task::spawn_blocking(all_local_blocking).await {
|
||||||
|
Ok(items) => feed.extend(items),
|
||||||
|
Err(e) => log::error!("[qbz-slint] all-local feed load failed: {e}"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let locals = crate::local_favorites::list();
|
let locals = crate::local_favorites::list();
|
||||||
let n = locals.len();
|
let n = locals.len();
|
||||||
for (i, lf) in locals.into_iter().enumerate() {
|
for (i, lf) in locals.into_iter().enumerate() {
|
||||||
@@ -331,6 +339,184 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
|
|||||||
Ok(feed)
|
Ok(feed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The "all" local scope (Settings > Local Library): every local-library
|
||||||
|
/// album + artist + track, mirroring the Local Library tabs' own queries so
|
||||||
|
/// both surfaces agree (same Plex union, same network-folder gate, same
|
||||||
|
/// album-identity mode, offline-cache copies excluded — they must never
|
||||||
|
/// duplicate Qobuz rows in this feed). Runs on a blocking thread — rusqlite
|
||||||
|
/// is sync. Off-thread companion of the local arm of `load_library_all`.
|
||||||
|
fn all_local_blocking() -> Vec<Feed> {
|
||||||
|
let mut out: Vec<Feed> = Vec::new();
|
||||||
|
let exclude_network = crate::local_library::exclude_network_folders_now();
|
||||||
|
let group_mode = qbz_library::album_grouping::AlbumGroupMode::from_pref(
|
||||||
|
&crate::locallibrary_prefs::albums_id_mode(),
|
||||||
|
);
|
||||||
|
let plex_path = crate::local_library::plex_cache_db_path();
|
||||||
|
let plex_enabled = crate::plex_settings::get().enabled;
|
||||||
|
|
||||||
|
// Plex tracks ride TWO sections (artist names + the track list) — fetch
|
||||||
|
// the bounded set once (mirrors the Tracks tab's page-1 merge).
|
||||||
|
let plex_tracks: Vec<qbz_library::LocalTrack> = if plex_enabled {
|
||||||
|
qbz_plex::plex_cache_search_tracks(String::new(), None)
|
||||||
|
.map(|rows| {
|
||||||
|
rows.into_iter()
|
||||||
|
.map(crate::local_library::map_plex_cached_to_local_track)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Albums — the Albums tab's own full-load page (Plex union folded in
|
||||||
|
// by the query when enabled). ---
|
||||||
|
let albums = crate::library_db::with_db(|db| {
|
||||||
|
db.get_albums_metadata_page(
|
||||||
|
0,
|
||||||
|
crate::local_library::ALBUMS_FULL_LOAD_LIMIT,
|
||||||
|
None,
|
||||||
|
"artist",
|
||||||
|
"asc",
|
||||||
|
true,
|
||||||
|
exclude_network,
|
||||||
|
plex_path.as_deref(),
|
||||||
|
group_mode,
|
||||||
|
)
|
||||||
|
.map(|p| p.albums)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let n = albums.len();
|
||||||
|
for (i, a) in albums.into_iter().enumerate() {
|
||||||
|
let source = if a.id.starts_with("plex:") {
|
||||||
|
"plex"
|
||||||
|
} else {
|
||||||
|
"local"
|
||||||
|
};
|
||||||
|
let (tier, detail, _) =
|
||||||
|
crate::quality::badge(&a.format.to_string(), a.bit_depth, Some(a.sample_rate));
|
||||||
|
out.push(Feed {
|
||||||
|
kind: "album".into(),
|
||||||
|
group: "local".into(),
|
||||||
|
source: source.into(),
|
||||||
|
subtitle: a.artist.clone(),
|
||||||
|
artist: a.artist,
|
||||||
|
artist_id: String::new(),
|
||||||
|
album: String::new(),
|
||||||
|
album_id: String::new(),
|
||||||
|
image_url: a.artwork_path.unwrap_or_default(),
|
||||||
|
quality_tier: tier.into(),
|
||||||
|
quality_detail: detail.into(),
|
||||||
|
is_favorite: crate::local_favorites::is_favorite("album", &a.id),
|
||||||
|
genre: String::new(),
|
||||||
|
added_rank: rank(i, n),
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Artists — local DB names + names that only exist on Plex tracks
|
||||||
|
// (a local + Plex artist of the same name counts once). ---
|
||||||
|
let local_artist_names: Vec<String> = crate::library_db::with_db(|db| db.get_artists())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| a.name)
|
||||||
|
.filter(|n| !n.trim().is_empty())
|
||||||
|
.collect();
|
||||||
|
let local_keys: std::collections::HashSet<String> = local_artist_names
|
||||||
|
.iter()
|
||||||
|
.map(|n| n.trim().to_lowercase())
|
||||||
|
.collect();
|
||||||
|
let mut artist_names = local_artist_names;
|
||||||
|
{
|
||||||
|
let mut seen = local_keys.clone();
|
||||||
|
for t in &plex_tracks {
|
||||||
|
let name = t.artist.trim();
|
||||||
|
if !name.is_empty() && seen.insert(name.to_lowercase()) {
|
||||||
|
artist_names.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artist_names.sort_by_key(|n| n.to_lowercase());
|
||||||
|
let n = artist_names.len();
|
||||||
|
for (i, name) in artist_names.into_iter().enumerate() {
|
||||||
|
let source = if local_keys.contains(&name.trim().to_lowercase()) {
|
||||||
|
"local"
|
||||||
|
} else {
|
||||||
|
"plex"
|
||||||
|
};
|
||||||
|
out.push(Feed {
|
||||||
|
kind: "artist".into(),
|
||||||
|
group: "local".into(),
|
||||||
|
source: source.into(),
|
||||||
|
subtitle: String::new(),
|
||||||
|
artist: String::new(),
|
||||||
|
artist_id: String::new(),
|
||||||
|
album: String::new(),
|
||||||
|
album_id: String::new(),
|
||||||
|
image_url: String::new(),
|
||||||
|
quality_tier: String::new(),
|
||||||
|
quality_detail: String::new(),
|
||||||
|
is_favorite: crate::local_favorites::is_favorite("artist", &name),
|
||||||
|
genre: String::new(),
|
||||||
|
added_rank: rank(i, n),
|
||||||
|
id: name.clone(),
|
||||||
|
title: name,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tracks — local pages (offline-cache copies excluded in SQL) + the
|
||||||
|
// Plex set fetched above. ---
|
||||||
|
let mut tracks: Vec<qbz_library::LocalTrack> = Vec::new();
|
||||||
|
const PAGE: u64 = 200;
|
||||||
|
let mut offset = 0u64;
|
||||||
|
loop {
|
||||||
|
let rows = crate::library_db::with_db(|db| {
|
||||||
|
db.search_with_filter_page("", offset, PAGE, false, exclude_network, "default")
|
||||||
|
});
|
||||||
|
let Some(rows) = rows else { break };
|
||||||
|
let full = rows.len() as u64 == PAGE;
|
||||||
|
tracks.extend(rows);
|
||||||
|
if !full || tracks.len() >= 200_000 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += PAGE;
|
||||||
|
}
|
||||||
|
tracks.extend(plex_tracks);
|
||||||
|
let n = tracks.len();
|
||||||
|
for (i, t) in tracks.into_iter().enumerate() {
|
||||||
|
let (id, source) = match t.source.as_deref() {
|
||||||
|
Some("plex") => (format!("plex:{}", t.file_path), "plex"),
|
||||||
|
Some("qobuz_download") => continue, // belt: the SQL already excludes
|
||||||
|
_ => (t.file_path.clone(), "local"),
|
||||||
|
};
|
||||||
|
let (tier, detail, _) =
|
||||||
|
crate::quality::badge(&t.format.to_string(), t.bit_depth, Some(t.sample_rate));
|
||||||
|
out.push(Feed {
|
||||||
|
kind: "track".into(),
|
||||||
|
group: "local".into(),
|
||||||
|
source: source.into(),
|
||||||
|
subtitle: t.artist.clone(),
|
||||||
|
artist: t.artist,
|
||||||
|
artist_id: String::new(),
|
||||||
|
album: t.album,
|
||||||
|
album_id: t.album_group_key,
|
||||||
|
image_url: t.artwork_path.unwrap_or_default(),
|
||||||
|
quality_tier: tier.into(),
|
||||||
|
quality_detail: detail.into(),
|
||||||
|
is_favorite: crate::local_favorites::is_favorite("track", &id),
|
||||||
|
genre: t.genre.unwrap_or_default(),
|
||||||
|
added_rank: rank(i, n),
|
||||||
|
id,
|
||||||
|
title: t.title,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn to_item(f: &Feed) -> LibraryFeedItem {
|
fn to_item(f: &Feed) -> LibraryFeedItem {
|
||||||
LibraryFeedItem {
|
LibraryFeedItem {
|
||||||
kind: f.kind.clone().into(),
|
kind: f.kind.clone().into(),
|
||||||
@@ -477,24 +663,286 @@ pub fn derive(window: &AppWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
st.set_items_visible(ModelRc::new(VecModel::from(out)));
|
st.set_items_visible(ModelRc::new(VecModel::from(out)));
|
||||||
|
|
||||||
|
// The grid/list only fire window-changed when the BAND changes, not when
|
||||||
|
// the rows under it change — re-dispatch covers for the current band over
|
||||||
|
// the fresh visible set (no-op where covers already ride the full model).
|
||||||
|
dispatch_library_all_window(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build cover-download jobs for the CURRENT visible feed. Call after apply and
|
// ---- Windowed artwork (mirrors favorites' albums grid, Phase 1 pattern) ---
|
||||||
/// after every derive (the ImageCache dedups already-decoded covers, so
|
|
||||||
/// re-dispatching on filter/sort is cheap). Indices target `items-visible`.
|
/// Generation guard, bumped on every Library-All (re)load. A stale in-flight
|
||||||
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
|
/// cover fetch (an older load's job) is discarded on apply so it can't land
|
||||||
let visible = window.global::<LibraryAllState>().get_items_visible();
|
/// on the replacement set.
|
||||||
|
static LIB_ALL_GEN: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// True if `gen` is still the current Library-All generation. The artwork
|
||||||
|
/// pipeline calls this before applying a decoded cover so an in-flight job
|
||||||
|
/// from a superseded load doesn't paint the new model.
|
||||||
|
pub fn library_all_gen_current(gen: u64) -> bool {
|
||||||
|
LIB_ALL_GEN.load(Ordering::SeqCst) == gen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last row band reported by the windowed grid/list (item indices into
|
||||||
|
/// `items-visible`, prefetch margin already included by the view).
|
||||||
|
static LIB_ALL_WINDOW: std::sync::Mutex<(usize, usize)> = std::sync::Mutex::new((0, 59));
|
||||||
|
|
||||||
|
/// Cover keys currently in the artwork pipeline (dedupe during fast scroll).
|
||||||
|
/// Freed on apply; cleared on reloads.
|
||||||
|
fn lib_all_inflight() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||||
|
static S: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
S.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image-cache handle captured at load time so the window dispatcher can
|
||||||
|
/// spawn artwork jobs outside the load path. Plex params are re-read at
|
||||||
|
/// dispatch time (`plex_settings::get`), so only the cache rides along here.
|
||||||
|
fn lib_all_dispatch_ctx() -> &'static std::sync::Mutex<Option<ImageCache>> {
|
||||||
|
static S: std::sync::OnceLock<std::sync::Mutex<Option<ImageCache>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
S.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A windowed artwork job finished (applied or dropped) — free its slot so
|
||||||
|
/// the dispatcher can request the key again after an eviction.
|
||||||
|
pub fn artwork_job_done(key: &str) {
|
||||||
|
lib_all_inflight().lock().unwrap().remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the windowed artwork pipeline for a fresh Library-All load: bump the
|
||||||
|
/// generation (orphans every in-flight job — dropped on apply), free their
|
||||||
|
/// dedupe slots and stash the image-cache handle the dispatchers spawn jobs
|
||||||
|
/// with. Runs on the UI thread BEFORE `apply_library_all`.
|
||||||
|
pub fn begin_library_all_artwork(image_cache: ImageCache) {
|
||||||
|
LIB_ALL_GEN.fetch_add(1, Ordering::SeqCst);
|
||||||
|
lib_all_inflight().lock().unwrap().clear();
|
||||||
|
*lib_all_dispatch_ctx().lock().unwrap() = Some(image_cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch throttle for the view's band reports (leading + trailing edge,
|
||||||
|
/// UI thread) — same rationale as the favorites albums grid: coalesce fling
|
||||||
|
/// crossings to one dispatch per interval.
|
||||||
|
const LIB_ALL_DISPATCH_THROTTLE_MS: u64 = 180;
|
||||||
|
thread_local! {
|
||||||
|
static LIB_ALL_BAND: crate::viewport::BandDispatcher =
|
||||||
|
crate::viewport::BandDispatcher::new(LIB_ALL_DISPATCH_THROTTLE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The windowed grid/list reported a new visible row band. The band is stored
|
||||||
|
/// immediately (model rebuilds re-read it); the artwork dispatch is throttled
|
||||||
|
/// through the BandDispatcher.
|
||||||
|
pub fn window_changed(window: &AppWindow, first: i32, last: i32) {
|
||||||
|
let first = first.max(0) as usize;
|
||||||
|
let last = last.max(first as i32) as usize;
|
||||||
|
*LIB_ALL_WINDOW.lock().unwrap() = (first, last);
|
||||||
|
let gen = LIB_ALL_GEN.load(Ordering::SeqCst);
|
||||||
|
let weak = window.as_weak();
|
||||||
|
LIB_ALL_BAND.with(|d| {
|
||||||
|
d.report(Box::new(move || {
|
||||||
|
if !library_all_gen_current(gen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
dispatch_library_all_window(&w);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch covers for the current window (over `items-visible`) and evict
|
||||||
|
/// decoded covers far outside it back to the placeholder, so cover RAM scales
|
||||||
|
/// with the viewport instead of the library. Delivery is key-keyed
|
||||||
|
/// (`LibraryAllById`), so a derive re-sort between dispatch and apply cannot
|
||||||
|
/// land a cover on the wrong card; a reload bumps `LIB_ALL_GEN` and the apply
|
||||||
|
/// arm drops the stale image.
|
||||||
|
pub fn dispatch_library_all_window(window: &AppWindow) {
|
||||||
|
let (first, last) = *LIB_ALL_WINDOW.lock().unwrap();
|
||||||
|
let Some(image_cache) = lib_all_dispatch_ctx().lock().unwrap().clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let gen = LIB_ALL_GEN.load(Ordering::SeqCst);
|
||||||
|
let state = window.global::<LibraryAllState>();
|
||||||
|
let visible = state.get_items_visible();
|
||||||
|
let len = visible.row_count();
|
||||||
|
if len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let last = last.min(len - 1);
|
||||||
|
if first > last {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Retention = the window plus one window-span on each side. Beyond it,
|
||||||
|
// covers return to the placeholder; re-entry is cheap (byte-budgeted
|
||||||
|
// decoded cache, else a bounded re-decode through the disk cache).
|
||||||
|
let span = last - first + 1;
|
||||||
|
let keep_lo = first.saturating_sub(span);
|
||||||
|
let keep_hi = (last + span).min(len - 1);
|
||||||
let mut jobs = Vec::new();
|
let mut jobs = Vec::new();
|
||||||
for i in 0..visible.row_count() {
|
{
|
||||||
if let Some(item) = visible.row_data(i) {
|
let mut inflight = lib_all_inflight().lock().unwrap();
|
||||||
let url = item.image_url.to_string();
|
for vi in first..=last {
|
||||||
if !url.is_empty() {
|
let Some(item) = visible.row_data(vi) else { continue };
|
||||||
|
if item.image.size().width > 0 || item.image_url.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = feed_key(item.kind.as_str(), item.id.as_str());
|
||||||
|
if inflight.insert(key.clone()) {
|
||||||
jobs.push(ArtworkJob {
|
jobs.push(ArtworkJob {
|
||||||
target: ArtworkTarget::LibraryAllCover { index: i },
|
target: ArtworkTarget::LibraryAllById { key, gen },
|
||||||
url,
|
url: item.image_url.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jobs
|
for vi in (0..keep_lo).chain(keep_hi + 1..len) {
|
||||||
|
let Some(item) = visible.row_data(vi) else { continue };
|
||||||
|
if item.image.size().width > 0 {
|
||||||
|
set_library_all_artwork(
|
||||||
|
window,
|
||||||
|
&feed_key(item.kind.as_str(), item.id.as_str()),
|
||||||
|
slint::Image::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !jobs.is_empty() {
|
||||||
|
// Mixed payload (Qobuz http / local fs / Plex /library/) — route each
|
||||||
|
// cover by scheme so local/Plex covers decode.
|
||||||
|
let plex = crate::plex_settings::get();
|
||||||
|
crate::artwork::spawn_search_loads(
|
||||||
|
jobs,
|
||||||
|
plex.base_url,
|
||||||
|
plex.token,
|
||||||
|
window.as_weak(),
|
||||||
|
image_cache,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable artwork key for a feed row. Qobuz numeric ids overlap across entity
|
||||||
|
/// types (a track id can equal an artist id), so the kind prefixes the key.
|
||||||
|
fn feed_key(kind: &str, id: &str) -> String {
|
||||||
|
format!("{kind}:{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a freshly-decoded (or evicted) cover BY KEY on the full `items` model
|
||||||
|
/// AND the rendered `items-visible`. Writing the full model too is what keeps
|
||||||
|
/// covers across derives: `derive` clones rows out of `items`, so a cover
|
||||||
|
/// that only ever landed on the visible copy was blanked by every search
|
||||||
|
/// keystroke (and re-fought by the whole dispatch pipeline each time).
|
||||||
|
pub fn set_library_all_artwork(window: &AppWindow, key: &str, image: slint::Image) {
|
||||||
|
let st = window.global::<LibraryAllState>();
|
||||||
|
for model in [st.get_items(), st.get_items_visible()] {
|
||||||
|
for i in 0..model.row_count() {
|
||||||
|
let Some(mut item) = model.row_data(i) else { continue };
|
||||||
|
if feed_key(item.kind.as_str(), item.id.as_str()) == key {
|
||||||
|
item.image = image.clone();
|
||||||
|
model.set_row_data(i, item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flip the favorite/follow flag BY KEY on the full `items` model AND the
|
||||||
|
/// rendered `items-visible` — the Library-All leg of the app-wide optimistic
|
||||||
|
/// favorite flips (`set_row_favorite` / `set_album_row_favorite` /
|
||||||
|
/// `search::mark_artist_followed` don't know this surface). Called for both
|
||||||
|
/// the optimistic flip and the failure rollback.
|
||||||
|
pub fn set_feed_favorite(window: &AppWindow, kind: &str, id: &str, value: bool) {
|
||||||
|
let key = feed_key(kind, id);
|
||||||
|
let st = window.global::<LibraryAllState>();
|
||||||
|
for model in [st.get_items(), st.get_items_visible()] {
|
||||||
|
for i in 0..model.row_count() {
|
||||||
|
let Some(mut item) = model.row_data(i) else { continue };
|
||||||
|
if feed_key(item.kind.as_str(), item.id.as_str()) == key {
|
||||||
|
if item.is_favorite != value {
|
||||||
|
item.is_favorite = value;
|
||||||
|
model.set_row_data(i, item);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when a feed id belongs to the LOCAL/Plex world, not Qobuz. Local
|
||||||
|
/// tracks are file paths (`/music/x.flac` or `plex:<path>` — Qobuz track ids
|
||||||
|
/// are numeric), local albums are group keys (`plex:…` or containing `|`/`/`),
|
||||||
|
/// local artists are plain names (Qobuz artist ids are numeric).
|
||||||
|
fn is_local_feed_id(kind: &str, id: &str) -> bool {
|
||||||
|
match kind {
|
||||||
|
"track" | "artist" => id.parse::<u64>().is_err(),
|
||||||
|
"album" => id.starts_with("plex:") || id.contains('|') || id.contains('/'),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a LOCAL/Plex feed row's heart against the local-favorites store.
|
||||||
|
/// The All-feed media-action arms receive only `(kind, id, action)` and would
|
||||||
|
/// otherwise fire the Qobuz favorite API at a file path / group key / artist
|
||||||
|
/// name — always an error toast (owner report 2026-07-24). Returns
|
||||||
|
/// `Some(new_state)` when the id was local and the toggle was handled here
|
||||||
|
/// (the caller must NOT continue into its Qobuz path); `None` for Qobuz ids.
|
||||||
|
///
|
||||||
|
/// Every local row in the feed is favorited by construction (the store feeds
|
||||||
|
/// the feed), so the common click is an UN-favorite; the re-favorite path
|
||||||
|
/// rebuilds the store snapshot from the feed row itself (title / subtitle /
|
||||||
|
/// artwork / source all ride the model). Works offline — the store is local.
|
||||||
|
pub fn toggle_local_feed_favorite(window: &AppWindow, kind: &str, id: &str) -> Option<bool> {
|
||||||
|
if !is_local_feed_id(kind, id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let new_state = if crate::local_favorites::is_favorite(kind, id) {
|
||||||
|
if let Err(e) = crate::local_favorites::unfavorite(kind, id) {
|
||||||
|
log::error!("[qbz-slint] local unfavorite ({kind}:{id}) failed: {e}");
|
||||||
|
return Some(true); // state unchanged — keep the heart filled
|
||||||
|
}
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
// Re-favorite: rebuild the snapshot from the feed row.
|
||||||
|
let st = window.global::<LibraryAllState>();
|
||||||
|
let items = st.get_items();
|
||||||
|
let mut snap = None;
|
||||||
|
for i in 0..items.row_count() {
|
||||||
|
let Some(item) = items.row_data(i) else { continue };
|
||||||
|
if feed_key(item.kind.as_str(), item.id.as_str()) == feed_key(kind, id) {
|
||||||
|
snap = Some(crate::local_favorites::LocalFavItem {
|
||||||
|
kind: kind.to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
title: item.title.to_string(),
|
||||||
|
subtitle: item.subtitle.to_string(),
|
||||||
|
artwork_url: item.image_url.to_string(),
|
||||||
|
artist: item.artist.to_string(),
|
||||||
|
source: item.source.to_string(),
|
||||||
|
favorited_at: 0, // the service stamps `now` itself
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match snap {
|
||||||
|
Some(s) if s.source == "local" || s.source == "plex" => {
|
||||||
|
if let Err(e) = crate::local_favorites::favorite(&s) {
|
||||||
|
log::error!("[qbz-slint] local favorite ({kind}:{id}) failed: {e}");
|
||||||
|
return Some(false); // state unchanged — keep the heart hollow
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Not in the feed (or a malformed source) — nothing to snapshot;
|
||||||
|
// treat as handled-but-unchanged rather than falling into Qobuz.
|
||||||
|
_ => return Some(false),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set_feed_favorite(window, kind, id, new_state);
|
||||||
|
// Same toast wording as the Qobuz album/track favorite arms.
|
||||||
|
crate::toast::success(
|
||||||
|
window,
|
||||||
|
if new_state {
|
||||||
|
"Added to favorites"
|
||||||
|
} else {
|
||||||
|
"Removed from favorites"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Some(new_state)
|
||||||
}
|
}
|
||||||
@@ -677,7 +677,7 @@ pub fn clear_album_filter(window: &AppWindow) {
|
|||||||
/// disabled OR the data dir is unavailable, so the Albums union degrades to
|
/// disabled OR the data dir is unavailable, so the Albums union degrades to
|
||||||
/// local-only (identical behaviour to before Plex existed). Mirrors the Tauri
|
/// local-only (identical behaviour to before Plex existed). Mirrors the Tauri
|
||||||
/// command's resolution (`commands_v2/library.rs`).
|
/// command's resolution (`commands_v2/library.rs`).
|
||||||
fn plex_cache_db_path() -> Option<std::path::PathBuf> {
|
pub(crate) fn plex_cache_db_path() -> Option<std::path::PathBuf> {
|
||||||
if !crate::plex_settings::get().enabled {
|
if !crate::plex_settings::get().enabled {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -737,7 +737,7 @@ pub fn invalidate_artists(window: &AppWindow) {
|
|||||||
/// one shot (search/sort/filter/group are all derived client-side over the
|
/// one shot (search/sort/filter/group are all derived client-side over the
|
||||||
/// cached set in `derive_albums`), so we request a single large page rather
|
/// cached set in `derive_albums`), so we request a single large page rather
|
||||||
/// than truly paginating. `total` from the page is informational here.
|
/// than truly paginating. `total` from the page is informational here.
|
||||||
const ALBUMS_FULL_LOAD_LIMIT: u64 = 1_000_000;
|
pub(crate) const ALBUMS_FULL_LOAD_LIMIT: u64 = 1_000_000;
|
||||||
|
|
||||||
/// The user's album-identity mode (Albums tab dropdown / Settings, persisted
|
/// The user's album-identity mode (Albums tab dropdown / Settings, persisted
|
||||||
/// in locallibrary_ui.json). Read on the UI thread and captured into the
|
/// in locallibrary_ui.json). Read on the UI thread and captured into the
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ use slint::{ComponentHandle, Model};
|
|||||||
|
|
||||||
use crate::adapter::SlintAdapter;
|
use crate::adapter::SlintAdapter;
|
||||||
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
|
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
|
||||||
use crate::playback::{after_track_change, refresh_sidebar};
|
use crate::playback::{
|
||||||
|
after_track_change, batch_all_qconnect_castable, refresh_sidebar, sync_qconnect_after_add,
|
||||||
|
};
|
||||||
use crate::{AppWindow, ContentView, NavState, PlaylistState, TrackItem};
|
use crate::{AppWindow, ContentView, NavState, PlaylistState, TrackItem};
|
||||||
|
|
||||||
type Runtime = Arc<AppRuntime<SlintAdapter>>;
|
type Runtime = Arc<AppRuntime<SlintAdapter>>;
|
||||||
@@ -1648,6 +1650,56 @@ pub fn enqueue_by_id(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play-later variant of `enqueue_by_id` (#442): the playlist's rows land at
|
||||||
|
/// the END of the manual block in NATURAL order (each `add_track_later`
|
||||||
|
/// appends — no reverse). Rides the playback.rs QConnect pattern (batch
|
||||||
|
/// routing to the peer's manual-block tail + post-add sync; local/plex rows
|
||||||
|
/// are refused by the router's all-or-nothing admission, exactly like the
|
||||||
|
/// other source-typed batch paths). The OFFLINE-ONLY stamp rule is unchanged
|
||||||
|
/// (D8: nothing from it ever reaches the cloud push).
|
||||||
|
pub fn enqueue_later_by_id(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
playlist_id: String,
|
||||||
|
) {
|
||||||
|
handle.spawn(async move {
|
||||||
|
let Some(data) = load(&runtime, &playlist_id).await else {
|
||||||
|
crate::toast::error_weak(&weak, qbz_i18n::t("Couldn't load this playlist"));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let tracks: Vec<QueueTrack> = data
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| row_queue_track(&r.item))
|
||||||
|
.collect();
|
||||||
|
if tracks.is_empty() {
|
||||||
|
crate::toast::error_weak(&weak, qbz_i18n::t("Nothing playable in this playlist right now"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// QConnect CONTROLLER mode: route the whole playlist to the peer's
|
||||||
|
// manual-block tail; false when no peer is active, so the local loop
|
||||||
|
// below runs unchanged.
|
||||||
|
if let Some(svc) = crate::qconnect_service::service() {
|
||||||
|
let routed: Vec<(u64, Option<String>)> =
|
||||||
|
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
|
||||||
|
if svc.play_later_batch_on_peer_if_active(&routed).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
|
for track in tracks {
|
||||||
|
runtime.core().add_track_later(track).await;
|
||||||
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
|
if data.offline_only {
|
||||||
|
runtime.core().set_queue_offline_only(true);
|
||||||
|
}
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────── reorder (B2) ────────────────────────
|
// ──────────────────────── reorder (B2) ────────────────────────
|
||||||
|
|
||||||
/// Move the clicked row one slot up/down within the open LOCAL playlist's
|
/// Move the clicked row one slot up/down within the open LOCAL playlist's
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ pub fn ephemeral_path() -> Option<String> {
|
|||||||
read().ephemeral_folder.filter(|s| !s.is_empty())
|
read().ephemeral_folder.filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The persisted album-identity mode ("folder" | "metadata") — the feed
|
||||||
|
/// loader reads it without a window handle (Settings/Library-All scope).
|
||||||
|
pub fn albums_id_mode() -> String {
|
||||||
|
read().albums_id_mode
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist (or clear) the ephemeral-folder path (read-modify-write).
|
/// Persist (or clear) the ephemeral-folder path (read-modify-write).
|
||||||
pub fn save_ephemeral_path(path: Option<&str>) {
|
pub fn save_ephemeral_path(path: Option<&str>) {
|
||||||
let mut p = read();
|
let mut p = read();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
//! My QBZ — Collection / Mixtape DETAIL **playback** (Phase-2 Slice 5).
|
//! My QBZ — Collection / Mixtape DETAIL **playback** (Phase-2 Slice 5).
|
||||||
//!
|
//!
|
||||||
//! Wires the detail view's hero Play / Shuffle CTAs, the per-row Play action,
|
//! Wires the detail view's hero Play / Shuffle CTAs, the per-row Play action,
|
||||||
//! and the per-row context menu (play / play-next / add-to-queue) to the
|
//! and the per-row context menu (play / play-next / play-later /
|
||||||
//! shared `qbz-mixtape` ENQUEUE resolver, then drives the already-shared
|
//! add-to-queue) to the shared `qbz-mixtape` ENQUEUE resolver, then drives
|
||||||
//! `qbz-core` queue + `qbz-app` `RuntimeManager` queue-source stamp.
|
//! the already-shared `qbz-core` queue + `qbz-app` `RuntimeManager`
|
||||||
|
//! queue-source stamp.
|
||||||
//!
|
//!
|
||||||
//! Behavior is 1:1 with Tauri's `v2_enqueue_collection` /
|
//! Behavior is 1:1 with Tauri's `v2_enqueue_collection` /
|
||||||
//! `v2_enqueue_collection_item` (spec 40 §5/§6, gotchas §9):
|
//! `v2_enqueue_collection_item` (spec 40 §5/§6, gotchas §9):
|
||||||
@@ -47,6 +48,9 @@ enum RowMode {
|
|||||||
Play,
|
Play,
|
||||||
/// Insert the item's resolved tracks immediately after the current track.
|
/// Insert the item's resolved tracks immediately after the current track.
|
||||||
PlayNext,
|
PlayNext,
|
||||||
|
/// Insert the item's resolved tracks at the END of the manual block
|
||||||
|
/// (#442 "Play later") — NATURAL order, each insert appends to the block.
|
||||||
|
PlayLater,
|
||||||
/// Append the item's resolved tracks at the end of the queue.
|
/// Append the item's resolved tracks at the end of the queue.
|
||||||
AddToQueue,
|
AddToQueue,
|
||||||
}
|
}
|
||||||
@@ -56,6 +60,7 @@ impl RowMode {
|
|||||||
match action {
|
match action {
|
||||||
"play" => Some(Self::Play),
|
"play" => Some(Self::Play),
|
||||||
"play-next" | "play_next" => Some(Self::PlayNext),
|
"play-next" | "play_next" => Some(Self::PlayNext),
|
||||||
|
"play-later" | "play_later" => Some(Self::PlayLater),
|
||||||
"add-to-queue" | "add_to_queue" | "append" => Some(Self::AddToQueue),
|
"add-to-queue" | "add_to_queue" | "append" => Some(Self::AddToQueue),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -381,7 +386,8 @@ pub fn play_item(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Per-row context-menu action (`on_item_action`): play / play-next /
|
/// Per-row context-menu action (`on_item_action`): play / play-next /
|
||||||
/// add-to-queue for the single item identified by `source_item_id`.
|
/// play-later / add-to-queue for the single item identified by
|
||||||
|
/// `source_item_id`.
|
||||||
pub fn item_action(
|
pub fn item_action(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
weak: slint::Weak<AppWindow>,
|
weak: slint::Weak<AppWindow>,
|
||||||
@@ -435,6 +441,16 @@ pub fn item_action(
|
|||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
|
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
|
||||||
}
|
}
|
||||||
|
RowMode::PlayLater => {
|
||||||
|
// #442: NATURAL order — each insert appends to the END of the
|
||||||
|
// manual block (after every play-next/play-later already
|
||||||
|
// queued, before the source resumes).
|
||||||
|
for track in tracks {
|
||||||
|
runtime.core().add_track_later(track).await;
|
||||||
|
}
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
|
||||||
|
}
|
||||||
RowMode::AddToQueue => {
|
RowMode::AddToQueue => {
|
||||||
runtime.core().add_tracks(tracks).await;
|
runtime.core().add_tracks(tracks).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
@@ -444,24 +460,70 @@ pub fn item_action(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bulk-bar queue placement (spec 12 §13.1 + #442 "Play later").
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum BulkEnqueueMode {
|
||||||
|
/// Append the batch at the end of the queue.
|
||||||
|
Append,
|
||||||
|
/// Insert the whole batch immediately after the current track, in REVERSE
|
||||||
|
/// so the first resolved track lands first (same rule as per-row PlayNext).
|
||||||
|
Next,
|
||||||
|
/// Insert the batch at the END of the manual block (#442 "Play later") —
|
||||||
|
/// NATURAL order, each `add_track_later` appends to the block.
|
||||||
|
Later,
|
||||||
|
}
|
||||||
|
|
||||||
/// **Bulk** enqueue for the detail select-mode bulk bar (spec 12 §13.1 Add to
|
/// **Bulk** enqueue for the detail select-mode bulk bar (spec 12 §13.1 Add to
|
||||||
/// queue / Play next). Resolves EACH selected `MixtapeCollectionItem` through
|
/// queue / Play next): `play_next` selects [`BulkEnqueueMode::Next`] over
|
||||||
/// the same `ProdItemResolver` the per-row path uses (so Qobuz albums/tracks/
|
/// [`BulkEnqueueMode::Append`]. See `bulk_enqueue_inner` for the contract.
|
||||||
/// playlists + local/Plex all resolve), flattens them in selection order, then:
|
|
||||||
/// - **play_next = true**: insert the whole batch immediately after the current
|
|
||||||
/// track, in REVERSE so the first resolved track lands first (same rule as the
|
|
||||||
/// per-row `PlayNext`).
|
|
||||||
/// - **play_next = false**: append the batch at the end of the queue.
|
|
||||||
///
|
|
||||||
/// Never replaces the queue and never stamps the queue-source collection
|
|
||||||
/// (append/play-next preserve context, mirroring the per-row contract). Items
|
|
||||||
/// that resolve to nothing are logged + skipped; an all-empty batch toasts.
|
|
||||||
pub fn bulk_enqueue(
|
pub fn bulk_enqueue(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
weak: slint::Weak<AppWindow>,
|
weak: slint::Weak<AppWindow>,
|
||||||
handle: tokio::runtime::Handle,
|
handle: tokio::runtime::Handle,
|
||||||
items: Vec<MixtapeCollectionItem>,
|
items: Vec<MixtapeCollectionItem>,
|
||||||
play_next: bool,
|
play_next: bool,
|
||||||
|
) {
|
||||||
|
let mode = if play_next {
|
||||||
|
BulkEnqueueMode::Next
|
||||||
|
} else {
|
||||||
|
BulkEnqueueMode::Append
|
||||||
|
};
|
||||||
|
bulk_enqueue_inner(runtime, weak, handle, items, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #442 "Play later" bulk enqueue (spec 12 §13.1 bar entry): same resolve +
|
||||||
|
/// flatten as `bulk_enqueue`, placed at the END of the manual block.
|
||||||
|
pub fn bulk_enqueue_later(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
items: Vec<MixtapeCollectionItem>,
|
||||||
|
) {
|
||||||
|
bulk_enqueue_inner(runtime, weak, handle, items, BulkEnqueueMode::Later);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Bulk** enqueue for the detail select-mode bulk bar (spec 12 §13.1 Add to
|
||||||
|
/// queue / Play next / Play later). Resolves EACH selected
|
||||||
|
/// `MixtapeCollectionItem` through the same `ProdItemResolver` the per-row
|
||||||
|
/// path uses (so Qobuz albums/tracks/playlists + local/Plex all resolve),
|
||||||
|
/// flattens them in selection order, then places the batch per `mode`:
|
||||||
|
/// - **Next**: insert the whole batch immediately after the current track, in
|
||||||
|
/// REVERSE so the first resolved track lands first (same rule as the per-row
|
||||||
|
/// `PlayNext`).
|
||||||
|
/// - **Later**: insert at the END of the manual block in NATURAL order (each
|
||||||
|
/// `add_track_later` appends — #442).
|
||||||
|
/// - **Append**: append the batch at the end of the queue.
|
||||||
|
///
|
||||||
|
/// Never replaces the queue and never stamps the queue-source collection
|
||||||
|
/// (append/play-next/play-later preserve context, mirroring the per-row
|
||||||
|
/// contract). Items that resolve to nothing are logged + skipped; an
|
||||||
|
/// all-empty batch toasts.
|
||||||
|
fn bulk_enqueue_inner(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
items: Vec<MixtapeCollectionItem>,
|
||||||
|
mode: BulkEnqueueMode,
|
||||||
) {
|
) {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -510,18 +572,30 @@ pub fn bulk_enqueue(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if play_next {
|
match mode {
|
||||||
// REVERSE so the first resolved track lands immediately after the
|
BulkEnqueueMode::Next => {
|
||||||
// current track (spec §9.8).
|
// REVERSE so the first resolved track lands immediately after the
|
||||||
for track in tracks.into_iter().rev() {
|
// current track (spec §9.8).
|
||||||
runtime.core().add_track_next(track).await;
|
for track in tracks.into_iter().rev() {
|
||||||
|
runtime.core().add_track_next(track).await;
|
||||||
|
}
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
|
||||||
|
}
|
||||||
|
BulkEnqueueMode::Later => {
|
||||||
|
// #442: NATURAL order — each insert appends to the END of the
|
||||||
|
// manual block.
|
||||||
|
for track in tracks {
|
||||||
|
runtime.core().add_track_later(track).await;
|
||||||
|
}
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
|
||||||
|
}
|
||||||
|
BulkEnqueueMode::Append => {
|
||||||
|
runtime.core().add_tracks(tracks).await;
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
|
||||||
}
|
}
|
||||||
refresh_sidebar(false);
|
|
||||||
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
|
|
||||||
} else {
|
|
||||||
runtime.core().add_tracks(tracks).await;
|
|
||||||
refresh_sidebar(false);
|
|
||||||
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2213,6 +2213,7 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
|
|||||||
album: album_display.clone(),
|
album: album_display.clone(),
|
||||||
duration: (duration > 0).then(|| std::time::Duration::from_secs(duration as u64)),
|
duration: (duration > 0).then(|| std::time::Duration::from_secs(duration as u64)),
|
||||||
art_url: mpris_art,
|
art_url: mpris_art,
|
||||||
|
url: qbz_media_controls::xesam_url_for(track.source.as_deref(), track.id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
mc.set_playback(
|
mc.set_playback(
|
||||||
@@ -3020,6 +3021,7 @@ pub fn enqueue_artist_top_selected(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
if next {
|
if next {
|
||||||
for track in tracks.into_iter().rev() {
|
for track in tracks.into_iter().rev() {
|
||||||
runtime.core().add_track_next(track).await;
|
runtime.core().add_track_next(track).await;
|
||||||
@@ -3027,11 +3029,54 @@ pub fn enqueue_artist_top_selected(
|
|||||||
} else {
|
} else {
|
||||||
runtime.core().add_tracks(tracks).await;
|
runtime.core().add_tracks(tracks).await;
|
||||||
}
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
|
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play-later variant of `enqueue_artist_top_selected` (#442): same
|
||||||
|
/// re-fetch + id filter + page order, but the selection lands at the END of
|
||||||
|
/// the manual block — NATURAL order (each `add_track_later` appends, no
|
||||||
|
/// reverse) and the QConnect batch rides `play_later_batch_on_peer_if_active`.
|
||||||
|
pub fn enqueue_artist_top_later(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
artist_id: String,
|
||||||
|
ids: Vec<String>,
|
||||||
|
) {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle.spawn(async move {
|
||||||
|
let Some(all) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let want: std::collections::HashSet<u64> =
|
||||||
|
ids.iter().filter_map(|s| s.parse::<u64>().ok()).collect();
|
||||||
|
let tracks: Vec<QueueTrack> =
|
||||||
|
all.into_iter().filter(|qt| want.contains(&qt.id)).collect();
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(svc) = crate::qconnect_service::service() {
|
||||||
|
let routed: Vec<(u64, Option<String>)> =
|
||||||
|
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
|
||||||
|
if svc.play_later_batch_on_peer_if_active(&routed).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
|
for track in tracks {
|
||||||
|
runtime.core().add_track_later(track).await;
|
||||||
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Shuffle-play ALL of the artist's Popular tracks (section "more" menu).
|
/// Shuffle-play ALL of the artist's Popular tracks (section "more" menu).
|
||||||
/// Re-fetches, xorshift-shuffles (same seedless mix as `play_album_shuffled`),
|
/// Re-fetches, xorshift-shuffles (same seedless mix as `play_album_shuffled`),
|
||||||
/// and replaces the queue.
|
/// and replaces the queue.
|
||||||
@@ -3858,7 +3903,9 @@ pub fn enqueue_album(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tok
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
runtime.core().add_tracks(tracks).await;
|
runtime.core().add_tracks(tracks).await;
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
|
||||||
});
|
});
|
||||||
@@ -3962,14 +4009,107 @@ pub fn enqueue_album_next(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Insert in reverse so the tracks end up in the correct order.
|
// Insert in reverse so the tracks end up in the correct order.
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
for track in tracks.into_iter().rev() {
|
for track in tracks.into_iter().rev() {
|
||||||
runtime.core().add_track_next(track).await;
|
runtime.core().add_track_next(track).await;
|
||||||
}
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
|
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert an album's tracks at the END of the manual block ("Play later",
|
||||||
|
/// #442): after every play-next / play-later already queued, before the
|
||||||
|
/// source resumes. Unlike play-next there is NO reverse — each
|
||||||
|
/// `add_track_later` appends to the block, so the natural order is preserved.
|
||||||
|
pub fn enqueue_album_later(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
album_id: String,
|
||||||
|
) {
|
||||||
|
handle.spawn(async move {
|
||||||
|
let album = match runtime.core().get_album(&album_id).await {
|
||||||
|
Ok(album) => album,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[qbz-slint] playback: play-later get_album {album_id} failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let album_title = album.title.clone();
|
||||||
|
let album_artist = album.artist.name.clone();
|
||||||
|
let album_artwork = album.image.best().cloned().unwrap_or_default();
|
||||||
|
crate::recently::remember_album_meta(&album.id, album_card_meta(&album));
|
||||||
|
// Drop blacklisted tracks (composer-aware, album-primary fallback)
|
||||||
|
// before play-later — same predicate as album play-all (D-FIX-b).
|
||||||
|
let album_primary = Some(album.artist.id);
|
||||||
|
let tracks: Vec<QueueTrack> = album
|
||||||
|
.tracks
|
||||||
|
.as_ref()
|
||||||
|
.map(|container| container.items.as_slice())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter(|track| !track_is_blacklisted_full(track, album_primary))
|
||||||
|
.map(|track| {
|
||||||
|
make_queue_track(track, &album.id, &album_title, &album_artist, &album_artwork, album.version.as_deref())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// QConnect CONTROLLER mode: route the whole album to the peer's
|
||||||
|
// manual-block tail. All-or-nothing admission inside the router;
|
||||||
|
// false when no peer is active, so the local loop runs unchanged.
|
||||||
|
if let Some(svc) = crate::qconnect_service::service() {
|
||||||
|
let routed: Vec<(u64, Option<String>)> =
|
||||||
|
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
|
||||||
|
if svc.play_later_batch_on_peer_if_active(&routed).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NATURAL order — each insert appends to the manual block.
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
|
for track in tracks {
|
||||||
|
runtime.core().add_track_later(track).await;
|
||||||
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// QConnect sync-on-add predicate (#442): true when EVERY track of an added
|
||||||
|
/// batch is Qobuz-castable (mirrors `sync_local_queue_if_changed`'s
|
||||||
|
/// all-or-nothing admission — `local` / `plex` refused, offline
|
||||||
|
/// `qobuz_download` eligible). A non-castable batch skips the post-add push so
|
||||||
|
/// a local/plex add never trips the refusal toast on every click; the next
|
||||||
|
/// track tick already surfaces that state.
|
||||||
|
pub(crate) fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool {
|
||||||
|
tracks.iter().all(|qt| {
|
||||||
|
qt.id > 0
|
||||||
|
&& !matches!(
|
||||||
|
qt.source.as_deref().map(|s| s.to_ascii_lowercase()).as_deref(),
|
||||||
|
Some("local") | Some("plex")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// QConnect sync-on-add (#442): after a local queue ADD (play-next / play-later
|
||||||
|
/// / add-to-queue), push the updated queue to the session immediately so a
|
||||||
|
/// connected controller sees the new item AT its position — previously it only
|
||||||
|
/// re-synced on the next track transition. `sync_local_queue_if_changed`
|
||||||
|
/// self-gates (no-op when not connected, when a peer owns playback, or when the
|
||||||
|
/// queue is unchanged), so this is cheap in every non-renderer situation.
|
||||||
|
pub(crate) async fn sync_qconnect_after_add(added_castable: bool) {
|
||||||
|
if !added_castable {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(svc) = crate::qconnect_service::service() {
|
||||||
|
svc.sync_local_queue_if_changed().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Enqueue a single track at the end of the current queue.
|
/// Enqueue a single track at the end of the current queue.
|
||||||
pub fn enqueue_track(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, track_id: u64) {
|
pub fn enqueue_track(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, track_id: u64) {
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
@@ -4010,6 +4150,8 @@ pub fn enqueue_track(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tok
|
|||||||
let queue_track =
|
let queue_track =
|
||||||
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
|
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
|
||||||
runtime.core().add_track(queue_track).await;
|
runtime.core().add_track(queue_track).await;
|
||||||
|
// QConnect sync-on-add (#442): single catalog track — always castable.
|
||||||
|
sync_qconnect_after_add(true).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
|
||||||
});
|
});
|
||||||
@@ -4060,11 +4202,68 @@ pub fn play_track_next(
|
|||||||
let queue_track =
|
let queue_track =
|
||||||
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
|
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
|
||||||
runtime.core().add_track_next(queue_track).await;
|
runtime.core().add_track_next(queue_track).await;
|
||||||
|
// QConnect sync-on-add (#442): single catalog track — always castable.
|
||||||
|
sync_qconnect_after_add(true).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
|
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert a single track at the END of the manual block ("Play later", #442):
|
||||||
|
/// after every play-next / play-later already queued, before the source
|
||||||
|
/// resumes. In QConnect CONTROLLER mode it routes to the peer's manual-block
|
||||||
|
/// tail (`play_later_on_peer_if_active` steers `insert_after`; the protocol
|
||||||
|
/// has no "later" — best-effort positionally, the cloud stays authoritative).
|
||||||
|
pub fn play_track_later(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
track_id: u64,
|
||||||
|
) {
|
||||||
|
handle.spawn(async move {
|
||||||
|
if let Some(svc) = crate::qconnect_service::service() {
|
||||||
|
// Single-track play-later always builds a Qobuz catalog track
|
||||||
|
// (`make_queue_track` source = "qobuz"), so it is always castable.
|
||||||
|
if svc
|
||||||
|
.play_later_on_peer_if_active(track_id, Some("qobuz"))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let track = match runtime.core().get_track(track_id).await {
|
||||||
|
Ok(track) => track,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[qbz-slint] playback: play-later get_track {track_id} failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
|
||||||
|
Some(album) => (
|
||||||
|
album.id.clone(),
|
||||||
|
album.title.clone(),
|
||||||
|
album.image.best().cloned().unwrap_or_default(),
|
||||||
|
),
|
||||||
|
None => (String::new(), String::new(), String::new()),
|
||||||
|
};
|
||||||
|
let album_artist = track
|
||||||
|
.performer
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let queue_track =
|
||||||
|
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
|
||||||
|
runtime.core().add_track_later(queue_track).await;
|
||||||
|
// QConnect sync-on-add (#442): push the updated queue to the session
|
||||||
|
// immediately so controllers see the new item AT its manual-block
|
||||||
|
// position — previously they only re-synced on the next track
|
||||||
|
// transition. Single catalog track — always castable.
|
||||||
|
sync_qconnect_after_add(true).await;
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Play a whole playlist (by id) NOW — replace the queue with the playlist's
|
/// Play a whole playlist (by id) NOW — replace the queue with the playlist's
|
||||||
/// tracks and start at the first one. Fetches the tracks fresh, so it works
|
/// tracks and start at the first one. Fetches the tracks fresh, so it works
|
||||||
/// from any playlist CARD (Discover / Search / Label carousels) without a
|
/// from any playlist CARD (Discover / Search / Label carousels) without a
|
||||||
@@ -4178,6 +4377,7 @@ pub fn enqueue_playlist(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
if next {
|
if next {
|
||||||
// Reverse so the inserted block keeps the playlist's order.
|
// Reverse so the inserted block keeps the playlist's order.
|
||||||
for track in tracks.into_iter().rev() {
|
for track in tracks.into_iter().rev() {
|
||||||
@@ -4186,11 +4386,76 @@ pub fn enqueue_playlist(
|
|||||||
} else {
|
} else {
|
||||||
runtime.core().add_tracks(tracks).await;
|
runtime.core().add_tracks(tracks).await;
|
||||||
}
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
|
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert a whole playlist (by id) at the END of the manual block ("Play
|
||||||
|
/// later", #442). Mirrors `enqueue_playlist` — same fetch + mixed-sidecar
|
||||||
|
/// interleave + blacklist filter + QConnect batch routing — but the local
|
||||||
|
/// insert loops `add_track_later` in NATURAL order (each insert appends to
|
||||||
|
/// the block; successive later-inserts keep FIFO order, no reverse).
|
||||||
|
pub fn enqueue_playlist_later(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
playlist_id: String,
|
||||||
|
) {
|
||||||
|
let Ok(pid) = playlist_id.parse::<u64>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
handle.spawn(async move {
|
||||||
|
let playlist = match runtime.core().get_playlist(pid).await {
|
||||||
|
Ok(playlist) => playlist,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[qbz-slint] playback: play-later get_playlist {pid} failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let qobuz_tracks: Vec<Track> = playlist.tracks.map(|c| c.items).unwrap_or_default();
|
||||||
|
// Same mixed-playlist merge as `enqueue_playlist` (see its MIXED
|
||||||
|
// comment): interleave the local/Plex sidecar rows at their stored
|
||||||
|
// slots so every row keeps its source. Pure-Qobuz playlists read an
|
||||||
|
// empty sidecar.
|
||||||
|
let qobuz_count = qobuz_tracks.len() as u32;
|
||||||
|
let sidecar = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::local_playlist::read_sidecar_rows_blocking(pid, qobuz_count, true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let rows = crate::playlist::interleave_rows(qobuz_tracks, sidecar);
|
||||||
|
// Drop blacklisted Qobuz rows (performer; local/Plex rows kept by the
|
||||||
|
// source guard). Silent early-return when nothing playable remains.
|
||||||
|
let tracks: Vec<QueueTrack> = filter_blacklisted_queue(
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|row| crate::local_playlist::row_queue_track(&row.item))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// QConnect CONTROLLER mode: route the whole playlist to the peer's
|
||||||
|
// manual-block tail. All-or-nothing admission inside the router;
|
||||||
|
// false when no peer is active, so the local loop runs unchanged.
|
||||||
|
if let Some(svc) = crate::qconnect_service::service() {
|
||||||
|
let routed: Vec<(u64, Option<String>)> =
|
||||||
|
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
|
||||||
|
if svc.play_later_batch_on_peer_if_active(&routed).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
|
for track in tracks {
|
||||||
|
runtime.core().add_track_later(track).await;
|
||||||
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Append (or insert-next) a batch of already-fetched tracks to the queue
|
/// Append (or insert-next) a batch of already-fetched tracks to the queue
|
||||||
/// without re-fetching them. Used by the favorites bulk bar.
|
/// without re-fetching them. Used by the favorites bulk bar.
|
||||||
/// Resolve a list of Qobuz track ids (order-preserving) and enqueue them at the
|
/// Resolve a list of Qobuz track ids (order-preserving) and enqueue them at the
|
||||||
@@ -4273,6 +4538,7 @@ pub fn enqueue_tracks(
|
|||||||
} else {
|
} else {
|
||||||
tracks
|
tracks
|
||||||
};
|
};
|
||||||
|
let mut added: Vec<QueueTrack> = Vec::with_capacity(ordered.len());
|
||||||
for track in ordered {
|
for track in ordered {
|
||||||
let (album_id, album_title, album_artwork) = track
|
let (album_id, album_title, album_artwork) = track
|
||||||
.album
|
.album
|
||||||
@@ -4293,11 +4559,72 @@ pub fn enqueue_tracks(
|
|||||||
let qt =
|
let qt =
|
||||||
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
|
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
|
||||||
if next {
|
if next {
|
||||||
runtime.core().add_track_next(qt).await;
|
runtime.core().add_track_next(qt.clone()).await;
|
||||||
} else {
|
} else {
|
||||||
runtime.core().add_track(qt).await;
|
runtime.core().add_track(qt.clone()).await;
|
||||||
|
}
|
||||||
|
added.push(qt);
|
||||||
|
}
|
||||||
|
sync_qconnect_after_add(batch_all_qconnect_castable(&added)).await;
|
||||||
|
refresh_sidebar(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play-later variant of `enqueue_tracks` (#442): the catalog Track batch
|
||||||
|
/// lands at the END of the manual block in NATURAL order (each
|
||||||
|
/// `add_track_later` appends to the block — no reverse, unlike play-next).
|
||||||
|
/// Same blacklist filter + QConnect controller routing (play_later batch).
|
||||||
|
pub fn enqueue_tracks_later(
|
||||||
|
runtime: Runtime,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
tracks: Vec<qbz_models::Track>,
|
||||||
|
) {
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Drop blacklisted tracks (performer OR composer — D-FEAT) from the bulk
|
||||||
|
// batch before routing/enqueueing. Silent early-return when 0 remain.
|
||||||
|
let tracks: Vec<qbz_models::Track> = tracks
|
||||||
|
.into_iter()
|
||||||
|
.filter(|track| !track_is_blacklisted_full(track, None))
|
||||||
|
.collect();
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle.spawn(async move {
|
||||||
|
// QConnect CONTROLLER mode: same all-or-nothing batch admission as
|
||||||
|
// `enqueue_tracks`, routed to the peer's manual-block tail.
|
||||||
|
if let Some(svc) = crate::qconnect_service::service() {
|
||||||
|
let routed: Vec<(u64, Option<String>)> =
|
||||||
|
tracks.iter().map(|track| (track.id, None)).collect();
|
||||||
|
if svc.play_later_batch_on_peer_if_active(&routed).await {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut added: Vec<QueueTrack> = Vec::with_capacity(tracks.len());
|
||||||
|
for track in tracks {
|
||||||
|
let (album_id, album_title, album_artwork) = track
|
||||||
|
.album
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| {
|
||||||
|
(
|
||||||
|
a.id.clone(),
|
||||||
|
a.title.clone(),
|
||||||
|
a.image.best().cloned().unwrap_or_default(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let album_artist = track
|
||||||
|
.performer
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let qt =
|
||||||
|
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
|
||||||
|
runtime.core().add_track_later(qt.clone()).await;
|
||||||
|
added.push(qt);
|
||||||
|
}
|
||||||
|
sync_qconnect_after_add(batch_all_qconnect_castable(&added)).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4339,6 +4666,7 @@ pub fn enqueue_queue_tracks(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
if next {
|
if next {
|
||||||
// Reverse so the inserted block keeps the selection's order.
|
// Reverse so the inserted block keeps the selection's order.
|
||||||
for track in tracks.into_iter().rev() {
|
for track in tracks.into_iter().rev() {
|
||||||
@@ -4347,11 +4675,48 @@ pub fn enqueue_queue_tracks(
|
|||||||
} else {
|
} else {
|
||||||
runtime.core().add_tracks(tracks).await;
|
runtime.core().add_tracks(tracks).await;
|
||||||
}
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
|
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert a batch of source-aware QueueTracks at the END of the manual block
|
||||||
|
/// (#442 "Play later"): selection order is preserved (each insert appends to
|
||||||
|
/// the block, no reverse needed). QConnect CONTROLLER mode routes the whole
|
||||||
|
/// batch to the peer's manual-block tail (`play_later_batch_on_peer_if_active`
|
||||||
|
/// — the protocol has no "later"; `insert_after` is steered best-effort).
|
||||||
|
pub fn enqueue_queue_tracks_later(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
tracks: Vec<QueueTrack>,
|
||||||
|
) {
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tracks = filter_blacklisted_queue(tracks);
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle.spawn(async move {
|
||||||
|
if let Some(svc) = crate::qconnect_service::service() {
|
||||||
|
let routed: Vec<(u64, Option<String>)> =
|
||||||
|
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
|
||||||
|
if svc.play_later_batch_on_peer_if_active(&routed).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||||
|
for track in tracks {
|
||||||
|
runtime.core().add_track_later(track).await;
|
||||||
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
|
refresh_sidebar(false);
|
||||||
|
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Append (or insert-next) a batch of already-loaded LocalLibrary rows to the
|
/// Append (or insert-next) a batch of already-loaded LocalLibrary rows to the
|
||||||
/// queue. Mirrors `enqueue_tracks` but for `LocalTrack`: `local_queue_track`
|
/// queue. Mirrors `enqueue_tracks` but for `LocalTrack`: `local_queue_track`
|
||||||
/// builds source-aware QueueTracks (is_local=true; "local"/"qobuz_download")
|
/// builds source-aware QueueTracks (is_local=true; "local"/"qobuz_download")
|
||||||
@@ -4373,14 +4738,42 @@ pub fn enqueue_local_tracks(
|
|||||||
} else {
|
} else {
|
||||||
tracks
|
tracks
|
||||||
};
|
};
|
||||||
for track in &ordered {
|
// qobuz_download rows ARE Qobuz-castable (the sync pushes their real
|
||||||
let qt = local_queue_track(track);
|
// Qobuz id); plain `local` rows skip the post-add push (#442).
|
||||||
|
let qts: Vec<QueueTrack> = ordered.iter().map(local_queue_track).collect();
|
||||||
|
let added_castable = batch_all_qconnect_castable(&qts);
|
||||||
|
for qt in qts {
|
||||||
if next {
|
if next {
|
||||||
runtime.core().add_track_next(qt).await;
|
runtime.core().add_track_next(qt).await;
|
||||||
} else {
|
} else {
|
||||||
runtime.core().add_track(qt).await;
|
runtime.core().add_track(qt).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
|
refresh_sidebar(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play-later variant of `enqueue_local_tracks` (#442): the LocalLibrary
|
||||||
|
/// batch lands at the END of the manual block in NATURAL order (each
|
||||||
|
/// `add_track_later` appends — no reverse). Same `local_queue_track`
|
||||||
|
/// source-aware build; qobuz_download rows stay Qobuz-castable for the
|
||||||
|
/// post-add sync, plain `local` rows skip it.
|
||||||
|
pub fn enqueue_local_tracks_later(
|
||||||
|
runtime: Runtime,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
tracks: Vec<qbz_library::LocalTrack>,
|
||||||
|
) {
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle.spawn(async move {
|
||||||
|
let qts: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
|
||||||
|
let added_castable = batch_all_qconnect_castable(&qts);
|
||||||
|
for qt in qts {
|
||||||
|
runtime.core().add_track_later(qt).await;
|
||||||
|
}
|
||||||
|
sync_qconnect_after_add(added_castable).await;
|
||||||
refresh_sidebar(false);
|
refresh_sidebar(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4415,6 +4808,20 @@ pub fn toggle_play_pause(
|
|||||||
crate::session_persist::capture_and_save(&runtime).await;
|
crate::session_persist::capture_and_save(&runtime).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Not playing — but a LOAD may be in flight: the engine reports
|
||||||
|
// is_playing=false until the stream starts, so a pause issued during
|
||||||
|
// the load window used to fall into the resume branch below and get
|
||||||
|
// swallowed (the track started anyway; the sleep inhibitor then
|
||||||
|
// stayed held with no successful pause to release it — #661).
|
||||||
|
// Cancelling the in-flight play is the only sane "pause" there.
|
||||||
|
let pending = PENDING_PLAY_ID.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if pending != 0 {
|
||||||
|
if let Err(e) = runtime.core().stop() {
|
||||||
|
log::error!("[qbz-slint] playback: stop-during-load failed: {e}");
|
||||||
|
}
|
||||||
|
clear_loading(&weak, pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Not playing: resume an existing stream, or cold-start the current
|
// Not playing: resume an existing stream, or cold-start the current
|
||||||
// queue track when nothing is loaded.
|
// queue track when nothing is loaded.
|
||||||
if runtime.core().player().has_loaded_audio() {
|
if runtime.core().player().has_loaded_audio() {
|
||||||
|
|||||||
@@ -320,8 +320,10 @@ pub fn reset_session(_window: &AppWindow) {
|
|||||||
|
|
||||||
/// Order playlists by the active sort (mirrors `applySortToList`):
|
/// Order playlists by the active sort (mirrors `applySortToList`):
|
||||||
/// name (locale-ish), playcount desc, tracks (remote+local) desc, custom
|
/// name (locale-ish), playcount desc, tracks (remote+local) desc, custom
|
||||||
/// (position asc); `recent` keeps the API order.
|
/// (position asc); `recent` keeps the API order (which IS newest-first —
|
||||||
fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str) {
|
/// #657). Every option's `asc == true` is its natural direction; `false`
|
||||||
|
/// reverses it (re-selecting the menu option flips the direction).
|
||||||
|
fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str, asc: bool) {
|
||||||
match sort {
|
match sort {
|
||||||
"name" => list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
|
"name" => list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
|
||||||
"playcount" => list.sort_by(|a, b| b.play_count.cmp(&a.play_count)),
|
"playcount" => list.sort_by(|a, b| b.play_count.cmp(&a.play_count)),
|
||||||
@@ -330,6 +332,9 @@ fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str) {
|
|||||||
// "recent" — keep insertion (API) order.
|
// "recent" — keep insertion (API) order.
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
if !asc {
|
||||||
|
list.reverse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display-stage union of a Qobuz playlist and a LOCAL (library.db)
|
/// Display-stage union of a Qobuz playlist and a LOCAL (library.db)
|
||||||
@@ -390,8 +395,9 @@ impl PmEntry<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `sort_playlists`, over the merged Qobuz + local display set (same
|
/// `sort_playlists`, over the merged Qobuz + local display set (same
|
||||||
/// comparators; see `PmEntry` for the missing-stat rules).
|
/// comparators; see `PmEntry` for the missing-stat rules). `asc == false`
|
||||||
fn sort_entries(list: &mut [PmEntry], sort: &str) {
|
/// reverses the option's natural direction (#657 caret).
|
||||||
|
fn sort_entries(list: &mut [PmEntry], sort: &str, asc: bool) {
|
||||||
match sort {
|
match sort {
|
||||||
"name" => list.sort_by_key(|e| e.name_lower()),
|
"name" => list.sort_by_key(|e| e.name_lower()),
|
||||||
"playcount" => list.sort_by(|a, b| b.play_count().cmp(&a.play_count())),
|
"playcount" => list.sort_by(|a, b| b.play_count().cmp(&a.play_count())),
|
||||||
@@ -400,6 +406,9 @@ fn sort_entries(list: &mut [PmEntry], sort: &str) {
|
|||||||
// "recent" — Qobuz keeps API order, locals stay after it.
|
// "recent" — Qobuz keeps API order, locals stay after it.
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
if !asc {
|
||||||
|
list.reverse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The LOCAL playlists that pass the toolbar filters, name-sorted (their
|
/// The LOCAL playlists that pass the toolbar filters, name-sorted (their
|
||||||
@@ -576,6 +585,7 @@ pub fn rebuild(window: &AppWindow) {
|
|||||||
let query = state.get_search_query().trim().to_lowercase();
|
let query = state.get_search_query().trim().to_lowercase();
|
||||||
let filter = state.get_filter().to_string();
|
let filter = state.get_filter().to_string();
|
||||||
let sort = state.get_sort().to_string();
|
let sort = state.get_sort().to_string();
|
||||||
|
let sort_asc = state.get_sort_asc();
|
||||||
let view_mode = state.get_view_mode().to_string();
|
let view_mode = state.get_view_mode().to_string();
|
||||||
let folder_mode = state.get_folder_mode();
|
let folder_mode = state.get_folder_mode();
|
||||||
|
|
||||||
@@ -608,14 +618,14 @@ pub fn rebuild(window: &AppWindow) {
|
|||||||
// Qobuz set (B4) — see `PmEntry` for the missing-stat sort rules.
|
// Qobuz set (B4) — see `PmEntry` for the missing-stat sort rules.
|
||||||
let mut entries: Vec<PmEntry> = filtered.iter().map(PmEntry::Qobuz).collect();
|
let mut entries: Vec<PmEntry> = filtered.iter().map(PmEntry::Qobuz).collect();
|
||||||
entries.extend(local_entries(&data, &query, &filter).into_iter().map(PmEntry::Local));
|
entries.extend(local_entries(&data, &query, &filter).into_iter().map(PmEntry::Local));
|
||||||
sort_entries(&mut entries, &sort);
|
sort_entries(&mut entries, &sort, sort_asc);
|
||||||
let playlist_items: Vec<PmPlaylistItem> = entries.iter().map(|e| e.item()).collect();
|
let playlist_items: Vec<PmPlaylistItem> = entries.iter().map(|e| e.item()).collect();
|
||||||
let visible_count = playlist_items.len();
|
let visible_count = playlist_items.len();
|
||||||
|
|
||||||
// Tree rows (folder headers + nested + root playlists). Built only
|
// Tree rows (folder headers + nested + root playlists). Built only
|
||||||
// when the tree view is active; otherwise an empty model.
|
// when the tree view is active; otherwise an empty model.
|
||||||
let tree = if folder_mode && view_mode == "tree" {
|
let tree = if folder_mode && view_mode == "tree" {
|
||||||
build_tree(&data, &query, &filter, &sort)
|
build_tree(&data, &query, &filter, &sort, sort_asc)
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
@@ -667,7 +677,7 @@ pub fn search_menu_folders(window: &AppWindow, query: &str) {
|
|||||||
|
|
||||||
/// Flatten folders + their (expanded) playlists + root playlists into the
|
/// Flatten folders + their (expanded) playlists + root playlists into the
|
||||||
/// tree model. Auto-expands all folders the first time the tree opens.
|
/// tree model. Auto-expands all folders the first time the tree opens.
|
||||||
fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTreeRow> {
|
fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str, sort_asc: bool) -> Vec<PmTreeRow> {
|
||||||
// Auto-expand on first tree open (Tauri's treeInitialized).
|
// Auto-expand on first tree open (Tauri's treeInitialized).
|
||||||
{
|
{
|
||||||
let mut init = TREE_INIT.lock().unwrap_or_else(|e| e.into_inner());
|
let mut init = TREE_INIT.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
@@ -714,7 +724,7 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
|
|||||||
if (searching || offline) && members.is_empty() {
|
if (searching || offline) && members.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sort_playlists(&mut members, sort);
|
sort_playlists(&mut members, sort, sort_asc);
|
||||||
let is_exp = searching || expanded.contains(&f.id);
|
let is_exp = searching || expanded.contains(&f.id);
|
||||||
rows.push(PmTreeRow {
|
rows.push(PmTreeRow {
|
||||||
kind: "folder".into(),
|
kind: "folder".into(),
|
||||||
@@ -747,7 +757,7 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
|
|||||||
.collect();
|
.collect();
|
||||||
let mut entries: Vec<PmEntry> = root.iter().map(PmEntry::Qobuz).collect();
|
let mut entries: Vec<PmEntry> = root.iter().map(PmEntry::Qobuz).collect();
|
||||||
entries.extend(local_entries(data, query, filter).into_iter().map(PmEntry::Local));
|
entries.extend(local_entries(data, query, filter).into_iter().map(PmEntry::Local));
|
||||||
sort_entries(&mut entries, sort);
|
sort_entries(&mut entries, sort, sort_asc);
|
||||||
for e in &entries {
|
for e in &entries {
|
||||||
rows.push(PmTreeRow {
|
rows.push(PmTreeRow {
|
||||||
kind: "playlist".into(),
|
kind: "playlist".into(),
|
||||||
|
|||||||
@@ -305,6 +305,146 @@ pub struct SlintQconnectService {
|
|||||||
/// echo `QueueUpdated` lands (which would otherwise double-push on the next
|
/// echo `QueueUpdated` lands (which would otherwise double-push on the next
|
||||||
/// track tick). Cleared on disconnect.
|
/// track tick). Cleared on disconnect.
|
||||||
last_pushed_queue_ids: Mutex<Option<Vec<u64>>>,
|
last_pushed_queue_ids: Mutex<Option<Vec<u64>>>,
|
||||||
|
/// Controller-mode mirror of the local queue's manual block (#442 "Play
|
||||||
|
/// later"): steers `insert_after` for play-later routing. See the struct
|
||||||
|
/// docs on `ControllerManualBlock`.
|
||||||
|
controller_manual: Mutex<ControllerManualBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller-mode mirror of the local queue's manual block (#442 "Play later").
|
||||||
|
///
|
||||||
|
/// The QConnect protocol has no "later" concept and the peer's queue carries no
|
||||||
|
/// manual-block metadata; worse, every cloud echo re-materializes the LOCAL core
|
||||||
|
/// queue via `materialize_remote_queue`, resetting its `manual_next_count` — so
|
||||||
|
/// the block tail is tracked HERE instead. Every play-next / play-later routed
|
||||||
|
/// to the peer is recorded as a PENDING insert (anchor + track ids); the next
|
||||||
|
/// `reconcile` confirms it against the fresh snapshot once the echo lands (the
|
||||||
|
/// inserted ids show up after the anchor we used). The tail is dropped when the
|
||||||
|
/// renderer's current track advances at/past it, and everything resets when the
|
||||||
|
/// queue's major version changes (a wholesale replace wipes the block).
|
||||||
|
/// Best-effort by design: it only steers `insert_after`; the cloud remains the
|
||||||
|
/// source of truth.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ControllerManualBlock {
|
||||||
|
/// queue_item_id of the deepest confirmed manual item (the block tail).
|
||||||
|
tail_qid: Option<u64>,
|
||||||
|
/// Renderer current-track qid at the last reconcile (advance detection).
|
||||||
|
current_qid: Option<u64>,
|
||||||
|
/// Queue major version at the last reconcile (replace detection).
|
||||||
|
queue_major: Option<u64>,
|
||||||
|
/// Sent-but-unconfirmed inserts: (insert_after anchor we used, track ids).
|
||||||
|
pending: Vec<(u64, Vec<u64>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControllerManualBlock {
|
||||||
|
/// Reconcile with the latest peer snapshot BEFORE computing a new anchor.
|
||||||
|
fn reconcile(&mut self, renderer: &QConnectRendererState, queue: &QConnectQueueState) {
|
||||||
|
let items = &queue.queue_items;
|
||||||
|
|
||||||
|
// Queue replaced wholesale → the block is gone.
|
||||||
|
if self.queue_major != Some(queue.version.major) {
|
||||||
|
self.queue_major = Some(queue.version.major);
|
||||||
|
self.current_qid = renderer.current_track.as_ref().map(|i| i.queue_item_id);
|
||||||
|
self.tail_qid = None;
|
||||||
|
self.pending.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance detection: the current track moving consumes manual items.
|
||||||
|
// The tail only survives while it sits strictly AFTER the current track.
|
||||||
|
let new_current = renderer.current_track.as_ref().map(|i| i.queue_item_id);
|
||||||
|
if new_current != self.current_qid {
|
||||||
|
if let (Some(tail), Some(cur)) = (self.tail_qid, new_current) {
|
||||||
|
let tail_pos = items.iter().position(|i| i.queue_item_id == tail);
|
||||||
|
let cur_pos = items.iter().position(|i| i.queue_item_id == cur);
|
||||||
|
let tail_still_ahead = matches!((tail_pos, cur_pos), (Some(t), Some(c)) if t > c);
|
||||||
|
if !tail_still_ahead {
|
||||||
|
self.tail_qid = None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.tail_qid = None;
|
||||||
|
}
|
||||||
|
self.current_qid = new_current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm pendings against the snapshot: the echo has landed once the
|
||||||
|
// inserted ids show up shortly after the anchor we used. The small scan
|
||||||
|
// window tolerates a play-next interleaving between two play-laters
|
||||||
|
// (which pushes an earlier play-later one position deeper).
|
||||||
|
let mut confirmed: Vec<u64> = Vec::new();
|
||||||
|
self.pending.retain(|(anchor, ids)| {
|
||||||
|
let Some(anchor_pos) = items.iter().position(|i| i.queue_item_id == *anchor) else {
|
||||||
|
return false; // anchor gone — the queue moved on; drop.
|
||||||
|
};
|
||||||
|
let window_end = (anchor_pos + 1 + ids.len() + 2).min(items.len());
|
||||||
|
let window = &items[anchor_pos + 1..window_end];
|
||||||
|
match ids.last() {
|
||||||
|
Some(last_id) => match window.iter().find(|i| i.track_id == *last_id) {
|
||||||
|
Some(item) => {
|
||||||
|
confirmed.push(item.queue_item_id);
|
||||||
|
false // confirmed → drop from pending
|
||||||
|
}
|
||||||
|
None => true, // echo not here yet
|
||||||
|
},
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Raise the tail to the DEEPEST confirmed item — never lower it (two
|
||||||
|
// rapid inserts confirm in send order, but the second anchor sits
|
||||||
|
// shallower when its echo landed first).
|
||||||
|
let deepest = confirmed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|qid| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.position(|i| i.queue_item_id == qid)
|
||||||
|
.map(|pos| (pos, qid))
|
||||||
|
})
|
||||||
|
.max_by_key(|(pos, _)| *pos);
|
||||||
|
if let Some((pos, qid)) = deepest {
|
||||||
|
let raises = self
|
||||||
|
.tail_qid
|
||||||
|
.and_then(|tail| items.iter().position(|i| i.queue_item_id == tail))
|
||||||
|
.map(|tail_pos| pos > tail_pos)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if raises {
|
||||||
|
self.tail_qid = Some(qid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// queue_item_id AFTER which a play-later insert lands: the confirmed block
|
||||||
|
/// tail while it is still ahead of the current track, else the current
|
||||||
|
/// track itself (degrades to play-next — also under shuffle, mirroring the
|
||||||
|
/// local `add_track_later` shuffle fallback).
|
||||||
|
fn later_anchor(
|
||||||
|
&self,
|
||||||
|
renderer: &QConnectRendererState,
|
||||||
|
queue: &QConnectQueueState,
|
||||||
|
) -> Option<u64> {
|
||||||
|
let current = renderer.current_track.as_ref()?.queue_item_id;
|
||||||
|
if queue.shuffle_mode {
|
||||||
|
return Some(current);
|
||||||
|
}
|
||||||
|
let items = &queue.queue_items;
|
||||||
|
if let Some(tail) = self.tail_qid {
|
||||||
|
let cur_pos = items.iter().position(|i| i.queue_item_id == current);
|
||||||
|
let tail_pos = items.iter().position(|i| i.queue_item_id == tail);
|
||||||
|
if let (Some(c), Some(t)) = (cur_pos, tail_pos) {
|
||||||
|
if t > c {
|
||||||
|
return Some(tail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a just-sent insert so the next reconcile can confirm its echo.
|
||||||
|
fn note_sent(&mut self, anchor: Option<u64>, ids: Vec<u64>) {
|
||||||
|
if let (Some(anchor), false) = (anchor, ids.is_empty()) {
|
||||||
|
self.pending.push((anchor, ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Slint-local mirror of the Tauri `QconnectVisibleQueueProjection` reduced to
|
/// Slint-local mirror of the Tauri `QconnectVisibleQueueProjection` reduced to
|
||||||
@@ -450,6 +590,7 @@ impl SlintQconnectService {
|
|||||||
window,
|
window,
|
||||||
custom_device_name: Arc::new(tokio::sync::RwLock::new(saved_name)),
|
custom_device_name: Arc::new(tokio::sync::RwLock::new(saved_name)),
|
||||||
last_pushed_queue_ids: Mutex::new(None),
|
last_pushed_queue_ids: Mutex::new(None),
|
||||||
|
controller_manual: Mutex::new(ControllerManualBlock::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,6 +1296,7 @@ impl SlintQconnectService {
|
|||||||
&self.window,
|
&self.window,
|
||||||
format!("-> play_next QueueInsertTracks {track_id} after={insert_after:?}"),
|
format!("-> play_next QueueInsertTracks {track_id} after={insert_after:?}"),
|
||||||
);
|
);
|
||||||
|
self.note_controller_insert(insert_after, &[track_id]).await;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!("[QConnect] play_next_on_peer: insert failed: {err}");
|
log::warn!("[QConnect] play_next_on_peer: insert failed: {err}");
|
||||||
@@ -1330,7 +1472,8 @@ impl SlintQconnectService {
|
|||||||
.and_then(|item| i64::try_from(item.queue_item_id).ok())
|
.and_then(|item| i64::try_from(item.queue_item_id).ok())
|
||||||
});
|
});
|
||||||
|
|
||||||
let ids: Vec<i64> = tracks.iter().map(|(id, _)| *id as i64).collect();
|
let ids_u64: Vec<u64> = tracks.iter().map(|(id, _)| *id).collect();
|
||||||
|
let ids: Vec<i64> = ids_u64.iter().map(|id| *id as i64).collect();
|
||||||
let count = ids.len();
|
let count = ids.len();
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"track_ids": ids,
|
"track_ids": ids,
|
||||||
@@ -1354,6 +1497,7 @@ impl SlintQconnectService {
|
|||||||
&self.window,
|
&self.window,
|
||||||
format!("-> play_next QueueInsertTracks {count} tracks after={insert_after:?}"),
|
format!("-> play_next QueueInsertTracks {count} tracks after={insert_after:?}"),
|
||||||
);
|
);
|
||||||
|
self.note_controller_insert(insert_after, &ids_u64).await;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!("[QConnect] play_next_batch_on_peer: insert failed: {err}");
|
log::warn!("[QConnect] play_next_batch_on_peer: insert failed: {err}");
|
||||||
@@ -1362,6 +1506,163 @@ impl SlintQconnectService {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the `insert_after` anchor for a controller-mode PLAY-LATER: the
|
||||||
|
/// manual-block tail while it is still ahead of the peer's current track,
|
||||||
|
/// else the current track itself (degrades to play-next). Reconciles the
|
||||||
|
/// `ControllerManualBlock` against the freshest snapshot first. `None` when
|
||||||
|
/// no snapshot is available (the command then omits `insert_after`, same as
|
||||||
|
/// the play-next path).
|
||||||
|
async fn controller_later_anchor(&self) -> Option<i64> {
|
||||||
|
let (renderer, queue, _session) = self
|
||||||
|
.effective_remote_renderer_snapshot()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()?;
|
||||||
|
let mut guard = self.controller_manual.lock().await;
|
||||||
|
guard.reconcile(&renderer, &queue);
|
||||||
|
guard
|
||||||
|
.later_anchor(&renderer, &queue)
|
||||||
|
.and_then(|qid| i64::try_from(qid).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a just-sent controller-mode insert (play-next or play-later) so
|
||||||
|
/// the next `controller_later_anchor` can confirm its echo and keep the
|
||||||
|
/// manual-block tail past it.
|
||||||
|
async fn note_controller_insert(&self, anchor: Option<i64>, ids: &[u64]) {
|
||||||
|
let anchor = anchor.and_then(|v| u64::try_from(v).ok());
|
||||||
|
let mut guard = self.controller_manual.lock().await;
|
||||||
|
guard.note_sent(anchor, ids.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller play-LATER routing (#442): when QBZ is CONTROLLING a peer
|
||||||
|
/// renderer, insert the track at the END of the peer's manual block (after
|
||||||
|
/// every play-next / play-later already routed, before the source resumes)
|
||||||
|
/// instead of right after the current track. The protocol has no "later" —
|
||||||
|
/// the position is steered via `insert_after` against the tracked block
|
||||||
|
/// tail (`ControllerManualBlock`); best-effort, the cloud stays the source
|
||||||
|
/// of truth. Same admission + echo contract as `play_next_on_peer_if_active`.
|
||||||
|
pub async fn play_later_on_peer_if_active(&self, track_id: u64, source: Option<&str>) -> bool {
|
||||||
|
let peer_active = self.is_peer_renderer_active().await;
|
||||||
|
if !peer_active {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.is_track_castable(track_id, source) {
|
||||||
|
log::info!(
|
||||||
|
"[QConnect] play_later_on_peer: track {track_id} not Qobuz-castable; refusing"
|
||||||
|
);
|
||||||
|
crate::toast::error_weak(&self.window, qbz_i18n::t("Track not castable to Qobuz Connect"));
|
||||||
|
dev_push_event(
|
||||||
|
&self.window,
|
||||||
|
format!("-> play_later REFUSED (non-Qobuz track {track_id})"),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let insert_after = self.controller_later_anchor().await;
|
||||||
|
|
||||||
|
let mut payload = json!({
|
||||||
|
"track_ids": [track_id as i64],
|
||||||
|
"context_uuid": Uuid::new_v4().to_string(),
|
||||||
|
"autoplay_reset": false,
|
||||||
|
"autoplay_loading": false,
|
||||||
|
});
|
||||||
|
if let Some(insert_after) = insert_after {
|
||||||
|
payload["insert_after"] = json!(insert_after);
|
||||||
|
}
|
||||||
|
|
||||||
|
match self
|
||||||
|
.send_command(QueueCommandType::CtrlSrvrQueueInsertTracks, payload)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!(
|
||||||
|
"[QConnect] play_later_on_peer: inserted track {track_id} (after={insert_after:?})"
|
||||||
|
);
|
||||||
|
dev_push_event(
|
||||||
|
&self.window,
|
||||||
|
format!("-> play_later QueueInsertTracks {track_id} after={insert_after:?}"),
|
||||||
|
);
|
||||||
|
self.note_controller_insert(insert_after, &[track_id]).await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("[QConnect] play_later_on_peer: insert failed: {err}");
|
||||||
|
// Still handled: a peer owns playback; never fall back to local.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller play-LATER routing for a MULTI-track batch (#442). Same
|
||||||
|
/// all-or-nothing admission as `play_next_batch_on_peer_if_active`; the
|
||||||
|
/// whole block lands after the manual-block tail in NATURAL order (the
|
||||||
|
/// server preserves list order), mirroring the local
|
||||||
|
/// `enqueue_queue_tracks_later` semantics.
|
||||||
|
pub async fn play_later_batch_on_peer_if_active(
|
||||||
|
&self,
|
||||||
|
tracks: &[(u64, Option<String>)],
|
||||||
|
) -> bool {
|
||||||
|
if !self.is_peer_renderer_active().await {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((bad_id, _)) = tracks
|
||||||
|
.iter()
|
||||||
|
.find(|(id, source)| !self.is_track_castable(*id, source.as_deref()))
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
"[QConnect] play_later_batch_on_peer: track {bad_id} not Qobuz-castable; refusing whole batch"
|
||||||
|
);
|
||||||
|
crate::toast::error_weak(&self.window, qbz_i18n::t("Some tracks can't be cast to Qobuz Connect"));
|
||||||
|
dev_push_event(
|
||||||
|
&self.window,
|
||||||
|
format!(
|
||||||
|
"-> play_later REFUSED (non-Qobuz track {bad_id} in batch of {})",
|
||||||
|
tracks.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let insert_after = self.controller_later_anchor().await;
|
||||||
|
|
||||||
|
let ids_u64: Vec<u64> = tracks.iter().map(|(id, _)| *id).collect();
|
||||||
|
let ids: Vec<i64> = ids_u64.iter().map(|id| *id as i64).collect();
|
||||||
|
let count = ids.len();
|
||||||
|
let mut payload = json!({
|
||||||
|
"track_ids": ids,
|
||||||
|
"context_uuid": Uuid::new_v4().to_string(),
|
||||||
|
"autoplay_reset": false,
|
||||||
|
"autoplay_loading": false,
|
||||||
|
});
|
||||||
|
if let Some(insert_after) = insert_after {
|
||||||
|
payload["insert_after"] = json!(insert_after);
|
||||||
|
}
|
||||||
|
|
||||||
|
match self
|
||||||
|
.send_command(QueueCommandType::CtrlSrvrQueueInsertTracks, payload)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!(
|
||||||
|
"[QConnect] play_later_batch_on_peer: inserted {count} tracks (after={insert_after:?})"
|
||||||
|
);
|
||||||
|
dev_push_event(
|
||||||
|
&self.window,
|
||||||
|
format!("-> play_later QueueInsertTracks {count} tracks after={insert_after:?}"),
|
||||||
|
);
|
||||||
|
self.note_controller_insert(insert_after, &ids_u64).await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("[QConnect] play_later_batch_on_peer: insert failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// True when a PEER renderer currently owns playback (controller mode). Reads
|
/// True when a PEER renderer currently owns playback (controller mode). Reads
|
||||||
/// the session under the sync-state lock. Shared by the play-next /
|
/// the session under the sync-state lock. Shared by the play-next /
|
||||||
/// add-to-queue routing entry points.
|
/// add-to-queue routing entry points.
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ struct RowData {
|
|||||||
artwork_url: String,
|
artwork_url: String,
|
||||||
playing: bool,
|
playing: bool,
|
||||||
is_ephemeral: bool,
|
is_ephemeral: bool,
|
||||||
|
// #442 section header drawn ABOVE this row: "" | "next-in-queue" (first
|
||||||
|
// manual row) | "next-up" (first row after the manual block).
|
||||||
|
section: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `M:SS` duration string.
|
/// `M:SS` duration string.
|
||||||
@@ -144,6 +147,7 @@ fn row_from(track: &QueueTrack, playing: bool) -> RowData {
|
|||||||
playing,
|
playing,
|
||||||
is_ephemeral: track.source.as_deref() == Some("ephemeral")
|
is_ephemeral: track.source.as_deref() == Some("ephemeral")
|
||||||
|| crate::ephemeral::is_ephemeral_id(track.id as i64),
|
|| crate::ephemeral::is_ephemeral_id(track.id as i64),
|
||||||
|
section: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +262,21 @@ impl QueueController {
|
|||||||
|
|
||||||
let page_rows: Vec<RowData> = filtered[start..end]
|
let page_rows: Vec<RowData> = filtered[start..end]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| row_from(t, false))
|
.enumerate()
|
||||||
|
.map(|(i, t)| {
|
||||||
|
let mut r = row_from(t, false);
|
||||||
|
// #442 section markers — only on the unfiltered list (a
|
||||||
|
// search result has no meaningful sections).
|
||||||
|
if query.is_empty() {
|
||||||
|
let g = start + i;
|
||||||
|
if state.manual_next_count > 0 && g == 0 {
|
||||||
|
r.section = "next-in-queue".into();
|
||||||
|
} else if g == state.manual_next_count {
|
||||||
|
r.section = "next-up".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// page-start / page-end are 1-based for the human-readable counter.
|
// page-start / page-end are 1-based for the human-readable counter.
|
||||||
@@ -436,6 +454,25 @@ impl QueueController {
|
|||||||
qs.set_page_count(page_count as i32);
|
qs.set_page_count(page_count as i32);
|
||||||
qs.set_page_start(page_start as i32);
|
qs.set_page_start(page_start as i32);
|
||||||
qs.set_page_end(page_end as i32);
|
qs.set_page_end(page_end as i32);
|
||||||
|
qs.set_page_section_count(
|
||||||
|
page_rows.iter().filter(|r| !r.section.is_empty()).count() as i32
|
||||||
|
);
|
||||||
|
// #442 drag-math aids (see QueueSidebar's headers-above).
|
||||||
|
qs.set_page_manual_header(
|
||||||
|
query.is_empty() && state.manual_next_count > 0 && start == 0
|
||||||
|
);
|
||||||
|
qs.set_page_manual_pos(if query.is_empty()
|
||||||
|
&& state.manual_next_count >= start
|
||||||
|
&& state.manual_next_count < end
|
||||||
|
{
|
||||||
|
(state.manual_next_count - start) as i32
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
});
|
||||||
|
// #442 menu-hint numbers (TrackContextMenu suffix).
|
||||||
|
qs.set_queue_length(state.total_tracks as i32);
|
||||||
|
qs.set_queue_current_index(state.current_index.map(|i| i as i32).unwrap_or(-1));
|
||||||
|
qs.set_manual_count(state.manual_next_count as i32);
|
||||||
|
|
||||||
let history_items: Vec<QueueItem> =
|
let history_items: Vec<QueueItem> =
|
||||||
history_rows.iter().map(|r| to_item_reuse(r, &prior_all)).collect();
|
history_rows.iter().map(|r| to_item_reuse(r, &prior_all)).collect();
|
||||||
@@ -982,6 +1019,7 @@ fn to_item_reuse(
|
|||||||
duration: row.duration.clone().into(),
|
duration: row.duration.clone().into(),
|
||||||
explicit: row.explicit,
|
explicit: row.explicit,
|
||||||
is_ephemeral: row.is_ephemeral,
|
is_ephemeral: row.is_ephemeral,
|
||||||
|
section: row.section.clone().into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,10 +93,11 @@ pub struct SidebarData {
|
|||||||
static EXPANDED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
|
static EXPANDED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||||
/// Last loaded data, so expand/move rebuild without a refetch.
|
/// Last loaded data, so expand/move rebuild without a refetch.
|
||||||
static CACHE: LazyLock<Mutex<SidebarData>> = LazyLock::new(|| Mutex::new(SidebarData::default()));
|
static CACHE: LazyLock<Mutex<SidebarData>> = LazyLock::new(|| Mutex::new(SidebarData::default()));
|
||||||
/// Active sort option, mirrored from `SidebarState.sort-option`. Session
|
/// Active sort option + direction, mirrored from `SidebarState.sort-option` /
|
||||||
/// scope (Tauri persists in localStorage; we have no equivalent store
|
/// `.sort-asc`. Session scope (Tauri persists in localStorage; we have no
|
||||||
/// here yet, so this matches the in-session behavior).
|
/// equivalent store here yet, so this matches the in-session behavior).
|
||||||
static SORT: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new("name".to_string()));
|
static SORT: LazyLock<Mutex<(String, bool)>> =
|
||||||
|
LazyLock::new(|| Mutex::new(("name".to_string(), true)));
|
||||||
/// Active playlist-name search query (lowercased), mirrored from
|
/// Active playlist-name search query (lowercased), mirrored from
|
||||||
/// `SidebarState.search-query`. Filters the rebuilt list recursively.
|
/// `SidebarState.search-query`. Filters the rebuilt list recursively.
|
||||||
static SEARCH: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
|
static SEARCH: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
|
||||||
@@ -111,16 +112,28 @@ pub fn set_loading(window: &AppWindow, loading: bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update the active sort option and re-render (matches Tauri's five
|
/// Update the active sort option and re-render (matches Tauri's five
|
||||||
/// options). Unknown values fall back to "name".
|
/// options). Unknown values fall back to "name". PlaylistView-style toggle:
|
||||||
|
/// re-selecting the ACTIVE option flips its direction (#657); a new option
|
||||||
|
/// starts at its natural direction (`asc == true` everywhere: name A→Z,
|
||||||
|
/// recent newest-first, tracks most-first, custom position asc).
|
||||||
pub fn set_sort(window: &AppWindow, option: &str) {
|
pub fn set_sort(window: &AppWindow, option: &str) {
|
||||||
let opt = match option {
|
let opt = match option {
|
||||||
"name" | "recent" | "tracks" | "playcount" | "custom" => option,
|
"name" | "recent" | "tracks" | "playcount" | "custom" => option,
|
||||||
_ => "name",
|
_ => "name",
|
||||||
};
|
};
|
||||||
|
let mut asc = true;
|
||||||
if let Ok(mut s) = SORT.lock() {
|
if let Ok(mut s) = SORT.lock() {
|
||||||
*s = opt.to_string();
|
if s.0 == opt {
|
||||||
|
s.1 = !s.1;
|
||||||
|
} else {
|
||||||
|
s.0 = opt.to_string();
|
||||||
|
s.1 = true;
|
||||||
|
}
|
||||||
|
asc = s.1;
|
||||||
}
|
}
|
||||||
window.global::<SidebarState>().set_sort_option(opt.into());
|
let st = window.global::<SidebarState>();
|
||||||
|
st.set_sort_option(opt.into());
|
||||||
|
st.set_sort_asc(asc);
|
||||||
rebuild(window);
|
rebuild(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,20 +146,28 @@ pub fn set_search(window: &AppWindow, query: &str) {
|
|||||||
rebuild(window);
|
rebuild(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Order playlists by the active sort option, mirroring Tauri's
|
/// Order playlists by the active sort option + direction, mirroring Tauri's
|
||||||
/// comparators. `recent` keeps reverse insertion order (most-recently
|
/// comparators. `recent` keeps the API order, which IS newest-first (#657 —
|
||||||
/// added first); `playcount` has no per-playlist count source here, so it
|
/// the old unconditional `.reverse()` was written when the list arrived
|
||||||
/// stays stable like Tauri does when `play_count` is absent (0).
|
/// oldest-first and had been flipping it to oldest-first ever since);
|
||||||
|
/// `playcount` has no per-playlist count source here, so it also keeps the
|
||||||
|
/// API order like Tauri does when `play_count` is absent. Every option's
|
||||||
|
/// `asc == true` is its natural direction; `false` reverses it.
|
||||||
fn sort_playlists(playlists: &[SidebarPlaylist]) -> Vec<SidebarPlaylist> {
|
fn sort_playlists(playlists: &[SidebarPlaylist]) -> Vec<SidebarPlaylist> {
|
||||||
let sort = SORT.lock().map(|s| s.clone()).unwrap_or_else(|_| "name".into());
|
let (sort, asc) = SORT
|
||||||
|
.lock()
|
||||||
|
.map(|s| s.clone())
|
||||||
|
.unwrap_or_else(|_| ("name".into(), true));
|
||||||
let mut out: Vec<SidebarPlaylist> = playlists.to_vec();
|
let mut out: Vec<SidebarPlaylist> = playlists.to_vec();
|
||||||
match sort.as_str() {
|
match sort.as_str() {
|
||||||
"name" => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
|
"name" => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
|
||||||
"recent" => out.reverse(),
|
|
||||||
"tracks" => out.sort_by(|a, b| b.tracks_count.cmp(&a.tracks_count)),
|
"tracks" => out.sort_by(|a, b| b.tracks_count.cmp(&a.tracks_count)),
|
||||||
"playcount" => { /* no play_count source — stable, like Tauri's absent field */ }
|
|
||||||
"custom" => out.sort_by(|a, b| a.position.cmp(&b.position)),
|
"custom" => out.sort_by(|a, b| a.position.cmp(&b.position)),
|
||||||
_ => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
|
// recent / playcount — API order (newest first).
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if !asc {
|
||||||
|
out.reverse();
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -408,6 +429,30 @@ pub fn rename_entry(window: &AppWindow, id: &str, name: &str) {
|
|||||||
rebuild(window);
|
rebuild(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optimistically INSERT a freshly-created Qobuz playlist row (import or
|
||||||
|
/// duplicate-create) so the sidebar shows it instantly — the authoritative
|
||||||
|
/// `getUserPlaylists` read lags the create by seconds (the import flow
|
||||||
|
/// reconciles with a bounded retry, mirroring `reconcile_sidebar_after_rename`).
|
||||||
|
/// Idempotent by id.
|
||||||
|
pub fn insert_qobuz_entry(window: &AppWindow, id: u64, name: &str, tracks_count: u32) {
|
||||||
|
if let Ok(mut cache) = CACHE.lock() {
|
||||||
|
if !cache.playlists.iter().any(|p| p.id == id) {
|
||||||
|
cache.playlists.insert(
|
||||||
|
0,
|
||||||
|
SidebarPlaylist {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
tracks_count,
|
||||||
|
cover_urls: Vec::new(),
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuild(window);
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a playlist `SidebarEntry` (with its cover URLs for the
|
/// Build a playlist `SidebarEntry` (with its cover URLs for the
|
||||||
/// micro-collage). The decoded `cover*` images stay default here and are
|
/// micro-collage). The decoded `cover*` images stay default here and are
|
||||||
/// filled asynchronously by the artwork pipeline (see `artwork_jobs`).
|
/// filled asynchronously by the artwork pipeline (see `artwork_jobs`).
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! Steam on-screen keyboard via the deeplink — Steam Deck plan PR 4
|
||||||
|
//! (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5).
|
||||||
|
//!
|
||||||
|
//! The Deck OSK only opens when something asks the Steam client for it. For
|
||||||
|
//! a non-Steam shortcut the only programmatic path is the deeplink
|
||||||
|
//! `steam://open/keyboard?...&Mode=N` (and `steam://close/keyboard`) — the
|
||||||
|
//! same call Valve's own SDL fires on the Deck, proven for non-Steam apps.
|
||||||
|
//! Entered text arrives as ordinary key events, so no extra plumbing: a text
|
||||||
|
//! field gains focus → we fire open; it loses focus → we fire close. Mode 0
|
||||||
|
//! (single-line) covers every field in the app today (login is browser-OAuth,
|
||||||
|
//! so there is no in-app email field to flag Mode 2 for).
|
||||||
|
//!
|
||||||
|
//! Activation gate: `SteamDeck=1` env (set by SteamOS Game Mode) or
|
||||||
|
//! `SDL_ENABLE_STEAM_SCREEN_KEYBOARD=1`, with a manual override
|
||||||
|
//! `QBZ_STEAM_OSK=0/1` (0 wins over everything — diagnostics).
|
||||||
|
|
||||||
|
/// True when the Steam OSK integration should fire.
|
||||||
|
fn enabled() -> bool {
|
||||||
|
match std::env::var("QBZ_STEAM_OSK") {
|
||||||
|
Ok(v) if v == "1" => return true,
|
||||||
|
Ok(v) if v == "0" => return false,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
std::env::var("SteamDeck").map(|v| v == "1").unwrap_or(false)
|
||||||
|
|| std::env::var_os("SDL_ENABLE_STEAM_SCREEN_KEYBOARD").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A text field's focus flipped (wired to `UiFocusState.osk` in main.rs).
|
||||||
|
/// Fire-and-forget: the deeplink has no dismissal callback, so ALWAYS send
|
||||||
|
/// close on blur — a stranded OSK is worse than a redundant close.
|
||||||
|
pub fn focus_changed(focused: bool, mode: i32) {
|
||||||
|
if !enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = if focused {
|
||||||
|
format!(
|
||||||
|
"steam://open/keyboard?XPosition=0&YPosition=0&Width=0&Height=0&Mode={mode}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"steam://close/keyboard".to_string()
|
||||||
|
};
|
||||||
|
// xdg-open routes the steam:// scheme to the running Steam client; the
|
||||||
|
// fallback pokes Steam directly (only if already running).
|
||||||
|
if std::process::Command::new("xdg-open")
|
||||||
|
.arg(&url)
|
||||||
|
.spawn()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
let _ = std::process::Command::new("steam")
|
||||||
|
.arg("-ifrunning")
|
||||||
|
.arg(&url)
|
||||||
|
.spawn();
|
||||||
|
}
|
||||||
|
log::info!("[steam-osk] {} (mode {mode})", if focused { "open" } else { "close" });
|
||||||
|
}
|
||||||
@@ -406,6 +406,16 @@ pub struct UiPrefs {
|
|||||||
/// per-machine app preference — your Discord client is per-machine.)
|
/// per-machine app preference — your Discord client is per-machine.)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub discord_rpc_enabled: bool,
|
pub discord_rpc_enabled: bool,
|
||||||
|
/// Animate the now-playing row's equalizer bars. Default OFF — a static
|
||||||
|
/// pause glyph + accent edge mark instead; the animated bars hold
|
||||||
|
/// measurable CPU on some machines (owner call 2026-07-24).
|
||||||
|
#[serde(default)]
|
||||||
|
pub play_indicator_animation: bool,
|
||||||
|
/// Invert the two-finger touchpad swipe navigation direction (#653).
|
||||||
|
/// Default OFF = the natural-scrolling convention (fingers right = back,
|
||||||
|
/// fingers left = forward).
|
||||||
|
#[serde(default)]
|
||||||
|
pub invert_swipe_navigation: bool,
|
||||||
/// Show the opt-in "Purchases" navigation entry (sidebar / header). Default
|
/// Show the opt-in "Purchases" navigation entry (sidebar / header). Default
|
||||||
/// OFF — the whole DRM-free Purchases feature is hidden until the user
|
/// OFF — the whole DRM-free Purchases feature is hidden until the user
|
||||||
/// enables it (mirrors Tauri `showPurchases`, default `false`). Re-seeded
|
/// enables it (mirrors Tauri `showPurchases`, default `false`). Re-seeded
|
||||||
@@ -772,6 +782,8 @@ impl Default for UiPrefs {
|
|||||||
system_notifications: default_system_notifications(),
|
system_notifications: default_system_notifications(),
|
||||||
musicbrainz_enabled: default_musicbrainz_enabled(),
|
musicbrainz_enabled: default_musicbrainz_enabled(),
|
||||||
discord_rpc_enabled: false,
|
discord_rpc_enabled: false,
|
||||||
|
play_indicator_animation: false,
|
||||||
|
invert_swipe_navigation: false,
|
||||||
show_purchases: false,
|
show_purchases: false,
|
||||||
nav_tb_purchases: false,
|
nav_tb_purchases: false,
|
||||||
use_system_title_bar: default_use_system_title_bar(),
|
use_system_title_bar: default_use_system_title_bar(),
|
||||||
|
|||||||