diff --git a/.gitignore b/.gitignore index 88d1ed0a..eebaae11 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ sdkconfig sdkconfig.old .DS_Store compile_commands.json +build/ diff --git a/Justfile b/Justfile deleted file mode 100644 index 80290f37..00000000 --- a/Justfile +++ /dev/null @@ -1,27 +0,0 @@ -env := "OTA_APP" - -# Build PlatformIO firmware -build-firmware: - python3 scripts/build_firmware.py {{env}} - -# Firmware binary path -_bin := ".pio/build/" + env + "/firmware.bin" - -# Build + upload via USB -upload: - pio run -e {{env}} -t upload - -# Build + upload + monitor -monitor: - pio run -e {{env}} -t upload -t monitor - -# Package firmware.bin into PocketMuse.tar -package: - python3 scripts/package_ota.py {{env}} - -# Clean build artifacts -clean: - rm -rf .pio - -# Full build: firmware -> OTA tarball -build: build-firmware package diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0b9838b5 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# PocketMage app build recipe for PocketMuse. +# +# Point at the SDK checkout first: +# export PM_SDK_ROOT=/path/to/PocketMage_SDK +# or pass it per-invocation: +# make SDK_ROOT=/path/to/PocketMage_SDK + +SDK_ROOT ?= $(PM_SDK_ROOT) +APP_NAME ?= $(notdir $(CURDIR)) + +# The pack target ships $(APP_NAME)_ICON.bin; regenerate it from the PNG +# asset whenever the name or the artwork changes. Plain `make` (the ELF only) +# needs no Pillow; `make pack` does. +$(APP_NAME)_ICON.bin: assets/icon.png tools/make_icon.py + python3 tools/make_icon.py assets/icon.png $@ + +APP_SRCS := \ + src/main.cpp \ + src/muse.cpp \ + src/library.cpp \ + src/playlist.cpp \ + src/ui_player.cpp \ + src/ui/ui_browser.cpp \ + src/ui/ui_nowplaying.cpp \ + src/ui/ui_playlist.cpp \ + src/ui/ui_helpers.cpp + +APP_CPPFLAGS += -Iinclude + +include $(SDK_ROOT)/tools/app.mk + +icon: $(APP_NAME)_ICON.bin diff --git a/PocketMuse.tar b/PocketMuse.tar deleted file mode 100644 index 9a65d48b..00000000 Binary files a/PocketMuse.tar and /dev/null differ diff --git a/README.md b/README.md index 75ae6a44..f9461364 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,40 @@ # PocketMuse -Music player app for the PocketMage PDA. Streams MP3, (AAC and WAV soon) -from SD card over I2S single-pin PDM output through the piezo buzzer GPIO. +Music player app for the PocketMage PDA. Streams MP3 from SD through the OS +audio service, using the built-in PWM piezo or a USB Audio Class speaker. Supports library browsing, now-playing with album art, playlist management, -shuffle, and loop modes. Runs as an OTA application slot on the PocketMage OS. +shuffle, and loop modes. Runs as a dynamically loaded ELF app on PocketMage OS. ## Build -Requires PlatformIO, `just`, and the standard ESP32-S3 toolchain. +PocketMuse is an ELF app built against the PocketMage SDK. +Build it with the `pm` CLI from the SDK checkout: ```sh -just build +pm build --pack ``` -This runs two scripts in sequence: +That compiles `src/`, links `build/PocketMuse.app.elf` plus the +`build/PocketMuse.elfo` overlay, and packs them into `build/PocketMuse.tar` +for installation through the OS app manager. Packaging requires the SDK's +`elf_overlay_relink` and `elf_overlay_emit` tools on `PATH`. -1. `scripts/build_firmware.py` - run `pio run -e OTA_APP`. -2. `scripts/package_ota.py` - wrap `firmware.bin` into `PocketMuse.tar` for - OTA distribution. - -The default environment is `OTA_APP`. Set `env` in the `Justfile` or pass -`ENV=` to build a different PlatformIO environment. +For an ELF-only clean build from this checkout: ```sh -just upload # build + upload via USB -just monitor # build + upload + serial monitor +export SDK_ROOT="$(cd ../PocketMage_PDA/Code/PocketMageOS/lib/PocketMage_SDK && pwd)" +make SDK_ROOT="$SDK_ROOT" clean +make SDK_ROOT="$SDK_ROOT" elf ``` +The SDK build recipe locates the installed Espressif Xtensa C++ toolchain. +Build the matching host from `PocketMage_PDA/Code/PocketMageOS` with +`pio run -e PM_BETA`. The host must export the playback-request, image-region, +SD-stream, drawing, and activity APIs used by this app. + +`mp3` files live in `/music/` at the root of the SD card; the app creates that +directory along with `/music/.muse/` on first launch. + ## Key bindings | Input | Action | @@ -35,50 +43,90 @@ just monitor # build + upload + serial monitor | UP / DOWN | Volume (Now Playing) | | SPACE | Play / pause | | ENTER | Select / play | -| P | Cycle source (Lib/Fav/Pl) | +| P | Cycle source in browser; show queue from Now Playing | | F | Toggle favorite | | S | Toggle shuffle | | L | Cycle loop (None/One/All) | -| D | Delete from playlist | -| B | Back to browser | +| D | Remove selected favorite / playlist entry; keep the music file | +| U | Output: Auto / Piezo / USB | +| B | Switch browser / Now Playing; return from queue | | ? | Help overlay | | ESC / A | Exit to PocketMage OS | Touch scroll on the slider controls volume in all modes. -## Dependencies +## Playback and storage + +- Auto selects a connected UAC speaker; Piezo forces the built-in output. + USB falls back to piezo when no compatible speaker is available. The USB + status chip appears only while USB is the active playback output. +- Volume and output preference are shared SDK settings in NVS (`PMAudio`). Favorites use + an absolute-path list; playlists are `.m3u` files under + `/music/.muse/playlists/`, with one absolute SD path per entry. Blank lines, + comments, LF, and CRLF are supported. Empty sources remain browsable. +- Natural EOF advances once. Repeat-one affects automatic progression; + manual next still skips. Shuffle stops after its cycle unless repeat-all + is enabled. Favorite edits outside the active source preserve the current + shuffle cycle; a missing playing track keeps the not-found index at `-1`. +- ID3v2.3/v2.4 tags and embedded JPEG/PNG cover regions are read by the SDK's + streaming media service. The OS decodes covers into a 1-bit image buffer. Unsupported/missing artwork + uses the app's fallback display. +- The OLED uses decoded PCM frame timing for elapsed playback. Total duration + comes from MPEG headers, including Xing/VBRI when present, with a bitrate + estimate when an exact duration is unavailable. +- Idle sleep belongs to the OS and uses its `TIMEOUT` setting. Native and ELF + input reset the same timer. Playback, open SD streams, USB audio presence, + USB mode, and OS timeout-inhibition flags keep the device awake. + +## Architecture + +PocketMuse calls the SDK C ABI; it does not link Arduino or audio/display +drivers. The host owns Espressif `esp_audio_codec`, `pwm_audio`, `usb_stream`, +JPEGDEC/PNGdec, GxEPD2, U8g2, SD access, and sleep management. + +Playback queries, metadata/cover probing, image-region decoding, SD operations, +and canvas primitives are generated wrappers over native SDK services. The SDK +owns PCM gain, timing, shared audio settings, and metadata parsing. PocketMuse +owns its library, playlist, shuffle, and presentation state. The SDK retains +ABI 2 adapters for existing apps. + +USB serial is the default connector role. Piezo and unattached Auto sessions +retain it; an attached accessory receives one bounded UAC probe. An unsupported +device can then be tried as a keyboard. App exit waits for audio cleanup while +its callbacks and code remain resident. + +## Native tests + +```sh +python3 tests/run.py +``` + +Requires Python 3 and a C++17 compiler with AddressSanitizer and UBSan. `CXX` +selects the compiler; `SDK_ROOT` overrides the adjacent SDK checkout. The +tests exercise playlist parsing/mutation, shuffle continuity, playback progression, source +cycling, output preference, playback request results, and idle-timer rollover +and inhibition. They use in-memory SD files and require no device. -- **PlatformIO** (`espressif32` platform, Arduino framework) -- **arduino-audio-tools** - audio pipeline (I2S, source callbacks, fading) -- **arduino-libhelix** - MP3/AAC decode -- **GxEPD2** (fork: - [`ashtf8/GxEPD2_Editable_useFastFullUpdate`](https://github.com/ashtf8/GxEPD2_Editable_useFastFullUpdate)) -- **JPEGDEC** - album art decode -- **PNGdec** - album art decode -- **U8g2** - OLED display +USB enumeration/hotplug, audible output, album-art rendering, both displays, +and deep sleep still require validation on a PocketMage device. ## Project layout ```text src/ - main.cpp audio pipeline, app entry, setup/loop + main.cpp app entry, input and render loop muse.cpp app-wide globals library.cpp SD card track scanner (/music/) playlist.cpp playlist manager (favorites, .m3u) - metacache.cpp ID3 metadata cache on SD - albumart.cpp ID3v2 APIC extraction, JPEG/PNG decode, 1-bit render - ui_player.cpp playback control, shuffle, duration compute + ui_player.cpp playback control, shuffle, shared media-service queries ui/ ui_browser.cpp library/favorites/playlist browser ui_nowplaying.cpp now-playing screen with album art ui_playlist.cpp playlist queue view ui_helpers.cpp header/footer/scrollbar/OLED/time formatting include/ public headers -lib/ - PocketMage/ shared PocketMage PDA framework (submodule) -scripts/ - build_firmware.py PlatformIO build helper - package_ota.py firmware -> tar for OTA +tests/ native regression suites and in-memory SD support +design/ chosen display mockups ``` ## License diff --git a/app.properties b/app.properties new file mode 100644 index 00000000..a7307ccb --- /dev/null +++ b/app.properties @@ -0,0 +1,4 @@ +name=PocketMuse +version=0.2.0 +author=NellowTCS +scope=all diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 00000000..1d633164 Binary files /dev/null and b/assets/icon.png differ diff --git a/include/albumart.h b/include/albumart.h deleted file mode 100644 index 91340887..00000000 --- a/include/albumart.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once -#include -#include -#include - -class AlbumArt { -public: - static constexpr int kMaxSize = 120; - - enum Format { kNone, kJPEG, kPNG }; - - AlbumArt(); - ~AlbumArt(); - - bool load(const char* mp3Path); - void clear(); - - bool hasArt() const { return format_ != kNone; } - Format format() const { return format_; } - bool isJPEG() const { return format_ == kJPEG; } - bool isPNG() const { return format_ == kPNG; } - - int width() const { return img_w_; } - int height() const { return img_h_; } - - bool render1Bit(uint8_t* einkBuf, int bufW, int bufH, - int outX, int outY, int maxSize = kMaxSize); - -private: - static constexpr const char* kCachePath = "/music/.muse/cache_art.jpg"; - - bool ensureMuseDir_(); - bool extractAPIC_(const char* mp3Path); - bool detectFormat_(); - bool renderJPEG_(File& file, uint8_t* einkBuf, int bufW, int bufH, - int outX, int outY, int maxSize); - bool renderPNG_(File& file, uint8_t* einkBuf, int bufW, int bufH, - int outX, int outY, int maxSize); - - Format format_; - int img_w_; - int img_h_; -}; diff --git a/include/config.h b/include/config.h deleted file mode 120000 index 1ff388ac..00000000 --- a/include/config.h +++ /dev/null @@ -1 +0,0 @@ -../../PocketMageOS/include/config.h \ No newline at end of file diff --git a/include/globals.h b/include/globals.h deleted file mode 120000 index 2fe32e80..00000000 --- a/include/globals.h +++ /dev/null @@ -1 +0,0 @@ -../../PocketMageOS/include/globals.h \ No newline at end of file diff --git a/include/library.h b/include/library.h index 579d0627..22eba53e 100644 --- a/include/library.h +++ b/include/library.h @@ -1,23 +1,29 @@ #pragma once -#include -#include -#include -#include + +#include +#include class Library { public: - Library(); + Library(); + ~Library(); - int scan(); - void clear(); + int scan(); + void clear(); - int count() const { return (int)tracks_.size(); } - const String& path(int index) const; + int count() const { return count_; } + const char *path(int index) const; private: - void scanDir_(const char* dirname, int depth); + void scanDir_(const char *dirname, int depth, int maxDepth); - static constexpr int kMaxDepth = 2; + static constexpr int kMaxDepth = 3; + static constexpr int kFallbackDepth = 1; + static constexpr int kMaxTracks = 400; - std::vector tracks_; + const char **tracks_; + int tracks_cap_; + int count_; }; + +Library &library(); diff --git a/include/metacache.h b/include/metacache.h deleted file mode 100644 index 76691f7b..00000000 --- a/include/metacache.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once -#include -#include -#include - -class MetadataCache { -public: - bool load(const char* path, char* title, size_t tSize, - char* artist, size_t aSize, char* album, size_t lSize); - void save(const char* path, const char* title, const char* artist, const char* album); - void clear(); - -private: - static constexpr const char* kPath = "/music/.muse/metadata.bin"; - static constexpr const char* kTmp = "/music/.muse/.meta_tmp"; - static constexpr uint32_t kMagic = 0x4D455441; - - bool readStr(File& f, char* buf, size_t bufSize); - bool skipStr(File& f); -}; diff --git a/include/muse.h b/include/muse.h index a01e515f..a4c9a8ca 100644 --- a/include/muse.h +++ b/include/muse.h @@ -1,57 +1,51 @@ #pragma once -#include -#include -#include "library.h" + +#include +#include + #include "playlist.h" +// App modes +enum AppMode : uint8_t { + MODE_BROWSER, + MODE_NOWPLAYING, + MODE_PLAYLIST, + MODE_HELP, +}; + // Player state enum class PlayerState : uint8_t { - Stopped, - Playing, - Paused, + Stopped, + Playing, + Paused, }; // Loop mode enum class LoopMode : uint8_t { - None, - One, - All, + None, + One, + All, }; -// App modes -enum AppMode : uint8_t { - MODE_BROWSER, - MODE_NOWPLAYING, - MODE_PLAYLIST, - MODE_HELP, -}; - -// App globals -extern AppMode g_appMode; -extern AppMode g_prevMode; -extern bool g_needsRedraw; -extern int g_selIndex; +extern AppMode g_appMode; +extern AppMode g_prevMode; +extern bool g_needsRedraw; +extern int g_selIndex; +extern int g_trackCount; -// Library / source -extern Library g_library; -extern PlaylistManager g_playlistMgr; -extern int g_trackCount; - -// Now playing -extern char g_nowPath[256]; -extern char g_nowTitle[128]; -extern char g_nowArtist[128]; -extern char g_nowAlbum[128]; +extern char g_nowPath[256]; +extern char g_nowTitle[128]; +extern char g_nowArtist[128]; +extern char g_nowAlbum[128]; extern uint32_t g_nowDuration; -extern float g_nowProgress; -extern int g_nowTrackIndex; -extern bool g_isFavorite; -extern volatile PlayerState g_playState; +extern float g_nowProgress; +extern uint32_t g_nowElapsed; +extern int g_nowTrackIndex; +extern bool g_isFavorite; +extern PlayerState g_playState; -// Playback extern LoopMode g_loopMode; -extern bool g_shuffleEnabled; -extern volatile uint8_t g_volume; +extern bool g_shuffleEnabled; // UI prototypes void browser_init(); @@ -60,33 +54,8 @@ void browser_render(); void nowplaying_init(); void nowplaying_process_key(char ch); +void nowplaying_cache_art(const char *path, const struct pm_media_info &info); void nowplaying_render(); -void nowplaying_cache_art(const char* path); void playlist_process_key(char ch); void playlist_render(); - -void ui_update_oled(); -void ui_show_help(); - -// Shared UI helpers -void draw_header(const char* title); -void draw_footer(const char* hints); -void draw_scrollbar(int total, int visible, int position); -void format_time(char* buf, size_t bufSize, uint32_t seconds); -void get_display_name(const char* path, char* out, size_t maxLen); - -// Player control helpers -void player_play_index(int index); -void player_next_track(); -void player_prev_track(); -void player_cycle_source(); -void player_toggle_favorite(); -bool get_track_path(int index, char* buf, size_t bufSize); - -// Low-level playback control -void music_stop(); -void music_play(const char* path); -void music_pause(); -void music_resume(); -uint32_t compute_duration(const char* path); diff --git a/include/paths.h b/include/paths.h new file mode 100644 index 00000000..074e56d6 --- /dev/null +++ b/include/paths.h @@ -0,0 +1,12 @@ +#pragma once + +namespace paths { + +constexpr const char *kMusicDir = "/music"; +constexpr const char *kStateDir = "/music/.muse"; +constexpr const char *kPlaylistsDir = "/music/.muse/playlists"; +constexpr const char *kFavorites = "/music/.muse/favs.txt"; +constexpr const char *kMetadata = "/music/.muse/metadata.bin"; +constexpr const char *kAlbumArt = "/music/.muse/cache_art.jpg"; + +} // namespace paths diff --git a/include/playlist.h b/include/playlist.h index 330a986f..c26fefe6 100644 --- a/include/playlist.h +++ b/include/playlist.h @@ -1,107 +1,91 @@ #pragma once -#include -#include -#include -#include -#include -// Plays directory -static constexpr const char* kMuseDir = "/music/.muse"; -static constexpr const char* kPlDir = "/music/.muse/playlists"; -static constexpr const char* kFavFile = "/music/.muse/favs.txt"; +#include +#include enum class PlaySource : uint8_t { - Library, - Favorites, - Playlist, + Library, + Favorites, + Playlist, }; struct PlaylistMeta { - String filename; // full path to .m3u file - String name; // display name (filename without .m3u) - int count; // total entries (-1 if not loaded) + char filename[256]; + char name[64]; + int count; }; -// Lightweight cursor into a playlist for streaming access. -// Builds a small offset table in RAM (~4 bytes/entry) so paging -// is a single seek + read per page, not a full re-scan. class PlaylistCursor { public: - PlaylistCursor() : file_size_(0), count_(0), offsets_(nullptr) {} + PlaylistCursor(); + ~PlaylistCursor(); - bool load(const char* m3uPath, bool isFav); - void unload(); + /** Load path offsets, accepting an empty or absent list as empty. + * Return false on invalid paths, allocation failure, or nonempty-file read failure. + */ + bool load(const char *path, bool isFav); + void unload(); - int count() const { return count_; } - bool valid() const { return offsets_ != nullptr; } + int count() const { return count_; } + bool valid() const { return path_[0] != '\0'; } - // Read entry at index into pathBuf. Returns true if found. - bool getEntry(int index, char* pathBuf, size_t bufSize); - - // Read a page of counts into the given vector. Clears vec first. - bool readPage(int startIndex, int count, std::vector& out); + bool getEntry(int index, char *pathBuf, size_t bufSize) const; private: - bool parseM3U_(File& f); - bool parseFavs_(File& f); - bool readLine_(File& f, char* buf, size_t bufSize); - - uint32_t file_size_; - int count_; - char path_[256]; - uint32_t* offsets_; // heap-allocated byte offsets for each entry + bool parseM3U_(const char *data, size_t size); + bool parseFavs_(const char *data, size_t size); - static constexpr int kScanBufSize = 512; + uint32_t file_size_; + int count_; + char path_[256]; + uint32_t *offsets_; }; class PlaylistManager { public: - PlaylistManager(); + PlaylistManager(); + ~PlaylistManager(); - // Scan the playlists directory. Must be called at least once. - bool begin(); + bool begin(); - // Playlist listing - int playlistCount() const { return (int)playlists_.size(); } - const PlaylistMeta& playlistMeta(int idx) const; - int findPlaylist(const char* name) const; + int playlistCount() const { return playlist_count_; } + const PlaylistMeta &playlistMeta(int idx) const; + int findPlaylist(const char *name) const; - // Playlist CRUD - bool createPlaylist(const char* name); - bool deletePlaylist(int idx); + bool createPlaylist(const char *name); + bool deletePlaylist(int idx); - // Current source - PlaySource source() const { return source_; } - const PlaylistCursor& cursor() const { return cursor_; } - int playlistIndex() const { return pl_index_; } + PlaySource source() const { return source_; } + const PlaylistCursor &cursor() const { return cursor_; } + int playlistIndex() const { return pl_index_; } - // Source switching - bool playLibrary(); // switch to full library as source - bool playFavorites(); // switch to favorites - bool playPlaylist(int idx); // switch to a named playlist + bool playLibrary(); + /** Select Favorites, including an empty list; return false on a read failure. */ + bool playFavorites(); + /** Select playlist idx; failed reads preserve metadata for retry and return false. */ + bool playPlaylist(int idx); - // Active source helpers - int entryCount() const; - bool readPage(int start, int count, std::vector& out); - bool getEntry(int index, char* pathBuf, size_t bufSize); + int entryCount() const; + bool getEntry(int index, char *pathBuf, size_t bufSize); - // Favorites - bool toggleFavorite(const char* path); - bool isFavorite(const char* path); - int favoriteCount(); + bool toggleFavorite(const char *path); + bool isFavorite(const char *path); + int favoriteCount(); - // Playlist content management - bool addToPlaylist(int plIdx, const char* songPath); - bool removeFromPlaylist(int plIdx, int entryIndex); + bool addToPlaylist(int plIdx, const char *songPath); + bool removeFromPlaylist(int plIdx, int entryIndex); private: - void scanPlaylists_(); - bool removeLine_(const char* filePath, const char* line); + void scanPlaylists_(); - PlaySource source_; - PlaylistCursor cursor_; - int pl_index_; - std::vector playlists_; + PlaySource source_; + PlaylistCursor cursor_; + int pl_index_; + PlaylistMeta *playlists_; + int playlists_cap_; + int playlist_count_; - static constexpr int kMaxFavs = 256; + static constexpr int kMaxFavs = 256; }; + +PlaylistManager &playlistMgr(); diff --git a/include/ui_helpers.h b/include/ui_helpers.h new file mode 100644 index 00000000..ee3922a8 --- /dev/null +++ b/include/ui_helpers.h @@ -0,0 +1,19 @@ +#pragma once + +#include "muse.h" + +#include + +const char *source_label(); +/** Draw source-specific empty/error guidance on e-ink without refreshing it. */ +void draw_empty_source(); + +void draw_header(const char *title); +void draw_footer(const char *hints); +void draw_scrollbar(int total, int visible, int position); +void format_time(char *buf, size_t bufSize, uint32_t seconds); +void get_display_name(const char *path, char *out, size_t maxLen); +void truncate_text(const char *src, char *dst, size_t maxDst); +void ui_update_oled(); +void ui_show_help(); +void draw_transport_icon_eink(int x, int y, int h, PlayerState state); diff --git a/include/ui_player.h b/include/ui_player.h new file mode 100644 index 00000000..307ffb34 --- /dev/null +++ b/include/ui_player.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +/** Current permutation of source indices, owned by the player. */ +extern int *g_shuffleIdx; +/** Number of allocated shuffle indices. */ +extern int g_shuffleIdxCount; +/** Current position in the shuffle permutation, or -1 before playback. */ +extern int g_shufflePos; + +/** Rebuild shuffle for the active source; disable shuffle on allocation failure. */ +void build_shuffle(); + +/** Copy source entry index into buf; return false if unavailable or too long. */ +bool get_track_path(int index, char *buf, size_t bufSize); + +/** Play source entry index and refresh its metadata and album art. */ +void player_play_index(int index); +/** Advance manually using shuffle/repeat-all; ignore repeat-one. */ +void player_next_track(); +/** Advance on EOF, honoring repeat-one. */ +void player_advance_auto(); +/** Select the preceding source entry using the current shuffle/loop mode. */ +void player_prev_track(); +/** Cycle Library, Favorites, and playlists; reset selection and refresh counts. */ +void player_cycle_source(); +/** Toggle the playing track's favorite membership. */ +void player_toggle_favorite(); +/** Toggle path membership, refresh the active list, and report storage failure. */ +void player_toggle_favorite_path(const char *path); +/** Remove the selection from Favorites or a playlist, preserving music files. */ +void player_remove_selected(); +/** Cycle repeat None, One, and All. */ +void player_cycle_loop_mode(); +/** Toggle shuffle and rebuild its order when enabled. */ +void player_toggle_shuffle(); +/** Poll the request; advance once on EOF and stop on failure/cancellation. */ +void player_poll(); + +/** Increase, apply, and persist volume; return whether it changed. */ +bool player_volume_up(); +/** Decrease, apply, and persist volume; return whether it changed. */ +bool player_volume_down(); + +/** Cycle Auto, Piezo, and USB; apply and persist the preference. */ +void player_cycle_sink(); +/** Return true only while the OS is actively streaming to USB. */ +bool player_sink_is_usb_active(); +/** Return the current preference/output label for the OLED. */ +const char *sink_label(); + +/** Cancel playback and disarm automatic progression. */ +void music_stop(); +/** Request playback of path and capture its OS request ID; report start failure. */ +void music_play(const char *path); +/** Pause the current OS audio request. */ +void music_pause(); +/** Resume the current OS audio request. */ +void music_resume(); diff --git a/lib/PocketMage b/lib/PocketMage deleted file mode 120000 index 642ef7dc..00000000 --- a/lib/PocketMage +++ /dev/null @@ -1 +0,0 @@ -../../PocketMageOS/lib/PocketMage \ No newline at end of file diff --git a/partitions_OTA_APP.csv b/partitions_OTA_APP.csv deleted file mode 120000 index 3fba22c3..00000000 --- a/partitions_OTA_APP.csv +++ /dev/null @@ -1 +0,0 @@ -../PocketMageOS/partitions_OTA_APP.csv \ No newline at end of file diff --git a/platformio.ini b/platformio.ini deleted file mode 100644 index 3be32d43..00000000 --- a/platformio.ini +++ /dev/null @@ -1,50 +0,0 @@ -[platformio] -default_envs = OTA_APP - -[common] -platform = espressif32 -board = esp32-s3-devkitc-1 -framework = arduino -board_upload.flash_size = 16MB -board_build.mcu = esp32s3 -board_build.flash_mode = qio - -[env:OTA_APP] -extends = common -board_build.partitions = partitions_OTA_APP.csv -build_flags = - -DOTA_APP_FLAG=1 - -DPOCKETMAGE_HW_VERSION=0 - -DPOCKETMUSE_FAST_REFRESH=1 - -DBOARD_HAS_PSRAM=0 - -DMONITOR_SPEED=115200 - -DARDUINO_USB_MODE=1 - -DARDUINO_USB_CDC_ON_BOOT=1 - -DARDUINO_RUNNING_CORE=1 - -DARDUINO_EVENT_RUNNING_CORE=1 - -DCORE_DEBUG_LEVEL=3 - -DDISABLE_DIAGNOSTIC_OUTPUT - -DPNG_MAX_BUFFERED_PIXELS=5120 - -std=gnu++17 - -Wno-unused-parameter - -Iinclude - -I../PocketMageOS/include -build_unflags = - -std=gnu++11 -lib_deps = - adafruit/Adafruit GFX Library @ ^1.12.0 - adafruit/Adafruit TCA8418 @ ^1.0.2 - adafruit/RTClib @ ^2.1.4 - olikraus/U8g2 @ ^2.36.4 - adafruit/Adafruit MPR121 @ ^1.1.3 - gmarty2000/Buzzer @ ^1.0.0 - https://github.com/TalismanDesign/MP2722_Arduino.git - https://github.com/TalismanDesign/U8g2_for_Adafruit_GFX.git - https://github.com/ashtf8/GxEPD2_Editable_useFastFullUpdate.git - https://github.com/esp32beans/ESP32_USB_Host_HID.git - tobozo/ESP32-targz - https://github.com/esp-arduino-libs/ESP32_USB_Stream.git - https://github.com/bitbank2/JPEGDEC.git - https://github.com/bitbank2/PNGdec.git - https://github.com/pschatzmann/arduino-audio-tools.git - https://github.com/pschatzmann/arduino-libhelix.git \ No newline at end of file diff --git a/scripts/build_firmware.py b/scripts/build_firmware.py deleted file mode 100755 index f2494cb1..00000000 --- a/scripts/build_firmware.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -"""Build PlatformIO firmware. - -Usage: - python3 scripts/build_firmware.py [env] - -Defaults to OTA_APP if no env is given. -""" - -import os -import subprocess -import sys - - -def main() -> None: - pio_env = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("ENV", "OTA_APP") - script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - print(f"=== PlatformIO ({pio_env}) ===") - subprocess.run( - ["pio", "run", "-e", pio_env], - cwd=script_dir, - check=True, - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/package_ota.py b/scripts/package_ota.py deleted file mode 100755 index 35d43391..00000000 --- a/scripts/package_ota.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -"""Package firmware.bin into a tarball for OTA upload. - -Usage: - python3 scripts/package_ota.py [env] - -Output: PocketMuse.tar in the project root. -""" - -import os -import subprocess -import sys -import tempfile - - -def main() -> None: - pio_env = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("ENV", "OTA_APP") - script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - bin_path = os.path.join(script_dir, ".pio", "build", pio_env, "firmware.bin") - tar_path = os.path.join(script_dir, "PocketMuse.tar") - - if not os.path.isfile(bin_path): - print(f"Error: {bin_path} not found", file=sys.stderr) - sys.exit(1) - - with tempfile.TemporaryDirectory() as tmp: - tmpbin = os.path.join(tmp, "firmware.bin") - import shutil - shutil.copy2(bin_path, tmpbin) - subprocess.run( - ["tar", "cf", tar_path, "-C", tmp, "firmware.bin"], - check=True, - ) - - st = os.stat(tar_path) - print(f"=== {tar_path} ({st.st_size} bytes) ===") - - -if __name__ == "__main__": - main() diff --git a/src/UTILS.cpp b/src/UTILS.cpp deleted file mode 120000 index b905796e..00000000 --- a/src/UTILS.cpp +++ /dev/null @@ -1 +0,0 @@ -../../PocketMageOS/src/UTILS.cpp \ No newline at end of file diff --git a/src/albumart.cpp b/src/albumart.cpp deleted file mode 100644 index e0190c43..00000000 --- a/src/albumart.cpp +++ /dev/null @@ -1,520 +0,0 @@ -#include "albumart.h" -#include -#include -#include -#include -#include - -// Helpers -static uint32_t readSyncsafeInt(const uint8_t* buf) { - return ((uint32_t)buf[0] << 21) | - ((uint32_t)buf[1] << 14) | - ((uint32_t)buf[2] << 7) | - (uint32_t)buf[3]; -} - -// PNGdec file callbacks -static void* pngOpenCb(const char* filename, int32_t* pSize) { - File* f = new File(global_fs->open(filename, "r")); - if (!*f) { delete f; return nullptr; } - *pSize = f->size(); - return f; -} - -static void pngCloseCb(void* pHandle) { - if (!pHandle) return; - delete static_cast(pHandle); -} - -static int32_t pngReadCb(PNGFILE* pFile, uint8_t* buf, int32_t iLen) { - auto* f = static_cast(pFile->fHandle); - return f ? f->read(buf, iLen) : 0; -} - -static int32_t pngSeekCb(PNGFILE* pFile, int32_t iPos) { - auto* f = static_cast(pFile->fHandle); - if (!f) return -1; - return f->seek(iPos) ? iPos : -1; -} - -// Render context shared by JPEG and PNG code paths -struct RenderCtx { - uint8_t* einkBuf; - int bufW, bufH; - int outX, outY; - int fullW, fullH; - int outW, outH; -}; - -// PNG-specific extension: Floyd–Steinberg dither state -struct PngRenderCtx : RenderCtx { - int pixelType; // PNG_PIXEL_xxx - int16_t* err; // current-row error (size fullW + 2) - int16_t* nextErr; // next-row accumulation (size fullW + 2) -}; - -// Grayscale extraction (single pixel from PNGDRAW) -static int pixelToGray(PNGDRAW* pDraw, int x) { - // pDraw->pPalette is always non-null (points to ucPalette[] in the PNGIMAGE - // struct), even for non-palette images where it contains only zeros. - // Must check the pixel type, not the pointer. - uint8_t* p = pDraw->pPixels; - PngRenderCtx* ctx = (PngRenderCtx*)pDraw->pUser; - int type = ctx->pixelType; - - if (type == PNG_PIXEL_INDEXED) { - uint8_t* pal = pDraw->pPalette; - int idx = p[x]; - int r = pal[idx * 3]; - int g = pal[idx * 3 + 1]; - int b = pal[idx * 3 + 2]; - return (r * 77 + g * 150 + b * 29) >> 8; - } - if (type == PNG_PIXEL_TRUECOLOR || type == PNG_PIXEL_TRUECOLOR_ALPHA) { - int i = x * (type == PNG_PIXEL_TRUECOLOR_ALPHA ? 4 : 3); - int r = p[i]; - int g = p[i + 1]; - int b = p[i + 2]; - return (r * 77 + g * 150 + b * 29) >> 8; - } - // Grayscale / grayscale+alpha - if (type == PNG_PIXEL_GRAY_ALPHA) - return p[x * 2]; - return p[x]; -} - -// PNG draw callback: Floyd–Steinberg dither + nearest-neighbour scale -static int pngDrawCb(PNGDRAW* pDraw) { - PngRenderCtx* ctx = (PngRenderCtx*)pDraw->pUser; - if (!ctx || !ctx->einkBuf) return 0; - - int y = pDraw->y; - int w = pDraw->iWidth; - int fullW = ctx->fullW; - int outH = ctx->outH; - int outW = ctx->outW; - int16_t* err = ctx->err; - int16_t* nxt = ctx->nextErr; - - int oy = y * outH / ctx->fullH; - bool writeOutput = (oy < outH); - - int dstRowBytes = (ctx->bufW + 7) / 8; - int prevOx = -1; - - for (int x = 0; x < w; x++) { - int gray = pixelToGray(pDraw, x); - int v = gray + err[x + 1]; - if (v < 0) v = 0; - if (v > 255) v = 255; - int bit = v > 127 ? 1 : 0; // 1 = white, 0 = black - int qErr = v - (bit ? 255 : 0); // always ≤ 0 with this threshold - - // Floyd–Steinberg error diffusion - err[x + 2] += (qErr * 7) / 16; // right - nxt[x] += (qErr * 5) / 16; // bottom - nxt[x + 1] += (qErr * 1) / 16; // bottom-right - if (x > 0) - nxt[x - 1] += (qErr * 3) / 16; // bottom-left - - if (!writeOutput) continue; - - // Nearest-neighbour downscale - int ox = x * outW / fullW; - if (ox == prevOx) continue; - prevOx = ox; - if (ox >= outW) continue; - - int einkX = ctx->outX + ox; - int einkY = ctx->outY + oy; - if (einkX >= ctx->bufW || einkY >= ctx->bufH) continue; - - int idx = einkY * dstRowBytes + (einkX >> 3); - if (idx >= ctx->bufH * dstRowBytes) continue; - - if (!bit) { - ctx->einkBuf[idx] |= (1 << (7 - (einkX & 7))); - } else { - ctx->einkBuf[idx] &= ~(1 << (7 - (einkX & 7))); - } - } - - // Swap error buffers for next row - ctx->err = nxt; - ctx->nextErr = err; - memset(ctx->nextErr, 0, (fullW + 2) * sizeof(int16_t)); - return 1; -} - -// JPEG draw callback -static int jpegDrawCb(JPEGDRAW* pDraw) { - RenderCtx* ctx = (RenderCtx*)pDraw->pUser; - if (!ctx || !ctx->einkBuf) return 0; - - uint8_t* src = (uint8_t*)pDraw->pPixels; - int srcW = pDraw->iWidth; - int srcH = pDraw->iHeight; - int srcRowB = (srcW + 7) / 8; - int srcBaseY = pDraw->y; - - int dstRowB = (ctx->bufW + 7) / 8; - - for (int sy = 0; sy < srcH; sy++) { - int fy = srcBaseY + sy; - if (fy >= ctx->fullH) break; - - int oy = fy * ctx->outH / ctx->fullH; - if (oy >= ctx->outH) continue; - int einkY = ctx->outY + oy; - if (einkY >= ctx->bufH) continue; - - for (int sx = 0; sx < srcW; sx++) { - int fx = pDraw->x + sx; - if (fx >= ctx->fullW) break; - - int ox = fx * ctx->outW / ctx->fullW; - if (ox >= ctx->outW) continue; - int einkX = ctx->outX + ox; - if (einkX >= ctx->bufW) continue; - - int bit = (src[sy * srcRowB + (sx >> 3)] >> (7 - (sx & 7))) & 1; - - int idx = einkY * dstRowB + (einkX >> 3); - if (idx < ctx->bufH * dstRowB) { - if (!bit) { - ctx->einkBuf[idx] |= (1 << (7 - (einkX & 7))); - } else { - ctx->einkBuf[idx] &= ~(1 << (7 - (einkX & 7))); - } - } - } - } - return 1; -} - -// AlbumArt implementation -AlbumArt::AlbumArt() - : format_(kNone), img_w_(0), img_h_(0) -{ -} - -AlbumArt::~AlbumArt() { - clear(); -} - -bool AlbumArt::ensureMuseDir_() { - if (!global_fs) return false; - global_fs->mkdir("/music/.muse"); - return true; -} - -// ID3v2 APIC extraction -bool AlbumArt::extractAPIC_(const char* mp3Path) { - if (!global_fs) return false; - - File mp3 = global_fs->open(mp3Path, "r"); - if (!mp3) return false; - - uint8_t hdr[10]; - if (mp3.read(hdr, 10) != 10) { mp3.close(); return false; } - if (hdr[0] != 'I' || hdr[1] != 'D' || hdr[2] != '3') { mp3.close(); return false; } - - uint8_t ver = hdr[3]; - uint8_t flags = hdr[5]; - uint32_t tagSize = readSyncsafeInt(hdr + 6); - - if (ver < 3 || ver > 4) { mp3.close(); return false; } - - if (flags & 0x40) { - uint8_t ext[4]; - if (mp3.read(ext, 4) != 4) { mp3.close(); return false; } - uint32_t extSize = (ver == 4) ? readSyncsafeInt(ext) - : ((uint32_t)ext[0] << 24 | (uint32_t)ext[1] << 16 | - (uint32_t)ext[2] << 8 | ext[3]); - mp3.seek(extSize, SeekCur); - } - - bool footer = (ver == 4) && (flags & 0x10); - size_t remain = tagSize - (footer ? 10 : 0); - - global_fs->remove(kCachePath); - File tmp = global_fs->open(kCachePath, "w"); - if (!tmp) { mp3.close(); return false; } - - bool found = false; - - while (remain >= 10) { - uint8_t fhdr[10]; - if (mp3.read(fhdr, 10) != 10) break; - remain -= 10; - - char id[5] = {}; - memcpy(id, fhdr, 4); - if (id[0] == '\0') break; - - uint32_t fsize = (ver == 4) - ? readSyncsafeInt(fhdr + 4) - : ((uint32_t)fhdr[4] << 24 | (uint32_t)fhdr[5] << 16 | - (uint32_t)fhdr[6] << 8 | fhdr[7]); - if (fsize == 0 || fsize > remain) break; - - if (strcmp(id, "APIC") != 0) { - mp3.seek(fsize, SeekCur); - remain -= fsize; - continue; - } - - // APIC body - uint8_t encoding; - if (mp3.read(&encoding, 1) != 1) break; - fsize--; remain--; - - for (;;) { - uint8_t b; - if (mp3.read(&b, 1) != 1 || fsize == 0) break; - fsize--; remain--; - if (b == 0) break; - } - - if (fsize == 0) break; - mp3.seek(1, SeekCur); - fsize--; remain--; - - if (encoding == 0x00 || encoding == 0x03) { - for (;;) { - uint8_t b; - if (mp3.read(&b, 1) != 1 || fsize == 0) break; - fsize--; remain--; - if (b == 0) break; - } - } else { - for (;;) { - uint8_t b0, b1; - if (mp3.read(&b0, 1) != 1 || mp3.read(&b1, 1) != 1 || fsize < 2) break; - fsize -= 2; remain -= 2; - if (b0 == 0 && b1 == 0) break; - } - } - - uint8_t buf[512]; - while (fsize > 0) { - size_t chunk = (fsize > sizeof(buf)) ? sizeof(buf) : fsize; - size_t rd = mp3.read(buf, chunk); - if (rd == 0) break; - tmp.write(buf, rd); - fsize -= rd; - remain -= rd; - } - - found = true; - break; - } - - tmp.close(); - mp3.close(); - - if (found) { - format_ = kJPEG; // tentative, detectFormat_() will correct - if (!detectFormat_()) { - global_fs->remove(kCachePath); - format_ = kNone; - return false; - } - } else { - global_fs->remove(kCachePath); - } - - return found; -} - -// File-format probing (JPEG vs PNG) -bool AlbumArt::detectFormat_() { - if (!global_fs) return false; - File f = global_fs->open(kCachePath, "r"); - if (!f) return false; - - uint8_t magic[4]; - int n = f.read(magic, 4); - f.close(); - if (n < 4) return false; - - if (magic[0] == 0xFF && magic[1] == 0xD8) { - format_ = kJPEG; - return true; - } - if (magic[0] == 0x89 && magic[1] == 'P' && magic[2] == 'N' && magic[3] == 'G') { - format_ = kPNG; - return true; - } - return false; // unknown format -} - -// Lifecycle -bool AlbumArt::load(const char* mp3Path) { - if (format_ != kNone) clear(); - if (!ensureMuseDir_()) return false; - return extractAPIC_(mp3Path); -} - -void AlbumArt::clear() { - if (format_ != kNone && global_fs) { - global_fs->remove(kCachePath); - } - format_ = kNone; - img_w_ = img_h_ = 0; -} - -// Render to 1-bit e-ink buffer -bool AlbumArt::render1Bit(uint8_t* einkBuf, int bufW, int bufH, - int outX, int outY, int maxSize) -{ - if (format_ == kNone || !global_fs) return false; - - File file = global_fs->open(kCachePath, "r"); - if (!file) return false; - - if (format_ == kJPEG) - return renderJPEG_(file, einkBuf, bufW, bufH, outX, outY, maxSize); - if (format_ == kPNG) - return renderPNG_(file, einkBuf, bufW, bufH, outX, outY, maxSize); - - file.close(); - return false; -} - -// JPEG render path (delegates to JPEGDEC with ONE_BIT_DITHERED) -bool AlbumArt::renderJPEG_(File& file, uint8_t* einkBuf, int bufW, int bufH, - int outX, int outY, int maxSize) -{ - JPEGDEC jpeg; - if (!jpeg.open(file, jpegDrawCb)) { - file.close(); - return false; - } - - int imgW = jpeg.getWidth(); - int imgH = jpeg.getHeight(); - if (imgW <= 0 || imgH <= 0) { - jpeg.close(); file.close(); - return false; - } - - int ss = jpeg.getSubSample(); - int mcuCX, mcuCY; - switch (ss) { - case 0x12: mcuCX = 8; mcuCY = 16; break; - case 0x21: mcuCX = 16; mcuCY = 8; break; - case 0x22: mcuCX = 16; mcuCY = 16; break; - default: mcuCX = 8; mcuCY = 8; break; - } - - float scale = (float)maxSize / (imgW > imgH ? imgW : imgH); - int outW = (int)(imgW * scale); - int outH = (int)(imgH * scale); - if (outW < 1) outW = 1; - if (outH < 1) outH = 1; - - img_w_ = outW; - img_h_ = outH; - - int stripMCUs = 128 / mcuCX; - if (stripMCUs < 1) stripMCUs = 1; - - int ditherW = ((imgW + mcuCX - 1) / mcuCX) * mcuCX; - int ditherSize = ditherW * mcuCY; - uint8_t* ditherBuf = new (std::nothrow) uint8_t[ditherSize]; - if (!ditherBuf) { - jpeg.close(); file.close(); - return false; - } - - RenderCtx ctx; - ctx.einkBuf = einkBuf; - ctx.bufW = bufW; - ctx.bufH = bufH; - ctx.outX = outX; - ctx.outY = outY; - ctx.fullW = imgW; - ctx.fullH = imgH; - ctx.outW = outW; - ctx.outH = outH; - - jpeg.setPixelType(ONE_BIT_DITHERED); - jpeg.setMaxOutputSize(stripMCUs); - jpeg.setUserPointer(&ctx); - - bool ok = jpeg.decodeDither(0, 0, ditherBuf, 0); - - jpeg.close(); - file.close(); - delete[] ditherBuf; - return ok; -} - -// PNG render path -bool AlbumArt::renderPNG_(File& file, uint8_t* einkBuf, int bufW, int bufH, - int outX, int outY, int maxSize) -{ - // PNGIMAGE embeds a 32KB zlib buffer so it must be heap-allocated to avoid - // stack overflow on the ~4 KB appTask stack. - auto* png = new (std::nothrow) PNG; - if (!png) { file.close(); return false; } - - if (png->open(kCachePath, pngOpenCb, pngCloseCb, pngReadCb, pngSeekCb, pngDrawCb) - != PNG_SUCCESS) { - delete png; file.close(); - return false; - } - - int imgW = png->getWidth(); - int imgH = png->getHeight(); - if (imgW <= 0 || imgH <= 0) { - png->close(); delete png; file.close(); - return false; - } - - float scale = (float)maxSize / (imgW > imgH ? imgW : imgH); - int outW = (int)(imgW * scale); - int outH = (int)(imgH * scale); - if (outW < 1) outW = 1; - if (outH < 1) outH = 1; - - img_w_ = outW; - img_h_ = outH; - - // Allocate Floyd–Steinberg error buffers (full width + margin) - int errSize = (imgW + 2) * sizeof(int16_t); - auto* err0 = new (std::nothrow) int16_t[imgW + 2]; - auto* err1 = new (std::nothrow) int16_t[imgW + 2]; - if (!err0 || !err1) { - delete[] err0; delete[] err1; - png->close(); delete png; file.close(); - return false; - } - memset(err0, 0, errSize); - memset(err1, 0, errSize); - - PngRenderCtx ctx; - ctx.einkBuf = einkBuf; - ctx.bufW = bufW; - ctx.bufH = bufH; - ctx.outX = outX; - ctx.outY = outY; - ctx.fullW = imgW; - ctx.fullH = imgH; - ctx.outW = outW; - ctx.outH = outH; - ctx.pixelType = png->getPixelType(); - ctx.err = err0; - ctx.nextErr = err1; - - int rc = png->decode(&ctx, PNG_FAST_PALETTE); - png->close(); - delete png; - file.close(); - - delete[] err0; - delete[] err1; - - return rc == PNG_SUCCESS; -} diff --git a/src/globals.cpp b/src/globals.cpp deleted file mode 120000 index f6740e96..00000000 --- a/src/globals.cpp +++ /dev/null @@ -1 +0,0 @@ -../../PocketMageOS/src/globals.cpp \ No newline at end of file diff --git a/src/library.cpp b/src/library.cpp index cc21c089..6cc4463e 100644 --- a/src/library.cpp +++ b/src/library.cpp @@ -1,66 +1,136 @@ #include "library.h" -#include + +#include "paths.h" +#include "pm_app_api.h" + #include +#include +#include +#include + +namespace { + +struct Entry { + char name[128]; + bool is_dir; +}; +constexpr size_t kMaxName = sizeof(Entry::name); +constexpr size_t kMaxPath = 256; + +} -Library::Library() {} +Library::Library() : tracks_(nullptr), tracks_cap_(0), count_(0) {} + +Library::~Library() { + clear(); + std::free(tracks_); + tracks_ = nullptr; + tracks_cap_ = 0; +} void Library::clear() { - tracks_.clear(); + for (int i = 0; i < count_; i++) { + std::free((void *)tracks_[i]); + } + count_ = 0; } int Library::scan() { - clear(); + clear(); - if (!global_fs) { - return -1; - } - - File root = global_fs->open("/music"); - if (!root || !root.isDirectory()) { - return -1; - } - root.close(); + scanDir_(paths::kMusicDir, 0, kMaxDepth); + printf("[muse] scan %s -> %d tracks\n", paths::kMusicDir, count_); + fflush(stdout); - scanDir_("/music", 0); + if (count_ == 0) { + scanDir_("/", 0, kFallbackDepth); + printf("[muse] scan / -> %d tracks\n", count_); + fflush(stdout); + } - std::sort(tracks_.begin(), tracks_.end(), - [](const String& a, const String& b) { - return strcasecmp(a.c_str(), b.c_str()) < 0; - }); + std::sort(tracks_, tracks_ + count_, + [](const char *a, const char *b) { return strcasecmp(a, b) < 0; }); - return count(); + return count_; } -const String& Library::path(int index) const { - return tracks_[index]; +const char *Library::path(int index) const { + if (index < 0 || index >= count_) + return nullptr; + return tracks_[index]; } -void Library::scanDir_(const char* dirname, int depth) { - File dir = global_fs->open(dirname); - if (!dir || !dir.isDirectory()) { +void Library::scanDir_(const char *dirname, int depth, int maxDepth) { + if (depth > maxDepth) + return; + + int count = pm_sd_dir_open(dirname); + printf("[muse] dir %s entries=%d\n", dirname, count); + fflush(stdout); + if (count < 0) + return; + if (count == 0) { + pm_sd_dir_close(); + return; + } + + Entry *entries = static_cast(std::malloc(count * sizeof(Entry))); + if (!entries) { + pm_sd_dir_close(); + return; + } + int found = 0; + for (int i = 0; i < count; i++) { + if (pm_sd_dir_entry(i, entries[found].name, kMaxName, + &entries[found].is_dir)) { + found++; + } + } + pm_sd_dir_close(); + + for (int i = 0; i < found; i++) { + const Entry &e = entries[i]; + if (e.is_dir) { + char sub[kMaxPath]; + snprintf(sub, sizeof(sub), "%s/%s", dirname, e.name); + scanDir_(sub, depth + 1, maxDepth); + continue; + } + + const char *dot = std::strrchr(e.name, '.'); + if (!dot || strcasecmp(dot, ".mp3") != 0) + continue; + if (count_ >= kMaxTracks) + break; + + if (count_ >= tracks_cap_) { + int new_cap = tracks_cap_ ? tracks_cap_ * 2 : 64; + if (new_cap > kMaxTracks) + new_cap = kMaxTracks; + const char **nt = static_cast( + std::realloc((void *)tracks_, new_cap * sizeof(const char *))); + if (!nt) { + std::free(entries); return; + } + tracks_ = nt; + tracks_cap_ = new_cap; } - File entry = dir.openNextFile(); - while (entry) { - if (entry.isDirectory()) { - if (depth + 1 < kMaxDepth) { - String sub = String(dirname) + "/" + entry.name(); - scanDir_(sub.c_str(), depth + 1); - } - } else { - String name = entry.name(); - if (name.startsWith("._")) { - entry = dir.openNextFile(); - continue; - } - if (name.endsWith(".mp3") || name.endsWith(".MP3")) { - String full = String(dirname) + "/" + name; - tracks_.push_back(full); - } - } - entry = dir.openNextFile(); + size_t len = std::strlen(dirname) + 1 + std::strlen(e.name) + 1; + char *full = static_cast(std::malloc(len)); + if (!full) { + std::free(entries); + return; } + snprintf(full, len, "%s/%s", dirname, e.name); + tracks_[count_++] = full; + } + + std::free(entries); +} - dir.close(); +Library &library() { + static Library lib; + return lib; } diff --git a/src/main.cpp b/src/main.cpp index e1639a1f..4be66a63 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,400 +1,129 @@ -#include -#include "AudioTools.h" -#include "AudioTools/CoreAudio/AudioPlayer.h" -#include "AudioTools/CoreAudio/ResampleStream.h" -#include "AudioTools/AudioCodecs/CodecHelix.h" -#include "AudioTools/Disk/AudioSource.h" -#include "AudioTools/Disk/FileLoop.h" +#include "library.h" #include "muse.h" -#include "metacache.h" - -#if OTA_APP - -using namespace audio_tools; - -// Audio pipeline components (global so callbacks can reach them) -static AudioSourceCallback s_source; // callbacks open files from SD -static DecoderHelix s_decoder; // MP3/AAC/WAV auto-detect -static I2SStream s_i2sOut; // I2S DMA output on GPIO 17 -// Workaround: this audio-tools snapshot's ResampleStream hides the base p_out, -// so setOutput() never reaches the pointer its write path actually uses (null -// deref on any write incl. silence). attach() sets that shadowed pointer. -class ResampleStream48 : public ResampleStream { - public: - void attach(Print &out) { p_out = &out; } -}; -static ResampleStream48 s_resample; // resamples every track to 48 kHz -AudioPlayer s_player(s_source, s_resample, s_decoder); - -// Currently-open audio file and its size (updated by callbacks) -static File s_audioFile; -static size_t s_fileSize = 0; - -// FileLoop for LoopMode::One (wraps current file for endless replay) -static FileLoop s_fileLoop; -static bool s_useLoop = false; - -// Metadata cache -static MetadataCache s_metaCache; -static int s_metaFields = 0; - -// Log-only observer: reports each format change. The I2S/PDM output runs at a -// FIXED 48 kHz (an exact multiple of the 6.144 MHz PDM clock), and ResampleStream -// adapts every track to it, so no driver re-install is ever needed here. -class I2SInfoLogger : public AudioInfoSupport { - void setAudioInfo(AudioInfo info) override { - Serial.printf("[audio] decoder %du/%db/%dch -> fixed 48k i2s-clk %.0fHz\r\n", - info.sample_rate, info.bits_per_sample, info.channels, - i2s_get_clk(I2S_NUM_0)); - } - AudioInfo audioInfo() override { return AudioInfo(); } -}; -static I2SInfoLogger s_i2sLogger; - -// Metadata callback (fires during s_player.copy()) -static void onMetadata(MetaDataType type, const char* str, int len) { - switch (type) { - case Title: - strncpy(g_nowTitle, str, sizeof(g_nowTitle) - 1); - g_nowTitle[sizeof(g_nowTitle) - 1] = '\0'; - s_metaFields |= 1; - break; - case Artist: - strncpy(g_nowArtist, str, sizeof(g_nowArtist) - 1); - g_nowArtist[sizeof(g_nowArtist) - 1] = '\0'; - s_metaFields |= 2; - break; - case Album: - strncpy(g_nowAlbum, str, sizeof(g_nowAlbum) - 1); - g_nowAlbum[sizeof(g_nowAlbum) - 1] = '\0'; - s_metaFields |= 4; - break; - default: break; - } - // Cache when all three fields are received - if (s_metaFields == 7 && g_nowPath[0]) { - s_metaCache.save(g_nowPath, g_nowTitle, g_nowArtist, g_nowAlbum); - } -} - -// Pre-read metadata from file (faster than waiting for streaming callback) -static void pre_read_metadata(const char* path) { - if (!path || !path[0] || !global_fs) return; - - g_nowTitle[0] = '\0'; - g_nowArtist[0] = '\0'; - g_nowAlbum[0] = '\0'; - s_metaFields = 0; - - // Check cache first - if (s_metaCache.load(path, g_nowTitle, sizeof(g_nowTitle), - g_nowArtist, sizeof(g_nowArtist), - g_nowAlbum, sizeof(g_nowAlbum))) { - return; - } - - // Cache miss — quick pre-read of first 10 KB - File f = global_fs->open(path, "r"); - if (!f) return; - - uint8_t buf[10240]; - size_t n = f.read(buf, sizeof(buf)); - f.close(); - if (n == 0) return; - - MetaDataID3 meta; - meta.setCallback(onMetadata); - meta.begin(); - meta.write(buf, n); - meta.end(); - - if (g_nowTitle[0] || g_nowArtist[0] || g_nowAlbum[0]) { - s_metaCache.save(path, g_nowTitle, g_nowArtist, g_nowAlbum); - } -} - -// AudioSourceCallbacks -static Stream* openStream(const char* path, size_t& fileSize) { - if (!path || !path[0]) return nullptr; - File f = global_fs->open(path, "r"); - if (!f) return nullptr; - fileSize = f.size(); - - if (g_loopMode == LoopMode::One) { - s_fileLoop.end(); - s_fileLoop.setFile(f); - s_fileLoop.setLoopCount(-1); - s_fileLoop.begin(); - s_useLoop = true; - return &s_fileLoop; - } - - s_audioFile = f; - s_useLoop = false; - return &s_audioFile; -} - -static Stream* onSelectStream(int index) { - s_fileLoop.end(); - if (s_audioFile) s_audioFile.close(); - - if (index == -1) { - return openStream(s_source.getPath(), s_fileSize); - } - - char path[256]; - if (g_playlistMgr.source() == PlaySource::Library) { - if (index < 0 || index >= g_library.count()) return nullptr; - strncpy(path, g_library.path(index).c_str(), sizeof(path) - 1); - path[sizeof(path) - 1] = '\0'; - } else { - if (!g_playlistMgr.getEntry(index, path, sizeof(path))) return nullptr; - } - return openStream(path, s_fileSize); -} - -// Called by AudioPlayer::next() on EOF for auto-advance -static Stream* onNextStream(int offset) { - if (offset <= 0) return nullptr; - - if (g_loopMode == LoopMode::One) { - // FileLoop handles endless replay; this should never be called - return nullptr; - } - - if (g_loopMode == LoopMode::All) { - int next = (g_nowTrackIndex + 1) % g_trackCount; - char path[256]; - if (!get_track_path(next, path, sizeof(path))) return nullptr; - s_fileLoop.end(); - if (s_audioFile) s_audioFile.close(); - - File f = global_fs->open(path, "r"); - if (!f) return nullptr; - s_fileSize = f.size(); - s_audioFile = f; - s_useLoop = false; - - g_nowTrackIndex = next; - strncpy(g_nowPath, path, sizeof(g_nowPath) - 1); - g_nowPath[sizeof(g_nowPath) - 1] = '\0'; - g_nowDuration = compute_duration(path); - g_nowProgress = 0.0f; - g_isFavorite = g_playlistMgr.isFavorite(path); - pre_read_metadata(path); - nowplaying_cache_art(path); +#include "playlist.h" +#include "ui_helpers.h" +#include "ui_player.h" + +#include + +#include +#include +#include + +static bool exit_key(char ch) { + return ch == PM_KEY_ESCAPE || ch == PM_KEY_ASCII_ESCAPE || ch == 'A'; +} + +static void process_key(char ch) { + if (exit_key(ch)) { + music_stop(); + g_appMode = MODE_BROWSER; + return; + } + + if (g_appMode != MODE_HELP && (ch == 'u' || ch == 'U')) { + player_cycle_sink(); + return; + } + + switch (g_appMode) { + case MODE_BROWSER: + browser_process_key(ch); + break; + case MODE_NOWPLAYING: + nowplaying_process_key(ch); + break; + case MODE_PLAYLIST: + playlist_process_key(ch); + break; + case MODE_HELP: + g_appMode = g_prevMode; + g_needsRedraw = true; + break; + } +} + +extern "C" int main(int argc, char **argv) { + (void)argc; + (void)argv; + if (pm_app_abi() != PM_APP_API_ABI) { + printf("PocketMuse: host ABI mismatch: app %u, host %u\n", PM_APP_API_ABI, + static_cast(pm_app_abi())); + pm_oled_sysmsg("SDK ABI mismatch", 2000); + return 1; + } + + printf("PocketMuse starting (SDK %s)\n", PM_SDK_VERSION); + + pm_audio_begin(); + + pm_oled_init(); + + if (!playlistMgr().begin()) { + pm_oled_sysmsg("Music storage unavailable", 1200); + pm_audio_stop(); + return 1; + } + + library().scan(); + g_trackCount = library().count(); + + browser_init(); + + bool exit_requested = false; + + while (!exit_requested) { + char ch = pm_kb_read(); + if (ch) { + process_key(ch); + pm_activity(); + if (exit_key(ch)) { + exit_requested = true; + break; + } + } + + int scrollVec = pm_touch_get_scroll_vector(); + if (scrollVec != 0) { + pm_activity(); + if (scrollVec > 0 ? player_volume_up() : player_volume_down()) g_needsRedraw = true; - return &s_audioFile; } - return nullptr; -} + player_poll(); -// Playback control helpers (called from ui_*.cpp) -void music_stop() { - s_player.setActive(false); -} - -void music_play(const char* path) { - pre_read_metadata(path); - s_player.setAutoFade(true); - s_player.setPath(path); - s_player.play(); -} - -void music_pause() { - s_player.setAutoFade(false); - s_player.setActive(false); -} - -void music_resume() { - s_player.play(); -} - -// appTask: UI, progress, volume -static void appTask(void*) { - vTaskDelay(pdMS_TO_TICKS(200)); - for (;;) { - processKB_APP(); - - // Touch slider: volume - { - static unsigned long lastTouchMs = 0; - unsigned long now = millis(); - int scroll = 0; - if (now - lastTouchMs >= 200) { - lastTouchMs = now; - scroll = TOUCH().getScrollVector(); - } - if (scroll > 0 && g_volume < 245) { - g_volume += 10; - s_player.setVolume(g_volume / 255.0f); - g_needsRedraw = true; - } else if (scroll < 0 && g_volume > 10) { - g_volume -= 10; - s_player.setVolume(g_volume / 255.0f); - g_needsRedraw = true; - } - } - - // Progress from file position - if (s_useLoop) { - if (s_fileLoop.file() && s_fileSize > 0) { - g_nowProgress = (float)s_fileLoop.file().position() / s_fileSize; - } - } else if (s_audioFile && s_fileSize > 0) { - g_nowProgress = (float)s_audioFile.position() / s_fileSize; - } - - // Update global play state - if (s_player.isActive()) { - g_playState = PlayerState::Playing; - } else if (g_nowTrackIndex >= 0 && g_nowPath[0]) { - g_playState = PlayerState::Paused; - } else { - g_playState = PlayerState::Stopped; - } - - ui_update_oled(); - vTaskDelay(pdMS_TO_TICKS(50)); - yield(); - } -} - -// Application entry points (called from PocketMage framework) -void APP_INIT() { -#if defined(POCKETMUSE_FAST_REFRESH) && POCKETMUSE_FAST_REFRESH - FAST_REFRESH = true; -#endif - - // Restore volume - prefs.begin("PocketMuse", true); - g_volume = prefs.getUChar("volume", 200); - prefs.end(); - - // Configure I2S output on BZ_PIN (GPIO 17). - // Single-pin mode: BCK and WS clocks generated internally, only DATA pin used. - // A simple RC low-pass filter on the pin converts the bitstream to analog audio. - // Fixed 48 kHz PDM: 128 * 48000 = 6.144 MHz, an exact clock for the legacy S3 - // PDM driver. All other rates are handled by ResampleStream (see below). - auto cfg = s_i2sOut.defaultConfig(); - cfg.pin_data = BZ_PIN; - cfg.pin_bck = -1; - cfg.sample_rate = 48000; - cfg.channels = 2; - cfg.bits_per_sample = 16; - cfg.signal_type = PDM; - s_i2sOut.begin(cfg); - - Serial.printf("[audio] i2s begin requested=%lu actual=%.0fHz\r\n", - (unsigned long)cfg.sample_rate, i2s_get_clk(I2S_NUM_0)); - - // Resampler: always output the fixed 48 kHz rate of the PDM hardware. - s_resample.attach(s_i2sOut); - s_resample.begin(AudioInfo(48000, 2, 16), 48000); - Serial.printf("[audio] resampler output fixed at %dHz (1:1 at start)\r\n", 48000); - - // Set up AudioSourceCallback - s_source.setCallbackSelectStream(onSelectStream); - - // Initialize the pipeline (setupFade, volume_out.begin, etc.) - // without selecting a stream or activating playback. - s_player.begin(-1, false); - - // Enable auto-next for gapless track advancement - s_player.setAutoNext(true); - s_source.setCallbackNextStream(onNextStream); - s_decoder.addNotifyAudioChange(s_i2sLogger); - - - - // Set up metadata callback (fires during copy() via built-in MetaDataID3) - s_player.setMetadataCallback(onMetadata); - - // Set volume - s_player.setVolume(g_volume / 255.0f); - - // Scan library - if (global_fs) { - g_library.scan(); - g_playlistMgr.begin(); - } - g_trackCount = g_library.count(); - browser_init(); -} - -void processKB_APP() { - char ch = KB().updateKeypress(); - if (!ch) return; - - if (ch == 18) { - KB().setKeyboardState(KB().getKeyboardState() == FUNC ? NORMAL : FUNC); - return; + static bool s_usbActive = false; + bool usbActive = player_sink_is_usb_active(); + if (usbActive != s_usbActive) { + s_usbActive = usbActive; + g_needsRedraw = true; } - if (ch == 17) { - KB().setKeyboardState(KB().getKeyboardState() == SHIFT ? NORMAL : SHIFT); - return; - } - if (ch == 12 || ch == 27 || ch == 65) { - music_stop(); - prefs.begin("PocketMuse", false); - prefs.putUChar("volume", g_volume); - prefs.end(); - KB().setKeyboardState(NORMAL); - rebootToPocketMage(); - return; - } - - switch (g_appMode) { - case MODE_BROWSER: browser_process_key(ch); break; - case MODE_NOWPLAYING: nowplaying_process_key(ch); break; - case MODE_PLAYLIST: playlist_process_key(ch); break; - case MODE_HELP: g_appMode = g_prevMode; g_needsRedraw = true; break; - } - ui_update_oled(); -} - -void einkHandler_APP() { - if (!g_needsRedraw) return; - - g_needsRedraw = false; - switch (g_appMode) { - case MODE_HELP: ui_show_help(); break; - case MODE_BROWSER: browser_render(); break; - case MODE_NOWPLAYING: nowplaying_render(); break; - case MODE_PLAYLIST: playlist_render(); break; - } -} - -void applicationEinkHandler() { - einkHandler_APP(); -} - -void einkHandler(void* parameter) { - vTaskDelay(pdMS_TO_TICKS(250)); - UBaseType_t minStack = UINT32_MAX; - for (;;) { - applicationEinkHandler(); - UBaseType_t hwm = uxTaskGetStackHighWaterMark(NULL); - if (hwm < minStack) { - minStack = hwm; - Serial.printf("einkHandlerTask HWM: %u\n", hwm); - } - vTaskDelay(pdMS_TO_TICKS(50)); - yield(); - } + if (g_needsRedraw) { + g_needsRedraw = false; + switch (g_appMode) { + case MODE_HELP: + ui_show_help(); + break; + case MODE_BROWSER: + browser_render(); + break; + case MODE_NOWPLAYING: + nowplaying_render(); + break; + case MODE_PLAYLIST: + playlist_render(); + break; + } + } + + pm_audio_dispatch_events(); + + delay(50); + } + + pm_audio_stop(); + pm_oled_sysmsg("PocketMuse exited", 800); + return 0; } - -void setup() { - PocketMage_INIT(); - xTaskCreatePinnedToCore(appTask, "appTask", 32768, NULL, 2, NULL, 1); - APP_INIT(); -} - -void loop() { - s_player.copy(); -} - -#endif diff --git a/src/metacache.cpp b/src/metacache.cpp deleted file mode 100644 index 477576ff..00000000 --- a/src/metacache.cpp +++ /dev/null @@ -1,214 +0,0 @@ -#include "metacache.h" -#include -#include - -bool MetadataCache::readStr(File& f, char* buf, size_t bufSize) { - if (!f || bufSize == 0) return false; - buf[0] = '\0'; - uint16_t len; - if (f.read((uint8_t*)&len, sizeof(len)) != sizeof(len)) return false; - if (len == 0) return true; - size_t toRead = len < bufSize - 1 ? len : bufSize - 1; - if (f.read((uint8_t*)buf, toRead) != toRead) return false; - buf[toRead] = '\0'; - if (toRead < len) f.seek(f.position() + (len - toRead)); - return true; -} - -bool MetadataCache::skipStr(File& f) { - uint16_t len; - if (f.read((uint8_t*)&len, sizeof(len)) != sizeof(len)) return false; - if (len > 0) f.seek(f.position() + len); - return true; -} - -bool MetadataCache::load(const char* path, char* title, size_t tSize, - char* artist, size_t aSize, char* album, size_t lSize) { - if (!path || !path[0] || !global_fs) return false; - - File f = global_fs->open(kPath, "r"); - if (!f) return false; - - uint32_t magic, count; - if (f.read((uint8_t*)&magic, sizeof(magic)) != sizeof(magic) || magic != kMagic) { - f.close(); return false; - } - if (f.read((uint8_t*)&count, sizeof(count)) != sizeof(count)) { - f.close(); return false; - } - - for (uint32_t i = 0; i < count; i++) { - uint16_t pathLen; - if (f.read((uint8_t*)&pathLen, sizeof(pathLen)) != sizeof(pathLen)) break; - - uint32_t entryStart = f.position(); - - // Check if this entry's path matches - bool match = false; - if (pathLen > 0) { - size_t pathStrLen = strlen(path); - if (pathLen == pathStrLen + 1) { - char* p = new char[pathLen]; - if (f.read((uint8_t*)p, pathLen) == pathLen) { - match = (strcmp(p, path) == 0); - } - delete[] p; - } else { - f.seek(f.position() + pathLen); - } - } - - if (match) { - readStr(f, title, tSize); - readStr(f, artist, aSize); - readStr(f, album, lSize); - f.close(); - return true; - } - - f.seek(entryStart + pathLen); - skipStr(f); skipStr(f); skipStr(f); - } - - f.close(); - return false; -} - -void MetadataCache::save(const char* path, const char* title, - const char* artist, const char* album) { - if (!path || !path[0] || !global_fs) return; - - global_fs->mkdir("/music/.muse"); - - // Read existing entries into temp file, updating the matching one - File old = global_fs->open(kPath, "r"); - File tmp = global_fs->open(kTmp, "w"); - if (!tmp) { if (old) old.close(); return; } - - uint32_t magic = kMagic; - tmp.write((uint8_t*)&magic, sizeof(magic)); - - if (old) { - uint32_t oldMagic, count; - if (old.read((uint8_t*)&oldMagic, sizeof(oldMagic)) == sizeof(oldMagic) && - oldMagic == kMagic && - old.read((uint8_t*)&count, sizeof(count)) == sizeof(count)) { - - // Write count placeholder, will update at end - uint32_t countPos = tmp.position(); - tmp.write((uint8_t*)&count, sizeof(count)); - - uint32_t newCount = 0; - bool updated = false; - size_t pathLen = strlen(path) + 1; - - for (uint32_t i = 0; i < count; i++) { - uint16_t epLen; - if (old.read((uint8_t*)&epLen, sizeof(epLen)) != sizeof(epLen)) break; - - uint32_t entryStart = old.position(); - char* epBuf = new char[epLen]; - if (old.read((uint8_t*)epBuf, epLen) != epLen) { - delete[] epBuf; break; - } - - bool isMatch = !updated && (size_t)epLen == pathLen && - strcmp(epBuf, path) == 0; - - if (isMatch) { - // Replace with new data - uint16_t len = pathLen; - tmp.write((uint8_t*)&len, sizeof(len)); - tmp.write((uint8_t*)path, len); - - auto writeStr = [&](const char* s) { - uint16_t sl = s ? strlen(s) + 1 : 0; - tmp.write((uint8_t*)&sl, sizeof(sl)); - if (sl > 0) tmp.write((uint8_t*)s, sl); - }; - writeStr(title); - writeStr(artist); - writeStr(album); - updated = true; - newCount++; - } else { - // Copy entry unchanged - uint16_t len = epLen; - tmp.write((uint8_t*)&len, sizeof(len)); - tmp.write((uint8_t*)epBuf, epLen); - - // Copy title, artist, album - for (int j = 0; j < 3; j++) { - uint16_t sl; - if (old.read((uint8_t*)&sl, sizeof(sl)) != sizeof(sl)) { - delete[] epBuf; old.close(); tmp.close(); - global_fs->remove(kTmp); - return; - } - tmp.write((uint8_t*)&sl, sizeof(sl)); - if (sl > 0) { - uint8_t* sbuf = new uint8_t[sl]; - old.read(sbuf, sl); - tmp.write(sbuf, sl); - delete[] sbuf; - } - } - newCount++; - } - delete[] epBuf; - } - - // If not updated (new entry), append it - if (!updated) { - uint16_t len = pathLen; - tmp.write((uint8_t*)&len, sizeof(len)); - tmp.write((uint8_t*)path, len); - - auto writeStr = [&](const char* s) { - uint16_t sl = s ? strlen(s) + 1 : 0; - tmp.write((uint8_t*)&sl, sizeof(sl)); - if (sl > 0) tmp.write((uint8_t*)s, sl); - }; - writeStr(title); writeStr(artist); writeStr(album); - newCount++; - } - - // Update count - uint32_t endPos = tmp.position(); - tmp.seek(countPos); - tmp.write((uint8_t*)&newCount, sizeof(newCount)); - tmp.seek(endPos); - - old.close(); - } else { - old.close(); - // Corrupted file, start fresh - tmp.seek(0); - tmp.write((uint8_t*)&magic, sizeof(magic)); - goto writeNew; - } - } else { - writeNew:; - uint32_t one = 1; - tmp.write((uint8_t*)&one, sizeof(one)); - uint16_t len = strlen(path) + 1; - tmp.write((uint8_t*)&len, sizeof(len)); - tmp.write((uint8_t*)path, len); - - auto writeStr = [&](const char* s) { - uint16_t sl = s ? strlen(s) + 1 : 0; - tmp.write((uint8_t*)&sl, sizeof(sl)); - if (sl > 0) tmp.write((uint8_t*)s, sl); - }; - writeStr(title); writeStr(artist); writeStr(album); - } - - tmp.close(); - - global_fs->remove(kPath); - global_fs->rename(kTmp, kPath); -} - -void MetadataCache::clear() { - if (global_fs) global_fs->remove(kPath); -} diff --git a/src/muse.cpp b/src/muse.cpp index 05f7e160..5dafd5c0 100644 --- a/src/muse.cpp +++ b/src/muse.cpp @@ -1,24 +1,21 @@ #include "muse.h" -AppMode g_appMode = MODE_BROWSER; -AppMode g_prevMode = MODE_BROWSER; -bool g_needsRedraw = false; -int g_selIndex = 0; +AppMode g_appMode = MODE_BROWSER; +AppMode g_prevMode = MODE_BROWSER; +bool g_needsRedraw = false; +int g_selIndex = 0; +int g_trackCount = 0; -Library g_library; -PlaylistManager g_playlistMgr; -int g_trackCount = 0; +char g_nowPath[256] = {}; +char g_nowTitle[128] = {}; +char g_nowArtist[128] = {}; +char g_nowAlbum[128] = {}; +uint32_t g_nowDuration = 0; +float g_nowProgress = 0.0f; +uint32_t g_nowElapsed = 0; +int g_nowTrackIndex = -1; +bool g_isFavorite = false; +PlayerState g_playState = PlayerState::Stopped; -char g_nowPath[256] = {}; -char g_nowTitle[128] = {}; -char g_nowArtist[128] = {}; -char g_nowAlbum[128] = {}; -uint32_t g_nowDuration = 0; -float g_nowProgress = 0.0f; -int g_nowTrackIndex = -1; -bool g_isFavorite = false; -volatile PlayerState g_playState = PlayerState::Stopped; - -LoopMode g_loopMode = LoopMode::None; -bool g_shuffleEnabled = false; -volatile uint8_t g_volume = 200; +LoopMode g_loopMode = LoopMode::None; +bool g_shuffleEnabled = false; diff --git a/src/playlist.cpp b/src/playlist.cpp index 2af70d17..b202f478 100644 --- a/src/playlist.cpp +++ b/src/playlist.cpp @@ -1,441 +1,463 @@ #include "playlist.h" -#include -#include -// helpers - -// Read one line (including trailing \n) from a File. buf is always null-terminated. -// Returns false only on immediate EOF/error (no bytes read). -static bool readLine(File& f, char* buf, size_t bufSize) { - if (!f || f.available() <= 0) return false; - int i = 0; - while (i < (int)bufSize - 1) { - int c = f.read(); - if (c < 0) break; - buf[i++] = (char)c; - if (c == '\n') break; - } - buf[i] = '\0'; - return i > 0; -} +#include "paths.h" +#include "pm_app_api.h" -// Trim trailing \r and \n in-place. -static void trimCRLF(char* buf) { - size_t len = strlen(buf); - while (len > 0 && (buf[len - 1] == '\r' || buf[len - 1] == '\n')) - buf[--len] = '\0'; +#include +#include +#include +#include + +static char *readTextFile(const char *path, size_t *outSize) { + size_t fsize = pm_sd_get_file_size(path); + if (fsize == 0 || fsize >= UINT32_MAX) + return nullptr; + char *buf = static_cast(std::malloc(fsize + 1)); + if (!buf) + return nullptr; + if (!pm_sd_read_binary_file(path, reinterpret_cast(buf), fsize)) { + std::free(buf); + return nullptr; + } + buf[fsize] = '\0'; + if (outSize) + *outSize = fsize; + return buf; } -// PlaylistCursor - -bool PlaylistCursor::load(const char* m3uPath, bool isFav) { - unload(); - if (!global_fs) return false; +static constexpr size_t kPathCapacity = 256; +static constexpr char kM3UHeader[] = "#EXTM3U\n"; + +// Counting, lookup and deletion share the same definition of a visible entry. +static bool nextEntry(const char *data, size_t size, size_t &pos, + size_t &start, size_t &length) { + while (pos < size) { + start = pos; + while (pos < size && data[pos] != '\n' && data[pos] != '\r') + ++pos; + length = pos - start; + while (pos < size && (data[pos] == '\n' || data[pos] == '\r')) + ++pos; + size_t first = start; + while (first < start + length && + (data[first] == ' ' || data[first] == '\t')) + ++first; + if (first == start + length || data[first] == '#' || + length >= kPathCapacity || std::memchr(data + start, '\0', length)) + continue; + return true; + } + return false; +} - File f = global_fs->open(m3uPath, "r"); - if (!f) return false; +static bool validPath(const char *path) { + return path && path[0] == '/' && std::strlen(path) < kPathCapacity && + !std::strchr(path, '\r') && !std::strchr(path, '\n'); +} - bool ok = isFav ? parseFavs_(f) : parseM3U_(f); - file_size_ = f.size(); - f.close(); +static bool rewriteEntries(const char *file, int removeIndex, + const char *removePath, const char *appendPath, + bool m3u) { + size_t size = pm_sd_get_file_size(file); + char *data = size ? readTextFile(file, &size) : nullptr; + if (size && !data) + return false; + size_t extra = (appendPath ? std::strlen(appendPath) : 0) + sizeof(kM3UHeader) + 2; + if (size > std::numeric_limits::max() - extra) { + std::free(data); + return false; + } + char *result = static_cast(std::malloc(size + extra)); + if (!result) { + std::free(data); + return false; + } + size_t used = 0; + if (m3u) { + used = sizeof(kM3UHeader) - 1; + std::memcpy(result, kM3UHeader, used); + } + size_t pos = 0, start = 0, length = 0; + int index = 0; + while (nextEntry(data, size, pos, start, length)) { + bool remove = index++ == removeIndex || + (removePath && length == std::strlen(removePath) && + std::memcmp(data + start, removePath, length) == 0); + if (remove) + continue; + std::memcpy(result + used, data + start, length); + used += length; + result[used++] = '\n'; + } + if (appendPath) { + size_t length = std::strlen(appendPath); + std::memcpy(result + used, appendPath, length); + used += length; + result[used++] = '\n'; + } + bool ok = pm_sd_write_binary_file(file, reinterpret_cast(result), used); + std::free(result); + std::free(data); + return ok; +} - if (!ok || count_ == 0) { unload(); return false; } - strncpy(path_, m3uPath, sizeof(path_) - 1); - path_[sizeof(path_) - 1] = '\0'; - return true; +PlaylistCursor::PlaylistCursor() : file_size_(0), count_(0), offsets_(nullptr) { + path_[0] = '\0'; } +PlaylistCursor::~PlaylistCursor() { unload(); } + void PlaylistCursor::unload() { - delete[] offsets_; - offsets_ = nullptr; - count_ = 0; - file_size_ = 0; + std::free(offsets_); + offsets_ = nullptr; + count_ = 0; + file_size_ = 0; + path_[0] = '\0'; } -bool PlaylistCursor::getEntry(int index, char* pathBuf, size_t bufSize) { - if (!valid() || index < 0 || index >= count_ || !global_fs) return false; +bool PlaylistCursor::load(const char *path, bool isFav) { + unload(); + if (!validPath(path)) + return false; + if (pm_sd_get_file_size(path) == 0) { + std::strcpy(path_, path); + return true; + } + size_t fsize = 0; + char *data = readTextFile(path, &fsize); + if (!data) + return false; - File f = global_fs->open(path_, "r"); - if (!f) return false; + bool ok = isFav ? parseFavs_(data, fsize) : parseM3U_(data, fsize); + std::free(data); - f.seek(offsets_[index]); - bool ok = readLine(f, pathBuf, bufSize); - f.close(); + if (!ok) { + unload(); + return false; + } - if (ok) trimCRLF(pathBuf); - return ok && strlen(pathBuf) > 0; + std::strncpy(path_, path, sizeof(path_) - 1); + path_[sizeof(path_) - 1] = '\0'; + return true; } -bool PlaylistCursor::readPage(int startIndex, int count, std::vector& out) { - out.clear(); - if (!valid() || startIndex < 0 || count <= 0 || !global_fs) return false; - - int endIndex = startIndex + count; - if (endIndex > count_) endIndex = count_; +bool PlaylistCursor::getEntry(int index, char *pathBuf, size_t bufSize) const { + if (pathBuf && bufSize) + pathBuf[0] = '\0'; + if (!valid() || index < 0 || index >= count_ || !pathBuf || bufSize == 0) + return false; - File f = global_fs->open(path_, "r"); - if (!f) return false; + size_t fsize = 0; + char *data = readTextFile(path_, &fsize); + if (!data) + return false; - for (int i = startIndex; i < endIndex; i++) { - f.seek(offsets_[i]); - char buf[256]; - if (readLine(f, buf, sizeof(buf))) { - trimCRLF(buf); - if (strlen(buf) > 0) out.emplace_back(buf); - } - } + size_t off = offsets_[index]; + if (off >= fsize || fsize != file_size_) { + std::free(data); + return false; + } + size_t end = off; + while (end < fsize && data[end] != '\n' && data[end] != '\r') + end++; + size_t copyLen = static_cast(end - off); + if (copyLen >= bufSize) { + std::free(data); + return false; + } + std::memcpy(pathBuf, data + off, copyLen); + pathBuf[copyLen] = '\0'; + std::free(data); - f.close(); - return !out.empty(); + return pathBuf[0] != '\0'; } -bool PlaylistCursor::parseM3U_(File& f) { - int cap = 128; - uint32_t* off = new uint32_t[cap]; - int n = 0; - char buf[kScanBufSize]; - - while (f.available()) { - uint32_t lineStart = f.position(); - if (!readLine(f, buf, sizeof(buf))) break; - - trimCRLF(buf); - if (strlen(buf) == 0) continue; - - // Skip comments (#EXTM3U, #EXTINF, #) - if (buf[0] == '#') continue; - - if (n >= cap) { - cap *= 2; - uint32_t* nf = new uint32_t[cap]; - memcpy(nf, off, n * sizeof(uint32_t)); - delete[] off; - off = nf; - } - off[n++] = lineStart; +bool PlaylistCursor::parseM3U_(const char *data, size_t size) { + int cap = 128; + uint32_t *off = static_cast(std::malloc(cap * sizeof(uint32_t))); + if (!off) + return false; + int n = 0; + size_t pos = 0, lineStart = 0, length = 0; + while (nextEntry(data, size, pos, lineStart, length)) { + if (n >= cap) { + if (cap > std::numeric_limits::max() / 2 || + static_cast(cap) > SIZE_MAX / (2 * sizeof(uint32_t))) { + std::free(off); + return false; + } + cap *= 2; + uint32_t *nf = + static_cast(std::malloc(cap * sizeof(uint32_t))); + if (!nf) { + std::free(off); + return false; + } + std::memcpy(nf, off, n * sizeof(uint32_t)); + std::free(off); + off = nf; } - - if (n == 0) { delete[] off; return false; } - count_ = n; - offsets_ = off; - return true; + off[n++] = static_cast(lineStart); + } + + if (n == 0) { + std::free(off); + off = nullptr; + } + count_ = n; + offsets_ = off; + file_size_ = static_cast(size); + return true; } -bool PlaylistCursor::parseFavs_(File& f) { - int cap = 128; - uint32_t* off = new uint32_t[cap]; - int n = 0; - char buf[kScanBufSize]; - - while (f.available()) { - uint32_t lineStart = f.position(); - if (!readLine(f, buf, sizeof(buf))) break; - - trimCRLF(buf); - if (strlen(buf) == 0) continue; - - if (n >= cap) { - cap *= 2; - uint32_t* nf = new uint32_t[cap]; - memcpy(nf, off, n * sizeof(uint32_t)); - delete[] off; - off = nf; - } - off[n++] = lineStart; - } - - if (n == 0) { delete[] off; return false; } - count_ = n; - offsets_ = off; - return true; +bool PlaylistCursor::parseFavs_(const char *data, size_t size) { + return parseM3U_(data, size); } -// PlaylistManager -PlaylistManager::PlaylistManager() : source_(PlaySource::Library), pl_index_(-1) {} +PlaylistManager::PlaylistManager() + : source_(PlaySource::Library), pl_index_(-1), playlists_(nullptr), + playlists_cap_(0), playlist_count_(0) {} + +PlaylistManager::~PlaylistManager() { + std::free(playlists_); + playlists_ = nullptr; + playlists_cap_ = 0; + playlist_count_ = 0; +} bool PlaylistManager::begin() { - if (!global_fs) return false; - global_fs->mkdir(kMuseDir); - global_fs->mkdir(kPlDir); - scanPlaylists_(); - return true; + const bool musicOk = pm_sd_mkdir(paths::kMusicDir); + const bool stateOk = pm_sd_mkdir(paths::kStateDir); + const bool plOk = pm_sd_mkdir(paths::kPlaylistsDir); + printf("[muse] dirs music=%d state=%d playlists=%d\n", musicOk, stateOk, + plOk); + fflush(stdout); + if (!musicOk || !stateOk || !plOk) + return false; + scanPlaylists_(); + return true; } -const PlaylistMeta& PlaylistManager::playlistMeta(int idx) const { - static PlaylistMeta empty; - if (idx < 0 || idx >= (int)playlists_.size()) return empty; - return playlists_[idx]; +const PlaylistMeta &PlaylistManager::playlistMeta(int idx) const { + static PlaylistMeta empty; + if (idx < 0 || idx >= playlist_count_) + return empty; + return playlists_[idx]; } -int PlaylistManager::findPlaylist(const char* name) const { - for (int i = 0; i < (int)playlists_.size(); i++) { - if (playlists_[i].name == name) return i; - } - return -1; +int PlaylistManager::findPlaylist(const char *name) const { + for (int i = 0; i < playlist_count_; i++) { + if (std::strcmp(playlists_[i].name, name) == 0) + return i; + } + return -1; } -bool PlaylistManager::createPlaylist(const char* name) { - if (!global_fs || findPlaylist(name) >= 0) return false; +bool PlaylistManager::createPlaylist(const char *name) { + if (findPlaylist(name) >= 0) + return false; - String fpath = String(kPlDir) + "/" + name + ".m3u"; - File f = global_fs->open(fpath, "w"); - if (!f) return false; - f.println("#EXTM3U"); - f.close(); + char fpath[256]; + snprintf(fpath, sizeof(fpath), "%s/%s.m3u", paths::kPlaylistsDir, name); - scanPlaylists_(); - return true; + pm_sd_write_file(fpath, "#EXTM3U\n"); + + scanPlaylists_(); + return true; } bool PlaylistManager::deletePlaylist(int idx) { - if (!global_fs || idx < 0 || idx >= (int)playlists_.size()) return false; + if (idx < 0 || idx >= playlist_count_) + return false; - if (source_ == PlaySource::Playlist && pl_index_ == idx) { - cursor_.unload(); - source_ = PlaySource::Library; - pl_index_ = -1; - } + if (source_ == PlaySource::Playlist && pl_index_ == idx) { + cursor_.unload(); + source_ = PlaySource::Library; + pl_index_ = -1; + } - bool ok = global_fs->remove(playlists_[idx].filename.c_str()); - if (ok) scanPlaylists_(); - return ok; + pm_sd_delete_file(playlists_[idx].filename); + scanPlaylists_(); + return true; } bool PlaylistManager::playLibrary() { - cursor_.unload(); - source_ = PlaySource::Library; - pl_index_ = -1; - return true; + cursor_.unload(); + source_ = PlaySource::Library; + pl_index_ = -1; + return true; } bool PlaylistManager::playFavorites() { - cursor_.unload(); - if (!cursor_.load(kFavFile, true)) return false; - source_ = PlaySource::Favorites; - pl_index_ = -1; - return true; + source_ = PlaySource::Favorites; + pl_index_ = -1; + return cursor_.load(paths::kFavorites, true); } bool PlaylistManager::playPlaylist(int idx) { - if (idx < 0 || idx >= (int)playlists_.size()) return false; - - cursor_.unload(); - if (!cursor_.load(playlists_[idx].filename.c_str(), false)) { - playlists_.erase(playlists_.begin() + idx); - return false; - } + if (idx < 0 || idx >= playlist_count_) + return false; - source_ = PlaySource::Playlist; - playlists_[idx].count = cursor_.count(); - pl_index_ = idx; - return true; + source_ = PlaySource::Playlist; + pl_index_ = idx; + bool loaded = cursor_.load(playlists_[idx].filename, false); + playlists_[idx].count = loaded ? cursor_.count() : -1; + return loaded; } int PlaylistManager::entryCount() const { - return cursor_.count(); + if (source_ == PlaySource::Library) + return 0; + return cursor_.count(); } -bool PlaylistManager::readPage(int start, int count, std::vector& out) { - return cursor_.readPage(start, count, out); +bool PlaylistManager::getEntry(int index, char *pathBuf, size_t bufSize) { + return cursor_.getEntry(index, pathBuf, bufSize); } -bool PlaylistManager::getEntry(int index, char* pathBuf, size_t bufSize) { - return cursor_.getEntry(index, pathBuf, bufSize); +bool PlaylistManager::toggleFavorite(const char *path) { + if (!validPath(path)) + return false; + bool wasFav = isFavorite(path); + if (!rewriteEntries(paths::kFavorites, -1, wasFav ? path : nullptr, + wasFav ? nullptr : path, false)) + return wasFav; + + if (source_ == PlaySource::Favorites) { + // An empty favorites file leaves an empty cursor in the same view. + cursor_.load(paths::kFavorites, true); + } + return !wasFav; } -// Favorites - -bool PlaylistManager::toggleFavorite(const char* path) { - if (!global_fs) return false; - - bool wasFav = isFavorite(path); - if (wasFav) { - if (!removeLine_(kFavFile, path)) return true; // unchanged, still fav - } else { - File f = global_fs->open(kFavFile, "a"); - if (!f) return false; // unchanged, still not fav - f.println(path); - f.close(); - } - - if (source_ == PlaySource::Favorites) { - cursor_.unload(); - cursor_.load(kFavFile, true); - } - return !wasFav; -} +bool PlaylistManager::isFavorite(const char *path) { + if (!validPath(path)) + return false; + size_t fsize = 0; + char *data = readTextFile(paths::kFavorites, &fsize); + if (!data) + return false; -bool PlaylistManager::isFavorite(const char* path) { - if (!global_fs) return false; - File f = global_fs->open(kFavFile, "r"); - if (!f) return false; - - char buf[256]; - while (f.available()) { - int i = 0; - while (i < 255) { - int c = f.read(); - if (c < 0 || c == '\n') break; - buf[i++] = (char)c; - } - buf[i] = '\0'; - if (strcmp(buf, path) == 0) { f.close(); return true; } + bool found = false; + size_t pathLen = std::strlen(path); + size_t pos = 0, start = 0, length = 0; + while (nextEntry(data, fsize, pos, start, length)) { + if (length == pathLen && std::memcmp(data + start, path, length) == 0) { + found = true; + break; } - - f.close(); - return false; + } + std::free(data); + return found; } int PlaylistManager::favoriteCount() { - if (!global_fs) return 0; - File f = global_fs->open(kFavFile, "r"); - if (!f) return 0; - - int count = 0; - char buf[256]; - while (f.available()) { - int i = 0; - while (i < 255) { - int c = f.read(); - if (c < 0 || c == '\n') break; - buf[i++] = (char)c; - } - buf[i] = '\0'; - if (i > 0) count++; - } - - f.close(); - return count; + size_t fsize = 0; + char *data = readTextFile(paths::kFavorites, &fsize); + if (!data) + return 0; + + int count = 0; + size_t pos = 0, start = 0, length = 0; + while (nextEntry(data, fsize, pos, start, length)) + ++count; + std::free(data); + return count; } -// Playlist content management - -bool PlaylistManager::addToPlaylist(int plIdx, const char* songPath) { - if (!global_fs || plIdx < 0 || plIdx >= (int)playlists_.size()) return false; +bool PlaylistManager::addToPlaylist(int plIdx, const char *songPath) { + if (plIdx < 0 || plIdx >= playlist_count_ || !validPath(songPath)) + return false; - File f = global_fs->open(playlists_[plIdx].filename.c_str(), "a"); - if (!f) return false; - f.println(songPath); - f.close(); + if (!rewriteEntries(playlists_[plIdx].filename, -1, nullptr, songPath, true)) + return false; - if (source_ == PlaySource::Playlist && pl_index_ == plIdx) { - cursor_.unload(); - cursor_.load(playlists_[plIdx].filename.c_str(), false); - playlists_[plIdx].count = cursor_.count(); - } else { - playlists_[plIdx].count = -1; - } + if (source_ == PlaySource::Playlist && pl_index_ == plIdx) { + cursor_.unload(); + cursor_.load(playlists_[plIdx].filename, false); + playlists_[plIdx].count = cursor_.count(); + } else { + playlists_[plIdx].count = -1; + } - return true; + return true; } bool PlaylistManager::removeFromPlaylist(int plIdx, int entryIndex) { - if (!global_fs || plIdx < 0 || plIdx >= (int)playlists_.size()) return false; - - PlaylistCursor tmp; - if (!tmp.load(playlists_[plIdx].filename.c_str(), false)) return false; - if (entryIndex < 0 || entryIndex >= tmp.count()) { tmp.unload(); return false; } - - const char* tmpPath = "/music/.muse/.pl_tmp"; - File out = global_fs->open(tmpPath, "w"); - if (!out) { tmp.unload(); return false; } - - out.println("#EXTM3U"); - char p[256]; - for (int i = 0; i < tmp.count(); i++) { - if (i == entryIndex) continue; - if (tmp.getEntry(i, p, sizeof(p))) out.println(p); - } + if (plIdx < 0 || plIdx >= playlist_count_) + return false; + PlaylistCursor tmp; + if (!tmp.load(playlists_[plIdx].filename, false)) + return false; + if (entryIndex < 0 || entryIndex >= tmp.count()) { tmp.unload(); - out.close(); + return false; + } - global_fs->remove(playlists_[plIdx].filename.c_str()); - global_fs->rename(tmpPath, playlists_[plIdx].filename.c_str()); + if (!rewriteEntries(playlists_[plIdx].filename, entryIndex, nullptr, nullptr, true)) + return false; - if (source_ == PlaySource::Playlist && pl_index_ == plIdx) { - cursor_.unload(); - cursor_.load(playlists_[plIdx].filename.c_str(), false); - playlists_[plIdx].count = cursor_.count(); - } else { - playlists_[plIdx].count = -1; - } + tmp.unload(); + if (source_ == PlaySource::Playlist && pl_index_ == plIdx) { + cursor_.load(playlists_[plIdx].filename, false); + playlists_[plIdx].count = cursor_.count(); + } else { + playlists_[plIdx].count = -1; + } - return true; + return true; } -// Private helpers - void PlaylistManager::scanPlaylists_() { - playlists_.clear(); - if (!global_fs) return; - - File dir = global_fs->open(kPlDir); - if (!dir || !dir.isDirectory()) return; - - File entry; - while ((entry = dir.openNextFile())) { - if (entry.isDirectory()) { entry.close(); continue; } - - String fname = String(entry.name()); - if (!fname.endsWith(".m3u")) { entry.close(); continue; } - - // Strip .m3u and directory prefix for display name - String dname = fname.substring(0, fname.length() - 4); - int slash = dname.lastIndexOf('/'); - if (slash >= 0) dname = dname.substring(slash + 1); - - PlaylistMeta meta; - meta.filename = fname; - meta.name = dname; - meta.count = -1; - playlists_.push_back(meta); - entry.close(); + playlist_count_ = 0; + + int count = pm_sd_dir_open(paths::kPlaylistsDir); + if (count < 0) + return; + + char name[128]; + bool is_dir; + for (int i = 0; i < count; i++) { + if (!pm_sd_dir_entry(i, name, sizeof(name), &is_dir)) + continue; + if (is_dir) + continue; + + size_t nlen = std::strlen(name); + if (nlen < 4 || std::strcmp(name + nlen - 4, ".m3u") != 0) + continue; + + if (playlist_count_ >= playlists_cap_) { + int new_cap = playlists_cap_ ? playlists_cap_ * 2 : 16; + PlaylistMeta *np = static_cast( + std::realloc(playlists_, new_cap * sizeof(PlaylistMeta))); + if (!np) { + pm_sd_dir_close(); + return; + } + playlists_ = np; + playlists_cap_ = new_cap; } - dir.close(); + PlaylistMeta &meta = playlists_[playlist_count_]; + snprintf(meta.filename, sizeof(meta.filename), "%s/%s", + paths::kPlaylistsDir, name); + std::strncpy(meta.name, name, sizeof(meta.name) - 1); + meta.name[sizeof(meta.name) - 1] = '\0'; + char *dot = std::strrchr(meta.name, '.'); + if (dot) + *dot = '\0'; + meta.count = -1; + playlist_count_++; + } + + pm_sd_dir_close(); } -bool PlaylistManager::removeLine_(const char* filePath, const char* line) { - if (!global_fs) return false; - - File f = global_fs->open(filePath, "r"); - if (!f) return false; - - const char* tmpPath = "/music/.muse/.fav_tmp"; - File out = global_fs->open(tmpPath, "w"); - if (!out) { f.close(); return false; } - - char buf[256]; - bool found = false; - while (f.available()) { - int i = 0; - while (i < 255) { - int c = f.read(); - if (c < 0 || c == '\n') break; - buf[i++] = (char)c; - } - buf[i] = '\0'; - - if (!found && strcmp(buf, line) == 0) { - found = true; - continue; - } - out.println(buf); - } - - f.close(); - out.close(); - - global_fs->remove(filePath); - global_fs->rename(tmpPath, filePath); - - if (source_ == PlaySource::Favorites) { - cursor_.unload(); - cursor_.load(kFavFile, true); - } - - return found; +PlaylistManager &playlistMgr() { + static PlaylistManager mgr; + return mgr; } diff --git a/src/ui/ui_browser.cpp b/src/ui/ui_browser.cpp index a9283fbc..7cf1b80a 100644 --- a/src/ui/ui_browser.cpp +++ b/src/ui/ui_browser.cpp @@ -1,180 +1,193 @@ -#include +#include "library.h" #include "muse.h" +#include "playlist.h" +#include "ui_helpers.h" +#include "ui_player.h" -static constexpr int kItemH = 10; -static constexpr int kListTop = 32; -static constexpr int kListBot = kEinkContentH; -static constexpr int kMaxVis = (kListBot - kListTop) / kItemH; // 18 rows +#include "paths.h" +#include "pm_app_api.h" -static int g_scrollOffset = 0; +#include +#include -static const char* source_label() { - switch (g_playlistMgr.source()) { - case PlaySource::Library: return "Library"; - case PlaySource::Favorites: return "Favorites"; - case PlaySource::Playlist: { - int pi = g_playlistMgr.playlistIndex(); - if (pi >= 0) return g_playlistMgr.playlistMeta(pi).name.c_str(); - return "Playlist"; - } - } - return "Library"; -} +static constexpr int kItemH = 16; +static constexpr int kListTop = 36; +static constexpr int kListBot = 216; +static constexpr int kMaxVis = (kListBot - kListTop) / kItemH; + +static int g_scrollOffset = 0; static int track_count() { - switch (g_playlistMgr.source()) { - case PlaySource::Library: return g_library.count(); - case PlaySource::Favorites: - case PlaySource::Playlist: return g_playlistMgr.entryCount(); - } - return 0; + switch (playlistMgr().source()) { + case PlaySource::Library: + return library().count(); + case PlaySource::Favorites: + case PlaySource::Playlist: + return playlistMgr().entryCount(); + } + return 0; } void browser_init() { - g_appMode = MODE_BROWSER; - g_selIndex = 0; - g_scrollOffset = 0; - g_trackCount = track_count(); - g_needsRedraw = true; + g_appMode = MODE_BROWSER; + g_selIndex = 0; + g_scrollOffset = 0; + g_trackCount = track_count(); + g_needsRedraw = true; } void browser_process_key(char ch) { - int n = track_count(); - int page = kMaxVis; - - switch (ch) { - case 19: // LEFT - prev track - if (g_selIndex > 0) { - g_selIndex--; - if (g_selIndex < g_scrollOffset) g_scrollOffset = g_selIndex; - g_needsRedraw = true; - } - break; - case 21: // RIGHT - next track - if (g_selIndex < n - 1) { - g_selIndex++; - if (g_selIndex >= g_scrollOffset + kMaxVis) g_scrollOffset = g_selIndex - kMaxVis + 1; - g_needsRedraw = true; - } - break; - case 32: case 13: // Space or Enter - if (n > 0 && g_selIndex < n) { - player_play_index(g_selIndex); - } - break; - case 'p': case 'P': - player_cycle_source(); - g_selIndex = 0; - g_scrollOffset = 0; - g_trackCount = track_count(); - g_needsRedraw = true; - break; - case 'f': case 'F': - if (n > 0 && g_selIndex < n) { - char path[256]; - if (get_track_path(g_selIndex, path, sizeof(path)) && path[0]) { - g_isFavorite = g_playlistMgr.toggleFavorite(path); - } - } - g_needsRedraw = true; - break; - case 's': case 'S': - g_shuffleEnabled = !g_shuffleEnabled; - g_needsRedraw = true; - break; - case 'l': case 'L': { - switch (g_loopMode) { - case LoopMode::None: g_loopMode = LoopMode::One; break; - case LoopMode::One: g_loopMode = LoopMode::All; break; - case LoopMode::All: g_loopMode = LoopMode::None; break; - } - g_needsRedraw = true; - break; + int n = track_count(); + + switch (ch) { + case PM_KEY_LEFT: + if (g_selIndex > 0) { + g_selIndex--; + if (g_selIndex < g_scrollOffset) + g_scrollOffset = g_selIndex; + g_needsRedraw = true; + } + break; + case PM_KEY_RIGHT: + if (g_selIndex < n - 1) { + g_selIndex++; + if (g_selIndex >= g_scrollOffset + kMaxVis) + g_scrollOffset = g_selIndex - kMaxVis + 1; + g_needsRedraw = true; + } + break; + case PM_KEY_SPACE: + case PM_KEY_ENTER: + if (n > 0 && g_selIndex < n) { + player_play_index(g_selIndex); + } + break; + case 'p': + case 'P': + player_cycle_source(); + g_scrollOffset = 0; + break; + case 'f': + case 'F': + if (n > 0 && g_selIndex < n) { + char path[256]; + if (get_track_path(g_selIndex, path, sizeof(path)) && path[0]) { + player_toggle_favorite_path(path); + } } - case 'd': case 'D': - if (g_playlistMgr.source() == PlaySource::Playlist && n > 0) { - int pi = g_playlistMgr.playlistIndex(); - int idx = g_selIndex; - if (idx < g_trackCount) { - g_playlistMgr.removeFromPlaylist(pi, idx); - g_trackCount = track_count(); - if (g_selIndex >= g_trackCount && g_selIndex > 0) g_selIndex--; - g_needsRedraw = true; - } - } - break; - case 'b': case 'B': - if (g_playState != PlayerState::Stopped) { - g_appMode = MODE_NOWPLAYING; - g_needsRedraw = true; - } - break; - case '?': - g_prevMode = g_appMode; - g_appMode = MODE_HELP; - g_needsRedraw = true; - break; + g_needsRedraw = true; + break; + case 's': + case 'S': + player_toggle_shuffle(); + break; + case 'l': + case 'L': + player_cycle_loop_mode(); + break; + case 'd': + case 'D': + player_remove_selected(); + if (g_selIndex < g_scrollOffset) + g_scrollOffset = g_selIndex; + break; + case 'b': + case 'B': + if (g_playState != PlayerState::Stopped) { + g_appMode = MODE_NOWPLAYING; + g_needsRedraw = true; } + break; + case '?': + g_prevMode = g_appMode; + g_appMode = MODE_HELP; + g_needsRedraw = true; + break; + } } void browser_render() { - beginEinkScreen(); - - char hdr[64]; - snprintf(hdr, sizeof(hdr), "%s [%d]", source_label(), g_trackCount); - draw_header(hdr); - - int n = track_count(); - if (n == 0) { - FontEngine::drawText(DisplayTarget::EINK, 8, 80, - "No tracks found", FontStyle::Body); - FontEngine::drawText(DisplayTarget::EINK, 8, 94, - "Place .mp3 files in /music/ on SD", FontStyle::Body); - draw_footer("No tracks - add .mp3 files to /music/"); - EINK().refresh(); - return; - } - - if (g_scrollOffset > n - kMaxVis) g_scrollOffset = n - kMaxVis; - if (g_scrollOffset < 0) g_scrollOffset = 0; - - int y = kListTop; - int end = g_scrollOffset + kMaxVis; - if (end > n) end = n; - - for (int i = g_scrollOffset; i < end; i++) { - char path[256]; - char label[64]; - get_track_path(i, path, sizeof(path)); - get_display_name(path, label, sizeof(label)); - - String row = truncateWithEllipsis(label, display.width() - 24, - FontStyle::Tiny); - - if (i == g_selIndex) { - display.fillRect(0, y - 8, display.width(), kItemH, GxEPD_BLACK); - u8g2f.setForegroundColor(GxEPD_WHITE); - } else { - u8g2f.setForegroundColor(GxEPD_BLACK); - } - - FontEngine::drawText(DisplayTarget::EINK, 8, y, row, FontStyle::Tiny); + pm_ui_begin_eink_screen(false); + + char hdr[96]; + snprintf(hdr, sizeof(hdr), "%s [%d]", source_label(), g_trackCount); + draw_header(hdr); + + int n = track_count(); + if (n == 0) { + draw_empty_source(); + draw_footer("P:source B:playing ?:help"); + pm_eink_refresh(); + return; + } + + if (g_scrollOffset > n - kMaxVis) + g_scrollOffset = n - kMaxVis; + if (g_scrollOffset < 0) + g_scrollOffset = 0; - y += kItemH; + int y = kListTop; + int end = g_scrollOffset + kMaxVis; + if (end > n) + end = n; + + int w = pm_eink_width(); + + // Fixed column for the serif track number; the title starts after it. + const int kNumX = 8; + const int kTitleX = 44; + // Room at the right for the drawn now-playing marker. + const int kMarkerW = 14; + + for (int i = g_scrollOffset; i < end; i++) { + char path[256] = {}; + char label[64]; + if (get_track_path(i, path, sizeof(path))) + get_display_name(path, label, sizeof(label)); + else + std::strcpy(label, "Unavailable track"); + + int baseline = y + 10; + int titleMaxW = w - kTitleX - kMarkerW - 6; + int style = i == g_selIndex ? PM_STYLE_SANS_BOLD : PM_STYLE_SMALL; + const char *title = pm_layout_truncate_with_ellipsis( + label, titleMaxW, style, PM_TARGET_EINK); + + char numBuf[16]; + snprintf(numBuf, sizeof(numBuf), "%2d.", i + 1); + + if (i == g_selIndex) + pm_eink_rect(0, y - 3, w - 1, y - 3 + kItemH - 1, false, true); + + // The track number is always serif; the title uses bold only when the + // row is selected, keeping the number visually distinct from the title. + pm_text(PM_TARGET_EINK, kNumX, baseline, numBuf, PM_STYLE_SMALL); + pm_text(PM_TARGET_EINK, kTitleX, baseline, title, + i == g_selIndex ? PM_STYLE_SANS_BOLD : PM_STYLE_SMALL); + + // Now-playing marker: a small drawn play triangle rather than a font + // glyph (the serif fonts render such glyphs as tofu). + if (std::strcmp(path, g_nowPath) == 0 && g_playState != PlayerState::Stopped) { + int triX = w - kMarkerW; + int triTop = y + 2; + int triH = 9; + draw_transport_icon_eink(triX, triTop, triH, g_playState); } - u8g2f.setForegroundColor(GxEPD_BLACK); - draw_scrollbar(n, kMaxVis, g_scrollOffset); + y += kItemH; + } - char footer[64] = {}; - if (g_playlistMgr.source() == PlaySource::Playlist) { - snprintf(footer, sizeof(footer), - "L/R:nav SPC:play P:src F:fav D:del ?:help"); - } else { - snprintf(footer, sizeof(footer), - "L/R:nav SPC:play P:src F:fav L:loop ?:help"); - } - draw_footer(footer); + draw_scrollbar(n, kMaxVis, g_scrollOffset); + + char footer[64] = {}; + if (playlistMgr().source() != PlaySource::Library) { + snprintf(footer, sizeof(footer), + "L/R:nav SPC:play P:src F:fav D:del ?:help"); + } else { + snprintf(footer, sizeof(footer), + "L/R:nav SPC:play P:src F:fav L:loop ?:help"); + } + draw_footer(footer); - EINK().refresh(); + pm_eink_refresh(); } diff --git a/src/ui/ui_helpers.cpp b/src/ui/ui_helpers.cpp index 89481890..cbed7c7e 100644 --- a/src/ui/ui_helpers.cpp +++ b/src/ui/ui_helpers.cpp @@ -1,136 +1,210 @@ -#include -#include "muse.h" +#include "ui_helpers.h" -static constexpr int kLineH = 10; +#include "muse.h" +#include "playlist.h" +#include "paths.h" +#include "ui_player.h" + +#include "pm_app_api.h" + +#include +#include + +const char *source_label() { + switch (playlistMgr().source()) { + case PlaySource::Library: + return "Library"; + case PlaySource::Favorites: + return "Favorites"; + case PlaySource::Playlist: { + int pi = playlistMgr().playlistIndex(); + if (pi >= 0) + return playlistMgr().playlistMeta(pi).name; + return "Playlist"; + } + } + return "Library"; +} -void draw_header(const char* title) { - u8g2f.setForegroundColor(GxEPD_BLACK); - FontEngine::drawText(DisplayTarget::EINK, 8, 16, - title ? title : "", FontStyle::Body); - display.drawFastHLine(0, 22, display.width(), GxEPD_BLACK); +void draw_empty_source() { + const char *title = "No tracks found"; + char hint[96]; + snprintf(hint, sizeof(hint), "Put .mp3 files in %s", paths::kMusicDir); + if (playlistMgr().source() != PlaySource::Library) { + if (!playlistMgr().cursor().valid()) { + title = "Cannot read tracks"; + std::strcpy(hint, "Check the SD card, then reopen this source."); + } else if (playlistMgr().source() == PlaySource::Favorites) { + title = "No favorites yet"; + std::strcpy(hint, "Press F on a track in the Library to add it."); + } else { + title = "Playlist is empty"; + std::strcpy(hint, "Add track paths to this .m3u file on SD."); + } + } + pm_text(PM_TARGET_EINK, 8, 80, title, PM_STYLE_SMALL); + pm_text(PM_TARGET_EINK, 8, 95, hint, PM_STYLE_SMALL); } -void draw_footer(const char* hints) { - EINK().drawStatusBar(hints ? hints : ""); +static int transport_icon(PlayerState state) { + switch (state) { + case PlayerState::Playing: + return PM_TRANSPORT_ICON_PLAY; + case PlayerState::Paused: + return PM_TRANSPORT_ICON_PAUSE; + case PlayerState::Stopped: + return PM_TRANSPORT_ICON_STOP; + } + return PM_TRANSPORT_ICON_STOP; } -void draw_scrollbar(int total, int visible, int position) { - if (total <= visible) return; - drawScrollbar(total, visible, position, -1, 0, kEinkContentH, 3, - false, GxEPD_BLACK, GxEPD_WHITE); +void draw_transport_icon_eink(int x, int y, int h, PlayerState state) { + pm_ui_draw_transport_icon(PM_TARGET_EINK, x, y, h + 1, transport_icon(state)); } -void format_time(char* buf, size_t bufSize, uint32_t seconds) { - uint32_t m = seconds / 60; - uint32_t s = seconds % 60; - snprintf(buf, bufSize, "%d:%02d", m, s); +void draw_header(const char *title) { + pm_text_color(PM_TARGET_EINK, 1); + pm_text(PM_TARGET_EINK, 8, 16, title ? title : "", PM_STYLE_BODY); + // Tiny PocketMuse mark in the top-right corner of every screen. + pm_text(PM_TARGET_EINK, pm_eink_width() - 16, 6, "PM", PM_STYLE_TINY); + pm_eink_rect(0, 22, pm_eink_width() - 1, 22, false, true); } -void get_display_name(const char* path, char* out, size_t maxLen) { - const char* name = strrchr(path, '/'); - if (name) name++; else name = path; - strncpy(out, name, maxLen - 1); - out[maxLen - 1] = '\0'; - char* dot = strrchr(out, '.'); - if (dot) { - char* p = dot; - while (*p) { *p = '\0'; p++; } - } +void draw_footer(const char *hints) { + pm_eink_status_bar(hints ? hints : "", false); } -void ui_update_oled() { - u8g2.clearBuffer(); - - // Top line: play state + now playing - String now = " "; - if (g_playState == PlayerState::Playing) { - now = ">> "; - now += g_nowTitle; - if (g_nowArtist[0]) { - now += " - "; - now += g_nowArtist; - } - } else if (g_playState == PlayerState::Paused) { - now = "|| "; - now += g_nowTitle; - } else if (g_nowTitle[0]) { - now += g_nowTitle; - } else { - now += "PocketMuse"; - } - FontEngine::drawText(DisplayTarget::OLED, 0, 8, now, FontStyle::Tiny); - - uint32_t elapsed = (uint32_t)(g_nowProgress * g_nowDuration); - char timeStr[32]; - char elStr[16], durStr[16]; - format_time(elStr, sizeof(elStr), elapsed); - format_time(durStr, sizeof(durStr), g_nowDuration); - snprintf(timeStr, sizeof(timeStr), "%s / %s", elStr, durStr); - - // Progress bar outline - u8g2.drawFrame(0, 14, 144, 7); - if (g_nowProgress > 0.0f) { - int filled = (int)(142 * g_nowProgress); - if (filled > 0) u8g2.drawBox(1, 15, filled, 5); - } +void draw_scrollbar(int total, int visible, int position) { + if (total <= visible) + return; + int h = pm_eink_height(); + pm_ui_draw_scrollbar(total, visible, position, -1, 0, h, 3, false, 0, 65535); +} - // Time on right side - FontEngine::drawText(DisplayTarget::OLED, 152, 20, timeStr, FontStyle::Tiny); +void format_time(char *buf, size_t bufSize, uint32_t seconds) { + uint32_t m = seconds / 60; + uint32_t s = seconds % 60; + snprintf(buf, bufSize, "%lu:%02lu", static_cast(m), + static_cast(s)); +} - // Bottom line: source + count - String src; - switch (g_playlistMgr.source()) { - case PlaySource::Library: src = "Library"; break; - case PlaySource::Favorites: src = "Favorites"; break; - case PlaySource::Playlist: src = "Playlist"; break; - } - char srcBuf[32]; - snprintf(srcBuf, sizeof(srcBuf), "%s [%d]", src.c_str(), g_trackCount); - FontEngine::drawText(DisplayTarget::OLED, 0, 30, srcBuf, FontStyle::Tiny); +void get_display_name(const char *path, char *out, size_t maxLen) { + if (!out || maxLen == 0) + return; + out[0] = '\0'; + if (!path) + return; + const char *name = std::strrchr(path, '/'); + if (name) + name++; + else + name = path; + std::strncpy(out, name, maxLen - 1); + out[maxLen - 1] = '\0'; + char *dot = std::strrchr(out, '.'); + if (dot) + *dot = '\0'; +} - // Volume bar on right side - int volPct = (g_volume * 100 + 127) / 255; - char volBuf[16]; - snprintf(volBuf, sizeof(volBuf), "V:%3d%%", volPct); - FontEngine::drawText(DisplayTarget::OLED, 152, 30, volBuf, FontStyle::Tiny); +void truncate_text(const char *src, char *dst, size_t maxDst) { + std::strncpy(dst, src, maxDst - 1); + dst[maxDst - 1] = '\0'; +} - u8g2.sendBuffer(); +void ui_update_oled() { + pm_oled_set_power_save(false); + pm_oled_clear(); + pm_text_color(PM_TARGET_OLED, 0); + + const int w = pm_oled_width(); + const int h = pm_oled_height(); + + // A font glyph for play/pause falls back to a tofu box in these u8g2 fonts, + // so the transport marker is drawn, not typed. Reserve a small column for it. + const int kIconBox = 7; // glyph fits in a 7px box + const int kIconTop = 2; // top of the glyph on line 1 + const int kTitleX = kIconBox + 4; + const int kTextOffsetY = 3; + const int kTitleBaseline = 11 + kTextOffsetY; + const int kSinkBaseline = 25 + kTextOffsetY; + + // Elapsed / total, right-aligned on line 1. + uint32_t elapsed = g_nowElapsed; + char timeBuf[32]; + char elStr[12], durStr[12]; + format_time(elStr, sizeof(elStr), elapsed); + format_time(durStr, sizeof(durStr), g_nowDuration); + snprintf(timeBuf, sizeof(timeBuf), "%s / %s", elStr, durStr); + int timeW = pm_text_width(PM_TARGET_OLED, timeBuf, PM_STYLE_TINY); + + pm_ui_draw_transport_icon(PM_TARGET_OLED, 0, kIconTop, kIconBox, + transport_icon(g_playState)); + + // Title fills the gap between the glyph and the time readout. + const char *rawTitle = g_nowTitle[0] ? g_nowTitle : "PocketMuse"; + int titleMaxW = w - kTitleX - timeW - 6; + if (titleMaxW < 16) + titleMaxW = 16; + const char *title = pm_layout_truncate_with_ellipsis(rawTitle, titleMaxW, + PM_STYLE_MEDIUM, + PM_TARGET_OLED); + pm_text(PM_TARGET_OLED, kTitleX, kTitleBaseline, title, PM_STYLE_MEDIUM); + pm_text(PM_TARGET_OLED, w - timeW, kTitleBaseline, timeBuf, PM_STYLE_TINY); + + pm_text(PM_TARGET_OLED, 0, kSinkBaseline, sink_label(), PM_STYLE_TINY); + + // A two-pixel frame has no interior. Use a dotted baseline so the empty + // portion stays distinct from the two-pixel filled portion on a 1-bit canvas. + const int kBarH = 2; + const int barY = h - kBarH; + constexpr int kTrackDotPitch = 4; + for (int x = 0; x < w; x += kTrackDotPitch) + pm_oled_box(x, h - 1, 1, 1, true); + if (g_nowProgress > 0.0f) { + float p = g_nowProgress > 1.0f ? 1.0f : g_nowProgress; + int filled = static_cast(w * p); + if (filled > 0) + pm_oled_box(0, barY, filled, kBarH, true); + } + + pm_oled_send(); } void ui_show_help() { - beginEinkScreen(); - - FontEngine::drawText(DisplayTarget::EINK, 8, 18, - "PocketMuse Controls", FontStyle::Body); - display.drawFastHLine(0, 22, display.width(), GxEPD_BLACK); - - struct Row { const char* key; const char* desc; }; - Row rows[] = { - { "LEFT/RIGHT","Navigate / skip track" }, - { "UP/DOWN", "Volume (Now Playing)" }, - { "SPACE", "Play / pause" }, - { "ENTER", "Select / play" }, - { "P", "Cycle source (Lib/Fav/Pl)" }, - { "F", "Toggle favorite" }, - { "S", "Toggle shuffle" }, - { "L", "Cycle loop (None/One/All)" }, - { "D", "Delete from playlist" }, - { "B", "Back to browser" }, - { "?", "Show this screen" }, - { "ESC / A", "Exit to OS" }, - }; - - int y = 38; - for (auto& r : rows) { - FontEngine::drawText(DisplayTarget::EINK, 8, y, r.key, FontStyle::Tiny); - FontEngine::drawText(DisplayTarget::EINK, 82, y, r.desc, FontStyle::Tiny); - y += 12; - } - - display.drawFastHLine(0, y + 4, display.width(), GxEPD_BLACK); - FontEngine::drawText(DisplayTarget::EINK, 8, y + 16, - "Press any key to close", FontStyle::Tiny); - - draw_footer("PocketMuse help"); - EINK().refresh(); -} \ No newline at end of file + pm_ui_begin_eink_screen(false); + + draw_header("PocketMuse Controls"); + + struct Row { + const char *key; + const char *desc; + }; + Row rows[] = { + {"LEFT/RIGHT", "Navigate / skip track"}, + {"UP/DOWN", "Volume (Now Playing)"}, + {"SPACE", "Play / pause"}, + {"ENTER", "Select / play"}, + {"P", "Cycle source (Lib/Fav/Pl)"}, + {"F", "Toggle favorite"}, + {"S", "Toggle shuffle"}, + {"L", "Cycle loop (None/One/All)"}, + {"D", "Remove favorite / playlist entry"}, + {"U", "Output: Auto / Piezo / USB"}, + {"B", "Back to browser"}, + {"?", "Show this screen"}, + {"ESC / A", "Exit to OS"}, + }; + + int y = 38; + for (auto &r : rows) { + pm_text(PM_TARGET_EINK, 8, y, r.key, PM_STYLE_TINY); + pm_text(PM_TARGET_EINK, 82, y, r.desc, PM_STYLE_TINY); + y += 12; + } + + pm_eink_rect(0, y + 4, pm_eink_width() - 1, y + 4, false, true); + pm_text(PM_TARGET_EINK, 8, y + 16, "Press any key to close", PM_STYLE_TINY); + + draw_footer("PocketMuse help"); + pm_eink_refresh(); +} diff --git a/src/ui/ui_nowplaying.cpp b/src/ui/ui_nowplaying.cpp index b82c3638..678994e9 100644 --- a/src/ui/ui_nowplaying.cpp +++ b/src/ui/ui_nowplaying.cpp @@ -1,6 +1,11 @@ -#include #include "muse.h" -#include "albumart.h" +#include "ui_helpers.h" +#include "ui_player.h" + +#include "pm_app_api.h" + +#include +#include static constexpr int kArtX = 8; static constexpr int kArtY = 28; @@ -8,244 +13,236 @@ static constexpr int kArtSize = 120; static constexpr int kInfoX = 136; static constexpr int kInfoW = 176; -void nowplaying_init() { - g_appMode = MODE_NOWPLAYING; - g_needsRedraw = true; -} +static uint8_t s_artCache[(kArtSize * kArtSize) / 8]; +static char s_artCachePath[256] = {}; +static int s_cachedW = 0, s_cachedH = 0; -static void truncate_text(const char* src, char* dst, size_t maxDst) { - strncpy(dst, src, maxDst - 1); - dst[maxDst - 1] = '\0'; +void nowplaying_init() { + g_appMode = MODE_NOWPLAYING; + g_needsRedraw = true; } void nowplaying_process_key(char ch) { - int n = g_trackCount > 0 ? g_trackCount : 1; - - switch (ch) { - case 32: case 13: // Space or Enter - if (g_playState == PlayerState::Playing) { - music_pause(); - g_playState = PlayerState::Paused; - } else if (g_playState == PlayerState::Paused) { - music_resume(); - g_playState = PlayerState::Playing; - } else { - if (g_nowTrackIndex >= 0 && g_nowTrackIndex < n) { - player_play_index(g_nowTrackIndex); - } - } - g_needsRedraw = true; - break; - case 19: // LEFT - prev track - if (n > 0) player_prev_track(); - g_needsRedraw = true; - break; - case 21: // RIGHT - next track - if (n > 0) player_next_track(); - g_needsRedraw = true; - break; - case 24: // UP - volume up - if (g_volume < 245) g_volume += 10; - else g_volume = 255; - g_needsRedraw = true; - break; - case 25: // DOWN - volume down - if (g_volume > 10) g_volume -= 10; - else g_volume = 0; - g_needsRedraw = true; - break; - case 'b': case 'B': - g_appMode = MODE_BROWSER; - g_needsRedraw = true; - break; - case 'p': case 'P': - if (g_trackCount > 0) { - g_appMode = MODE_PLAYLIST; - g_needsRedraw = true; - } - break; - case 'f': case 'F': { - if (g_nowPath[0]) { - g_isFavorite = g_playlistMgr.toggleFavorite(g_nowPath); - } - g_needsRedraw = true; - break; - } - case 's': case 'S': - g_shuffleEnabled = !g_shuffleEnabled; - g_needsRedraw = true; - break; - case 'l': case 'L': { - switch (g_loopMode) { - case LoopMode::None: g_loopMode = LoopMode::One; break; - case LoopMode::One: g_loopMode = LoopMode::All; break; - case LoopMode::All: g_loopMode = LoopMode::None; break; - } - g_needsRedraw = true; - break; - } - case '?': - g_prevMode = g_appMode; - g_appMode = MODE_HELP; - g_needsRedraw = true; - break; - } -} - -static uint8_t s_artCache[(kArtSize * kArtSize) / 8]; -static char s_artCachePath[256] = {}; -static int s_cachedW = 0, s_cachedH = 0; + int n = g_trackCount > 0 ? g_trackCount : 1; -void nowplaying_cache_art(const char* path) { - s_cachedW = 0; - s_cachedH = 0; - s_artCachePath[0] = '\0'; - - if (!path || !path[0]) return; - - AlbumArt art; - if (!art.load(path)) return; - - if (art.render1Bit(s_artCache, kArtSize, kArtSize, 0, 0, kArtSize)) { - s_cachedW = art.width(); - s_cachedH = art.height(); - strncpy(s_artCachePath, path, sizeof(s_artCachePath) - 1); - s_artCachePath[sizeof(s_artCachePath) - 1] = '\0'; - } -} - -void nowplaying_render() { - beginEinkScreen(); - - if (g_nowTitle[0] == '\0' && g_nowPath[0] == '\0') { - draw_header("Now Playing"); - FontEngine::drawText(DisplayTarget::EINK, 8, 80, - "Nothing playing", FontStyle::Tiny); - FontEngine::drawText(DisplayTarget::EINK, 8, 94, - "Select a track from the browser", FontStyle::Tiny); - draw_footer("B:browser ?:help"); - EINK().refresh(); - return; - } - - // Header - char hdr[32]; + switch (ch) { + case PM_KEY_SPACE: + case PM_KEY_ENTER: if (g_playState == PlayerState::Playing) { - snprintf(hdr, sizeof(hdr), "Now Playing"); + music_pause(); + g_playState = PlayerState::Paused; } else if (g_playState == PlayerState::Paused) { - snprintf(hdr, sizeof(hdr), "Paused"); + music_resume(); + g_playState = PlayerState::Playing; } else { - snprintf(hdr, sizeof(hdr), "Stopped"); + if (g_nowTrackIndex >= 0 && g_nowTrackIndex < n) { + player_play_index(g_nowTrackIndex); + } } - draw_header(hdr); - - bool artDrawn = false; - if (s_cachedW > 0 && s_cachedH > 0) { - display.drawBitmap(kArtX, kArtY, s_artCache, - s_cachedW, s_cachedH, GxEPD_BLACK); - artDrawn = true; + g_needsRedraw = true; + break; + case PM_KEY_LEFT: + if (n > 0) + player_prev_track(); + g_needsRedraw = true; + break; + case PM_KEY_RIGHT: + if (n > 0) + player_next_track(); + g_needsRedraw = true; + break; + case PM_KEY_UP: + player_volume_up(); + g_needsRedraw = true; + break; + case PM_KEY_DOWN: + player_volume_down(); + g_needsRedraw = true; + break; + case 'b': + case 'B': + g_appMode = MODE_BROWSER; + g_needsRedraw = true; + break; + case 'p': + case 'P': + if (g_trackCount > 0) { + g_appMode = MODE_PLAYLIST; + g_needsRedraw = true; } + break; + case 'f': + case 'F': + player_toggle_favorite(); + break; + case 's': + case 'S': + player_toggle_shuffle(); + break; + case 'l': + case 'L': + player_cycle_loop_mode(); + break; + case '?': + g_prevMode = g_appMode; + g_appMode = MODE_HELP; + g_needsRedraw = true; + break; + } +} - // If no art, draw a placeholder frame - if (!artDrawn) { - display.drawRect(kArtX, kArtY, kArtSize, kArtSize, GxEPD_BLACK); - FontEngine::drawText(DisplayTarget::EINK, kArtX + 32, kArtY + 64, - "No Art", FontStyle::Tiny); - } +void nowplaying_cache_art(const char *path, const pm_media_info &info) { + s_cachedW = 0; + s_cachedH = 0; + s_artCachePath[0] = '\0'; - // Track info - char buf[128]; - - // Title - truncate_text(g_nowTitle, buf, sizeof(buf)); - String title = truncateWithEllipsis(buf, kInfoW - 8, FontStyle::Body); - FontEngine::drawText(DisplayTarget::EINK, kInfoX, kArtY + 18, - title, FontStyle::Body); - - // Artist - truncate_text(g_nowArtist, buf, sizeof(buf)); - if (buf[0]) { - String artist = truncateWithEllipsis(buf, kInfoW - 8, FontStyle::Body); - FontEngine::drawText(DisplayTarget::EINK, kInfoX, kArtY + 40, - artist, FontStyle::Body); - } + if (!path || !path[0] || !info.coverLength) + return; - // Album - truncate_text(g_nowAlbum, buf, sizeof(buf)); - if (buf[0]) { - String album = truncateWithEllipsis(buf, kInfoW - 8, FontStyle::Body); - FontEngine::drawText(DisplayTarget::EINK, kInfoX, kArtY + 60, - album, FontStyle::Body); - } + std::memset(s_artCache, 0, sizeof(s_artCache)); - // Status line - int statusY = kArtY + 82; - int col = kInfoX; - if (g_isFavorite) { - FontEngine::drawText(DisplayTarget::EINK, col, statusY, - "Fav", FontStyle::Tiny); - col += 40; - } - switch (g_loopMode) { - case LoopMode::None: break; - case LoopMode::One: - FontEngine::drawText(DisplayTarget::EINK, col, statusY, - "R1", FontStyle::Tiny); - col += 35; - break; - case LoopMode::All: - FontEngine::drawText(DisplayTarget::EINK, col, statusY, - "RA", FontStyle::Tiny); - col += 35; - break; - } - if (g_shuffleEnabled) { - FontEngine::drawText(DisplayTarget::EINK, col, statusY, - "Sf", FontStyle::Tiny); - col += 30; - } + if (pm_image_decode_region_to_1bit(path, info.coverOffset, info.coverLength, s_artCache, + kArtSize, kArtSize, 0, 0, kArtSize, true)) { + s_cachedW = kArtSize; + s_cachedH = kArtSize; + std::strncpy(s_artCachePath, path, sizeof(s_artCachePath) - 1); + s_artCachePath[sizeof(s_artCachePath) - 1] = '\0'; + } +} - // Volume - char volBuf[16]; - snprintf(volBuf, sizeof(volBuf), "V:%d", (g_volume * 100 + 127) / 255); - FontEngine::drawText(DisplayTarget::EINK, col, statusY, - volBuf, FontStyle::Tiny); +// Draw a small outlined chip with a centered label. Returns the x just past +// the chip so chips can be laid out left-to-right. +static int draw_chip(int x, int y, const char *label) { + const int padX = 4; + const int chipH = 14; + int textW = pm_text_width(PM_TARGET_EINK, label, PM_STYLE_TINY); + int chipW = textW + padX * 2; + pm_eink_rect(x, y, x + chipW, y + chipH, false, true); + pm_text(PM_TARGET_EINK, x + padX, y + chipH - 4, label, PM_STYLE_TINY); + return x + chipW + 4; +} - // Play/pause status - int playStatusY = statusY + 16; - const char* playStatus; - if (g_playState == PlayerState::Playing) { - playStatus = "> Playing"; - } else if (g_playState == PlayerState::Paused) { - playStatus = "|| Paused"; - } else { - playStatus = "[] Stopped"; +void nowplaying_render() { + pm_ui_begin_eink_screen(false); + + if (g_nowTitle[0] == '\0' && g_nowPath[0] == '\0') { + draw_header("Now Playing"); + pm_text(PM_TARGET_EINK, 8, 80, "Nothing playing", PM_STYLE_TINY); + pm_text(PM_TARGET_EINK, 8, 94, "Select a track from the browser", + PM_STYLE_TINY); + draw_footer("B:browser ?:help"); + pm_eink_refresh(); + return; + } + + const char *hdr; + if (g_playState == PlayerState::Playing) + hdr = "Now Playing"; + else if (g_playState == PlayerState::Paused) + hdr = "Paused"; + else + hdr = "Stopped"; + draw_header(hdr); + + bool artDrawn = false; + if (s_cachedW > 0 && s_cachedH > 0) { + int rowBytes = (s_cachedW + 7) / 8; + for (int py = 0; py < s_cachedH; py++) { + for (int px = 0; px < s_cachedW; px++) { + int byteIdx = py * rowBytes + (px >> 3); + bool bit = (s_artCache[byteIdx] >> (7 - (px & 7))) & 1; + pm_eink_pixel(kArtX + px, kArtY + py, bit); + } } - FontEngine::drawText(DisplayTarget::EINK, kInfoX, playStatusY, - playStatus, FontStyle::Tiny); - - // Progress bar - int barY = kArtY + kArtSize + 14; - int barW = display.width() - 16; - display.drawRect(8, barY, barW, 8, GxEPD_BLACK); - if (g_nowProgress > 0.0f && g_nowProgress <= 1.0f) { - int filled = (int)((barW - 2) * g_nowProgress); - if (filled > 0) display.fillRect(9, barY + 1, filled, 6, GxEPD_BLACK); + artDrawn = true; + } + + if (!artDrawn) { + pm_eink_rect(kArtX, kArtY, kArtX + kArtSize - 1, kArtY + kArtSize - 1, + false, true); + pm_text(PM_TARGET_EINK, kArtX + 32, kArtY + 64, "No Art", PM_STYLE_TINY); + } + + // Title wraps up to two lines inside the info column; drop the rest. The + // wrapper measures against e-ink metrics, which is the target here. + int y = kArtY + 14; + const int kTitleLineH = 18; + int lineCount = pm_layout_word_wrap_count(g_nowTitle, kInfoW, PM_STYLE_BODY); + int maxTitleLines = 2; + for (int i = 0; i < lineCount && i < maxTitleLines; i++) { + char line[128]; + int n = pm_layout_word_wrap_get(g_nowTitle, kInfoW, PM_STYLE_BODY, i, line, + sizeof(line)); + if (n < 0) + break; + // If the title is longer than two lines, mark the truncated second line. + if (i == maxTitleLines - 1 && lineCount > maxTitleLines) { + char tail[132]; + snprintf(tail, sizeof(tail), "%s...", line); + const char *trunc = pm_layout_truncate_with_ellipsis( + tail, kInfoW, PM_STYLE_BODY, PM_TARGET_EINK); + pm_text(PM_TARGET_EINK, kInfoX, y, trunc, PM_STYLE_BODY); + } else { + pm_text(PM_TARGET_EINK, kInfoX, y, line, PM_STYLE_BODY); } - - // Time - char timeBuf[32]; - char elapsedStr[16]; - char durationStr[16]; - uint32_t elapsed = (uint32_t)(g_nowProgress * g_nowDuration); - format_time(elapsedStr, sizeof(elapsedStr), elapsed); - format_time(durationStr, sizeof(durationStr), g_nowDuration); - snprintf(timeBuf, sizeof(timeBuf), "%s / %s", elapsedStr, durationStr); - FontEngine::drawText(DisplayTarget::EINK, 8, barY + 24, - timeBuf, FontStyle::Tiny); - - // Footer - draw_footer("SPC:pause < >:skip /\\/:vol B:browser P:plist ?:help"); - - EINK().refresh(); + y += kTitleLineH; + } + + y += 4; + if (g_nowArtist[0]) { + const char *artist = pm_layout_truncate_with_ellipsis( + g_nowArtist, kInfoW, PM_STYLE_SMALL, PM_TARGET_EINK); + pm_text(PM_TARGET_EINK, kInfoX, y, artist, PM_STYLE_SMALL); + y += 16; + } + if (g_nowAlbum[0]) { + const char *album = pm_layout_truncate_with_ellipsis( + g_nowAlbum, kInfoW, PM_STYLE_SMALL, PM_TARGET_EINK); + pm_text(PM_TARGET_EINK, kInfoX, y, album, PM_STYLE_SMALL); + y += 16; + } + + // Transport glyph + label just under the metadata. + y += 6; + draw_transport_icon_eink(kInfoX, y - 10, 10, g_playState); + const char *playStatus; + if (g_playState == PlayerState::Playing) + playStatus = "Playing"; + else if (g_playState == PlayerState::Paused) + playStatus = "Paused"; + else + playStatus = "Stopped"; + pm_text(PM_TARGET_EINK, kInfoX + 16, y, playStatus, PM_STYLE_SMALL); + + // Bottom band: source label at the left, status chips, volume at the right. + int bandY = pm_eink_height() - 40; + pm_eink_rect(0, bandY - 6, pm_eink_width() - 1, bandY - 6, false, true); + + char volBuf[16]; + snprintf(volBuf, sizeof(volBuf), "Vol %d%%", (pm_audio_get_volume_level() * 100 + 127) / 255); + int volW = pm_text_width(PM_TARGET_EINK, volBuf, PM_STYLE_SMALL); + int volX = pm_eink_width() - 8 - volW; + const char *chips[] = {g_isFavorite ? "FAV" : nullptr, + g_loopMode == LoopMode::One ? "R1" : + g_loopMode == LoopMode::All ? "RA" : nullptr, + g_shuffleEnabled ? "SF" : nullptr, + player_sink_is_usb_active() ? "USB" : nullptr}; + int chipsWidth = 0; + for (const char *chip : chips) { + if (chip) + chipsWidth += pm_text_width(PM_TARGET_EINK, chip, PM_STYLE_TINY) + 12; + } + int chipX = volX - 8 - chipsWidth; + const char *source = pm_layout_truncate_with_ellipsis( + source_label(), chipX - 16, PM_STYLE_SMALL, PM_TARGET_EINK); + pm_text(PM_TARGET_EINK, 8, bandY + 6, source, PM_STYLE_SMALL); + for (const char *chip : chips) { + if (chip) + chipX = draw_chip(chipX, bandY - 3, chip); + } + pm_text(PM_TARGET_EINK, volX, bandY + 6, volBuf, PM_STYLE_SMALL); + + draw_footer("SPC:pause L/R:skip U:output B:browser ?:help"); + + pm_eink_refresh(); } diff --git a/src/ui/ui_playlist.cpp b/src/ui/ui_playlist.cpp index 11d51914..eff59a58 100644 --- a/src/ui/ui_playlist.cpp +++ b/src/ui/ui_playlist.cpp @@ -1,139 +1,136 @@ -#include #include "muse.h" +#include "playlist.h" +#include "ui_helpers.h" +#include "ui_player.h" -static constexpr int kItemH = 10; +#include "pm_app_api.h" + +#include +#include + +static constexpr int kItemH = 10; static constexpr int kListTop = 32; -static constexpr int kListBot = kEinkContentH; -static constexpr int kMaxVis = (kListBot - kListTop) / kItemH; // 18 rows +static constexpr int kListBot = 214; +static constexpr int kMaxVis = (kListBot - kListTop) / kItemH; static int g_plScroll = 0; void playlist_process_key(char ch) { - int n = g_trackCount; - - switch (ch) { - case 19: // LEFT - if (g_selIndex > 0) { - g_selIndex--; - if (g_selIndex < g_plScroll) g_plScroll = g_selIndex; - g_needsRedraw = true; - } - break; - case 21: // RIGHT - if (g_selIndex < n - 1) { - g_selIndex++; - if (g_selIndex >= g_plScroll + kMaxVis) g_plScroll = g_selIndex - kMaxVis + 1; - g_needsRedraw = true; - } - break; - case 32: case 13: // Space or Enter - if (n > 0 && g_selIndex < n) { - player_play_index(g_selIndex); - } - break; - case 'd': case 'D': - if (g_playlistMgr.source() == PlaySource::Playlist) { - int pi = g_playlistMgr.playlistIndex(); - int idx = g_selIndex; - if (idx < g_trackCount) { - g_playlistMgr.removeFromPlaylist(pi, idx); - g_trackCount = g_playlistMgr.entryCount(); - if (g_selIndex >= g_trackCount && g_selIndex > 0) g_selIndex--; - g_needsRedraw = true; - } - } - break; - case 'b': case 'B': - if (g_playState != PlayerState::Stopped) { - g_appMode = MODE_NOWPLAYING; - } else { - g_appMode = MODE_BROWSER; - } - g_needsRedraw = true; - break; - case '?': - g_prevMode = g_appMode; - g_appMode = MODE_HELP; - g_needsRedraw = true; - break; + int n = g_trackCount; + + switch (ch) { + case PM_KEY_LEFT: + if (g_selIndex > 0) { + g_selIndex--; + if (g_selIndex < g_plScroll) + g_plScroll = g_selIndex; + g_needsRedraw = true; + } + break; + case PM_KEY_RIGHT: + if (g_selIndex < n - 1) { + g_selIndex++; + if (g_selIndex >= g_plScroll + kMaxVis) + g_plScroll = g_selIndex - kMaxVis + 1; + g_needsRedraw = true; + } + break; + case PM_KEY_SPACE: + case PM_KEY_ENTER: + if (n > 0 && g_selIndex < n) { + player_play_index(g_selIndex); + } + break; + case 'd': + case 'D': + player_remove_selected(); + if (g_selIndex < g_plScroll) + g_plScroll = g_selIndex; + break; + case 'b': + case 'B': + if (g_playState != PlayerState::Stopped) { + g_appMode = MODE_NOWPLAYING; + } else { + g_appMode = MODE_BROWSER; } + g_needsRedraw = true; + break; + case '?': + g_prevMode = g_appMode; + g_appMode = MODE_HELP; + g_needsRedraw = true; + break; + } } void playlist_render() { - beginEinkScreen(); - - const char* srcName = ""; - if (g_playlistMgr.source() == PlaySource::Playlist) { - int pi = g_playlistMgr.playlistIndex(); - if (pi >= 0) srcName = g_playlistMgr.playlistMeta(pi).name.c_str(); + pm_ui_begin_eink_screen(false); + + char hdr[96]; + snprintf(hdr, sizeof(hdr), "%s [%d]", source_label(), g_trackCount); + draw_header(hdr); + + int n = g_trackCount; + if (n == 0) { + draw_empty_source(); + draw_footer("B:back ?:help"); + pm_eink_refresh(); + return; + } + + if (g_plScroll > n - kMaxVis) + g_plScroll = n - kMaxVis; + if (g_plScroll < 0) + g_plScroll = 0; + + int y = kListTop; + int end = g_plScroll + kMaxVis; + if (end > n) + end = n; + + int w = pm_eink_width(); + + for (int i = g_plScroll; i < end; i++) { + char path[256] = {}; + char label[56]; + if (get_track_path(i, path, sizeof(path))) + get_display_name(path, label, sizeof(label)); + else + std::strcpy(label, "Unavailable track"); + + char prefix[16]; + snprintf(prefix, sizeof(prefix), "%2d.", i + 1); + char row[96]; + snprintf(row, sizeof(row), "%s %s", prefix, label); + + // Truncate with ellipsis to fit the available width. + const char *ell = pm_layout_truncate_with_ellipsis( + row, w - 30, PM_STYLE_TINY, PM_TARGET_EINK); + strncpy(row, ell, sizeof(row) - 1); + row[sizeof(row) - 1] = '\0'; + + if (i == g_selIndex) { + pm_eink_rect(0, y - 8, w - 1, y - 8 + kItemH, false, true); } + pm_text(PM_TARGET_EINK, 8, y, row, PM_STYLE_TINY); - char hdr[64]; - snprintf(hdr, sizeof(hdr), "Playlist: %s [%d]", srcName, g_trackCount); - draw_header(hdr); - - int n = g_trackCount; - if (n == 0) { - FontEngine::drawText(DisplayTarget::EINK, 8, 80, - "Playlist is empty", FontStyle::Body); - draw_footer("B:back ?:help"); - EINK().refresh(); - return; + if (std::strcmp(path, g_nowPath) == 0 && g_playState != PlayerState::Stopped) { + draw_transport_icon_eink(w - 22, y - 7, 7, g_playState); } - if (g_plScroll > n - kMaxVis) g_plScroll = n - kMaxVis; - if (g_plScroll < 0) g_plScroll = 0; - - int y = kListTop; - int end = g_plScroll + kMaxVis; - if (end > n) end = n; - - for (int i = g_plScroll; i < end; i++) { - // Track number + name - char path[256]; - char label[56]; - get_track_path(i, path, sizeof(path)); - get_display_name(path, label, sizeof(label)); - - char prefix[8]; - snprintf(prefix, sizeof(prefix), "%2d.", i + 1); - String row = String(prefix) + " " + label; - row = truncateWithEllipsis(row, display.width() - 30, - FontStyle::Tiny); - - if (i == g_selIndex) { - display.fillRect(0, y - 8, display.width(), kItemH, GxEPD_BLACK); - u8g2f.setForegroundColor(GxEPD_WHITE); - } else if (i == g_nowTrackIndex) { - display.drawRect(4, y - 8, display.width() - 8, kItemH, GxEPD_BLACK); - u8g2f.setForegroundColor(GxEPD_BLACK); - } else { - u8g2f.setForegroundColor(GxEPD_BLACK); - } - - FontEngine::drawText(DisplayTarget::EINK, 8, y, row, FontStyle::Tiny); - - // Now playing indicator - if (i == g_nowTrackIndex && g_playState != PlayerState::Stopped) { - FontEngine::drawText(DisplayTarget::EINK, display.width() - 22, - y, ">", FontStyle::Tiny); - } - - y += kItemH; - } - u8g2f.setForegroundColor(GxEPD_BLACK); + y += kItemH; + } - draw_scrollbar(n, kMaxVis, g_plScroll); + draw_scrollbar(n, kMaxVis, g_plScroll); - char footer[64]; - if (g_playlistMgr.source() == PlaySource::Playlist) { - snprintf(footer, sizeof(footer), - "L/R:nav SPC:play D:del B:back ?:help"); - } else { - snprintf(footer, sizeof(footer), - "L/R:nav SPC:play B:back ?:help"); - } - draw_footer(footer); + char footer[64]; + if (playlistMgr().source() == PlaySource::Playlist) { + snprintf(footer, sizeof(footer), "L/R:nav SPC:play D:del B:back ?:help"); + } else { + snprintf(footer, sizeof(footer), "L/R:nav SPC:play B:back ?:help"); + } + draw_footer(footer); - EINK().refresh(); + pm_eink_refresh(); } diff --git a/src/ui_player.cpp b/src/ui_player.cpp index 617a4844..8a3da791 100644 --- a/src/ui_player.cpp +++ b/src/ui_player.cpp @@ -1,253 +1,470 @@ -#include +#include "ui_player.h" + +#include "library.h" #include "muse.h" -#include "albumart.h" -#include "metacache.h" -#include "AudioTools/Disk/WAVFileInfo.h" +#include "playlist.h" +#include "ui_helpers.h" + +#include "pm_app_api.h" + +#include #include -#include -#include - -using namespace audio_tools; - -// Fisher-Yates shuffle state -static std::vector s_shuffleIdx; -static int s_shufflePos = 0; - -static void build_shuffle() { - int n = g_trackCount; - if (n <= 0) { s_shuffleIdx.clear(); return; } - s_shuffleIdx.resize(n); - for (int i = 0; i < n; i++) s_shuffleIdx[i] = i; - for (int i = n - 1; i > 0; i--) { - int j = rand() % (i + 1); - std::swap(s_shuffleIdx[i], s_shuffleIdx[j]); - } - s_shufflePos = 0; -} -bool get_track_path(int index, char* buf, size_t bufSize) { - if (!buf || bufSize == 0) return false; - buf[0] = '\0'; +int *g_shuffleIdx = nullptr; +int g_shuffleIdxCount = 0; +int g_shufflePos = 0; - switch (g_playlistMgr.source()) { - case PlaySource::Library: - if (index >= 0 && index < g_library.count()) { - const String& p = g_library.path(index); - strncpy(buf, p.c_str(), bufSize - 1); - buf[bufSize - 1] = '\0'; - } - break; - case PlaySource::Favorites: - case PlaySource::Playlist: - g_playlistMgr.getEntry(index, buf, bufSize); +namespace { +uint32_t s_playbackId = 0; +} + +void build_shuffle() { + int n = g_trackCount; + if (n <= 0) { + std::free(g_shuffleIdx); + g_shuffleIdx = nullptr; + g_shuffleIdxCount = 0; + g_shufflePos = 0; + return; + } + if (!g_shuffleIdx || g_shuffleIdxCount != n) { + std::free(g_shuffleIdx); + g_shuffleIdx = static_cast(std::malloc(n * sizeof(int))); + g_shuffleIdxCount = g_shuffleIdx ? n : 0; + if (!g_shuffleIdx) { + g_shuffleEnabled = false; + pm_oled_sysmsg("Not enough memory for shuffle", 800); + return; + } + } + for (int i = 0; i < n; ++i) + g_shuffleIdx[i] = i; + for (int i = n - 1; i > 0; --i) { + int j = static_cast((static_cast(std::rand()) / + (static_cast(RAND_MAX) + 1.0)) * (i + 1)); + int value = g_shuffleIdx[i]; + g_shuffleIdx[i] = g_shuffleIdx[j]; + g_shuffleIdx[j] = value; + } + g_shufflePos = -1; + if (g_nowTrackIndex >= 0 && g_nowTrackIndex < n) { + for (int i = 0; i < n; ++i) { + if (g_shuffleIdx[i] == g_nowTrackIndex) { + g_shuffleIdx[i] = g_shuffleIdx[0]; + g_shuffleIdx[0] = g_nowTrackIndex; + g_shufflePos = 0; break; + } + } + } +} + +bool get_track_path(int index, char *buf, size_t bufSize) { + if (!buf || bufSize == 0) + return false; + buf[0] = '\0'; + + PlaySource src = playlistMgr().source(); + switch (src) { + case PlaySource::Library: + if (index >= 0 && index < library().count()) { + const char *p = library().path(index); + std::strncpy(buf, p, bufSize - 1); + buf[bufSize - 1] = '\0'; } - return buf[0] != '\0'; + break; + case PlaySource::Favorites: + case PlaySource::Playlist: + playlistMgr().getEntry(index, buf, bufSize); + break; + } + return buf[0] != '\0'; } -// Compute approximate duration from file size + format header. -uint32_t compute_duration(const char* path) { - if (!path || !path[0] || !global_fs) return 0; +void player_play_index(int index) { + char path[256]; + if (!get_track_path(index, path, sizeof(path)) || !path[0]) + return; + + music_stop(); - File f = global_fs->open(path, "r"); - if (!f) return 0; + g_nowTitle[0] = '\0'; + g_nowArtist[0] = '\0'; + g_nowAlbum[0] = '\0'; + g_nowDuration = 0; - size_t fileSize = f.size(); - if (fileSize < 128) { f.close(); return 0; } + get_display_name(path, g_nowTitle, sizeof(g_nowTitle)); - uint32_t duration = 0; + std::strncpy(g_nowPath, path, sizeof(g_nowPath) - 1); + g_nowPath[sizeof(g_nowPath) - 1] = '\0'; - // Check for WAV using WAVFileInfo - WAVFileInfo wav; - WAVAudioInfo info; - if (wav.getInfo(f, info) && info.data_length > 0) { - uint32_t bytesPerSec = info.sample_rate * info.channels * (info.bits_per_sample / 8); - if (bytesPerSec > 0) { - duration = info.data_length / bytesPerSec; - } + g_nowTrackIndex = index; + if (g_shuffleEnabled && g_shuffleIdx) { + for (int i = 0; i < g_shuffleIdxCount; ++i) { + if (g_shuffleIdx[i] == index) { + g_shufflePos = i; + break; + } } + } + g_nowProgress = 0.0f; + g_nowElapsed = 0; + g_isFavorite = playlistMgr().isFavorite(path); + g_playState = PlayerState::Playing; + + pm_media_info info{}; + if (pm_media_probe(path, &info)) { + if (info.title[0]) + std::strncpy(g_nowTitle, info.title, sizeof(g_nowTitle) - 1); + std::strncpy(g_nowArtist, info.artist, sizeof(g_nowArtist) - 1); + std::strncpy(g_nowAlbum, info.album, sizeof(g_nowAlbum) - 1); + g_nowTitle[sizeof(g_nowTitle) - 1] = '\0'; + g_nowArtist[sizeof(g_nowArtist) - 1] = '\0'; + g_nowAlbum[sizeof(g_nowAlbum) - 1] = '\0'; + const uint64_t seconds = info.durationMs / 1000; + g_nowDuration = static_cast(seconds > UINT32_MAX ? UINT32_MAX : seconds); + } + + nowplaying_cache_art(path, info); + + music_play(path); + + g_needsRedraw = true; + g_appMode = MODE_NOWPLAYING; +} - // If not WAV, try MPEG frame header for bitrate - if (duration == 0) { - f.seek(0); - uint8_t buf[4]; - size_t pos = 0; - while (pos < fileSize - 1) { - int b = f.read(); - if (b < 0) break; - pos++; - if (b != 0xFF) continue; - - int b2 = f.read(); - if (b2 < 0) break; - pos++; - if ((b2 & 0xE0) != 0xE0) continue; - - buf[0] = 0xFF; - buf[1] = b2; - if (f.read(buf + 2, 2) != 2) break; - pos += 2; - - int version = (buf[1] >> 3) & 3; - int layer = (buf[1] >> 1) & 3; - int bitrateIdx = buf[2] >> 4; - - if (version == 1 || layer == 0 || bitrateIdx == 0 || bitrateIdx == 15) { - continue; - } - - static const int bitrate_mp3[15] = { - 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 - }; - - int bitrate = 0; - if (version == 3 && layer == 1) { - bitrate = bitrate_mp3[bitrateIdx]; - } else if (version == 3 && layer == 2) { - static const int table_l2[15] = { - 0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384 - }; - bitrate = table_l2[bitrateIdx]; - } else if (version == 3 && layer == 3) { - static const int table_l1[15] = { - 0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448 - }; - bitrate = table_l1[bitrateIdx]; - } else if (version == 2 || version == 0) { - static const int table_v2_l3[15] = { - 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160 - }; - bitrate = table_v2_l3[bitrateIdx]; - } - - if (bitrate > 0) { - uint64_t bits = (uint64_t)fileSize * 8; - duration = bits / ((uint64_t)bitrate * 1000); - } - break; - } +void player_next_track() { + int n = g_trackCount; + if (n <= 0) { + music_stop(); + g_needsRedraw = true; + return; + } + int next; + + if (g_shuffleEnabled) { + if (!g_shuffleIdx || g_shuffleIdxCount != n) + build_shuffle(); + g_shufflePos++; + if (g_shufflePos >= n) { + if (g_loopMode != LoopMode::All) { + music_stop(); + g_needsRedraw = true; + return; + } + build_shuffle(); + g_shufflePos = n > 1 ? 1 : 0; } + next = g_shuffleIdx ? g_shuffleIdx[g_shufflePos] : 0; + } else { + next = g_nowTrackIndex + 1; + if (g_loopMode == LoopMode::All && next >= n) + next = 0; + } + + if (next >= 0 && next < n) { + player_play_index(next); + } else { + music_stop(); + g_playState = PlayerState::Stopped; + g_nowProgress = 0.0f; + g_needsRedraw = true; + } +} - f.close(); - return duration; +void player_advance_auto() { + // Called when the current track ends on its own. Repeat-one replays the + // same track; everything else defers to the manual next-track rules + // (shuffle, repeat-all, stop-at-end). + int n = g_trackCount > 0 ? g_trackCount : 1; + if (g_loopMode == LoopMode::One && g_nowTrackIndex >= 0 && + g_nowTrackIndex < n) { + char path[256]; + if (get_track_path(g_nowTrackIndex, path, sizeof(path)) && + std::strcmp(path, g_nowPath) == 0) { + player_play_index(g_nowTrackIndex); + return; + } + } + player_next_track(); } -void player_play_index(int index) { +void player_prev_track() { + int n = g_trackCount; + if (n <= 0) + return; + int prev; + + if (g_shuffleEnabled) { + if (!g_shuffleIdx || g_shuffleIdxCount != n) + build_shuffle(); + g_shufflePos--; + if (g_shufflePos < 0) + g_shufflePos = g_loopMode == LoopMode::All ? n - 1 : 0; + prev = g_shuffleIdx ? g_shuffleIdx[g_shufflePos] : 0; + } else { + prev = g_nowTrackIndex - 1; + if (prev < 0) { + prev = (g_loopMode == LoopMode::All) ? n - 1 : 0; + } + } + + if (prev >= 0 && prev < n) { + player_play_index(prev); + } +} + +void player_cycle_source() { + PlaySource cur = playlistMgr().source(); + bool loaded = true; + + switch (cur) { + case PlaySource::Library: + loaded = playlistMgr().playFavorites(); + break; + + case PlaySource::Favorites: { + int plCount = playlistMgr().playlistCount(); + if (plCount > 0) { + loaded = playlistMgr().playPlaylist(0); + } else { + playlistMgr().playLibrary(); + } + break; + } + case PlaySource::Playlist: { + int next = playlistMgr().playlistIndex() + 1; + if (next < playlistMgr().playlistCount()) { + loaded = playlistMgr().playPlaylist(next); + } else { + playlistMgr().playLibrary(); + } + break; + } + } + + if (!loaded) + pm_oled_sysmsg("Cannot read tracks from SD", 1000); + g_selIndex = 0; + g_trackCount = (playlistMgr().source() == PlaySource::Library) + ? library().count() + : playlistMgr().entryCount(); + g_nowTrackIndex = -1; + for (int i = 0; i < g_trackCount; ++i) { char path[256]; - if (!get_track_path(index, path, sizeof(path)) || !path[0]) return; + if (get_track_path(i, path, sizeof(path)) && std::strcmp(path, g_nowPath) == 0) { + g_nowTrackIndex = i; + break; + } + } + if (g_shuffleEnabled) + build_shuffle(); - music_stop(); + g_needsRedraw = true; +} - g_nowTitle[0] = '\0'; - g_nowArtist[0] = '\0'; - g_nowAlbum[0] = '\0'; - g_nowDuration = 0; +void player_toggle_favorite() { + player_toggle_favorite_path(g_nowPath); +} - get_display_name(path, g_nowTitle, sizeof(g_nowTitle)); +static void refresh_source_after_edit(int previousIndex) { + const int previousCount = g_trackCount; + g_trackCount = playlistMgr().source() == PlaySource::Library + ? library().count() : playlistMgr().entryCount(); + g_selIndex = g_trackCount > 0 && g_selIndex >= g_trackCount + ? g_trackCount - 1 : g_selIndex; + if (g_trackCount == 0) + g_selIndex = 0; + g_nowTrackIndex = previousIndex > 0 ? previousIndex - 1 : -1; + for (int i = 0; i < g_trackCount; ++i) { + char path[256]; + if (get_track_path(i, path, sizeof(path)) && std::strcmp(path, g_nowPath) == 0) { + g_nowTrackIndex = i; + break; + } + } + g_isFavorite = g_nowPath[0] && playlistMgr().isFavorite(g_nowPath); + if (g_shuffleEnabled && g_trackCount != previousCount) + build_shuffle(); + g_needsRedraw = true; +} - strncpy(g_nowPath, path, sizeof(g_nowPath) - 1); - g_nowPath[sizeof(g_nowPath) - 1] = '\0'; +void player_toggle_favorite_path(const char *path) { + if (!path || !path[0]) + return; + bool before = playlistMgr().isFavorite(path); + bool after = playlistMgr().toggleFavorite(path); + if (before == after) + pm_oled_sysmsg("Favorites update failed", 1000); + refresh_source_after_edit(g_nowTrackIndex); +} - g_nowTrackIndex = index; - g_nowDuration = compute_duration(path); - g_nowProgress = 0.0f; - g_isFavorite = g_playlistMgr.isFavorite(path); - g_playState = PlayerState::Playing; - nowplaying_cache_art(path); +void player_remove_selected() { + if (g_selIndex < 0 || g_selIndex >= g_trackCount) + return; + PlaySource source = playlistMgr().source(); + if (source == PlaySource::Library) + return; + int previousIndex = g_nowTrackIndex; + bool removed = false; + if (source == PlaySource::Playlist) { + removed = playlistMgr().removeFromPlaylist(playlistMgr().playlistIndex(), g_selIndex); + } else { + char path[256]; + if (get_track_path(g_selIndex, path, sizeof(path)) && playlistMgr().isFavorite(path)) + removed = !playlistMgr().toggleFavorite(path); + } + if (!removed) + pm_oled_sysmsg("Could not remove track", 1000); + refresh_source_after_edit(previousIndex); +} - music_play(path); +void player_cycle_loop_mode() { + switch (g_loopMode) { + case LoopMode::None: + g_loopMode = LoopMode::One; + break; + case LoopMode::One: + g_loopMode = LoopMode::All; + break; + case LoopMode::All: + g_loopMode = LoopMode::None; + break; + } + g_needsRedraw = true; +} - g_needsRedraw = true; - g_appMode = MODE_NOWPLAYING; +void player_toggle_shuffle() { + g_shuffleEnabled = !g_shuffleEnabled; + if (g_shuffleEnabled) + build_shuffle(); + g_needsRedraw = true; } -void player_next_track() { - int n = g_trackCount > 0 ? g_trackCount : 1; - int next; - - if (g_shuffleEnabled) { - if (s_shuffleIdx.empty() || (int)s_shuffleIdx.size() != n) build_shuffle(); - s_shufflePos++; - if (s_shufflePos >= n) { - build_shuffle(); - s_shufflePos = 0; - } - next = s_shuffleIdx[s_shufflePos]; - } else { - next = g_nowTrackIndex + 1; - if (g_loopMode == LoopMode::All && next >= n) next = 0; - } +void music_stop() { + s_playbackId = 0; + pm_audio_stop_playback(); + g_playState = PlayerState::Stopped; + g_nowProgress = 0.0f; + g_nowElapsed = 0; +} - if (next >= 0 && next < n) { - player_play_index(next); - } else { - music_stop(); - g_playState = PlayerState::Stopped; - g_nowProgress = 0.0f; - g_needsRedraw = true; - } +void music_play(const char *path) { + uint32_t previous = pm_audio_get_playback_id(); + pm_audio_play(path); + uint32_t requested = pm_audio_get_playback_id(); + s_playbackId = requested != previous ? requested : 0; + if (!s_playbackId) { + g_playState = PlayerState::Stopped; + pm_oled_sysmsg("Unable to start audio", 1000); + } } -void player_prev_track() { - int n = g_trackCount > 0 ? g_trackCount : 1; - int prev; - - if (g_shuffleEnabled) { - if (s_shuffleIdx.empty() || (int)s_shuffleIdx.size() != n) build_shuffle(); - s_shufflePos--; - if (s_shufflePos < 0) s_shufflePos = n - 1; - prev = s_shuffleIdx[s_shufflePos]; +void player_poll() { + if (!s_playbackId) + return; + int result = pm_audio_get_playback_result(s_playbackId); + if (result != PM_AUDIO_RESULT_PENDING) { + s_playbackId = 0; + if (result == PM_AUDIO_RESULT_ENDED) { + player_advance_auto(); } else { - prev = g_nowTrackIndex - 1; - if (prev < 0) { - prev = (g_loopMode == LoopMode::All) ? n - 1 : 0; - } + g_playState = PlayerState::Stopped; + if (result == PM_AUDIO_RESULT_FAILED) + pm_oled_sysmsg(pm_audio_get_last_error(), 1200); + g_needsRedraw = true; } + return; + } + + int state = pm_audio_get_state(); + PlayerState next = state == PM_AUDIO_STATE_PAUSED ? PlayerState::Paused + : PlayerState::Playing; + if (next != g_playState) { + g_playState = next; + g_needsRedraw = true; + } + if (state == PM_AUDIO_STATE_PLAYING) { + const uint64_t milliseconds = pm_audio_get_position_ms(); + g_nowElapsed = static_cast(milliseconds / 1000 > UINT32_MAX + ? UINT32_MAX : milliseconds / 1000); + g_nowProgress = g_nowDuration > 0 + ? static_cast(milliseconds) / + (static_cast(g_nowDuration) * 1000.0f) + : 0.0f; + if (g_nowProgress > 1.0f) + g_nowProgress = 1.0f; + } +} - if (prev >= 0 && prev < n) { - player_play_index(prev); - } +void music_pause() { + pm_audio_pause(); + g_playState = PlayerState::Paused; } -void player_cycle_source() { - PlaySource cur = g_playlistMgr.source(); - - switch (cur) { - case PlaySource::Library: - if (g_playlistMgr.playFavorites()) { - return; - } - g_playlistMgr.playLibrary(); - break; +void music_resume() { + pm_audio_resume(); + g_playState = PlayerState::Playing; +} - case PlaySource::Favorites: { - int plCount = g_playlistMgr.playlistCount(); - if (plCount > 0) { - g_playlistMgr.playPlaylist(0); - } else { - g_playlistMgr.playLibrary(); - } - break; - } - case PlaySource::Playlist: { - int next = g_playlistMgr.playlistIndex() + 1; - if (next < g_playlistMgr.playlistCount()) { - g_playlistMgr.playPlaylist(next); - } else { - g_playlistMgr.playLibrary(); - } - break; - } - } +namespace { - g_trackCount = g_playlistMgr.entryCount(); - if (g_playlistMgr.source() == PlaySource::Library) { - g_trackCount = g_library.count(); - } +// Volume keys and the scroll gesture both step by this amount. +constexpr uint8_t kVolumeStep = 10; + +void save_audio_settings() { + if (!pm_audio_save_settings()) + pm_oled_sysmsg(pm_audio_get_last_error(), 1000); } -void player_toggle_favorite() { - if (g_nowPath[0]) { - g_isFavorite = g_playlistMgr.toggleFavorite(g_nowPath); - } +} // namespace + +bool player_volume_up() { + const uint8_t volume = pm_audio_get_volume_level(); + if (volume == 255) + return false; + pm_audio_set_volume_level(volume > 255 - kVolumeStep ? 255 : volume + kVolumeStep); + save_audio_settings(); + return true; +} + +bool player_volume_down() { + const uint8_t volume = pm_audio_get_volume_level(); + if (volume == 0) + return false; + pm_audio_set_volume_level(volume < kVolumeStep ? 0 : volume - kVolumeStep); + save_audio_settings(); + return true; +} + +bool player_sink_is_usb_active() { + return pm_audio_get_active_output() == PM_AUDIO_OUTPUT_USB; +} + +void player_cycle_sink() { + switch (pm_audio_get_output()) { + case PM_AUDIO_OUTPUT_AUTO: + pm_audio_set_output(PM_AUDIO_OUTPUT_PIEZO); + break; + case PM_AUDIO_OUTPUT_PIEZO: + pm_audio_set_output(PM_AUDIO_OUTPUT_USB); + break; + default: + pm_audio_set_output(PM_AUDIO_OUTPUT_AUTO); + break; + } + save_audio_settings(); + g_needsRedraw = true; +} + +const char *sink_label() { + switch (pm_audio_get_output()) { + case PM_AUDIO_OUTPUT_AUTO: + return player_sink_is_usb_active() ? "Auto(USB)" : "Auto(Pz)"; + case PM_AUDIO_OUTPUT_PIEZO: + return "Piezo"; + case PM_AUDIO_OUTPUT_USB: + return player_sink_is_usb_active() ? "USB" : "USB(Pz)"; + } + return "Auto"; } diff --git a/tests/run.py b/tests/run.py new file mode 100644 index 00000000..dcf633db --- /dev/null +++ b/tests/run.py @@ -0,0 +1,34 @@ +"""Build and run regression tests against the SDK headers without hardware.""" + +import os +from pathlib import Path +import subprocess +import tempfile + + +def main(): + root = Path(__file__).resolve().parents[1] + sdk = Path(os.environ.get( + "SDK_ROOT", root.parent / "PocketMage_PDA/Code/PocketMageOS/lib/PocketMage_SDK" + )).resolve() + shared = ["tests/support/fake_sd.cpp", "src/playlist.cpp"] + tests = { + "playlist": ["tests/unit/playlist_test.cpp", *shared], + "player": ["tests/unit/player_test.cpp", *shared, "src/ui_player.cpp", + "src/muse.cpp", "src/library.cpp"], + "audio-playback": [str(sdk / "tools/pm/tests/native/audio_playback_test.cpp")], + "idle-timer": [str(sdk / "tools/pm/tests/native/idle_timer_test.cpp")], + } + with tempfile.TemporaryDirectory(prefix="pocketmuse-tests-") as build: + for name, sources in tests.items(): + binary = str(Path(build) / name) + subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-Iinclude", "-Itests/support", + "-idirafter", str(sdk), *sources, "-o", binary, + ], cwd=root, check=True) + subprocess.run([binary], check=True) + + +if __name__ == "__main__": + main() diff --git a/tests/support/fake_sd.cpp b/tests/support/fake_sd.cpp new file mode 100644 index 00000000..79014efe --- /dev/null +++ b/tests/support/fake_sd.cpp @@ -0,0 +1,46 @@ +#include "fake_sd.h" +#include "pm_app_api.h" + +#include + +std::map files; +bool failWrite = false; +bool failRead = false; + +extern "C" { +size_t pm_sd_get_file_size(const char *path) { return files[path].size(); } +bool pm_sd_read_binary_file(const char *path, uint8_t *out, size_t size) { + if (failRead) + return false; + const auto &data = files[path]; + if (size > data.size()) + return false; + std::memcpy(out, data.data(), size); + return true; +} +bool pm_sd_write_binary_file(const char *path, const uint8_t *data, size_t size) { + if (failWrite) + return false; + files[path].assign(reinterpret_cast(data), size); + return true; +} +bool pm_sd_append_binary_file(const char *path, const uint8_t *data, size_t size) { + if (failWrite) + return false; + files[path].append(reinterpret_cast(data), size); + return true; +} +void pm_sd_write_file(const char *path, const char *data) { files[path] = data; } +void pm_sd_append_file(const char *path, const char *data) { files[path] += data; } +void pm_sd_delete_file(const char *path) { files.erase(path); } +bool pm_sd_mkdir(const char *) { return true; } +int pm_sd_dir_open(const char *) { return 1; } +bool pm_sd_dir_entry(int index, char *name, size_t size, bool *isDir) { + if (index != 0 || size < sizeof("Road.m3u")) + return false; + std::strcpy(name, "Road.m3u"); + *isDir = false; + return true; +} +void pm_sd_dir_close() {} +} diff --git a/tests/support/fake_sd.h b/tests/support/fake_sd.h new file mode 100644 index 00000000..da66c436 --- /dev/null +++ b/tests/support/fake_sd.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +/** In-memory SD files and failure injection, reset by each test executable. */ +extern std::map files; +/** Fail writes without changing the stored file. */ +extern bool failWrite; +/** Fail reads without returning partial file contents. */ +extern bool failRead; diff --git a/tests/unit/player_test.cpp b/tests/unit/player_test.cpp new file mode 100644 index 00000000..619b5b02 --- /dev/null +++ b/tests/unit/player_test.cpp @@ -0,0 +1,246 @@ +#include "fake_sd.h" +#include "muse.h" +#include "paths.h" +#include "pm_app_api.h" +#include "ui_player.h" + +#include +#include +#include +#include +#include + +namespace { +uint32_t request = 0; +int result = PM_AUDIO_RESULT_PENDING; +int state = PM_AUDIO_STATE_IDLE; +int playCalls = 0; +bool connected = false; +int output = PM_AUDIO_OUTPUT_PIEZO; +uint8_t volume = 160; +uint64_t positionMs = 0; +bool savedSettings = false; +bool metadataAvailable = true; +uint32_t cachedCoverOffset = 0; +} + +extern "C" { +void pm_audio_play(const char *) { + ++request; + ++playCalls; + result = PM_AUDIO_RESULT_PENDING; +} +void pm_audio_stop_playback() { state = PM_AUDIO_STATE_IDLE; } +void pm_audio_pause() { state = PM_AUDIO_STATE_PAUSED; } +void pm_audio_resume() { state = PM_AUDIO_STATE_PLAYING; } +void pm_audio_set_volume(int8_t) {} +void pm_audio_set_volume_level(uint8_t value) { volume = value; } +uint8_t pm_audio_get_volume_level() { return volume; } +bool pm_audio_save_settings() { savedSettings = true; return true; } +int pm_audio_get_output() { return output; } +uint64_t pm_audio_get_position_ms() { return positionMs; } +uint32_t pm_audio_get_playback_id() { return request; } +int pm_audio_get_playback_result(uint32_t id) { + assert(id == request); + return result; +} +int pm_audio_get_state() { return state; } +uint32_t pm_audio_get_position() { return 0; } +const char *pm_audio_get_last_error() { return "decode failed"; } +void pm_oled_sysmsg(const char *, int) {} +bool pm_audio_usb_connected() { return connected; } +int pm_audio_get_active_output() { return output; } +void pm_audio_set_output(int value) { output = value; } +bool pm_media_probe(const char *, pm_media_info *out) { + if (!metadataAvailable) return false; + *out = {}; + std::strcpy(out->title, "Shared title"); + std::strcpy(out->artist, "Shared artist"); + std::strcpy(out->album, "Shared album"); + out->durationMs = 123456; + out->coverOffset = 34; + out->coverLength = 4; + out->coverFormat = PM_IMAGE_FORMAT_JPEG; + return true; +} +} + +// Rendering is outside playback control; keep the shared metadata handoff observable. +void nowplaying_cache_art(const char *, const pm_media_info &info) { + cachedCoverOffset = info.coverOffset; +} +void get_display_name(const char *path, char *out, size_t size) { + snprintf(out, size, "%s", path); +} + +static void favorite_edit_preserves_shuffle_cycle() { + files["/music/.muse/playlists/Road.m3u"] = + "/music/One.mp3\n/music/Two.mp3\n/music/Three.mp3\n"; + assert(playlistMgr().begin()); + assert(playlistMgr().playPlaylist(0)); + g_trackCount = 3; + g_loopMode = LoopMode::None; + player_play_index(0); + player_toggle_shuffle(); + const std::vector order(g_shuffleIdx, g_shuffleIdx + g_shuffleIdxCount); + std::set visited{g_nowTrackIndex}; + for (int i = 0; i < 2; ++i) { + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(visited.insert(g_nowTrackIndex).second); + const int position = g_shufflePos; + player_toggle_favorite(); + assert(g_shufflePos == position); + assert(std::vector(g_shuffleIdx, g_shuffleIdx + g_shuffleIdxCount) == order); + } + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(g_playState == PlayerState::Stopped); + player_toggle_shuffle(); +} + +static void edits_keep_absent_playing_track_at_not_found() { + files[paths::kFavorites] = "/music/One.mp3\n"; + assert(playlistMgr().playPlaylist(0)); + g_trackCount = 3; + player_play_index(2); + player_cycle_source(); // Library + player_cycle_source(); // Favorites, without the playing track + assert(g_nowTrackIndex == -1 && g_trackCount == 1); + player_toggle_favorite_path("/music/Two.mp3"); + assert(g_nowTrackIndex == -1 && g_trackCount == 2); + g_selIndex = 1; + player_remove_selected(); + assert(g_nowTrackIndex == -1 && g_trackCount == 1); + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(g_nowTrackIndex == 0); + assert(std::strcmp(g_nowPath, "/music/One.mp3") == 0); +} + +int main() { + files[paths::kFavorites] = "/music/One.mp3\n/music/Two.mp3\n/music/Three.mp3\n"; + assert(playlistMgr().playFavorites()); + g_trackCount = 3; + player_play_index(0); + assert(std::strcmp(g_nowTitle, "Shared title") == 0); + assert(std::strcmp(g_nowArtist, "Shared artist") == 0); + assert(std::strcmp(g_nowAlbum, "Shared album") == 0); + assert(g_nowDuration == 123 && cachedCoverOffset == 34); + metadataAvailable = false; + player_play_index(0); + assert(std::strcmp(g_nowTitle, "/music/One.mp3") == 0); + assert(!g_nowArtist[0] && !g_nowAlbum[0] && g_nowDuration == 0 && cachedCoverOffset == 0); + playCalls = 1; + metadataAvailable = true; + assert(g_nowTrackIndex == 0); + player_poll(); // The decoder has not yet observed the queued play request. + assert(playCalls == 1); + assert(g_playState == PlayerState::Playing); + g_nowDuration = 100; + positionMs = 25000; + state = PM_AUDIO_STATE_PLAYING; + player_poll(); + assert(g_nowProgress == 0.25f); + + state = PM_AUDIO_STATE_PAUSED; + player_poll(); + assert(g_playState == PlayerState::Paused); + assert(playCalls == 1); + state = PM_AUDIO_STATE_STOPPING; + player_poll(); + assert(playCalls == 1); + + result = PM_AUDIO_RESULT_ENDED; + state = PM_AUDIO_STATE_IDLE; + player_poll(); + assert(g_nowTrackIndex == 1); + assert(playCalls == 2); + player_poll(); + assert(playCalls == 2); + music_stop(); + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(playCalls == 2); + + g_loopMode = LoopMode::One; + player_play_index(0); + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(g_nowTrackIndex == 0); + player_next_track(); // Manual next must ignore repeat-one. + assert(g_nowTrackIndex == 1); + result = PM_AUDIO_RESULT_FAILED; + int before = playCalls; + player_poll(); + assert(g_playState == PlayerState::Stopped); + assert(playCalls == before); + + g_loopMode = LoopMode::None; + player_play_index(2); + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(g_playState == PlayerState::Stopped); + g_loopMode = LoopMode::All; + player_play_index(2); + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(g_nowTrackIndex == 0); + + // Shuffle visits the remaining entries once and stops without repeat-all. + g_loopMode = LoopMode::None; + player_toggle_shuffle(); + std::set visited{g_nowTrackIndex}; + for (int i = 0; i < 2; ++i) { + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(visited.insert(g_nowTrackIndex).second); + } + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(g_playState == PlayerState::Stopped); + player_toggle_shuffle(); + + player_play_index(1); + g_selIndex = 1; + player_remove_selected(); + assert(g_trackCount == 2); + assert(!g_isFavorite); + result = PM_AUDIO_RESULT_ENDED; + player_poll(); + assert(std::strcmp(g_nowPath, "/music/Three.mp3") == 0); + + // Source cycling visits empty Favorites and playlists without stale selections. + assert(playlistMgr().begin()); + assert(playlistMgr().playLibrary()); + files[paths::kFavorites].clear(); + files["/music/.muse/playlists/Road.m3u"] = "#EXTM3U\n"; + g_selIndex = 9; + player_cycle_source(); + assert(playlistMgr().source() == PlaySource::Favorites); + assert(g_trackCount == 0); + assert(g_selIndex == 0); + player_cycle_source(); + assert(playlistMgr().source() == PlaySource::Playlist); + assert(g_trackCount == 0); + player_cycle_source(); + assert(playlistMgr().source() == PlaySource::Library); + + output = PM_AUDIO_OUTPUT_AUTO; + player_cycle_sink(); + assert(output == PM_AUDIO_OUTPUT_PIEZO); + assert(savedSettings); + player_cycle_sink(); + assert(output == PM_AUDIO_OUTPUT_USB); + player_cycle_sink(); + assert(output == PM_AUDIO_OUTPUT_AUTO); + volume = 250; + assert(player_volume_up() && volume == 255); + assert(!player_volume_up()); + volume = 5; + assert(player_volume_down() && volume == 0); + assert(!player_volume_down()); + favorite_edit_preserves_shuffle_cycle(); + edits_keep_absent_playing_track_at_not_found(); + std::puts("player tests passed"); +} diff --git a/tests/unit/playlist_test.cpp b/tests/unit/playlist_test.cpp new file mode 100644 index 00000000..e3a3556f --- /dev/null +++ b/tests/unit/playlist_test.cpp @@ -0,0 +1,94 @@ +#include "paths.h" +#include "playlist.h" +#include "pm_app_api.h" +#include "fake_sd.h" + +#include +#include +#include +#include +#include + +namespace { +constexpr const char *kPlaylist = "/music/.muse/playlists/Road.m3u"; + +void expectEntry(const PlaylistCursor &cursor, int index, const char *expected) { + char path[256]; + assert(cursor.getEntry(index, path, sizeof(path))); + assert(std::strcmp(path, expected) == 0); +} +} + +int main() { + PlaylistManager manager; + assert(manager.begin()); + + files[paths::kFavorites] = "\r\n \t\n# favorites\r\n/music/One.mp3\r\n/music/Two.mp3"; + assert(manager.favoriteCount() == 2); + assert(manager.playFavorites()); + expectEntry(manager.cursor(), 0, "/music/One.mp3"); + expectEntry(manager.cursor(), 1, "/music/Two.mp3"); + assert(manager.isFavorite("/music/One.mp3")); + assert(!manager.toggleFavorite("/music/One.mp3")); + assert(manager.entryCount() == 1); + expectEntry(manager.cursor(), 0, "/music/Two.mp3"); + + // Removing by visible index must not count the M3U header or blank lines. + files[kPlaylist] = "#EXTM3U\n\n/music/One.mp3\r\n#EXTINF:2,Two\n/music/Two.mp3"; + assert(manager.playPlaylist(0)); + assert(manager.removeFromPlaylist(0, 0)); + assert(manager.entryCount() == 1); + expectEntry(manager.cursor(), 0, "/music/Two.mp3"); + assert(files[kPlaylist].find('\0') == std::string::npos); + + // A failed write leaves both membership and the visible cursor unchanged. + files[paths::kFavorites] = "/music/One.mp3\n"; + assert(manager.playFavorites()); + failWrite = true; + assert(manager.toggleFavorite("/music/One.mp3")); + assert(manager.isFavorite("/music/One.mp3")); + assert(!manager.toggleFavorite("/music/Two.mp3")); + assert(manager.entryCount() == 1); + failWrite = false; + + // Appending after a missing final newline must keep the entries separate. + files[paths::kFavorites] = "/music/One.mp3"; + assert(manager.toggleFavorite("/music/Two.mp3")); + assert(manager.favoriteCount() == 2); + assert(!manager.toggleFavorite("/music/One.mp3")); + assert(!manager.toggleFavorite("/music/Two.mp3")); + assert(manager.entryCount() == 0); + assert(manager.source() == PlaySource::Favorites); + + // Rewriting must preserve every entry in playlists larger than 4 KiB. + files[kPlaylist] = "#EXTM3U\n/music/Remove.mp3\n"; + for (int i = 0; i < 400; ++i) + files[kPlaylist] += "/music/Track" + std::to_string(i) + ".mp3\n"; + assert(manager.playPlaylist(0)); + assert(manager.removeFromPlaylist(0, 0)); + assert(manager.entryCount() == 400); + expectEntry(manager.cursor(), 399, "/music/Track399.mp3"); + + char path[8] = "stale"; + assert(!manager.cursor().getEntry(0, path, sizeof(path))); + assert(path[0] == '\0'); + + // Empty and unreadable playlists must remain discoverable for later retries. + files[kPlaylist] = "#EXTM3U\n"; + assert(manager.playPlaylist(0)); + assert(manager.entryCount() == 0); + assert(manager.playlistCount() == 1); + files[kPlaylist] = "/music/One.mp3\n"; + failRead = true; + assert(!manager.playPlaylist(0)); + assert(manager.playlistCount() == 1); + failRead = false; + assert(manager.playPlaylist(0)); + expectEntry(manager.cursor(), 0, "/music/One.mp3"); + + files[paths::kFavorites].clear(); + assert(manager.playFavorites()); + assert(manager.source() == PlaySource::Favorites); + assert(manager.entryCount() == 0); + std::puts("playlist tests passed"); +} diff --git a/tools/make_icon.py b/tools/make_icon.py new file mode 100644 index 00000000..bc5431a8 --- /dev/null +++ b/tools/make_icon.py @@ -0,0 +1,50 @@ +"""Render a 40x40 1-bit PocketMage app icon from an image. + +Usage: + python3 make_icon.py input.png output.bin +""" + +from pathlib import Path + +from PIL import Image + +ICON_SIZE = 40 +CONFIRMATION = 200 + + +def convert_icon(input_path: Path, output_path: Path) -> None: + img = Image.open(input_path) + img = img.convert("L").resize((ICON_SIZE, ICON_SIZE), Image.NEAREST) + + # ink when the source pixel is darker than the midpoint + ink = lambda v: 0 if v < 128 else 255 # noqa: E731 + img = img.point(ink, "1") + + pixels = img.load() + data = bytearray() + for y in range(ICON_SIZE): + for x0 in range(0, ICON_SIZE, 8): + byte = 0 + for bit in range(8): + byte <<= 1 + if x0 + bit < ICON_SIZE and pixels[x0 + bit, y] == 0: + byte |= 1 + data.append(byte) + + if len(data) != CONFIRMATION: + raise SystemExit(f"bug: expected {CONFIRMATION} bytes, got {len(data)}") + + output_path.write_bytes(data) + print(f"wrote {output_path} ({CONFIRMATION} bytes)") + + +def main() -> None: + import sys + + if len(sys.argv) != 3: + raise SystemExit("usage: python3 make_icon.py input.png output.bin") + convert_icon(Path(sys.argv[1]), Path(sys.argv[2])) + + +if __name__ == "__main__": + main() \ No newline at end of file