From 7197da21f6bcc1aaa5d1905228e0e2ec1caf3fa8 Mon Sep 17 00:00:00 2001 From: jacqueline Date: Mon, 17 Jul 2023 16:54:35 +1000 Subject: [PATCH] Basic playlists for upcoming Beware under-testing and bugs. Just getting something barebones in so that I can do rN+1 bringup --- src/audio/CMakeLists.txt | 2 +- src/audio/include/track_queue.hpp | 27 ++-- src/audio/track_queue.cpp | 168 ++++++++++++++++-------- src/database/CMakeLists.txt | 2 +- src/database/include/tag_parser.hpp | 14 +- src/database/include/track.hpp | 3 +- src/database/tag_parser.cpp | 7 + src/playlist/CMakeLists.txt | 10 ++ src/playlist/include/shuffler.hpp | 68 ++++++++++ src/playlist/include/source.hpp | 105 +++++++++++++++ src/playlist/shuffler.cpp | 166 +++++++++++++++++++++++ src/playlist/source.cpp | 145 ++++++++++++++++++++ src/ui/include/screen_track_browser.hpp | 8 +- src/ui/include/ui_events.hpp | 5 +- src/ui/screen_track_browser.cpp | 65 +++++---- src/ui/ui_fsm.cpp | 16 ++- src/util/CMakeLists.txt | 7 + src/util/include/bloom_filter.hpp | 40 ++++++ src/util/include/lru_cache.hpp | 69 ++++++++++ src/util/include/random.hpp | 39 ++++++ src/util/random.cpp | 37 ++++++ 21 files changed, 886 insertions(+), 117 deletions(-) create mode 100644 src/playlist/CMakeLists.txt create mode 100644 src/playlist/include/shuffler.hpp create mode 100644 src/playlist/include/source.hpp create mode 100644 src/playlist/shuffler.cpp create mode 100644 src/playlist/source.cpp create mode 100644 src/util/CMakeLists.txt create mode 100644 src/util/include/bloom_filter.hpp create mode 100644 src/util/include/lru_cache.hpp create mode 100644 src/util/include/random.hpp create mode 100644 src/util/random.cpp diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 38e367aa..2501f773 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -7,6 +7,6 @@ idf_component_register( "stream_message.cpp" "i2s_audio_output.cpp" "stream_buffer.cpp" "track_queue.cpp" "stream_event.cpp" "pipeline.cpp" "stream_info.cpp" "audio_fsm.cpp" INCLUDE_DIRS "include" - REQUIRES "codecs" "drivers" "cbor" "result" "tasks" "span" "memory" "tinyfsm" "database" "system_fsm") + REQUIRES "codecs" "drivers" "cbor" "result" "tasks" "span" "memory" "tinyfsm" "database" "system_fsm" "playlist") target_compile_options(${COMPONENT_LIB} PRIVATE ${EXTRA_WARNINGS}) diff --git a/src/audio/include/track_queue.hpp b/src/audio/include/track_queue.hpp index 840d71ee..49c0d61b 100644 --- a/src/audio/include/track_queue.hpp +++ b/src/audio/include/track_queue.hpp @@ -6,10 +6,12 @@ #pragma once -#include +#include +#include #include #include +#include "source.hpp" #include "track.hpp" namespace audio { @@ -44,7 +46,9 @@ class TrackQueue { * If there is no current track, the given track will begin playback. */ auto AddNext(database::TrackId) -> void; - auto AddNext(const std::vector&) -> void; + auto AddNext(std::shared_ptr) -> void; + + auto IncludeNext(std::shared_ptr) -> void; /* * Enqueues a track, placing it the end of all enqueued tracks. @@ -52,7 +56,9 @@ class TrackQueue { * If there is no current track, the given track will begin playback. */ auto AddLast(database::TrackId) -> void; - auto AddLast(const std::vector&) -> void; + auto AddLast(std::shared_ptr) -> void; + + auto IncludeLast(std::shared_ptr) -> void; /* * Advances to the next track in the queue, placing the current track at the @@ -65,11 +71,6 @@ class TrackQueue { * Removes all tracks from all queues, and stops any currently playing track. */ auto Clear() -> void; - /* - * Removes a specific track from the queue of upcoming tracks. Has no effect - * on the currently playing track. - */ - auto RemoveUpcoming(database::TrackId) -> void; TrackQueue(const TrackQueue&) = delete; TrackQueue& operator=(const TrackQueue&) = delete; @@ -77,9 +78,13 @@ class TrackQueue { private: mutable std::mutex mutex_; - std::deque played_; - std::deque upcoming_; - std::optional current_; + std::list>> + played_; + std::list, + std::shared_ptr>> + enqueued_; }; } // namespace audio diff --git a/src/audio/track_queue.cpp b/src/audio/track_queue.cpp index 1c233f8f..0709056f 100644 --- a/src/audio/track_queue.cpp +++ b/src/audio/track_queue.cpp @@ -8,10 +8,12 @@ #include #include +#include #include "audio_events.hpp" #include "audio_fsm.hpp" #include "event_queue.hpp" +#include "source.hpp" #include "track.hpp" #include "ui_fsm.hpp" @@ -21,107 +23,163 @@ TrackQueue::TrackQueue() {} auto TrackQueue::GetCurrent() const -> std::optional { const std::lock_guard lock(mutex_); - return current_; + if (enqueued_.empty()) { + return {}; + } + auto item = enqueued_.front(); + if (std::holds_alternative(item)) { + return std::get(item); + } + if (std::holds_alternative>(item)) { + return std::get>(item)->Current(); + } + if (std::holds_alternative>( + item)) { + return std::get>(item) + ->Current(); + } + return {}; } auto TrackQueue::GetUpcoming(std::size_t limit) const -> std::vector { const std::lock_guard lock(mutex_); std::vector ret; - limit = std::min(limit, upcoming_.size()); - std::for_each_n(upcoming_.begin(), limit, - [&](const auto i) { ret.push_back(i); }); + + auto it = enqueued_.begin(); + if (it == enqueued_.end()) { + return ret; + } + + // Don't include the current track. This is only relevant to raw track ids, + // since sources include multiple tracks. + if (std::holds_alternative(*it)) { + it++; + } + + while (limit > 0 && it != enqueued_.end()) { + auto item = *it; + if (std::holds_alternative(item)) { + ret.push_back(std::get(item)); + limit--; + } else if (std::holds_alternative>( + item)) { + limit -= + std::get>(item)->Peek(limit, &ret); + } else if (std::holds_alternative< + std::shared_ptr>(item)) { + limit -= + std::get>(item)->Peek( + limit, &ret); + } + it++; + } + return ret; } auto TrackQueue::AddNext(database::TrackId t) -> void { const std::lock_guard lock(mutex_); - if (!current_) { - current_ = t; - } else { - upcoming_.push_front(t); - } + enqueued_.push_front(t); + events::Dispatch({}); +} +auto TrackQueue::AddNext(std::shared_ptr src) -> void { + const std::lock_guard lock(mutex_); + enqueued_.push_front(src); events::Dispatch({}); } -auto TrackQueue::AddNext(const std::vector& t) -> void { +auto TrackQueue::IncludeNext(std::shared_ptr src) + -> void { const std::lock_guard lock(mutex_); - std::for_each(t.rbegin(), t.rend(), - [&](const auto i) { upcoming_.push_front(i); }); - if (!current_) { - current_ = upcoming_.front(); - upcoming_.pop_front(); - } + enqueued_.push_front(src); events::Dispatch({}); } auto TrackQueue::AddLast(database::TrackId t) -> void { const std::lock_guard lock(mutex_); - if (!current_) { - current_ = t; - } else { - upcoming_.push_back(t); - } + enqueued_.push_back(t); + events::Dispatch({}); +} +auto TrackQueue::AddLast(std::shared_ptr src) -> void { + const std::lock_guard lock(mutex_); + enqueued_.push_back(src); events::Dispatch({}); } -auto TrackQueue::AddLast(const std::vector& t) -> void { +auto TrackQueue::IncludeLast(std::shared_ptr src) + -> void { const std::lock_guard lock(mutex_); - std::for_each(t.begin(), t.end(), - [&](const auto i) { upcoming_.push_back(i); }); - if (!current_) { - current_ = upcoming_.front(); - upcoming_.pop_front(); - } + enqueued_.push_back(src); events::Dispatch({}); } auto TrackQueue::Next() -> void { const std::lock_guard lock(mutex_); - if (current_) { - played_.push_front(*current_); + if (enqueued_.empty()) { + return; + } + + auto item = enqueued_.front(); + if (std::holds_alternative(item)) { + played_.push_front(std::get(item)); + enqueued_.pop_front(); + } + if (std::holds_alternative>(item)) { + auto src = std::get>(item); + played_.push_front(*src->Current()); + if (!src->Advance()) { + enqueued_.pop_front(); + } } - if (!upcoming_.empty()) { - current_ = upcoming_.front(); - upcoming_.pop_front(); - } else { - current_.reset(); + if (std::holds_alternative>( + item)) { + auto src = std::get>(item); + if (!src->Advance()) { + played_.push_back(src); + enqueued_.pop_front(); + } } + events::Dispatch({}); } auto TrackQueue::Previous() -> void { const std::lock_guard lock(mutex_); - if (current_) { - upcoming_.push_front(*current_); + if (!enqueued_.empty() && + std::holds_alternative>( + enqueued_.front())) { + auto src = std::get>( + enqueued_.front()); + if (src->Previous()) { + events::Dispatch({}); + return; + } + } + + if (played_.empty()) { + return; } - if (!played_.empty()) { - current_ = played_.front(); - played_.pop_front(); - } else { - current_.reset(); + + auto item = played_.front(); + if (std::holds_alternative(item)) { + enqueued_.push_front(std::get(item)); + } else if (std::holds_alternative< + std::shared_ptr>(item)) { + enqueued_.push_front( + std::get>(item)); } + played_.pop_front(); + events::Dispatch({}); } auto TrackQueue::Clear() -> void { const std::lock_guard lock(mutex_); played_.clear(); - upcoming_.clear(); - current_.reset(); - events::Dispatch({}); -} - -auto TrackQueue::RemoveUpcoming(database::TrackId t) -> void { - const std::lock_guard lock(mutex_); - for (auto it = upcoming_.begin(); it != upcoming_.end(); it++) { - if (*it == t) { - upcoming_.erase(it); - return; - } - } + enqueued_.clear(); events::Dispatch({}); } diff --git a/src/database/CMakeLists.txt b/src/database/CMakeLists.txt index 04e1d5d8..c5cc59cb 100644 --- a/src/database/CMakeLists.txt +++ b/src/database/CMakeLists.txt @@ -5,7 +5,7 @@ idf_component_register( SRCS "env_esp.cpp" "database.cpp" "track.cpp" "records.cpp" "file_gatherer.cpp" "tag_parser.cpp" "index.cpp" INCLUDE_DIRS "include" - REQUIRES "result" "span" "esp_psram" "fatfs" "libtags" "komihash" "cbor" "tasks" "shared_string") + REQUIRES "result" "span" "esp_psram" "fatfs" "libtags" "komihash" "cbor" "tasks" "shared_string" "util") target_compile_options(${COMPONENT_LIB} PRIVATE ${EXTRA_WARNINGS}) diff --git a/src/database/include/tag_parser.hpp b/src/database/include/tag_parser.hpp index 4be5ad16..b0e9a151 100644 --- a/src/database/include/tag_parser.hpp +++ b/src/database/include/tag_parser.hpp @@ -8,6 +8,7 @@ #include +#include "lru_cache.hpp" #include "track.hpp" namespace database { @@ -21,8 +22,19 @@ class ITagParser { class TagParserImpl : public ITagParser { public: - virtual auto ReadAndParseTags(const std::string& path, TrackTags* out) + auto ReadAndParseTags(const std::string& path, TrackTags* out) -> bool override; + + private: + /* + * Cache of tags that have already been extracted from files. Ideally this + * cache should be slightly larger than any page sizes in the UI. + */ + util::LruCache<16, std::string, TrackTags> cache_; + + // We could also consider keeping caches of artist name -> shared_string and + // similar. This hasn't been done yet, as this isn't a common workload in any + // of our UI. }; } // namespace database diff --git a/src/database/include/track.hpp b/src/database/include/track.hpp index 620fc59e..78f973ac 100644 --- a/src/database/include/track.hpp +++ b/src/database/include/track.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include "leveldb/db.h" @@ -88,7 +89,7 @@ class TrackTags { private: Encoding encoding_; - std::map tags_; + std::unordered_map tags_; }; /* diff --git a/src/database/tag_parser.cpp b/src/database/tag_parser.cpp index 2b784ea5..06d8a8c9 100644 --- a/src/database/tag_parser.cpp +++ b/src/database/tag_parser.cpp @@ -97,6 +97,12 @@ static const char* kTag = "TAGS"; auto TagParserImpl::ReadAndParseTags(const std::string& path, TrackTags* out) -> bool { + std::optional cached = cache_.Get(path); + if (cached) { + *out = *cached; + return true; + } + if (path.ends_with(".m4a")) { // TODO(jacqueline): Re-enabled once libtags is fixed. ESP_LOGW(kTag, "skipping m4a %s", path.c_str()); @@ -160,6 +166,7 @@ auto TagParserImpl::ReadAndParseTags(const std::string& path, TrackTags* out) out->duration = ctx.duration; } + cache_.Put(path, *out); return true; } diff --git a/src/playlist/CMakeLists.txt b/src/playlist/CMakeLists.txt new file mode 100644 index 00000000..6c08dd5a --- /dev/null +++ b/src/playlist/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright 2023 jacqueline +# +# SPDX-License-Identifier: GPL-3.0-only + +idf_component_register( + SRCS "source.cpp" "shuffler.cpp" + INCLUDE_DIRS "include" + REQUIRES "database" "util") + +target_compile_options(${COMPONENT_LIB} PRIVATE ${EXTRA_WARNINGS}) diff --git a/src/playlist/include/shuffler.hpp b/src/playlist/include/shuffler.hpp new file mode 100644 index 00000000..affc6301 --- /dev/null +++ b/src/playlist/include/shuffler.hpp @@ -0,0 +1,68 @@ +/* + * Copyright 2023 jacqueline + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "bloom_filter.hpp" +#include "database.hpp" +#include "future_fetcher.hpp" +#include "random.hpp" +#include "source.hpp" +#include "track.hpp" + +namespace playlist { + +/* + * A source composes of other sources and/or specific extra tracks. Supports + * iteration over its contents in a random order. + */ +class Shuffler : public ISource { + public: + static auto Create() -> Shuffler*; + + explicit Shuffler( + util::IRandom* random, + std::unique_ptr> filter); + + auto Current() -> std::optional override; + auto Advance() -> std::optional override; + auto Peek(std::size_t, std::vector*) + -> std::size_t override; + + typedef std::variant> + Item; + auto Add(Item) -> void; + + /* + * Returns the enqueued items, starting from the current item, in their + * original insertion order. + */ + auto Unshuffle() -> std::vector; + + // Not copyable or movable. + + Shuffler(const Shuffler&) = delete; + Shuffler& operator=(const Shuffler&) = delete; + + private: + auto RefillBuffer() -> void; + + util::IRandom* random_; + + std::unique_ptr> already_played_; + bool out_of_items_; + + std::deque ordered_items_; + std::deque shuffled_items_buffer_; +}; + +} // namespace playlist diff --git a/src/playlist/include/source.hpp b/src/playlist/include/source.hpp new file mode 100644 index 00000000..069c1e93 --- /dev/null +++ b/src/playlist/include/source.hpp @@ -0,0 +1,105 @@ +/* + * Copyright 2023 jacqueline + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "bloom_filter.hpp" +#include "database.hpp" +#include "future_fetcher.hpp" +#include "random.hpp" +#include "track.hpp" + +namespace playlist { + +/* + * Stateful interface for iterating over a collection of tracks by id. + */ +class ISource { + public: + virtual ~ISource() {} + + virtual auto Current() -> std::optional = 0; + + /* + * Discards the current track id and continues to the next in this source. + * Returns the new current track id. + */ + virtual auto Advance() -> std::optional = 0; + + /* + * Repeatedly advances until a track with the given id is the current track. + * Returns false if this source ran out of tracks before the requested id + * was encounted, true otherwise. + */ + virtual auto AdvanceTo(database::TrackId id) -> bool { + for (auto t = Current(); t.has_value(); t = Advance()) { + if (*t == id) { + return true; + } + } + return false; + } + + /* + * Places the next n tracks into the given vector, in order. Does not change + * the value returned by Current(). + */ + virtual auto Peek(std::size_t n, std::vector*) + -> std::size_t = 0; +}; + +/* + * A Source that supports restarting iteration from its original initial + * value. + */ +class IResetableSource : public ISource { + public: + virtual ~IResetableSource() {} + + virtual auto Previous() -> std::optional = 0; + + /* + * Restarts iteration from this source's initial value. + */ + virtual auto Reset() -> void = 0; +}; + +class IndexRecordSource : public IResetableSource { + public: + IndexRecordSource(std::weak_ptr db, + std::shared_ptr>); + + IndexRecordSource(std::weak_ptr db, + std::shared_ptr>, + std::size_t, + std::shared_ptr>, + std::size_t); + + auto Current() -> std::optional override; + auto Advance() -> std::optional override; + auto Peek(std::size_t n, std::vector*) + -> std::size_t override; + + auto Previous() -> std::optional override; + auto Reset() -> void override; + + private: + std::weak_ptr db_; + + std::shared_ptr> initial_page_; + ssize_t initial_item_; + + std::shared_ptr> current_page_; + ssize_t current_item_; +}; + +} // namespace playlist diff --git a/src/playlist/shuffler.cpp b/src/playlist/shuffler.cpp new file mode 100644 index 00000000..b1c92335 --- /dev/null +++ b/src/playlist/shuffler.cpp @@ -0,0 +1,166 @@ +/* + * Copyright 2023 jacqueline + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#include "shuffler.hpp" + +#include +#include +#include +#include +#include + +#include "bloom_filter.hpp" +#include "database.hpp" +#include "komihash.h" +#include "random.hpp" +#include "track.hpp" + +static constexpr std::size_t kShufflerBufferSize = 32; + +namespace playlist { + +auto Shuffler::Create() -> Shuffler* { + return new Shuffler(util::sRandom, + std::make_unique>( + [](database::TrackId id) { + return komihash(&id, sizeof(database::TrackId), 0); + })); +} + +Shuffler::Shuffler(util::IRandom* random, + std::unique_ptr> filter) + : random_(random), already_played_(std::move(filter)) {} + +auto Shuffler::Current() -> std::optional { + if (shuffled_items_buffer_.empty()) { + return {}; + } + return shuffled_items_buffer_.front(); +} + +auto Shuffler::Advance() -> std::optional { + if (shuffled_items_buffer_.empty() && !out_of_items_) { + RefillBuffer(); + } + + auto res = Current(); + if (res) { + // Mark tracks off in the bloom filter only *after* they've been advanced + // past. This gives us the most flexibility for reshuffling when adding new + // items. + already_played_->Insert(*res); + shuffled_items_buffer_.pop_front(); + } + return res; +} + +auto Shuffler::Peek(std::size_t num, std::vector* out) + -> std::size_t { + if (shuffled_items_buffer_.size() < num) { + RefillBuffer(); + } + for (int i = 0; i < num; i++) { + if (i >= shuffled_items_buffer_.size()) { + // We must be out of data, since the buffer didn't fill up. + return i; + } + out->push_back(shuffled_items_buffer_.at(i)); + } + return num; +} + +auto Shuffler::Add(Item item) -> void { + ordered_items_.push_back(item); + out_of_items_ = false; + + // Empty out the buffer of already shuffled items, since we will need to + // shuffle again in order to incorporate the newly added item(s). We keep the + // current item however because we wouldn't want Add() to change the value of + // Current() unless we're completely out of items. + if (shuffled_items_buffer_.size() > 1) { + shuffled_items_buffer_.erase(shuffled_items_buffer_.begin() + 1, + shuffled_items_buffer_.end()); + } + RefillBuffer(); +} + +auto Shuffler::Unshuffle() -> std::vector { + std::vector ret; + database::TrackId current = shuffled_items_buffer_.front(); + bool has_found_current = false; + + for (const Item& item : ordered_items_) { + if (!has_found_current) { + // TODO(jacqueline): *Should* this include previous items? What is the + // 'previous' button meant to do after unshuffling? + if (std::holds_alternative(item)) { + has_found_current = current == std::get(item); + } else { + auto source = std::get>(item); + source->Reset(); + has_found_current = + std::get>(item)->AdvanceTo( + current); + } + } else { + ret.push_back(item); + } + } + + return ret; +} + +auto Shuffler::RefillBuffer() -> void { + // Don't waste time iterating if we know there's nothing new. + if (out_of_items_) { + return; + } + + int num_to_sample = kShufflerBufferSize - shuffled_items_buffer_.size(); + int resovoir_offset = shuffled_items_buffer_.size(); + + std::set in_buffer; + for (const database::TrackId& id : shuffled_items_buffer_) { + in_buffer.insert(id); + } + + uint32_t i = 0; + auto consider_item = [&, this](const database::TrackId& item) { + if (already_played_->Contains(item) || in_buffer.contains(item)) { + return; + } + if (i < num_to_sample) { + shuffled_items_buffer_.push_back(item); + } else { + uint32_t index_to_replace = random_->RangeInclusive(0, i); + if (index_to_replace < num_to_sample) { + shuffled_items_buffer_[resovoir_offset + index_to_replace] = item; + } + } + i++; + }; + + for (const Item& item : ordered_items_) { + if (std::holds_alternative(item)) { + std::invoke(consider_item, std::get(item)); + } else { + auto source = std::get>(item); + source->Reset(); + while (source->Advance()) { + std::invoke(consider_item, *source->Current()); + } + } + } + + out_of_items_ = i > num_to_sample; + // We've now got a random *selection*, but the order might be predictable + // (e.g. if there were only `num_to_sample` new items). Do a final in-memory + // shuffle. + std::random_shuffle(shuffled_items_buffer_.begin() + resovoir_offset, + shuffled_items_buffer_.end()); +} + +} // namespace playlist diff --git a/src/playlist/source.cpp b/src/playlist/source.cpp new file mode 100644 index 00000000..0df514e4 --- /dev/null +++ b/src/playlist/source.cpp @@ -0,0 +1,145 @@ +/* + * Copyright 2023 jacqueline + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#include "source.hpp" + +#include +#include +#include +#include +#include + +#include "esp_log.h" + +#include "bloom_filter.hpp" +#include "database.hpp" +#include "komihash.h" +#include "random.hpp" +#include "track.hpp" + +namespace playlist { + +IndexRecordSource::IndexRecordSource( + std::weak_ptr db, + std::shared_ptr> initial) + : db_(db), + initial_page_(initial), + initial_item_(0), + current_page_(initial_page_), + current_item_(initial_item_) {} + +IndexRecordSource::IndexRecordSource( + std::weak_ptr db, + std::shared_ptr> initial, + std::size_t initial_index, + std::shared_ptr> current, + std::size_t current_index) + : db_(db), + initial_page_(initial), + initial_item_(initial_index), + current_page_(current), + current_item_(current_index) {} + +auto IndexRecordSource::Current() -> std::optional { + if (current_page_->values().size() <= current_item_) { + return {}; + } + if (current_page_ == initial_page_ && current_item_ < initial_item_) { + return {}; + } + + return current_page_->values().at(current_item_).track(); +} + +auto IndexRecordSource::Advance() -> std::optional { + current_item_++; + if (current_item_ >= current_page_->values().size()) { + auto next_page = current_page_->next_page(); + if (!next_page) { + current_item_--; + return {}; + } + + auto db = db_.lock(); + if (!db) { + return {}; + } + + current_page_.reset(db->GetPage(&*next_page).get()); + current_item_ = 0; + } + + return Current(); +} + +auto IndexRecordSource::Previous() -> std::optional { + if (current_page_ == initial_page_ && current_item_ <= initial_item_) { + return {}; + } + + current_item_--; + if (current_item_ < 0) { + auto prev_page = current_page_->prev_page(); + if (!prev_page) { + return {}; + } + + auto db = db_.lock(); + if (!db) { + return {}; + } + + current_page_.reset(db->GetPage(&*prev_page).get()); + current_item_ = current_page_->values().size() - 1; + } + + return Current(); +} + +auto IndexRecordSource::Peek(std::size_t n, std::vector* out) + -> std::size_t { + if (current_page_->values().size() <= current_item_) { + return {}; + } + + auto db = db_.lock(); + if (!db) { + return 0; + } + + std::size_t items_added = 0; + + std::shared_ptr> working_page = + current_page_; + std::size_t working_item = current_item_ + 1; + + while (n > 0) { + if (working_item >= working_page->values().size()) { + auto next_page = current_page_->next_page(); + if (!next_page) { + break; + } + // TODO(jacqueline): It would probably be a good idea to hold onto these + // peeked pages, to avoid needing to look them up again later. + working_page.reset(db->GetPage(&*next_page).get()); + working_item = 0; + } + + out->push_back(working_page->values().at(working_item).track().value()); + n--; + items_added++; + working_item++; + } + + return items_added; +} + +auto IndexRecordSource::Reset() -> void { + current_page_ = initial_page_; + current_item_ = initial_item_; +} + +} // namespace playlist diff --git a/src/ui/include/screen_track_browser.hpp b/src/ui/include/screen_track_browser.hpp index af80f29c..3d347158 100644 --- a/src/ui/include/screen_track_browser.hpp +++ b/src/ui/include/screen_track_browser.hpp @@ -38,15 +38,14 @@ class TrackBrowser : public Screen { END = 1, }; auto AddLoadingIndictor(Position pos) -> void; - auto AddResults(Position pos, database::Result*) + auto AddResults(Position pos, + std::shared_ptr>) -> void; auto DropPage(Position pos) -> void; auto FetchNewPage(Position pos) -> void; auto GetNumRecords() -> std::size_t; auto GetItemIndex(lv_obj_t* obj) -> std::optional; - auto GetRecordByIndex(std::size_t index) - -> std::optional; std::weak_ptr db_; lv_obj_t* back_button_; @@ -57,7 +56,8 @@ class TrackBrowser : public Screen { std::optional*>> loading_page_; - std::deque>> + std::shared_ptr> initial_page_; + std::deque>> current_pages_; }; diff --git a/src/ui/include/ui_events.hpp b/src/ui/include/ui_events.hpp index cc7db349..a0ef1c31 100644 --- a/src/ui/include/ui_events.hpp +++ b/src/ui/include/ui_events.hpp @@ -6,6 +6,7 @@ #pragma once +#include #include "database.hpp" #include "index.hpp" #include "tinyfsm.hpp" @@ -25,7 +26,9 @@ struct OnSystemError : tinyfsm::Event {}; namespace internal { struct RecordSelected : tinyfsm::Event { - database::IndexRecord record; + std::shared_ptr> initial_page; + std::shared_ptr> page; + std::size_t record; }; struct IndexSelected : tinyfsm::Event { diff --git a/src/ui/screen_track_browser.cpp b/src/ui/screen_track_browser.cpp index a9333be4..07977710 100644 --- a/src/ui/screen_track_browser.cpp +++ b/src/ui/screen_track_browser.cpp @@ -66,7 +66,8 @@ TrackBrowser::TrackBrowser( list_(nullptr), loading_indicator_(nullptr), loading_pos_(END), - loading_page_(std::move(initial_page)), + loading_page_(move(initial_page)), + initial_page_(), current_pages_() { lv_obj_set_layout(root_, LV_LAYOUT_FLEX); lv_obj_set_size(root_, lv_pct(100), lv_pct(100)); @@ -102,7 +103,8 @@ auto TrackBrowser::Tick() -> void { } if (loading_page_->wait_for(std::chrono::seconds(0)) == std::future_status::ready) { - auto result = loading_page_->get(); + std::shared_ptr> result{ + loading_page_->get()}; AddResults(loading_pos_.value_or(END), result); loading_page_.reset(); @@ -126,18 +128,26 @@ auto TrackBrowser::OnItemSelected(lv_event_t* ev) -> void { } auto TrackBrowser::OnItemClicked(lv_event_t* ev) -> void { - auto index = GetItemIndex(lv_event_get_target(ev)); - if (!index) { + auto res = GetItemIndex(lv_event_get_target(ev)); + if (!res) { return; } - auto record = GetRecordByIndex(*index); - if (!record) { - return; + + auto index = *res; + for (const auto& page : current_pages_) { + for (std::size_t i = 0; i < page->values().size(); i++) { + if (index == 0) { + events::Dispatch( + internal::RecordSelected{ + .initial_page = initial_page_, + .page = page, + .record = i, + }); + return; + } + index--; + } } - ESP_LOGI(kTag, "clicked item %u (%s)", *index, - record->text().value_or("[nil]").c_str()); - events::Dispatch( - internal::RecordSelected{.record = *record}); } auto TrackBrowser::AddLoadingIndictor(Position pos) -> void { @@ -150,14 +160,18 @@ auto TrackBrowser::AddLoadingIndictor(Position pos) -> void { } } -auto TrackBrowser::AddResults(Position pos, - database::Result* results) - -> void { +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 database::IndexRecord& record) { auto text = record.text(); if (!text) { @@ -192,11 +206,11 @@ auto TrackBrowser::AddResults(Position pos, switch (pos) { case START: std::for_each(results->values().rbegin(), results->values().rend(), fn); - current_pages_.emplace_front(results); + current_pages_.push_front(results); break; case END: std::for_each(results->values().begin(), results->values().end(), fn); - current_pages_.emplace_back(results); + current_pages_.push_back(results); break; } @@ -302,24 +316,5 @@ auto TrackBrowser::GetItemIndex(lv_obj_t* obj) -> std::optional { return {}; } -auto TrackBrowser::GetRecordByIndex(std::size_t index) - -> std::optional { - std::size_t total_tracks = 0; - for (int i = 0; i < current_pages_.size(); i++) { - total_tracks += current_pages_.at(i)->values().size(); - } - ESP_LOGI(kTag, "total tracks %u, getting index %u", total_tracks, index); - - for (const auto& page : current_pages_) { - for (int i = 0; i < page->values().size(); i++) { - if (index == 0) { - return page->values().at(i); - } - index--; - } - } - return {}; -} - } // namespace screens } // namespace ui diff --git a/src/ui/ui_fsm.cpp b/src/ui/ui_fsm.cpp index 96949cd0..a9c3b61c 100644 --- a/src/ui/ui_fsm.cpp +++ b/src/ui/ui_fsm.cpp @@ -17,6 +17,7 @@ #include "screen_playing.hpp" #include "screen_splash.hpp" #include "screen_track_browser.hpp" +#include "source.hpp" #include "system_events.hpp" #include "touchwheel.hpp" #include "track_queue.hpp" @@ -117,20 +118,21 @@ void Browse::react(const internal::RecordSelected& ev) { return; } - if (ev.record.track()) { - ESP_LOGI(kTag, "selected track '%s'", ev.record.text()->c_str()); - // TODO(jacqueline): We should also send some kind of playlist info here. + auto record = ev.page->values().at(ev.record); + if (record.track()) { + ESP_LOGI(kTag, "selected track '%s'", record.text()->c_str()); sQueue->Clear(); - sQueue->AddLast(*ev.record.track()); + sQueue->IncludeLast(std::make_shared( + sDb, ev.initial_page, 0, ev.page, ev.record)); transit(); } else { - ESP_LOGI(kTag, "selected record '%s'", ev.record.text()->c_str()); - auto cont = ev.record.Expand(kRecordsPerPage); + ESP_LOGI(kTag, "selected record '%s'", record.text()->c_str()); + auto cont = record.Expand(kRecordsPerPage); if (!cont) { return; } auto query = db->GetPage(&cont.value()); - std::string title = ev.record.text().value_or("TODO"); + std::string title = record.text().value_or("TODO"); PushScreen( std::make_shared(sDb, title, std::move(query))); } diff --git a/src/util/CMakeLists.txt b/src/util/CMakeLists.txt new file mode 100644 index 00000000..0903a912 --- /dev/null +++ b/src/util/CMakeLists.txt @@ -0,0 +1,7 @@ +# Copyright 2023 jacqueline +# +# SPDX-License-Identifier: GPL-3.0-only + +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES "database") diff --git a/src/util/include/bloom_filter.hpp b/src/util/include/bloom_filter.hpp new file mode 100644 index 00000000..04b72e20 --- /dev/null +++ b/src/util/include/bloom_filter.hpp @@ -0,0 +1,40 @@ +/* + * Copyright 2023 jacqueline + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#pragma once + +#include +#include +#include + +namespace util { + +template +class BloomFilter { + public: + explicit BloomFilter(std::function hasher) + : hasher_(hasher), bits_() {} + + auto Insert(T val) -> void { + uint64_t hash = std::invoke(hasher_, val); + bits_[hash & 0xFFFF] = 1; + bits_[(hash >> 16) & 0xFFFF] = 1; + bits_[(hash >> 32) & 0xFFFF] = 1; + bits_[(hash >> 48) & 0xFFFF] = 1; + } + + auto Contains(T val) -> bool { + uint64_t hash = std::invoke(hasher_, val); + return bits_[hash & 0xFFFF] && bits_[(hash >> 16) & 0xFFFF] && + bits_[(hash >> 32) & 0xFFFF] && bits_[(hash >> 48) & 0xFFFF]; + } + + private: + std::function hasher_; + std::bitset<(1 << 16)> bits_; +}; + +} // namespace util diff --git a/src/util/include/lru_cache.hpp b/src/util/include/lru_cache.hpp new file mode 100644 index 00000000..8f955a07 --- /dev/null +++ b/src/util/include/lru_cache.hpp @@ -0,0 +1,69 @@ +/* + * Copyright 2023 jacqueline + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace util { + +/* + * Basic least recently used cache. Stores the `Size` most recently accessed + * entries in memory. + * + * Not safe for use from multiple tasks, but all operations are constant time. + */ +template +class LruCache { + public: + LruCache() : entries_(), key_to_it_() {} + + auto Put(K key, V val) -> void { + if (key_to_it_.contains(key)) { + // This key was already present. Overwrite by removing the previous + // value. + entries_.erase(key_to_it_[key]); + key_to_it_.erase(key); + } else if (entries_.size() >= Size) { + // Cache is full. Evict the last entry. + key_to_it_.erase(entries_.back().first); + entries_.pop_back(); + } + + // Add the new value. + entries_.push_front({key, val}); + key_to_it_[key] = entries_.begin(); + } + + auto Get(K key) -> std::optional { + if (!key_to_it_.contains(key)) { + return {}; + } + // Use splice() to move the entry to the front of the list. This approach + // doesn't invalidate any of the iterators in key_to_it_, and is constant + // time. + auto it = key_to_it_[key]; + entries_.splice(entries_.begin(), entries_, it); + return it->second; + } + + auto Clear() -> void { + entries_.clear(); + key_to_it_.clear(); + } + + private: + std::list> entries_; + std::unordered_map key_to_it_; +}; + +} // namespace util diff --git a/src/util/include/random.hpp b/src/util/include/random.hpp new file mode 100644 index 00000000..cd8ab1c6 --- /dev/null +++ b/src/util/include/random.hpp @@ -0,0 +1,39 @@ +/* + * Copyright 2023 jacqueline + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#pragma once + +#include + +#include "komihash.h" + +namespace util { + +class IRandom { + public: + virtual ~IRandom() {} + + virtual auto Next() -> std::uint64_t = 0; + virtual auto RangeInclusive(std::uint64_t lower, std::uint64_t upper) + -> std::uint64_t = 0; +}; + +extern IRandom* sRandom; + +class Random : public IRandom { + public: + Random(); + + auto Next() -> std::uint64_t override; + auto RangeInclusive(std::uint64_t lower, std::uint64_t upper) + -> std::uint64_t override; + + private: + std::uint64_t seed1_; + std::uint64_t seed2_; +}; + +} // namespace util diff --git a/src/util/random.cpp b/src/util/random.cpp new file mode 100644 index 00000000..ae543765 --- /dev/null +++ b/src/util/random.cpp @@ -0,0 +1,37 @@ +/* + * Copyright 2023 jacqueline + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#include "random.hpp" + +#include + +#include "esp_random.h" +#include "komihash.h" + +namespace util { + +IRandom* sRandom = new Random(); + +Random::Random() { + esp_fill_random(&seed1_, sizeof(seed1_)); + seed2_ = seed1_; + + // komirand needs four iterations to properly self-start. + for (int i = 0; i < 4; i++) { + Next(); + } +} + +auto Random::Next() -> std::uint64_t { + return komirand(&seed1_, &seed2_); +} + +auto Random::RangeInclusive(std::uint64_t lower, std::uint64_t upper) + -> std::uint64_t { + return (Next() % (upper - lower + 1)) + lower; +} + +} // namespace util