diff --git a/docs/images/music/pager-paused.png b/docs/images/music/pager-paused.png new file mode 100644 index 00000000..9bf952ca Binary files /dev/null and b/docs/images/music/pager-paused.png differ diff --git a/docs/images/music/tdeck-interrupted.png b/docs/images/music/tdeck-interrupted.png new file mode 100644 index 00000000..a990324f Binary files /dev/null and b/docs/images/music/tdeck-interrupted.png differ diff --git a/docs/uiux/components/music.md b/docs/uiux/components/music.md index 57a3a466..8313ed65 100644 --- a/docs/uiux/components/music.md +++ b/docs/uiux/components/music.md @@ -8,7 +8,9 @@ are never deleted by a playlist operation. Now Playing includes embedded artwork, title, artist, album, elapsed/total time, seek, previous/pause/next and repeat-all/repeat-one/shuffle. The artwork frame and -seek knob have disjoint bounds on every supported screen. The track-information +seek knob have disjoint bounds on every supported screen. Compact screens place +a single-line paused/interrupted status between the time labels, outside the +slider knob and playback controls. Longer status text elides. The track-information view pages through six ID3 fields at a time and keeps the cover visible. Lyrics use three lines with synchronized LRC selection or manual scrolling. @@ -76,7 +78,7 @@ write flash. A slider owns its whole drag and cannot page the dashboard. | Control | Owner and transition | | --- | --- | | Wi-Fi | Preserve saved profiles and priority. Coordinate transport clients before stopping the driver. Reject changes during OTA, calls, non-preemptible operations or external-storage suspension. Roll back a rejected preference; preserve initialization state after failed driver cleanup so retry is possible. | -| LoRa | A shared RF-permission policy is separate from mesh-protocol TX/RX configuration and temporary exclusive sessions. Reject a switch during an exclusive session. Reject new TX while disabled, finish current work and enter checked standby before acknowledging Off. Re-enable through the radio task; an uninitialized disabled radio can still be enabled. Preserve shared power rails. | +| LoRa | A shared RF-permission policy is separate from mesh-protocol TX/RX configuration and temporary exclusive sessions. Reject a switch during an exclusive session. Reject new TX while disabled, finish current work and enter checked standby before acknowledging Off. The control accepts an enable request while offline; the radio task reports transition failures instead of claiming hardware success. Preserve shared power rails. | | GPS | Use the existing configuration edit lease, GPS runtime enable operation and position projection. The user preference is separate from normal duty-cycle sleep and temporary power leases. | | Volume | Use the shared music/speaker volume. An idle adjustment updates the cached preference without creating a decoder, opening SD or starting a worker. Active playback defers flash persistence until audio is idle. | | Brightness | Map the percentage to the target's actual brightness range, apply through its device port and persist that native level. | @@ -133,6 +135,10 @@ preview services. Top-bar memory values and media metadata are fixture data. ![Pager Now Playing with cover, seek and playback modes](../../images/music/pager-now-playing.png) +![Pager paused status clear of the progress knob](../../images/music/pager-paused.png) + +![T-Deck interrupted status stays on one line above the controls](../../images/music/tdeck-interrupted.png) + ![Pager Quick controls without the empty footer](../../images/music/pager-quick-controls.png) ![P4 Music widget in the right dashboard](../../images/music/p4-music-widget.png) diff --git a/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp b/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp index 2d9c1446..ff2b43fb 100644 --- a/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp @@ -139,6 +139,7 @@ void now_playing(lv_coord_t width, lv_coord_t height) self.volume = label(panel, volume, w - 54, title_y + 46, 40, &lv_font_montserrat_12, true); self.playback_status = label(panel, "", text_x, title_y + 64, text_w, &lv_font_montserrat_12, true); lv_label_set_long_mode(self.playback_status, LV_LABEL_LONG_DOT); + lv_obj_set_height(self.playback_status, lv_font_get_line_height(&lv_font_montserrat_12)); const lv_coord_t controls_y = h - 44; const lv_coord_t progress_y = controls_y - 34; self.progress = lv_slider_create(panel); @@ -154,6 +155,14 @@ void now_playing(lv_coord_t width, lv_coord_t height) self.elapsed = label(panel, "0:00", 14, progress_y + 12, 70, &lv_font_montserrat_12, true); self.duration = label(panel, "--:--", w - 84, progress_y + 12, 70, &lv_font_montserrat_12, true); lv_obj_set_style_text_align(self.duration, LV_TEXT_ALIGN_RIGHT, 0); + if (!self.wide) + { + // Status shares the time row on compact screens, leaving the cover, + // metadata and the slider's painted knob area independent. + lv_obj_set_pos(self.playback_status, 92, progress_y + 12); + lv_obj_set_width(self.playback_status, w - 184); + lv_obj_set_style_text_align(self.playback_status, LV_TEXT_ALIGN_CENTER, 0); + } auto* mode = button(panel, "Repeat all", PlayMode, 92); self.mode = lv_obj_get_child(mode, 0); auto* previous = button(panel, LV_SYMBOL_PREV, Previous, 36); diff --git a/tests/site/music-native-ui.test.mjs b/tests/site/music-native-ui.test.mjs index 1eed9ec8..9e24a3a6 100644 --- a/tests/site/music-native-ui.test.mjs +++ b/tests/site/music-native-ui.test.mjs @@ -21,6 +21,14 @@ for(const [device,width,height] of boards.filter(([name])=>!process.env.MUSIC_UI click('Repeat one');assert.ok(snapshot().some(item=>item.text==='Shuffle')); click('Shuffle');assert.ok(snapshot().some(item=>item.text==='Repeat all')); await capture('now-playing'); + click('Pause');assert.ok(snapshot().some(item=>item.text==='Paused')); + assert.equal(runtime._native_music_geometry(),0,`${device}: paused status clears the progress knob and time labels`); + await capture('paused'); + runtime._native_preview_music_playing(2);tick();tick(); + assert.ok(snapshot().some(item=>item.text.startsWith('Paused for')),'interrupted status remains readable when elided'); + assert.equal(runtime._native_music_geometry(),0,`${device}: interrupted status clears the progress knob and time labels`); + await capture('interrupted'); + runtime._native_preview_music_playing(1);tick();tick(); click('Lyrics');assert.ok(snapshot().some(item=>item.text==='We take the quiet way home'));await capture('lyrics'); click('\uf053');click('Menu');click('Track info');await capture('metadata');assert.ok(snapshot().some(item=>item.text==='Aster')); click('\uf053');click('Library');click('Browse');click('Playlists');await capture('playlists'); diff --git a/tools/web_simulator/preview_music.cpp b/tools/web_simulator/preview_music.cpp index c14ba0cd..7682904d 100644 --- a/tools/web_simulator/preview_music.cpp +++ b/tools/web_simulator/preview_music.cpp @@ -306,9 +306,13 @@ class Source final : public music::IService ++out.revision; return true; } - void set_playing(bool value) + void set_playing(int value) { - if (value) play(1); + if (value) + { + play(1); + if (value == 2) out.playback = music::Playback::Interrupted; + } else out.playback = music::Playback::Stopped; } } source; @@ -319,7 +323,7 @@ void preview_bind_music() ui::menu::dashboard::bindMusicSource(&source); } music::IService* preview_music_service() { return &source; } -extern "C" EMSCRIPTEN_KEEPALIVE void native_preview_music_playing(int value) { source.set_playing(value != 0); } +extern "C" EMSCRIPTEN_KEEPALIVE void native_preview_music_playing(int value) { source.set_playing(value); } // QA only: inspect the actual shared LVGL objects, including slider knob clearance. extern "C" EMSCRIPTEN_KEEPALIVE int native_music_geometry() { @@ -335,5 +339,19 @@ extern "C" EMSCRIPTEN_KEEPALIVE int native_music_geometry() const auto knob = lv_obj_get_style_pad_top(self->progress, LV_PART_KNOB) + lv_obj_get_height(self->progress); if (cover.y2 + knob >= progress.y1) return 1; if (top.y2 >= body.y1 || body.y2 >= footer.y1) return 2; + if (self->playback_status && lv_label_get_text(self->playback_status)[0]) + { + lv_area_t status{}, elapsed{}, duration{}, controls{}; + lv_obj_get_coords(self->playback_status, &status); + lv_obj_get_coords(self->elapsed, &elapsed); + lv_obj_get_coords(self->duration, &duration); + lv_obj_get_coords(lv_obj_get_parent(self->play_label), &controls); + auto painted = progress; + painted.y1 -= lv_obj_get_style_pad_top(self->progress, LV_PART_KNOB) + 1; + painted.y2 += lv_obj_get_style_pad_bottom(self->progress, LV_PART_KNOB) + 1; + const auto overlaps = [](const lv_area_t& a, const lv_area_t& b) + { return a.x1 <= b.x2 && a.x2 >= b.x1 && a.y1 <= b.y2 && a.y2 >= b.y1; }; + if (overlaps(status, painted) || overlaps(status, elapsed) || overlaps(status, duration) || overlaps(status, controls)) return 3; + } return 0; }