ML-178: implementation
This commit is contained in:
+4
-1
@@ -83,6 +83,9 @@ GET https://music-library.claudio-ortolina.org/api/v1/collection/on_this_day?dat
|
||||
Authorization: Bearer <API_TOKEN>
|
||||
```
|
||||
|
||||
Fields used by the app: `title`, `artists`, `format`, `release_date`, `genres`, `record_type`, `purchased_at`, `micro_cover_url`, `mini_cover_url`, `thumb_url`.
|
||||
Fields used by the app: `title`, `artists`, `format`, `release_date`, `genres`, `record_type`, `purchased_at`, `micro_cover_url`, `mini_cover_url`, `thumb_url`, `selected_release_id`.
|
||||
|
||||
POST /api/v1/collection/:record_id/scrobble
|
||||
Authorization: Bearer <API_TOKEN>
|
||||
|
||||
When adding or changing API assumptions, update `README.md`.
|
||||
|
||||
@@ -103,6 +103,7 @@ The Presto runs `main.py` on boot. The app will:
|
||||
- Drag vertically to scroll if the content is taller than the screen
|
||||
- The header bar stays fixed at the top while the cover and info scroll underneath
|
||||
- Tap **< Back** to return to the day view
|
||||
- If the record can be scrobbled, a **"Scrobble" button** appears at the bottom — tap it to scrobble the release to Last.fm; "Done" confirms success
|
||||
|
||||
### Display sleep
|
||||
|
||||
@@ -153,6 +154,7 @@ Response format:
|
||||
"records": [
|
||||
{
|
||||
"id": "...",
|
||||
"selected_release_id": "abc-123-uuid",
|
||||
"title": "Album Title",
|
||||
"artists": ["Artist Name"],
|
||||
"micro_cover_url": "https://.../api/v1/assets/thumb.jpg?width=40",
|
||||
|
||||
+146
-1
@@ -106,6 +106,11 @@ DETAIL_INFO_GAP = 4
|
||||
DETAIL_TEXT_X = 40
|
||||
DETAIL_TEXT_W = WIDTH - (2 * DETAIL_TEXT_X)
|
||||
|
||||
# Scrobble button (detail view)
|
||||
SCROBBLE_BUTTON_W = 200
|
||||
SCROBBLE_BUTTON_H = 40
|
||||
SCROBBLE_BUTTON_GAP = 12 # vertical gap before button
|
||||
|
||||
RECORD_START_Y = DAY_HEADER_Y + DAY_HEADER_H + 8
|
||||
THUMB_SIZE = 40
|
||||
THUMB_MARGIN = 8
|
||||
@@ -131,6 +136,12 @@ ERROR_COLOR = (59, 130, 246)
|
||||
PLACEHOLDER_BG = (63, 63, 70)
|
||||
STATUS_TEXT = (161, 161, 170)
|
||||
|
||||
# Scrobble button colors
|
||||
SCROBBLE_BG = (55, 65, 81) # Same slate as HOME_BTN_BG
|
||||
SCROBBLE_TEXT = (228, 228, 231) # Same as TITLE_COLOR
|
||||
SCROBBLE_DONE_BG = (34, 197, 94) # Green success
|
||||
SCROBBLE_DONE_TEXT = (255, 255, 255)
|
||||
|
||||
# Days of week (Monday first, ISO 8601)
|
||||
DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
MONTH_NAMES = [
|
||||
@@ -222,6 +233,9 @@ _content_height = 0 # Cached total record list height
|
||||
detail_scroll_offset = 0 # Pixel scroll position for detail view
|
||||
_detail_content_height = 0 # Cached total detail content height
|
||||
|
||||
# Scrobble button state (per-record-detail-visit; resets on navigation)
|
||||
_scrobble_state = "idle" # "idle" | "loading" | "done"
|
||||
|
||||
# Search state
|
||||
search_query = "" # Current search buffer (max ~50 chars)
|
||||
search_results = [] # Records from search API
|
||||
@@ -263,6 +277,12 @@ _pen_placeholder = None
|
||||
_pen_status = None
|
||||
_pen_home_btn = None
|
||||
|
||||
# Scrobble button pens
|
||||
_pen_scrobble_bg = None
|
||||
_pen_scrobble_text = None
|
||||
_pen_scrobble_done_bg = None
|
||||
_pen_scrobble_done_text = None
|
||||
|
||||
|
||||
def _init_pens():
|
||||
"""Pre-create all pens from the RGB color constants."""
|
||||
@@ -288,6 +308,14 @@ def _init_pens():
|
||||
_pen_status = display.create_pen(*STATUS_TEXT)
|
||||
_pen_home_btn = display.create_pen(*HOME_BTN_BG)
|
||||
|
||||
# Scrobble button
|
||||
global _pen_scrobble_bg, _pen_scrobble_text
|
||||
global _pen_scrobble_done_bg, _pen_scrobble_done_text
|
||||
_pen_scrobble_bg = display.create_pen(*SCROBBLE_BG)
|
||||
_pen_scrobble_text = display.create_pen(*SCROBBLE_TEXT)
|
||||
_pen_scrobble_done_bg = display.create_pen(*SCROBBLE_DONE_BG)
|
||||
_pen_scrobble_done_text = display.create_pen(*SCROBBLE_DONE_TEXT)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER: TEXT WRAPPING
|
||||
@@ -1644,6 +1672,11 @@ def _measure_detail_content(rec):
|
||||
purch_str = "Purchased: " + display_text(str(purchased_at)[:10])
|
||||
h += _measure_detail_text_height(purch_str)
|
||||
|
||||
# Scrobble button (at bottom, only if scrobble-eligible)
|
||||
if rec.get("selected_release_id"):
|
||||
h += SCROBBLE_BUTTON_GAP
|
||||
h += SCROBBLE_BUTTON_H
|
||||
|
||||
_detail_content_height = h
|
||||
|
||||
|
||||
@@ -1708,6 +1741,19 @@ def draw_record_detail():
|
||||
y = y + DETAIL_COVER_SIZE + DETAIL_INFO_GAP
|
||||
_draw_detail_info_below_cover(rec, y)
|
||||
|
||||
# Scrobble button (at bottom of content, only if eligible)
|
||||
if rec.get("selected_release_id"):
|
||||
# Compute button Y from measured content height (y doesn't track
|
||||
# the bottom because _draw_detail_info_below_cover's return is
|
||||
# discarded — use _detail_content_height which already includes
|
||||
# the button height and gap from Step 4).
|
||||
button_y = (DETAIL_COVER_Y - offset
|
||||
+ _detail_content_height - SCROBBLE_BUTTON_H)
|
||||
# Only draw if the button is at least partially visible
|
||||
if (button_y + SCROBBLE_BUTTON_H > DAY_HEADER_Y + DAY_HEADER_H
|
||||
and button_y < HEIGHT):
|
||||
_draw_scrobble_button(rec, button_y)
|
||||
|
||||
# Scroll indicators
|
||||
display.set_font("bitmap14_outline")
|
||||
if offset > 0:
|
||||
@@ -1860,6 +1906,34 @@ def _draw_detail_text_line(text, y, pen, font="bitmap8", scale=1):
|
||||
return cy
|
||||
|
||||
|
||||
def _draw_scrobble_button(rec, y):
|
||||
"""Draw the scrobble button at (x, y). Button is horizontally centered.
|
||||
Visual state depends on global _scrobble_state."""
|
||||
global _scrobble_state
|
||||
|
||||
bx = (WIDTH - SCROBBLE_BUTTON_W) // 2
|
||||
|
||||
if _scrobble_state == "done":
|
||||
bg_pen = _pen_scrobble_done_bg
|
||||
text_pen = _pen_scrobble_done_text
|
||||
label = "Done"
|
||||
elif _scrobble_state == "loading":
|
||||
bg_pen = _pen_scrobble_bg
|
||||
text_pen = _pen_scrobble_text
|
||||
label = "..."
|
||||
else:
|
||||
bg_pen = _pen_scrobble_bg
|
||||
text_pen = _pen_scrobble_text
|
||||
label = "Scrobble"
|
||||
|
||||
display.set_pen(bg_pen)
|
||||
display.rectangle(bx, y, SCROBBLE_BUTTON_W, SCROBBLE_BUTTON_H)
|
||||
|
||||
display.set_pen(text_pen)
|
||||
display.set_font("bitmap8")
|
||||
_draw_centered_text(label, bx, y, SCROBBLE_BUTTON_W, SCROBBLE_BUTTON_H, 8)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TOUCH HANDLING
|
||||
# ============================================================================
|
||||
@@ -1976,6 +2050,63 @@ def touch_to_record_index(y, recs=None, offset=None):
|
||||
return None
|
||||
|
||||
|
||||
def _scrobble_button_hit_test(x, y, rec):
|
||||
"""Return True if the touch (x, y) falls within the scrobble button bounds.
|
||||
Accounts for current detail_scroll_offset.
|
||||
Ignores touches in the fixed header area (handled separately)."""
|
||||
global detail_scroll_offset
|
||||
|
||||
if not rec.get("selected_release_id"):
|
||||
return False
|
||||
|
||||
# Reject touches in the fixed header zone (handled by back-button logic)
|
||||
if y <= DAY_HEADER_Y + DAY_HEADER_H:
|
||||
return False
|
||||
|
||||
# Compute button Y position — must match the formula used in
|
||||
# draw_record_detail(). _detail_content_height already
|
||||
# includes SCROBBLE_BUTTON_GAP + SCROBBLE_BUTTON_H from _measure_detail_content.
|
||||
button_y = (DETAIL_COVER_Y - detail_scroll_offset
|
||||
+ _detail_content_height - SCROBBLE_BUTTON_H)
|
||||
bx = (WIDTH - SCROBBLE_BUTTON_W) // 2
|
||||
|
||||
return (bx <= x <= bx + SCROBBLE_BUTTON_W and
|
||||
button_y <= y <= button_y + SCROBBLE_BUTTON_H)
|
||||
|
||||
|
||||
def handle_scrobble_touch(rec):
|
||||
"""Execute the scrobble HTTP request and update button state.
|
||||
Blocks during the request (urequests is synchronous on MicroPython).
|
||||
On success, sets state to "done" (persists until navigation).
|
||||
On failure, reverts to "idle"."""
|
||||
global _scrobble_state
|
||||
|
||||
rec_id = rec.get("id")
|
||||
if not rec_id:
|
||||
return
|
||||
|
||||
# Set loading state and redraw immediately so the user sees "..."
|
||||
_scrobble_state = "loading"
|
||||
draw_record_detail()
|
||||
|
||||
# Make the POST request. urequests has limited timeout support on
|
||||
# MicroPython; if WiFi drops mid-request, the default timeout may
|
||||
# be 30+ seconds. Test this scenario on-device.
|
||||
url = API_BASE + "/api/v1/collection/" + str(rec_id) + "/scrobble"
|
||||
try:
|
||||
resp = urequests.post(url, headers=_auth_header())
|
||||
if resp.status_code == 200:
|
||||
_scrobble_state = "done"
|
||||
else:
|
||||
_scrobble_state = "idle"
|
||||
resp.close()
|
||||
except Exception:
|
||||
_scrobble_state = "idle"
|
||||
|
||||
gc.collect()
|
||||
draw_record_detail()
|
||||
|
||||
|
||||
def handle_home_touch(x, y):
|
||||
"""Process a touch event on the home/splash screen."""
|
||||
global state, search_query, keyboard_mode
|
||||
@@ -2097,6 +2228,7 @@ def handle_search_results_touch(x, y):
|
||||
if rec_idx is not None:
|
||||
selected_record_idx = rec_idx
|
||||
detail_scroll_offset = 0
|
||||
_scrobble_state = "idle"
|
||||
previous_state = STATE_SEARCH_RESULTS
|
||||
state = STATE_RECORD
|
||||
draw_record_detail()
|
||||
@@ -2198,7 +2330,7 @@ def main():
|
||||
global state, view_year, view_month, _last_touch
|
||||
global selected_day, selected_record_idx, records, records_error
|
||||
global scroll_offset, detail_scroll_offset
|
||||
global _dragging, previous_state
|
||||
global _dragging, previous_state, _scrobble_state
|
||||
global _last_activity, _last_drag_redraw
|
||||
global search_scroll_offset
|
||||
|
||||
@@ -2392,6 +2524,7 @@ def main():
|
||||
if rec_idx is not None:
|
||||
selected_record_idx = rec_idx
|
||||
detail_scroll_offset = 0
|
||||
_scrobble_state = "idle"
|
||||
previous_state = STATE_DAY
|
||||
state = STATE_RECORD
|
||||
draw_record_detail()
|
||||
@@ -2404,6 +2537,7 @@ def main():
|
||||
# Check back button immediately (no drag needed)
|
||||
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
|
||||
detail_scroll_offset = 0
|
||||
_scrobble_state = "idle"
|
||||
if previous_state == STATE_SEARCH_RESULTS:
|
||||
state = STATE_SEARCH_RESULTS
|
||||
draw_search_results()
|
||||
@@ -2450,6 +2584,17 @@ def main():
|
||||
detail_scroll_offset = new_offset
|
||||
draw_record_detail()
|
||||
|
||||
if not dragged:
|
||||
# Determine which record is being viewed
|
||||
if previous_state == STATE_SEARCH_RESULTS:
|
||||
rec = search_results[selected_record_idx]
|
||||
else:
|
||||
rec = records[selected_record_idx]
|
||||
|
||||
# Check scrobble button tap
|
||||
if _scrobble_button_hit_test(x, y, rec):
|
||||
handle_scrobble_touch(rec)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STARTUP
|
||||
|
||||
Reference in New Issue
Block a user