/* * Copyright 2023 jacqueline * * SPDX-License-Identifier: GPL-3.0-only */ #include #include #include "core/lv_obj.h" #include "core/lv_obj_scroll.h" #include "core/lv_obj_tree.h" #include "database.hpp" #include "event_queue.hpp" #include "extra/layouts/flex/lv_flex.h" #include "font/lv_symbol_def.h" #include "lvgl.h" #include "misc/lv_anim.h" #include "misc/lv_color.h" #include "model_top_bar.hpp" #include "screen_menu.hpp" #include "core/lv_event.h" #include "esp_log.h" #include "core/lv_group.h" #include "core/lv_obj_pos.h" #include "extra/widgets/list/lv_list.h" #include "extra/widgets/menu/lv_menu.h" #include "extra/widgets/spinner/lv_spinner.h" #include "hal/lv_hal_disp.h" #include "misc/lv_area.h" #include "screen_track_browser.hpp" #include "source.hpp" #include "themes.hpp" #include "track_queue.hpp" #include "ui_events.hpp" #include "ui_fsm.hpp" #include "widget_top_bar.hpp" #include "widgets/lv_label.h" [[maybe_unused]] static constexpr char kTag[] = "browser"; static constexpr int kMaxPages = 4; static constexpr int kPageBuffer = 6; namespace ui { namespace screens { static void item_click_cb(lv_event_t* ev) { if (ev->user_data == NULL) { return; } TrackBrowser* instance = reinterpret_cast(ev->user_data); instance->OnItemClicked(ev); } static void item_select_cb(lv_event_t* ev) { if (ev->user_data == NULL) { return; } TrackBrowser* instance = reinterpret_cast(ev->user_data); instance->OnItemSelected(ev); } TrackBrowser::TrackBrowser( models::TopBar& top_bar_model, audio::TrackQueue& queue, std::weak_ptr db, const std::pmr::vector& crumbs, std::future*>&& initial_page) : queue_(queue), db_(db), play_button_(nullptr), enqueue_button_(nullptr), list_(nullptr), loading_indicator_(nullptr), breadcrumbs_(crumbs), loading_pos_(END), loading_page_(move(initial_page)), initial_page_(), current_pages_() { lv_obj_set_layout(content_, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(content_, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(content_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); widgets::TopBar::Configuration config{ .show_back_button = true, .title = breadcrumbs_[0], }; auto top_bar = CreateTopBar(content_, config, top_bar_model); back_button_ = top_bar->button(); lv_obj_t* scrollable = lv_obj_create(content_); lv_obj_set_width(scrollable, lv_pct(100)); lv_obj_set_flex_grow(scrollable, 1); lv_obj_set_layout(scrollable, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(scrollable, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(scrollable, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); if (crumbs.size() > 1) { lv_obj_t* header = lv_obj_create(scrollable); lv_obj_set_size(header, lv_pct(100), LV_SIZE_CONTENT); lv_obj_set_layout(header, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(header, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(header, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_set_style_pad_left(header, 4, LV_PART_MAIN); lv_obj_set_style_pad_right(header, 4, LV_PART_MAIN); lv_obj_t* spacer = lv_obj_create(header); lv_obj_set_size(spacer, 1, 2); for (size_t i = 1; i < crumbs.size(); i++) { lv_obj_t* crumb = lv_label_create(header); lv_label_set_text(crumb, crumbs[i].c_str()); spacer = lv_obj_create(header); lv_obj_set_size(spacer, 1, 2); } spacer = lv_obj_create(header); lv_obj_set_size(spacer, 1, 2); lv_obj_t* buttons_container = lv_obj_create(header); lv_obj_set_width(buttons_container, lv_pct(100)); lv_obj_set_height(buttons_container, LV_SIZE_CONTENT); lv_obj_set_layout(buttons_container, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(buttons_container, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(buttons_container, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_t* label; play_button_ = lv_btn_create(buttons_container); label = lv_label_create(play_button_); lv_label_set_text(label, "Play all"); lv_group_add_obj(group_, play_button_); themes::Theme::instance()->ApplyStyle(play_button_, themes::Style::kButtonPrimary); lv_bind(play_button_, LV_EVENT_CLICKED, [&](lv_obj_t*) { if (!initial_page_) { return; } queue_.Clear(); queue_.IncludeNext(playlist::CreateSourceFromResults(db_, initial_page_)); events::Ui().Dispatch(internal::ShowNowPlaying{}); }); if (queue_.GetCurrent()) { spacer = lv_obj_create(buttons_container); lv_obj_set_size(spacer, 4, 1); enqueue_button_ = lv_btn_create(buttons_container); label = lv_label_create(enqueue_button_); lv_label_set_text(label, "Enqueue"); lv_group_add_obj(group_, enqueue_button_); themes::Theme::instance()->ApplyStyle(enqueue_button_, themes::Style::kButtonPrimary); lv_bind(enqueue_button_, LV_EVENT_CLICKED, [&](lv_obj_t*) { if (!initial_page_) { return; } queue_.IncludeNext( playlist::CreateSourceFromResults(db_, initial_page_)); }); } lv_obj_set_style_border_width(header, 1, LV_PART_MAIN); lv_obj_set_style_border_color(header, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_border_side(header, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); spacer = lv_obj_create(header); lv_obj_set_size(spacer, 1, 4); lv_obj_set_style_border_width(header, 1, LV_PART_MAIN); lv_obj_set_style_border_color( header, lv_palette_lighten(LV_PALETTE_GREY, 3), LV_PART_MAIN); } list_ = lv_list_create(scrollable); lv_obj_set_size(list_, lv_pct(100), LV_SIZE_CONTENT); // The default scrollbar is deceptive because we load in items progressively. // TODO/FIXME: this doesn't actually turn off the scrollbar, it seems. lv_obj_set_scrollbar_mode(content_, LV_SCROLLBAR_MODE_OFF); // Wrapping behaves in surprising ways, again due to progressing loading. lv_group_set_wrap(group_, false); } auto TrackBrowser::Tick() -> void { if (!loading_page_) { return; } if (!loading_page_->valid()) { // TODO(jacqueline): error case. return; } if (loading_page_->wait_for(std::chrono::seconds(0)) == std::future_status::ready) { std::shared_ptr> result{ loading_page_->get()}; AddResults(loading_pos_.value_or(END), result); loading_page_.reset(); loading_pos_.reset(); } } auto TrackBrowser::OnItemSelected(lv_event_t* ev) -> void { auto index = GetItemIndex(lv_event_get_target(ev)); if (!index) { return; } if (index < kPageBuffer) { FetchNewPage(START); return; } if (index > GetNumRecords() - kPageBuffer) { FetchNewPage(END); return; } } auto TrackBrowser::OnItemClicked(lv_event_t* ev) -> void { auto res = GetItemIndex(lv_event_get_target(ev)); if (!res) { return; } auto index = *res; for (const auto& page : current_pages_) { for (std::size_t i = 0; i < page->values().size(); i++) { if (index == 0) { auto text = page->values()[i]->text(); auto crumbs = breadcrumbs_; crumbs.push_back(text.value()); events::Ui().Dispatch(internal::RecordSelected{ .show_menu = ev->code == LV_EVENT_LONG_PRESSED, .new_crumbs = crumbs, .initial_page = initial_page_, .page = page, .record = i, }); return; } index--; } } } auto TrackBrowser::AddLoadingIndictor(Position pos) -> void { if (loading_indicator_) { return; } loading_indicator_ = lv_list_add_text(list_, "Loading..."); if (pos == START) { lv_obj_move_to_index(loading_indicator_, 0); } } auto TrackBrowser::AddResults( Position pos, std::shared_ptr> results) -> void { if (loading_indicator_ != nullptr) { lv_obj_del(loading_indicator_); loading_indicator_ = nullptr; } if (initial_page_ == nullptr) { initial_page_ = results; } auto fn = [&](const std::shared_ptr& record) { auto text = record->text(); if (!text) { // TODO(jacqueline): Display category-specific text. text = "[ no data ]"; } lv_obj_t* item = lv_list_add_btn(list_, NULL, text->c_str()); lv_label_set_long_mode(lv_obj_get_child(item, -1), LV_LABEL_LONG_DOT); lv_obj_add_event_cb(item, item_click_cb, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(item, item_click_cb, LV_EVENT_LONG_PRESSED, this); lv_obj_add_event_cb(item, item_select_cb, LV_EVENT_FOCUSED, this); if (pos == START) { lv_obj_move_to_index(item, 0); } }; lv_obj_t* focused = lv_group_get_focused(group_); // Adding objects at the start of the list will artificially scroll the list // up. Scroll it down by the height we're adding so that the user doesn't // notice any jank. if (pos == START) { int num_to_add = results->values().size(); // Assuming that all items are the same height, this item's y pos should be // exactly the height of the new items. lv_obj_t* representative_item = lv_obj_get_child(list_, num_to_add); if (representative_item != nullptr) { int scroll_adjustment = lv_obj_get_y(representative_item); lv_obj_scroll_by(list_, 0, -scroll_adjustment, LV_ANIM_OFF); } } switch (pos) { case START: std::for_each(results->values().rbegin(), results->values().rend(), fn); current_pages_.push_front(results); break; case END: std::for_each(results->values().begin(), results->values().end(), fn); current_pages_.push_back(results); break; } lv_group_remove_all_objs(group_); lv_group_add_obj(group_, back_button_); if (play_button_) { lv_group_add_obj(group_, play_button_); } if (enqueue_button_) { lv_group_add_obj(group_, enqueue_button_); } int num_children = lv_obj_get_child_cnt(list_); for (int i = 0; i < num_children; i++) { lv_group_add_obj(group_, lv_obj_get_child(list_, i)); } lv_group_focus_obj(focused); } auto TrackBrowser::DropPage(Position pos) -> void { if (pos == START) { // Removing objects from the start of the list will artificially scroll the // list down. Scroll it up by the height we're removing so that the user // doesn't notice any jank. int num_to_remove = current_pages_.front()->values().size(); lv_obj_t* new_top_obj = lv_obj_get_child(list_, num_to_remove); if (new_top_obj != nullptr) { int scroll_adjustment = lv_obj_get_y(new_top_obj); lv_obj_scroll_by(list_, 0, scroll_adjustment, LV_ANIM_OFF); } for (int i = 0; i < current_pages_.front()->values().size(); i++) { lv_obj_t* item = lv_obj_get_child(list_, 0); if (item == NULL) { continue; } lv_obj_del(item); } current_pages_.pop_front(); } else if (pos == END) { for (int i = 0; i < current_pages_.back()->values().size(); i++) { lv_obj_t* item = lv_obj_get_child(list_, lv_obj_get_child_cnt(list_) - 1); if (item == NULL) { continue; } lv_group_remove_obj(item); lv_obj_del(item); } current_pages_.pop_back(); } } auto TrackBrowser::FetchNewPage(Position pos) -> void { if (loading_page_) { return; } std::optional cont; switch (pos) { case START: cont = current_pages_.front()->prev_page(); break; case END: cont = current_pages_.back()->next_page(); break; } if (!cont) { return; } auto db = db_.lock(); if (!db) { return; } // If we already have a complete set of pages, drop the page that's furthest // away. if (current_pages_.size() >= kMaxPages) { switch (pos) { case START: DropPage(END); break; case END: DropPage(START); break; } } loading_pos_ = pos; loading_page_ = db->GetPage(&cont.value()); } auto TrackBrowser::GetNumRecords() -> std::size_t { return lv_obj_get_child_cnt(list_) - (loading_indicator_ != nullptr ? 1 : 0); } auto TrackBrowser::GetItemIndex(lv_obj_t* obj) -> std::optional { std::size_t child_count = lv_obj_get_child_cnt(list_); std::size_t index = 0; for (int i = 0; i < child_count; i++) { lv_obj_t* child = lv_obj_get_child(list_, i); if (child == loading_indicator_) { continue; } if (child == obj) { return index; } index++; } return {}; } } // namespace screens } // namespace ui