[presto] Add record detail page
This commit is contained in:
@@ -95,6 +95,15 @@ DAY_HEADER_Y = HEADER_Y
|
|||||||
DAY_HEADER_H = HEADER_H
|
DAY_HEADER_H = HEADER_H
|
||||||
DAY_HEADER_TEXT_H = HEADER_TEXT_H
|
DAY_HEADER_TEXT_H = HEADER_TEXT_H
|
||||||
DAY_COUNT_TEXT_H = 8
|
DAY_COUNT_TEXT_H = 8
|
||||||
|
|
||||||
|
# Detail view
|
||||||
|
DETAIL_COVER_SIZE = 150
|
||||||
|
DETAIL_COVER_X = (WIDTH - DETAIL_COVER_SIZE) // 2
|
||||||
|
DETAIL_COVER_Y = DAY_HEADER_Y + DAY_HEADER_H + 12
|
||||||
|
DETAIL_INFO_GAP = 16
|
||||||
|
DETAIL_TEXT_X = 40
|
||||||
|
DETAIL_TEXT_W = WIDTH - (2 * DETAIL_TEXT_X)
|
||||||
|
|
||||||
RECORD_START_Y = DAY_HEADER_Y + DAY_HEADER_H + 8
|
RECORD_START_Y = DAY_HEADER_Y + DAY_HEADER_H + 8
|
||||||
THUMB_SIZE = 40
|
THUMB_SIZE = 40
|
||||||
THUMB_MARGIN = 8
|
THUMB_MARGIN = 8
|
||||||
@@ -158,10 +167,12 @@ view_month = 5
|
|||||||
STATE_STARTUP = 0
|
STATE_STARTUP = 0
|
||||||
STATE_MONTH = 1
|
STATE_MONTH = 1
|
||||||
STATE_DAY = 2
|
STATE_DAY = 2
|
||||||
|
STATE_RECORD = 3
|
||||||
state = STATE_STARTUP
|
state = STATE_STARTUP
|
||||||
|
|
||||||
# Day view state
|
# Day view state
|
||||||
selected_day = 0 # 1-based day of month
|
selected_day = 0 # 1-based day of month
|
||||||
|
selected_record_idx = None # Index into records[] for detail view
|
||||||
records = [] # List of record dicts from API
|
records = [] # List of record dicts from API
|
||||||
records_error = False # True if API call failed
|
records_error = False # True if API call failed
|
||||||
# Scroll state
|
# Scroll state
|
||||||
@@ -169,6 +180,10 @@ scroll_offset = 0 # Pixel scroll position for day view
|
|||||||
_dragging = False # True while fast scroll redraws are active
|
_dragging = False # True while fast scroll redraws are active
|
||||||
_content_height = 0 # Cached total record list height
|
_content_height = 0 # Cached total record list height
|
||||||
|
|
||||||
|
# Detail view scroll
|
||||||
|
detail_scroll_offset = 0 # Pixel scroll position for detail view
|
||||||
|
_detail_content_height = 0 # Cached total detail content height
|
||||||
|
|
||||||
# Touch debounce
|
# Touch debounce
|
||||||
_last_touch = 0
|
_last_touch = 0
|
||||||
_last_drag_redraw = 0
|
_last_drag_redraw = 0
|
||||||
@@ -492,6 +507,8 @@ def redraw_current_view():
|
|||||||
draw_month_view()
|
draw_month_view()
|
||||||
elif state == STATE_DAY:
|
elif state == STATE_DAY:
|
||||||
draw_day_view()
|
draw_day_view()
|
||||||
|
elif state == STATE_RECORD:
|
||||||
|
draw_record_detail()
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1127,6 +1144,280 @@ def _draw_record_row(rec, y):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DRAWING: RECORD DETAIL
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _max_detail_scroll_offset():
|
||||||
|
"""Return the maximum pixel scroll offset for the detail view."""
|
||||||
|
if _detail_content_height == 0:
|
||||||
|
return 0
|
||||||
|
content_bottom = DETAIL_COVER_Y + _detail_content_height
|
||||||
|
return max(0, content_bottom - HEIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_detail_text_height(text, font="bitmap8", scale=1, max_w=None):
|
||||||
|
"""Return the pixel height that text would occupy when drawn wrapped."""
|
||||||
|
if max_w is None:
|
||||||
|
max_w = DETAIL_TEXT_W
|
||||||
|
lines = _wrapped_lines(text, max_w, scale=scale)
|
||||||
|
line_height = 8 * scale + 4
|
||||||
|
return len(lines) * line_height
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_detail_content(rec):
|
||||||
|
"""Compute and cache the total height of the scrollable detail content."""
|
||||||
|
global _detail_content_height
|
||||||
|
|
||||||
|
h = 0
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title = rec.get("_display_title",
|
||||||
|
display_text(rec.get("title", "Unknown Title")))
|
||||||
|
h += _measure_detail_text_height(title, font="bitmap14_outline", scale=1)
|
||||||
|
h += 6
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
artist_str = rec.get("_display_artists", "")
|
||||||
|
if not artist_str:
|
||||||
|
artists = rec.get("artists", [])
|
||||||
|
artist_str = display_text(", ".join(artists)) if artists else ""
|
||||||
|
if artist_str:
|
||||||
|
h += _measure_detail_text_height(artist_str)
|
||||||
|
h += 4
|
||||||
|
|
||||||
|
# Genres
|
||||||
|
genres = rec.get("genres", [])
|
||||||
|
if genres:
|
||||||
|
genre_str = display_text(", ".join(genres))
|
||||||
|
h += _measure_detail_text_height(genre_str)
|
||||||
|
h += 4
|
||||||
|
|
||||||
|
h += 6
|
||||||
|
|
||||||
|
# Metadata line: record_type | format | year
|
||||||
|
meta_parts = []
|
||||||
|
record_type = rec.get("record_type", "")
|
||||||
|
if record_type:
|
||||||
|
meta_parts.append(display_text(record_type))
|
||||||
|
record_format = rec.get("format", "")
|
||||||
|
if record_format:
|
||||||
|
meta_parts.append(display_text(record_format))
|
||||||
|
release_date = rec.get("release_date", "")
|
||||||
|
if release_date:
|
||||||
|
meta_parts.append(str(release_date)[:4])
|
||||||
|
|
||||||
|
if meta_parts:
|
||||||
|
meta_str = " | ".join(meta_parts)
|
||||||
|
h += _measure_detail_text_height(meta_str)
|
||||||
|
h += 4
|
||||||
|
|
||||||
|
# Purchased at
|
||||||
|
purchased_at = rec.get("purchased_at", "")
|
||||||
|
if purchased_at:
|
||||||
|
purch_str = "Purchased: " + display_text(str(purchased_at))
|
||||||
|
h += _measure_detail_text_height(purch_str)
|
||||||
|
|
||||||
|
# Total content height: cover + gap + info
|
||||||
|
_detail_content_height = DETAIL_COVER_SIZE + DETAIL_INFO_GAP + h
|
||||||
|
|
||||||
|
|
||||||
|
def _set_clip_below_header():
|
||||||
|
"""Restrict drawing to the area below the fixed header."""
|
||||||
|
try:
|
||||||
|
display.set_clip(0, DAY_HEADER_Y + DAY_HEADER_H,
|
||||||
|
WIDTH, HEIGHT - (DAY_HEADER_Y + DAY_HEADER_H))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_clip():
|
||||||
|
"""Remove any active clip rectangle."""
|
||||||
|
try:
|
||||||
|
display.remove_clip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def draw_record_detail():
|
||||||
|
"""Render the individual record detail view with large cover art
|
||||||
|
and extended metadata. Supports vertical scrolling."""
|
||||||
|
global detail_scroll_offset
|
||||||
|
|
||||||
|
display.set_pen(_pen_bg)
|
||||||
|
display.clear()
|
||||||
|
|
||||||
|
rec = records[selected_record_idx]
|
||||||
|
|
||||||
|
# Measure content height so scroll bounds are known
|
||||||
|
_measure_detail_content(rec)
|
||||||
|
|
||||||
|
# Clamp scroll offset
|
||||||
|
max_off = _max_detail_scroll_offset()
|
||||||
|
if detail_scroll_offset > max_off:
|
||||||
|
detail_scroll_offset = max_off
|
||||||
|
if detail_scroll_offset < 0:
|
||||||
|
detail_scroll_offset = 0
|
||||||
|
|
||||||
|
offset = detail_scroll_offset
|
||||||
|
|
||||||
|
_draw_detail_header()
|
||||||
|
|
||||||
|
# Clip scrolling content below the header
|
||||||
|
_set_clip_below_header()
|
||||||
|
|
||||||
|
_draw_detail_cover(rec, offset_y=offset)
|
||||||
|
_draw_detail_info(rec, offset_y=offset)
|
||||||
|
|
||||||
|
# Scroll indicators (inside clip, below header)
|
||||||
|
display.set_font("bitmap14_outline")
|
||||||
|
if offset > 0:
|
||||||
|
display.set_pen(_pen_arrow)
|
||||||
|
display.text("^", WIDTH // 2 - 8, DAY_HEADER_H + 4, scale=1)
|
||||||
|
if offset < max_off:
|
||||||
|
display.set_pen(_pen_arrow)
|
||||||
|
display.text("v", WIDTH // 2 - 8, HEIGHT - 24, scale=1)
|
||||||
|
|
||||||
|
_remove_clip()
|
||||||
|
|
||||||
|
presto.update()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_detail_header():
|
||||||
|
"""Draw the record detail header with back button."""
|
||||||
|
display.set_pen(_pen_cell_bg)
|
||||||
|
display.rectangle(0, DAY_HEADER_Y, WIDTH, DAY_HEADER_H)
|
||||||
|
|
||||||
|
# Back button
|
||||||
|
bx, by = BACK_X, BACK_Y
|
||||||
|
display.set_pen(_pen_placeholder)
|
||||||
|
display.rectangle(bx, by, BACK_W, BACK_H)
|
||||||
|
display.set_pen(_pen_back)
|
||||||
|
display.set_font("bitmap8")
|
||||||
|
_draw_centered_text("<", bx, by, BACK_W, BACK_H, DAY_COUNT_TEXT_H)
|
||||||
|
|
||||||
|
# Title label
|
||||||
|
display.set_pen(_pen_header_text)
|
||||||
|
display.set_font("bitmap14_outline")
|
||||||
|
label = "Record"
|
||||||
|
tw = display.measure_text(label, scale=1)
|
||||||
|
display.text(
|
||||||
|
label,
|
||||||
|
(WIDTH - tw) // 2,
|
||||||
|
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2,
|
||||||
|
scale=1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_detail_cover(rec, offset_y=0):
|
||||||
|
"""Fetch and draw the large cover art (prefers thumb_url)."""
|
||||||
|
cy = DETAIL_COVER_Y - offset_y
|
||||||
|
|
||||||
|
# Skip if entirely outside viewport
|
||||||
|
if cy + DETAIL_COVER_SIZE <= DAY_HEADER_Y + DAY_HEADER_H:
|
||||||
|
return
|
||||||
|
if cy >= HEIGHT:
|
||||||
|
return
|
||||||
|
|
||||||
|
thumb_url = (
|
||||||
|
rec.get("thumb_url", "")
|
||||||
|
or rec.get("mini_cover_url", "")
|
||||||
|
or rec.get("micro_cover_url", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not thumb_url:
|
||||||
|
_draw_placeholder(DETAIL_COVER_X, cy,
|
||||||
|
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use cached data or fetch
|
||||||
|
data = rec.get("_detail_thumb_data", None)
|
||||||
|
if data is None and not rec.get("_detail_thumb_failed", False):
|
||||||
|
data = fetch_thumbnail(thumb_url)
|
||||||
|
if data is None:
|
||||||
|
rec["_detail_thumb_failed"] = True
|
||||||
|
else:
|
||||||
|
rec["_detail_thumb_data"] = data
|
||||||
|
|
||||||
|
if data is not None:
|
||||||
|
draw_jpeg(data, DETAIL_COVER_X, cy,
|
||||||
|
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
|
||||||
|
else:
|
||||||
|
_draw_placeholder(DETAIL_COVER_X, cy,
|
||||||
|
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_detail_info(rec, offset_y=0):
|
||||||
|
"""Draw the extended record metadata below the cover image."""
|
||||||
|
info_y = DETAIL_COVER_Y + DETAIL_COVER_SIZE + DETAIL_INFO_GAP - offset_y
|
||||||
|
|
||||||
|
# Title (larger, bold-style font)
|
||||||
|
title = rec.get("_display_title",
|
||||||
|
display_text(rec.get("title", "Unknown Title")))
|
||||||
|
info_y = _draw_detail_text_line(title, info_y, _pen_title,
|
||||||
|
font="bitmap14_outline", scale=1)
|
||||||
|
info_y += 6
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
artist_str = rec.get("_display_artists", "")
|
||||||
|
if not artist_str:
|
||||||
|
artists = rec.get("artists", [])
|
||||||
|
artist_str = display_text(", ".join(artists)) if artists else ""
|
||||||
|
if artist_str:
|
||||||
|
info_y = _draw_detail_text_line(artist_str, info_y, _pen_artist)
|
||||||
|
info_y += 4
|
||||||
|
|
||||||
|
# Genres
|
||||||
|
genres = rec.get("genres", [])
|
||||||
|
if genres:
|
||||||
|
genre_str = display_text(", ".join(genres))
|
||||||
|
info_y = _draw_detail_text_line(genre_str, info_y, _pen_dim_text)
|
||||||
|
info_y += 4
|
||||||
|
|
||||||
|
info_y += 6
|
||||||
|
|
||||||
|
# Metadata: record_type | format | year
|
||||||
|
meta_parts = []
|
||||||
|
record_type = rec.get("record_type", "")
|
||||||
|
if record_type:
|
||||||
|
meta_parts.append(display_text(record_type))
|
||||||
|
record_format = rec.get("format", "")
|
||||||
|
if record_format:
|
||||||
|
meta_parts.append(display_text(record_format))
|
||||||
|
release_date = rec.get("release_date", "")
|
||||||
|
if release_date:
|
||||||
|
meta_parts.append(str(release_date)[:4])
|
||||||
|
|
||||||
|
if meta_parts:
|
||||||
|
meta_str = " | ".join(meta_parts)
|
||||||
|
info_y = _draw_detail_text_line(meta_str, info_y, _pen_dim_text)
|
||||||
|
info_y += 4
|
||||||
|
|
||||||
|
# Purchased at (full date)
|
||||||
|
purchased_at = rec.get("purchased_at", "")
|
||||||
|
if purchased_at:
|
||||||
|
purch_str = "Purchased: " + display_text(str(purchased_at))
|
||||||
|
info_y = _draw_detail_text_line(purch_str, info_y, _pen_dim_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_detail_text_line(text, y, pen, font="bitmap8", scale=1):
|
||||||
|
"""Draw text centered horizontally at y, wrapping if needed.
|
||||||
|
Returns the y-position after drawing (including wrapped lines)."""
|
||||||
|
max_w = DETAIL_TEXT_W
|
||||||
|
display.set_pen(pen)
|
||||||
|
display.set_font(font)
|
||||||
|
|
||||||
|
lines = _wrapped_lines(text, max_w, scale=scale)
|
||||||
|
line_height = 8 * scale + 4
|
||||||
|
cy = y
|
||||||
|
for line in lines:
|
||||||
|
tw = display.measure_text(line, scale=scale)
|
||||||
|
display.text(line, (WIDTH - tw) // 2, cy, scale=scale)
|
||||||
|
cy += line_height
|
||||||
|
|
||||||
|
return cy
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# TOUCH HANDLING
|
# TOUCH HANDLING
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1220,6 +1511,21 @@ def cell_to_day(row, col):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def touch_to_record_index(y):
|
||||||
|
"""Map a touch y-coordinate to a record list index, accounting for
|
||||||
|
scroll offset. Returns None if the touch doesn't hit a record row."""
|
||||||
|
if not records:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cy = RECORD_START_Y - scroll_offset
|
||||||
|
for i, rec in enumerate(records):
|
||||||
|
row_h = rec.get("_row_height", THUMB_SIZE + 8)
|
||||||
|
if cy <= y < cy + row_h:
|
||||||
|
return i
|
||||||
|
cy += row_h
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def handle_month_touch(x, y):
|
def handle_month_touch(x, y):
|
||||||
"""Process a touch event in month view state."""
|
"""Process a touch event in month view state."""
|
||||||
global view_year, view_month, selected_day, state, records, records_error, scroll_offset
|
global view_year, view_month, selected_day, state, records, records_error, scroll_offset
|
||||||
@@ -1286,7 +1592,7 @@ def handle_day_touch(x, y):
|
|||||||
draw_day_view()
|
draw_day_view()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Non-error row taps are intentionally ignored; there is no detail view.
|
# Row taps are handled by the main loop before this function is called.
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1306,7 +1612,8 @@ def main():
|
|||||||
"""Application entry point. Runs the boot sequence then enters the
|
"""Application entry point. Runs the boot sequence then enters the
|
||||||
main event loop."""
|
main event loop."""
|
||||||
global state, view_year, view_month, _last_touch
|
global state, view_year, view_month, _last_touch
|
||||||
global selected_day, records, records_error, scroll_offset
|
global selected_day, selected_record_idx, records, records_error
|
||||||
|
global scroll_offset, detail_scroll_offset
|
||||||
global _dragging
|
global _dragging
|
||||||
global _last_activity, _last_drag_redraw
|
global _last_activity, _last_drag_redraw
|
||||||
|
|
||||||
@@ -1428,9 +1735,65 @@ def main():
|
|||||||
draw_day_view()
|
draw_day_view()
|
||||||
|
|
||||||
if not dragged:
|
if not dragged:
|
||||||
# It was a tap — handle normally
|
# Check for record row tap -> detail view
|
||||||
|
if not records_error and records:
|
||||||
|
rec_idx = touch_to_record_index(y)
|
||||||
|
if rec_idx is not None:
|
||||||
|
selected_record_idx = rec_idx
|
||||||
|
detail_scroll_offset = 0
|
||||||
|
state = STATE_RECORD
|
||||||
|
draw_record_detail()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Otherwise handle as normal tap
|
||||||
handle_day_touch(x, y)
|
handle_day_touch(x, y)
|
||||||
|
|
||||||
|
elif state == STATE_RECORD:
|
||||||
|
# Check back button immediately (no drag needed)
|
||||||
|
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
|
||||||
|
state = STATE_DAY
|
||||||
|
detail_scroll_offset = 0
|
||||||
|
draw_day_view()
|
||||||
|
wait_for_touch_release()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Track vertical drag for scrolling detail view
|
||||||
|
drag_start_y = y
|
||||||
|
last_drag_y = y
|
||||||
|
pending_delta = 0
|
||||||
|
dragged = False
|
||||||
|
_last_drag_redraw = time.ticks_ms()
|
||||||
|
while True:
|
||||||
|
touch_point = read_touch()
|
||||||
|
if touch_point is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
current_y = touch_point[1]
|
||||||
|
if abs(current_y - drag_start_y) >= DRAG_REDRAW_PX:
|
||||||
|
dragged = True
|
||||||
|
|
||||||
|
pending_delta += last_drag_y - current_y
|
||||||
|
now = time.ticks_ms()
|
||||||
|
redraw_due = time.ticks_diff(now, _last_drag_redraw) >= DRAG_REDRAW_MS
|
||||||
|
if abs(pending_delta) >= DRAG_REDRAW_PX and redraw_due:
|
||||||
|
max_offset = _max_detail_scroll_offset()
|
||||||
|
new_offset = min(max(0, detail_scroll_offset + pending_delta), max_offset)
|
||||||
|
if new_offset != detail_scroll_offset:
|
||||||
|
detail_scroll_offset = new_offset
|
||||||
|
draw_record_detail()
|
||||||
|
_last_drag_redraw = now
|
||||||
|
pending_delta = 0
|
||||||
|
|
||||||
|
last_drag_y = current_y
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
if dragged and pending_delta:
|
||||||
|
max_offset = _max_detail_scroll_offset()
|
||||||
|
new_offset = min(max(0, detail_scroll_offset + pending_delta), max_offset)
|
||||||
|
if new_offset != detail_scroll_offset:
|
||||||
|
detail_scroll_offset = new_offset
|
||||||
|
draw_record_detail()
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STARTUP
|
# STARTUP
|
||||||
|
|||||||
Reference in New Issue
Block a user