6a3e9695db
Add a headless smoke-test harness for the Presto microPython app using pimoroni-emulator mock modules. A PRESTO_TEST_MODE=1 guard prevents main() from running at import time so draw functions can be called directly from tests. - presto/main.py: wrap main() with PRESTO_TEST_MODE guard - presto/tests/: conftest with emulator fixtures, 7 smoke tests - presto/mise.toml: add [tasks.test] - presto/README.md, presto/AGENTS.md: add Testing section - .gitignore: exclude test __pycache__, pytest_cache, fixture pngs
2719 lines
86 KiB
Python
2719 lines
86 KiB
Python
"""
|
||
Presto Music Library
|
||
===================================================
|
||
A MicroPython application for the Pimoroni Presto (RP2350B, 480x480 touch
|
||
display, WiFi) that connects to the Music Library API and lets you:
|
||
- browse records by release date
|
||
- search records in the collection
|
||
|
||
Features:
|
||
- WiFi auto-connect with retry
|
||
- NTP time synchronisation
|
||
- Full text search for collection records
|
||
- Month calendar view with day-of-week headers
|
||
- Tap a day to see records released on that date (cover art, artist, title)
|
||
- Navigate months with arrow buttons
|
||
- Error handling for network / API failures
|
||
|
||
Hardware: Pimoroni Presto running stock Presto firmware
|
||
(MicroPython + PicoGraphics + touch driver)
|
||
|
||
Setup:
|
||
1. Copy config.example.py to secrets.py and fill in your credentials
|
||
2. Copy main.py and secrets.py to the root of your Presto's flash
|
||
3. The app starts automatically on boot (main.py is the default entry point)
|
||
"""
|
||
|
||
# ============================================================================
|
||
# IMPORTS
|
||
# ============================================================================
|
||
|
||
import gc
|
||
import os
|
||
import time
|
||
import network
|
||
import ntptime
|
||
|
||
import urequests
|
||
|
||
# Presto hardware module (provided by stock firmware)
|
||
from presto import Presto
|
||
|
||
# JPEG decoder (provided by stock firmware; may be named jpegdec or picographics
|
||
# depending on firmware version — adjust import if needed)
|
||
try:
|
||
import jpegdec as _jpegdec_lib
|
||
_HAS_JPEGDEC = True
|
||
except ImportError:
|
||
_HAS_JPEGDEC = False
|
||
|
||
# ============================================================================
|
||
# CONFIGURATION
|
||
# ============================================================================
|
||
|
||
# API endpoint (production server)
|
||
API_BASE = "https://music-library.claudio-ortolina.org"
|
||
|
||
# Setup for the Presto display in full-resolution 480x480 mode.
|
||
presto = Presto(full_res=True)
|
||
display = presto.display
|
||
WIDTH, HEIGHT = display.get_bounds()
|
||
touch = presto.touch
|
||
|
||
|
||
UI_SCALE = 2
|
||
TEXT_SCALE = UI_SCALE
|
||
TITLE_TEXT_SCALE = UI_SCALE
|
||
PLACEHOLDER_TEXT_SCALE = UI_SCALE * 2
|
||
|
||
|
||
def px(value):
|
||
"""Scale a low-resolution layout measurement to full-resolution pixels."""
|
||
return value * UI_SCALE
|
||
|
||
|
||
TEXT_H = px(8)
|
||
TEXT_LINE_H = px(12)
|
||
TITLE_LINE_H = px(14)
|
||
|
||
# Calendar layout (computed from display bounds)
|
||
CELL_GAP = px(3)
|
||
CELLS_PER_ROW = 7
|
||
MAX_ROWS = 6
|
||
|
||
# Header
|
||
HEADER_Y = 0
|
||
HEADER_H = px(31)
|
||
HEADER_TEXT_H = px(14)
|
||
HEADER_SIDE_MARGIN = px(8)
|
||
HEADER_BUTTON_W = px(32)
|
||
HEADER_BUTTON_H = px(25)
|
||
HEADER_BUTTON_Y = HEADER_Y + (HEADER_H - HEADER_BUTTON_H) // 2
|
||
ARROW_W = HEADER_BUTTON_W
|
||
ARROW_H = HEADER_BUTTON_H
|
||
|
||
# Day-of-week labels
|
||
DOW_Y = HEADER_Y + HEADER_H + px(2)
|
||
DOW_H = px(16)
|
||
|
||
# Grid starts after day-of-week labels with a small gap
|
||
CALENDAR_TOP = DOW_Y + DOW_H + px(6)
|
||
|
||
# Day cells: fit 7 across with comfortable side margins
|
||
CELL_SIZE_BY_WIDTH = (WIDTH - px(16) - (CELLS_PER_ROW - 1) * CELL_GAP) // CELLS_PER_ROW
|
||
CELL_SIZE_BY_HEIGHT = (
|
||
HEIGHT - CALENDAR_TOP - px(8) - (MAX_ROWS - 1) * CELL_GAP
|
||
) // MAX_ROWS
|
||
CELL_SIZE = min(CELL_SIZE_BY_WIDTH, CELL_SIZE_BY_HEIGHT, px(78))
|
||
GRID_LEFT = (WIDTH - (CELLS_PER_ROW * CELL_SIZE + (CELLS_PER_ROW - 1) * CELL_GAP)) // 2
|
||
|
||
# Day view (proportional to cell size)
|
||
BACK_X = HEADER_SIDE_MARGIN
|
||
BACK_Y = HEADER_BUTTON_Y
|
||
BACK_W = HEADER_BUTTON_W
|
||
BACK_H = HEADER_BUTTON_H
|
||
DAY_HEADER_Y = HEADER_Y
|
||
DAY_HEADER_H = HEADER_H
|
||
DAY_HEADER_TEXT_H = HEADER_TEXT_H
|
||
DAY_COUNT_TEXT_H = TEXT_H
|
||
|
||
# Detail view
|
||
DETAIL_COVER_SIZE = px(150)
|
||
DETAIL_COVER_X = (WIDTH - DETAIL_COVER_SIZE) // 2
|
||
DETAIL_COVER_Y = DAY_HEADER_Y + DAY_HEADER_H + px(12)
|
||
DETAIL_INFO_GAP = px(4)
|
||
DETAIL_TEXT_X = px(40)
|
||
DETAIL_TEXT_W = WIDTH - (2 * DETAIL_TEXT_X)
|
||
|
||
# Scrobble button (detail view)
|
||
SCROBBLE_BUTTON_W = px(200)
|
||
SCROBBLE_BUTTON_H = px(40)
|
||
SCROBBLE_BUTTON_GAP = px(12) # vertical gap before button
|
||
|
||
RECORD_START_Y = DAY_HEADER_Y + DAY_HEADER_H + px(8)
|
||
THUMB_SIZE = px(40)
|
||
THUMB_MARGIN = px(8)
|
||
ROW_PAD_Y = px(8)
|
||
ROW_SEPARATOR_H = px(1)
|
||
TEXT_X = THUMB_MARGIN + THUMB_SIZE + px(12)
|
||
TEXT_W = WIDTH - TEXT_X - px(12)
|
||
|
||
# Colors (RGB tuples, converted to pens at runtime)
|
||
BG = (24, 24, 27)
|
||
CELL_BG = (39, 39, 42)
|
||
CELL_OTHER_MONTH = (30, 30, 34)
|
||
TODAY_BG = (239, 68, 68)
|
||
TODAY_TEXT = (255, 255, 255)
|
||
NORMAL_TEXT = (212, 212, 216)
|
||
DIM_TEXT = (113, 113, 122)
|
||
HEADER_TEXT = (228, 228, 231)
|
||
ARROW_COLOR = (161, 161, 170)
|
||
BACK_COLOR = (212, 212, 216)
|
||
TITLE_COLOR = (228, 228, 231)
|
||
ARTIST_COLOR = (161, 161, 170)
|
||
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 = [
|
||
"January", "February", "March", "April", "May", "June",
|
||
"July", "August", "September", "October", "November", "December",
|
||
]
|
||
|
||
# Touch debounce (milliseconds)
|
||
DEBOUNCE_MS = 300
|
||
DRAG_REDRAW_MS = 40
|
||
DRAG_REDRAW_PX = px(8)
|
||
TOUCH_RELEASE_TIMEOUT_MS = 3_000
|
||
|
||
# WiFi connection timeout (seconds)
|
||
WIFI_TIMEOUT = 30
|
||
|
||
# Display sleep
|
||
DISPLAY_SLEEP_MS = 60_000
|
||
DISPLAY_BRIGHTNESS = 1.0
|
||
DISPLAY_SLEEP_BRIGHTNESS = 0.0
|
||
DISPLAY_FADE_MS = 500
|
||
DISPLAY_FADE_STEPS = 20
|
||
|
||
# Home screen
|
||
HOME_TITLE = "Music Library"
|
||
HOME_TITLE_Y = px(60)
|
||
HOME_BUTTON_W = WIDTH - px(80)
|
||
HOME_BUTTON_H = px(50)
|
||
HOME_BUTTON_X = px(40)
|
||
HOME_BUTTON1_Y = px(120)
|
||
HOME_BUTTON2_Y = HOME_BUTTON1_Y + HOME_BUTTON_H + px(10)
|
||
HOME_BTN_BG = (55, 65, 81) # Slate blue — distinct from BG and CELL_BG
|
||
|
||
# On-screen keyboard
|
||
KB_MARGIN = px(20)
|
||
KB_KEY_GAP = px(3)
|
||
KB_KEY_H = px(36)
|
||
KB_KEY_W = (WIDTH - 2 * KB_MARGIN - 9 * KB_KEY_GAP) // 10
|
||
|
||
KB_INPUT_Y = px(42)
|
||
KB_INPUT_H = px(28)
|
||
KB_INPUT_MARGIN = px(40)
|
||
KB_ROWS_START_Y = KB_INPUT_Y + KB_INPUT_H + px(12)
|
||
|
||
ALPHA_KEYS = [
|
||
list("qwertyuiop"),
|
||
list("asdfghjkl"),
|
||
list("zxcvbnm"),
|
||
]
|
||
|
||
NUM_KEYS = [
|
||
list("1234567890"),
|
||
list("-/:;()!@#$"),
|
||
]
|
||
|
||
# ============================================================================
|
||
# STATE
|
||
# ============================================================================
|
||
|
||
# View state constants
|
||
STATE_MONTH = 0
|
||
STATE_DAY = 1
|
||
STATE_RECORD = 2
|
||
STATE_HOME = 3
|
||
STATE_SEARCH_INPUT = 4
|
||
STATE_SEARCH_RESULTS = 5
|
||
|
||
|
||
class DayListState:
|
||
"""Mutable state for the day records list."""
|
||
|
||
def __init__(self):
|
||
self.selected_day = 0
|
||
self.records = []
|
||
self.error = False
|
||
self.scroll_offset = 0
|
||
self.content_height = 0
|
||
|
||
|
||
class SearchState:
|
||
"""Mutable state for search input and search results."""
|
||
|
||
def __init__(self):
|
||
self.query = ""
|
||
self.results = []
|
||
self.error = False
|
||
self.scroll_offset = 0
|
||
self.content_height = 0
|
||
self.keyboard_mode = "alpha"
|
||
|
||
|
||
class DetailState:
|
||
"""Mutable state for the record detail view."""
|
||
|
||
def __init__(self):
|
||
self.selected_index = None
|
||
self.source_screen = None
|
||
self.scroll_offset = 0
|
||
self.content_height = 0
|
||
self.scrobble_state = "idle"
|
||
|
||
|
||
class TouchState:
|
||
"""Mutable state for touch debounce, drag, and display sleep."""
|
||
|
||
def __init__(self):
|
||
self.last_touch = 0
|
||
self.last_drag_redraw = 0
|
||
self.dragging = False
|
||
self.last_activity = 0
|
||
self.display_awake = True
|
||
|
||
|
||
class AppState:
|
||
"""Top-level mutable application state."""
|
||
|
||
def __init__(self):
|
||
self.api_token = ""
|
||
self.screen = STATE_HOME
|
||
self.today_year = 2026
|
||
self.today_month = 5
|
||
self.today_day = 1
|
||
self.view_year = 2026
|
||
self.view_month = 5
|
||
self.day = DayListState()
|
||
self.search = SearchState()
|
||
self.detail = DetailState()
|
||
self.touch = TouchState()
|
||
|
||
# ============================================================================
|
||
# HELPER: PEN CREATION
|
||
# ============================================================================
|
||
|
||
# We create pens once and reuse them to reduce GC pressure.
|
||
# Stored as module-level variables, initialised in init_display().
|
||
|
||
_pen_bg = None
|
||
_pen_cell_bg = None
|
||
_pen_cell_other = None
|
||
_pen_today_bg = None
|
||
_pen_today_text = None
|
||
_pen_normal_text = None
|
||
_pen_dim_text = None
|
||
_pen_header_text = None
|
||
_pen_arrow = None
|
||
_pen_back = None
|
||
_pen_title = None
|
||
_pen_artist = None
|
||
_pen_error = None
|
||
_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."""
|
||
global _pen_bg, _pen_cell_bg, _pen_cell_other, _pen_today_bg
|
||
global _pen_today_text, _pen_normal_text, _pen_dim_text, _pen_header_text
|
||
global _pen_arrow, _pen_back, _pen_title, _pen_artist, _pen_error
|
||
global _pen_placeholder, _pen_status, _pen_home_btn
|
||
|
||
_pen_bg = display.create_pen(*BG)
|
||
_pen_cell_bg = display.create_pen(*CELL_BG)
|
||
_pen_cell_other = display.create_pen(*CELL_OTHER_MONTH)
|
||
_pen_today_bg = display.create_pen(*TODAY_BG)
|
||
_pen_today_text = display.create_pen(*TODAY_TEXT)
|
||
_pen_normal_text = display.create_pen(*NORMAL_TEXT)
|
||
_pen_dim_text = display.create_pen(*DIM_TEXT)
|
||
_pen_header_text = display.create_pen(*HEADER_TEXT)
|
||
_pen_arrow = display.create_pen(*ARROW_COLOR)
|
||
_pen_back = display.create_pen(*BACK_COLOR)
|
||
_pen_title = display.create_pen(*TITLE_COLOR)
|
||
_pen_artist = display.create_pen(*ARTIST_COLOR)
|
||
_pen_error = display.create_pen(*ERROR_COLOR)
|
||
_pen_placeholder = display.create_pen(*PLACEHOLDER_BG)
|
||
_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
|
||
# ============================================================================
|
||
|
||
def draw_wrapped(text, x, y, max_width, pen, scale=TEXT_SCALE):
|
||
"""Draw text, wrapping at word boundaries to fit within max_width pixels.
|
||
|
||
PicoGraphics doesn't support automatic wrapping, so we manually break
|
||
long strings. Returns the Y position after the last line drawn.
|
||
"""
|
||
lines = _wrapped_lines(text, max_width, scale=scale)
|
||
|
||
line_height = _text_line_height(scale)
|
||
cy = y
|
||
display.set_pen(pen)
|
||
for line in lines:
|
||
display.text(line, x, cy, scale=scale)
|
||
cy += line_height
|
||
|
||
return cy
|
||
|
||
|
||
def draw_wrapped_lines(lines, x, y, pen, scale=TEXT_SCALE):
|
||
"""Draw pre-wrapped text lines and return the Y position after them."""
|
||
line_height = _text_line_height(scale)
|
||
cy = y
|
||
display.set_pen(pen)
|
||
for line in lines:
|
||
display.text(line, x, cy, scale=scale)
|
||
cy += line_height
|
||
|
||
return cy
|
||
|
||
|
||
def _text_line_height(scale=TEXT_SCALE):
|
||
"""Return a bitmap8 line height including scaled inter-line spacing."""
|
||
return 8 * scale + px(4)
|
||
|
||
|
||
def display_text(text):
|
||
"""Replace characters not available in the bitmap font."""
|
||
replacements = (
|
||
("–", "-"),
|
||
("—", "-"),
|
||
("−", "-"),
|
||
("‐", "-"),
|
||
("‑", "-"),
|
||
("‒", "-"),
|
||
("“", '"'),
|
||
("”", '"'),
|
||
("„", '"'),
|
||
("«", '"'),
|
||
("»", '"'),
|
||
("‘", "'"),
|
||
("’", "'"),
|
||
("‚", "'"),
|
||
("′", "'"),
|
||
("…", "..."),
|
||
("•", "*"),
|
||
("·", "-"),
|
||
("\u00a0", " "),
|
||
("\u202f", " "),
|
||
("\u2009", " "),
|
||
("\u200a", " "),
|
||
("\u200b", ""),
|
||
)
|
||
|
||
rendered = str(text)
|
||
for original, replacement in replacements:
|
||
rendered = rendered.replace(original, replacement)
|
||
|
||
return rendered
|
||
|
||
|
||
def _wrapped_line_count(text, max_width, scale=TEXT_SCALE):
|
||
"""Return how many wrapped lines the text will occupy."""
|
||
return max(1, len(_wrapped_lines(text, max_width, scale=scale)))
|
||
|
||
|
||
def _wrapped_lines(text, max_width, scale=TEXT_SCALE):
|
||
"""Return wrapped lines for text using the display font metrics."""
|
||
words = str(text).split()
|
||
lines = []
|
||
current_line = ""
|
||
|
||
if not words:
|
||
return lines
|
||
|
||
for word in words:
|
||
test_line = current_line + (" " if current_line else "") + word
|
||
w = display.measure_text(test_line, scale=scale)
|
||
if w <= max_width:
|
||
current_line = test_line
|
||
else:
|
||
if current_line:
|
||
lines.append(current_line)
|
||
if display.measure_text(word, scale=scale) > max_width:
|
||
lines.append(word)
|
||
current_line = ""
|
||
else:
|
||
current_line = word
|
||
|
||
if current_line:
|
||
lines.append(current_line)
|
||
|
||
return lines
|
||
|
||
|
||
# ============================================================================
|
||
# HELPER: DATE / CALENDAR MATH
|
||
# ============================================================================
|
||
|
||
def days_in_month(year, month):
|
||
"""Return the number of days in the given month."""
|
||
if month == 2:
|
||
# Leap year check
|
||
if (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0):
|
||
return 29
|
||
return 28
|
||
if month in (4, 6, 9, 11):
|
||
return 30
|
||
return 31
|
||
|
||
|
||
def day_of_week(year, month, day):
|
||
"""Return 0=Monday .. 6=Sunday using Zeller-like algorithm adjusted
|
||
for our calendar (Monday-first weeks)."""
|
||
# Tomohiko Sakamoto's algorithm, adjusted for Monday=0
|
||
t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
|
||
y = year - (1 if month < 3 else 0)
|
||
d = (y + y // 4 - y // 100 + y // 400 + t[month - 1] + day) % 7
|
||
# Convert Sunday=0 to Sunday=6, Monday=1 to Monday=0, etc.
|
||
return (d + 6) % 7
|
||
|
||
|
||
def previous_month(year, month):
|
||
"""Return (year, month) for the previous month."""
|
||
if month == 1:
|
||
return year - 1, 12
|
||
return year, month - 1
|
||
|
||
|
||
def next_month(year, month):
|
||
"""Return (year, month) for the next month."""
|
||
if month == 12:
|
||
return year + 1, 1
|
||
return year, month + 1
|
||
|
||
|
||
# ============================================================================
|
||
# NETWORKING
|
||
# ============================================================================
|
||
|
||
def connect_wifi(app):
|
||
"""Connect to WiFi using credentials from secrets.py.
|
||
|
||
Returns True on success, False on failure.
|
||
Blinks status messages on the display during the attempt.
|
||
"""
|
||
try:
|
||
import secrets
|
||
except ImportError:
|
||
draw_status("ERROR: secrets.py not found.\n"
|
||
"Copy config.example.py to secrets.py")
|
||
return False
|
||
|
||
ssid = getattr(secrets, "WIFI_SSID", "")
|
||
password = getattr(secrets, "WIFI_PASSWORD", "")
|
||
app.api_token = getattr(secrets, "API_TOKEN", "")
|
||
|
||
if not ssid:
|
||
draw_status("ERROR: WIFI_SSID not set in secrets.py")
|
||
return False
|
||
if not app.api_token:
|
||
draw_status("ERROR: API_TOKEN not set in secrets.py")
|
||
return False
|
||
|
||
draw_status("Connecting to WiFi:\n{}".format(ssid))
|
||
|
||
wlan = network.WLAN(network.STA_IF)
|
||
wlan.active(True)
|
||
|
||
if wlan.isconnected():
|
||
draw_status("Already connected.\nSyncing time...")
|
||
return True
|
||
|
||
wlan.connect(ssid, password)
|
||
|
||
# Wait for connection with timeout
|
||
elapsed = 0
|
||
while not wlan.isconnected() and elapsed < WIFI_TIMEOUT:
|
||
time.sleep(0.5)
|
||
elapsed += 0.5
|
||
|
||
if wlan.isconnected():
|
||
ip = wlan.ifconfig()[0]
|
||
draw_status("Connected.\nIP: {}\nSyncing time...".format(ip))
|
||
return True
|
||
else:
|
||
draw_status("WiFi connection failed.\n"
|
||
"Check credentials.\n"
|
||
"Retrying in 10 seconds...")
|
||
return False
|
||
|
||
|
||
def sync_time():
|
||
"""Synchronise system time via NTP. Non-fatal on failure."""
|
||
try:
|
||
ntptime.settime()
|
||
except Exception:
|
||
# NTP failure is non-fatal; calendar still works (shows RTC time)
|
||
pass
|
||
|
||
|
||
def set_today(app):
|
||
"""Read current date from system clock into app today fields."""
|
||
lt = time.localtime()
|
||
app.today_year = lt[0]
|
||
app.today_month = lt[1]
|
||
app.today_day = lt[2]
|
||
|
||
|
||
def make_date_string(year, month, day):
|
||
"""Format a date as YYYY-MM-DD for the API."""
|
||
return "{:04d}-{:02d}-{:02d}".format(year, month, day)
|
||
|
||
|
||
# ============================================================================
|
||
# DISPLAY SLEEP
|
||
# ============================================================================
|
||
|
||
def _fade_backlight(start, end, duration_ms=DISPLAY_FADE_MS, steps=DISPLAY_FADE_STEPS):
|
||
"""Gradually fade backlight from start to end brightness."""
|
||
step_delay = duration_ms / 1000.0 / steps
|
||
for i in range(steps + 1):
|
||
t = i / steps
|
||
brightness = start + (end - start) * t
|
||
try:
|
||
presto.set_backlight(brightness)
|
||
except Exception:
|
||
return
|
||
time.sleep(step_delay)
|
||
|
||
|
||
def sleep_display(app):
|
||
"""Turn off the display backlight with a gradual fade."""
|
||
_fade_backlight(DISPLAY_BRIGHTNESS, DISPLAY_SLEEP_BRIGHTNESS)
|
||
app.touch.display_awake = False
|
||
|
||
|
||
def wake_display(app):
|
||
"""Fade the backlight in, then refresh the clock, reconnect WiFi if
|
||
needed, and redraw the current view."""
|
||
_fade_backlight(DISPLAY_SLEEP_BRIGHTNESS, DISPLAY_BRIGHTNESS)
|
||
app.touch.display_awake = True
|
||
|
||
# Refresh today's date from the system clock (may have crossed midnight).
|
||
set_today(app)
|
||
|
||
if not wifi_connected():
|
||
ensure_wifi_connected(app)
|
||
|
||
redraw_current_view(app)
|
||
|
||
|
||
def wifi_connected():
|
||
"""Return True if the WLAN interface is currently connected."""
|
||
try:
|
||
wlan = network.WLAN(network.STA_IF)
|
||
wlan.active(True)
|
||
return wlan.isconnected()
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def ensure_wifi_connected(app):
|
||
"""Reconnect WiFi if it dropped while the display was asleep."""
|
||
if wifi_connected():
|
||
return True
|
||
|
||
return connect_wifi(app)
|
||
|
||
|
||
def redraw_current_view(app):
|
||
"""Redraw the active view after wake-time status messages."""
|
||
if app.screen == STATE_HOME:
|
||
draw_home_screen(app)
|
||
elif app.screen == STATE_MONTH:
|
||
draw_month_view(app)
|
||
elif app.screen == STATE_DAY:
|
||
draw_day_view(app)
|
||
elif app.screen == STATE_RECORD:
|
||
draw_record_detail(app)
|
||
elif app.screen == STATE_SEARCH_INPUT:
|
||
draw_search_input(app)
|
||
elif app.screen == STATE_SEARCH_RESULTS:
|
||
draw_search_results(app)
|
||
|
||
|
||
# ============================================================================
|
||
# API CLIENT
|
||
# ============================================================================
|
||
|
||
def _auth_header(app):
|
||
"""Return the Authorization header dict for urequests."""
|
||
return {"Authorization": "Bearer " + app.api_token}
|
||
|
||
|
||
def fetch_records(app, year, month, day):
|
||
"""Fetch records released on the given date from the API.
|
||
|
||
Returns (records_list, error_flag).
|
||
- On success: (list_of_dicts, False)
|
||
- On failure: ([], True)
|
||
"""
|
||
date_str = make_date_string(year, month, day)
|
||
url = API_BASE + "/api/v1/collection/on_this_day?date=" + date_str
|
||
|
||
try:
|
||
resp = urequests.get(url, headers=_auth_header(app))
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
recs = data.get("records", [])
|
||
resp.close()
|
||
gc.collect()
|
||
return recs, False
|
||
else:
|
||
resp.close()
|
||
gc.collect()
|
||
return [], True
|
||
except Exception as e:
|
||
# OSError for network failures, ValueError for JSON parse errors
|
||
gc.collect()
|
||
return [], True
|
||
|
||
|
||
def set_day_records(app, recs, had_error):
|
||
"""Set day-view records and prepare display caches."""
|
||
app.day.records = recs
|
||
app.day.error = had_error
|
||
app.day.scroll_offset = 0
|
||
|
||
if had_error:
|
||
app.day.content_height = 0
|
||
return
|
||
|
||
app.day.content_height = prepare_record_list(app, recs)
|
||
|
||
|
||
def fetch_search_results(app, query):
|
||
"""Fetch search results from the API.
|
||
|
||
Returns (records_list, error_flag). Same shape as fetch_records().
|
||
"""
|
||
# urequests doesn't have url-encode built-in; hand-encode common chars
|
||
encoded = query.replace(" ", "+")
|
||
url = API_BASE + "/api/v1/collection?q=" + encoded + "&limit=20"
|
||
|
||
try:
|
||
resp = urequests.get(url, headers=_auth_header(app))
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
recs = data.get("records", [])
|
||
resp.close()
|
||
gc.collect()
|
||
return recs, False
|
||
else:
|
||
resp.close()
|
||
gc.collect()
|
||
return [], True
|
||
except Exception:
|
||
gc.collect()
|
||
return [], True
|
||
|
||
|
||
def fetch_thumbnail(app, url):
|
||
"""Fetch a JPEG thumbnail from the given URL.
|
||
|
||
Returns the raw bytes on success, None on failure.
|
||
"""
|
||
try:
|
||
resp = urequests.get(url, headers=_auth_header(app))
|
||
if resp.status_code == 200:
|
||
data = resp.content
|
||
resp.close()
|
||
return data
|
||
resp.close()
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
# ============================================================================
|
||
# NAVIGATION
|
||
# ============================================================================
|
||
|
||
def go_home(app):
|
||
"""Navigate to the home screen."""
|
||
app.screen = STATE_HOME
|
||
draw_home_screen(app)
|
||
|
||
|
||
def open_month_for_today(app):
|
||
"""Open the month view focused on today's date."""
|
||
app.view_year = app.today_year
|
||
app.view_month = app.today_month
|
||
app.day.selected_day = app.today_day
|
||
app.screen = STATE_MONTH
|
||
draw_month_view(app)
|
||
|
||
|
||
def open_day(app, day_num):
|
||
"""Open a day, reset its list state, fetch records, and render it."""
|
||
app.day.selected_day = day_num
|
||
app.day.records = []
|
||
app.day.error = False
|
||
app.day.scroll_offset = 0
|
||
app.day.content_height = 0
|
||
app.screen = STATE_DAY
|
||
|
||
draw_status("Loading records for\n{} {}, {}...".format(
|
||
MONTH_NAMES[app.view_month - 1], day_num, app.view_year
|
||
))
|
||
|
||
recs, had_error = fetch_records(app, app.view_year, app.view_month, day_num)
|
||
set_day_records(app, recs, had_error)
|
||
draw_day_view(app)
|
||
|
||
|
||
def open_record(app, source_screen, record_idx):
|
||
"""Open a record detail page from day or search results."""
|
||
app.detail.selected_index = record_idx
|
||
app.detail.source_screen = source_screen
|
||
app.detail.scroll_offset = 0
|
||
app.detail.content_height = 0
|
||
app.detail.scrobble_state = "idle"
|
||
app.screen = STATE_RECORD
|
||
draw_record_detail(app)
|
||
|
||
|
||
def back_from_record(app):
|
||
"""Return from detail to the originating list and reset detail state."""
|
||
source_screen = app.detail.source_screen
|
||
app.detail.scroll_offset = 0
|
||
app.detail.content_height = 0
|
||
app.detail.scrobble_state = "idle"
|
||
app.detail.selected_index = None
|
||
app.detail.source_screen = None
|
||
|
||
if source_screen == STATE_SEARCH_RESULTS:
|
||
app.screen = STATE_SEARCH_RESULTS
|
||
draw_search_results(app)
|
||
else:
|
||
app.screen = STATE_DAY
|
||
draw_day_view(app)
|
||
|
||
|
||
def show_search_input(app, reset_keyboard=False):
|
||
"""Navigate to search input, optionally resetting keyboard mode."""
|
||
if reset_keyboard:
|
||
app.search.keyboard_mode = "alpha"
|
||
app.screen = STATE_SEARCH_INPUT
|
||
draw_search_input(app)
|
||
|
||
|
||
def show_search_results(app, recs, had_error):
|
||
"""Navigate to search results and reset result scroll state."""
|
||
app.search.results = recs
|
||
app.search.error = had_error
|
||
app.search.scroll_offset = 0
|
||
app.search.content_height = 0
|
||
|
||
if not had_error and recs:
|
||
app.search.content_height = prepare_record_list(app, recs)
|
||
|
||
app.screen = STATE_SEARCH_RESULTS
|
||
draw_search_results(app)
|
||
|
||
|
||
# ============================================================================
|
||
# JPEG RENDERING
|
||
# ============================================================================
|
||
|
||
def _jpeg_scale_options():
|
||
"""Return JPEG scale flags paired with their output-size divisors."""
|
||
full = getattr(_jpegdec_lib, "JPEG_SCALE_FULL", 0)
|
||
half = getattr(_jpegdec_lib, "JPEG_SCALE_HALF", 1)
|
||
quarter = getattr(_jpegdec_lib, "JPEG_SCALE_QUARTER", 2)
|
||
eighth = getattr(_jpegdec_lib, "JPEG_SCALE_EIGHTH", 3)
|
||
return ((full, 1), (half, 2), (quarter, 4), (eighth, 8))
|
||
|
||
|
||
def _close_jpeg(jpeg):
|
||
"""Release a jpegdec object if the firmware exposes close()."""
|
||
if jpeg is None:
|
||
return
|
||
|
||
try:
|
||
jpeg.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def draw_jpeg(data, x, y, max_w, max_h):
|
||
"""Decode and draw a JPEG image, scaled to fit within max_w x max_h
|
||
and centered in the bounding box.
|
||
|
||
Uses jpegdec module (standard on Pimoroni firmware).
|
||
Falls back to a placeholder rectangle on failure.
|
||
"""
|
||
if data is None:
|
||
_draw_placeholder(x, y, max_w, max_h)
|
||
return
|
||
|
||
# Try jpegdec module with smart scaling
|
||
if _HAS_JPEGDEC:
|
||
jpeg = None
|
||
try:
|
||
jpeg = _jpegdec_lib.JPEG(display)
|
||
try:
|
||
jpeg.open_RAM(memoryview(data))
|
||
except Exception:
|
||
jpeg.open_RAM(data)
|
||
|
||
# Attempt smart scaling: get dimensions, pick best scale, center
|
||
try:
|
||
img_h = jpeg.get_height()
|
||
img_w = jpeg.get_width()
|
||
# Try scales from largest to smallest; use the first that fits.
|
||
scale = None
|
||
divisor = 1
|
||
for scale_flag, scale_divisor in _jpeg_scale_options():
|
||
if img_w // scale_divisor <= max_w and img_h // scale_divisor <= max_h:
|
||
scale = scale_flag
|
||
divisor = scale_divisor
|
||
break
|
||
if scale is None:
|
||
scale, divisor = _jpeg_scale_options()[-1]
|
||
sw = img_w // divisor
|
||
sh = img_h // divisor
|
||
ox = x + (max_w - sw) // 2
|
||
oy = y + (max_h - sh) // 2
|
||
jpeg.decode(ox, oy, scale)
|
||
_close_jpeg(jpeg)
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
# Fallback: decode at quarter scale (works for most cover art)
|
||
jpeg.decode(x, y, getattr(_jpegdec_lib, "JPEG_SCALE_QUARTER", 2))
|
||
_close_jpeg(jpeg)
|
||
return
|
||
except Exception:
|
||
_close_jpeg(jpeg)
|
||
pass
|
||
|
||
# Method 2: PicoGraphics built-in JPEG support
|
||
try:
|
||
if hasattr(display, "open_jpeg_from_RAM"):
|
||
display.open_jpeg_from_RAM(data)
|
||
display.decode_jpeg(x, y)
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
# Method 3: alternate PicoGraphics JPEG API
|
||
try:
|
||
if hasattr(display, "draw_jpeg"):
|
||
display.draw_jpeg(data, x, y)
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
# All methods failed — draw placeholder
|
||
_draw_placeholder(x, y, max_w, max_h)
|
||
|
||
|
||
def _draw_placeholder(x, y, w, h):
|
||
"""Draw a grey placeholder rectangle with a question mark."""
|
||
display.set_pen(_pen_placeholder)
|
||
display.rectangle(x, y, w, h)
|
||
display.set_pen(_pen_dim_text)
|
||
cx = x + w // 2 - display.measure_text("?", scale=PLACEHOLDER_TEXT_SCALE) // 2
|
||
cy = y + h // 2 - px(10)
|
||
display.text("?", cx, cy, scale=PLACEHOLDER_TEXT_SCALE)
|
||
|
||
|
||
# ============================================================================
|
||
# DRAWING: STATUS SCREEN (startup / error)
|
||
# ============================================================================
|
||
|
||
def draw_status(message):
|
||
"""Draw a simple centered status message on the display (used during
|
||
startup and for persistent errors)."""
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
display.set_pen(_pen_status)
|
||
display.set_font("bitmap8")
|
||
|
||
lines = message.split("\n")
|
||
line_h = px(20)
|
||
total_h = len(lines) * line_h
|
||
start_y = (HEIGHT - total_h) // 2
|
||
|
||
for i, line in enumerate(lines):
|
||
w = display.measure_text(line, scale=TEXT_SCALE)
|
||
x = (WIDTH - w) // 2
|
||
display.text(line, x, start_y + i * line_h, scale=TEXT_SCALE)
|
||
|
||
presto.update()
|
||
|
||
|
||
# ============================================================================
|
||
# DRAWING: MONTH CALENDAR
|
||
# ============================================================================
|
||
|
||
def draw_month_view(app):
|
||
"""Render the full month calendar view including header, day-of-week
|
||
labels, day grid, and today highlight."""
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
_draw_month_header(app)
|
||
_draw_day_labels()
|
||
_draw_day_grid(app)
|
||
presto.update()
|
||
|
||
|
||
HOME_BTN_W = px(30)
|
||
HOME_BTN_H = px(25)
|
||
HOME_BTN_Y = HEADER_Y + (HEADER_H - HOME_BTN_H) // 2
|
||
|
||
|
||
def _draw_month_header(app):
|
||
"""Draw the month/year title, navigation arrows, and Home button."""
|
||
# Background bar
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(0, HEADER_Y, WIDTH, HEADER_H)
|
||
|
||
# Month and year text (centered between arrow buttons)
|
||
display.set_pen(_pen_header_text)
|
||
display.set_font("bitmap14_outline")
|
||
title = "{} {}".format(MONTH_NAMES[app.view_month - 1], app.view_year)
|
||
tw = display.measure_text(title, scale=TEXT_SCALE)
|
||
# Left boundary: after Home + left-arrow; right boundary: before right-arrow
|
||
title_left = HEADER_SIDE_MARGIN + HOME_BTN_W + px(4) + ARROW_W
|
||
title_right = WIDTH - ARROW_W - HEADER_SIDE_MARGIN
|
||
title_area = title_right - title_left
|
||
tx = title_left + (title_area - tw) // 2
|
||
ty = HEADER_Y + (HEADER_H - HEADER_TEXT_H) // 2
|
||
display.text(title, tx, ty, scale=TEXT_SCALE)
|
||
|
||
# Home button (leftmost)
|
||
hx = HEADER_SIDE_MARGIN
|
||
hy = HOME_BTN_Y
|
||
display.set_pen(_pen_placeholder)
|
||
display.rectangle(hx, hy, HOME_BTN_W, HOME_BTN_H)
|
||
display.set_pen(_pen_back)
|
||
display.set_font("bitmap8")
|
||
_draw_centered_text("H", hx, hy, HOME_BTN_W, HOME_BTN_H, TEXT_H)
|
||
|
||
# Left arrow (shifted right to make room for Home)
|
||
lx = hx + HOME_BTN_W + px(4)
|
||
ly = HEADER_BUTTON_Y
|
||
display.set_pen(_pen_placeholder)
|
||
display.rectangle(lx, ly, ARROW_W, ARROW_H)
|
||
display.set_pen(_pen_back)
|
||
display.set_font("bitmap8")
|
||
_draw_centered_text("<", lx, ly, ARROW_W, ARROW_H, TEXT_H)
|
||
|
||
# Right arrow
|
||
rx = WIDTH - ARROW_W - HEADER_SIDE_MARGIN
|
||
ry = HEADER_BUTTON_Y
|
||
display.set_pen(_pen_placeholder)
|
||
display.rectangle(rx, ry, ARROW_W, ARROW_H)
|
||
display.set_pen(_pen_back)
|
||
display.set_font("bitmap8")
|
||
_draw_centered_text(">", rx, ry, ARROW_W, ARROW_H, TEXT_H)
|
||
|
||
|
||
def _draw_centered_text(text, x, y, w, h, text_h, scale=TEXT_SCALE):
|
||
"""Draw text centered inside a rectangular area."""
|
||
tw = display.measure_text(text, scale=scale)
|
||
tx = x + (w - tw) // 2
|
||
ty = y + (h - text_h) // 2
|
||
display.text(text, tx, ty, scale=scale)
|
||
|
||
|
||
def _draw_day_labels():
|
||
"""Draw Mon..Sun labels below the header."""
|
||
display.set_pen(_pen_dim_text)
|
||
display.set_font("bitmap8")
|
||
for i, name in enumerate(DAY_NAMES):
|
||
cx = GRID_LEFT + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE // 2
|
||
tw = display.measure_text(name, scale=TEXT_SCALE)
|
||
display.text(name, cx - tw // 2, DOW_Y, scale=TEXT_SCALE)
|
||
|
||
|
||
def _draw_day_grid(app):
|
||
"""Draw the 7x6 grid of day cells for the current view month."""
|
||
days = days_in_month(app.view_year, app.view_month)
|
||
first_dow = day_of_week(app.view_year, app.view_month, 1)
|
||
|
||
display.set_font("bitmap8")
|
||
|
||
# Draw cells for 6 rows
|
||
for row in range(MAX_ROWS):
|
||
for col in range(CELLS_PER_ROW):
|
||
cell_index = row * CELLS_PER_ROW + col
|
||
day_num = cell_index - first_dow + 1
|
||
|
||
cx = GRID_LEFT + col * (CELL_SIZE + CELL_GAP)
|
||
cy = CALENDAR_TOP + row * (CELL_SIZE + CELL_GAP)
|
||
|
||
if day_num < 1 or day_num > days:
|
||
# Cell belongs to previous/next month — dim it
|
||
display.set_pen(_pen_cell_other)
|
||
display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE)
|
||
if day_num > days:
|
||
# Show overflow day numbers dimmed
|
||
overflow = day_num - days
|
||
display.set_pen(_pen_dim_text)
|
||
_draw_centered_num(overflow, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2)
|
||
continue
|
||
|
||
# Determine cell style
|
||
is_today = (
|
||
app.view_year == app.today_year
|
||
and app.view_month == app.today_month
|
||
and day_num == app.today_day
|
||
)
|
||
is_selected = (app.screen == STATE_DAY and day_num == app.day.selected_day)
|
||
|
||
# Handle today + selected combo specially (show both highlight and border)
|
||
if is_today and is_selected:
|
||
display.set_pen(_pen_today_bg)
|
||
display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE)
|
||
# Selection border on top of today highlight
|
||
display.set_pen(_pen_arrow)
|
||
display.line(cx, cy, cx + CELL_SIZE - 1, cy)
|
||
display.line(cx, cy + CELL_SIZE - 1, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1)
|
||
display.line(cx, cy, cx, cy + CELL_SIZE - 1)
|
||
display.line(cx + CELL_SIZE - 1, cy, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1)
|
||
display.set_pen(_pen_today_text)
|
||
_draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2)
|
||
continue
|
||
|
||
if is_today:
|
||
display.set_pen(_pen_today_bg)
|
||
elif is_selected:
|
||
# Outline the selected cell (non-today)
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE)
|
||
display.set_pen(_pen_arrow)
|
||
display.line(cx, cy, cx + CELL_SIZE - 1, cy)
|
||
display.line(cx, cy + CELL_SIZE - 1, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1)
|
||
display.line(cx, cy, cx, cy + CELL_SIZE - 1)
|
||
display.line(cx + CELL_SIZE - 1, cy, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1)
|
||
display.set_pen(_pen_normal_text)
|
||
_draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2)
|
||
continue
|
||
else:
|
||
display.set_pen(_pen_cell_bg)
|
||
|
||
display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE)
|
||
|
||
# Day number
|
||
if is_today:
|
||
display.set_pen(_pen_today_text)
|
||
else:
|
||
display.set_pen(_pen_normal_text)
|
||
_draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2)
|
||
|
||
|
||
def _draw_centered_num(num, cx, cy):
|
||
"""Draw a number centered at (cx, cy)."""
|
||
text = str(num)
|
||
tw = display.measure_text(text, scale=TEXT_SCALE)
|
||
display.text(text, cx - tw // 2, cy - px(5), scale=TEXT_SCALE)
|
||
|
||
|
||
# ============================================================================
|
||
# DRAWING: HOME SCREEN (splash)
|
||
# ============================================================================
|
||
|
||
def draw_home_screen(app):
|
||
"""Render the splash screen with two large touch targets."""
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
# Title
|
||
display.set_pen(_pen_title)
|
||
display.set_font("bitmap14_outline")
|
||
tw = display.measure_text(HOME_TITLE, scale=TEXT_SCALE)
|
||
display.text(HOME_TITLE, (WIDTH - tw) // 2, HOME_TITLE_Y, scale=TEXT_SCALE)
|
||
|
||
# Buttons
|
||
_draw_home_button(HOME_BUTTON1_Y, "Search Collection")
|
||
_draw_home_button(HOME_BUTTON2_Y, "Today's Records")
|
||
|
||
presto.update()
|
||
|
||
|
||
def _draw_home_button(y, label):
|
||
"""Draw a large rounded-rectangle button for the splash screen."""
|
||
display.set_pen(_pen_home_btn)
|
||
display.rectangle(HOME_BUTTON_X, y, HOME_BUTTON_W, HOME_BUTTON_H)
|
||
display.set_pen(_pen_today_text)
|
||
display.set_font("bitmap8")
|
||
tw = display.measure_text(label, scale=TEXT_SCALE)
|
||
tx = HOME_BUTTON_X + (HOME_BUTTON_W - tw) // 2
|
||
ty = y + (HOME_BUTTON_H - TEXT_H) // 2
|
||
display.text(label, tx, ty, scale=TEXT_SCALE)
|
||
|
||
|
||
# ============================================================================
|
||
# DRAWING: SEARCH INPUT (keyboard)
|
||
# ============================================================================
|
||
|
||
def draw_search_input(app):
|
||
"""Render the search input view with query field and on-screen keyboard."""
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
_draw_search_input_header()
|
||
|
||
# Query text field
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(KB_INPUT_MARGIN, KB_INPUT_Y,
|
||
WIDTH - 2 * KB_INPUT_MARGIN, KB_INPUT_H)
|
||
display.set_pen(_pen_header_text)
|
||
display.set_font("bitmap8")
|
||
# Show cursor as underscore if query is empty
|
||
display_q = app.search.query if app.search.query else "_"
|
||
display.text(
|
||
display_q,
|
||
KB_INPUT_MARGIN + px(8),
|
||
KB_INPUT_Y + px(12),
|
||
scale=TEXT_SCALE
|
||
)
|
||
|
||
_draw_keyboard(app)
|
||
presto.update()
|
||
|
||
|
||
def _draw_search_input_header():
|
||
"""Draw the search input header with title and Back-to-Home button."""
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(0, DAY_HEADER_Y, WIDTH, DAY_HEADER_H)
|
||
|
||
# Back-to-Home 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
|
||
display.set_pen(_pen_header_text)
|
||
display.set_font("bitmap14_outline")
|
||
title = "Search"
|
||
tw = display.measure_text(title, scale=TEXT_SCALE)
|
||
display.text(title, (WIDTH - tw) // 2,
|
||
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2, scale=TEXT_SCALE)
|
||
|
||
|
||
def _draw_keyboard(app):
|
||
"""Draw the on-screen QWERTY or numbers keyboard."""
|
||
display.set_font("bitmap8")
|
||
keys_matrix = ALPHA_KEYS if app.search.keyboard_mode == "alpha" else NUM_KEYS
|
||
toggle_label = "123" if app.search.keyboard_mode == "alpha" else "ABC"
|
||
|
||
cy = KB_ROWS_START_Y
|
||
for row_keys in keys_matrix:
|
||
num_keys = len(row_keys)
|
||
total_gap = (num_keys - 1) * KB_KEY_GAP
|
||
total_key_w = num_keys * KB_KEY_W
|
||
row_start_x = (WIDTH - total_key_w - total_gap) // 2
|
||
for i, char in enumerate(row_keys):
|
||
kx = row_start_x + i * (KB_KEY_W + KB_KEY_GAP)
|
||
_draw_key(kx, cy, char, KB_KEY_W, KB_KEY_H)
|
||
cy += KB_KEY_H + KB_KEY_GAP
|
||
|
||
# Bottom row: toggle | space | backspace | OK
|
||
bottom_w = (WIDTH - 2 * KB_MARGIN - 3 * KB_KEY_GAP) // 4
|
||
bx = KB_MARGIN
|
||
_draw_key(bx, cy, toggle_label, bottom_w, KB_KEY_H)
|
||
bx += bottom_w + KB_KEY_GAP
|
||
_draw_key(bx, cy, "Space", bottom_w, KB_KEY_H)
|
||
bx += bottom_w + KB_KEY_GAP
|
||
_draw_key(bx, cy, "<-", bottom_w, KB_KEY_H)
|
||
bx += bottom_w + KB_KEY_GAP
|
||
ok_pen = _pen_dim_text if app.search.query.strip() == "" else _pen_header_text
|
||
_draw_key(bx, cy, "OK", bottom_w, KB_KEY_H, text_pen=ok_pen)
|
||
|
||
|
||
def _draw_key(x, y, label, w, h, text_pen=None):
|
||
"""Draw a single keyboard key as a rounded rectangle with centered label."""
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(x, y, w, h)
|
||
pen = text_pen if text_pen is not None else _pen_normal_text
|
||
display.set_pen(pen)
|
||
tw = display.measure_text(label, scale=TEXT_SCALE)
|
||
tx = x + (w - tw) // 2
|
||
ty = y + (h - TEXT_H) // 2
|
||
display.text(label, tx, ty, scale=TEXT_SCALE)
|
||
|
||
|
||
def _keyboard_hit_test(app, x, y):
|
||
"""Map a touch (x, y) to a keyboard action.
|
||
|
||
Returns: a character string, "toggle", "space", "backspace", "ok", or None.
|
||
"""
|
||
if y < KB_ROWS_START_Y:
|
||
return None
|
||
|
||
keys_matrix = ALPHA_KEYS if app.search.keyboard_mode == "alpha" else NUM_KEYS
|
||
|
||
cy = KB_ROWS_START_Y
|
||
for row_keys in keys_matrix:
|
||
num_keys = len(row_keys)
|
||
total_gap = (num_keys - 1) * KB_KEY_GAP
|
||
total_key_w = num_keys * KB_KEY_W
|
||
row_start_x = (WIDTH - total_key_w - total_gap) // 2
|
||
for i, char in enumerate(row_keys):
|
||
kx = row_start_x + i * (KB_KEY_W + KB_KEY_GAP)
|
||
if kx <= x < kx + KB_KEY_W and cy <= y < cy + KB_KEY_H:
|
||
return char
|
||
cy += KB_KEY_H + KB_KEY_GAP
|
||
|
||
# Bottom row
|
||
bottom_w = (WIDTH - 2 * KB_MARGIN - 3 * KB_KEY_GAP) // 4
|
||
bx = KB_MARGIN
|
||
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
|
||
return "toggle"
|
||
bx += bottom_w + KB_KEY_GAP
|
||
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
|
||
return "space"
|
||
bx += bottom_w + KB_KEY_GAP
|
||
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
|
||
return "backspace"
|
||
bx += bottom_w + KB_KEY_GAP
|
||
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
|
||
return "ok"
|
||
|
||
return None
|
||
|
||
|
||
def _flash_key(kx, ky, kw, kh, label):
|
||
"""Brief visual feedback for a key press."""
|
||
_draw_key(kx, ky, label, kw, kh, text_pen=_pen_today_text)
|
||
display.set_pen(_pen_arrow)
|
||
display.rectangle(kx, ky, kw, kh)
|
||
presto.update()
|
||
time.sleep(0.05)
|
||
|
||
|
||
# ============================================================================
|
||
# DRAWING: SEARCH RESULTS
|
||
# ============================================================================
|
||
|
||
def draw_search_results(app):
|
||
"""Render the search results view, reusing record-list rendering."""
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
_draw_search_results_header(app)
|
||
|
||
if app.search.error:
|
||
_draw_search_error()
|
||
presto.update()
|
||
return
|
||
|
||
if not app.search.results:
|
||
_draw_search_empty()
|
||
presto.update()
|
||
return
|
||
|
||
_draw_search_record_list(app)
|
||
presto.update()
|
||
|
||
|
||
def _draw_search_results_header(app):
|
||
"""Draw the search results header with Back button and query label."""
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(0, DAY_HEADER_Y, WIDTH, DAY_HEADER_H)
|
||
|
||
# Back button → search input (preserves query)
|
||
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 "Search: <query>" — truncate for display only, never mutate
|
||
# the stored search query so user can refine on Back
|
||
display.set_pen(_pen_header_text)
|
||
display.set_font("bitmap14_outline")
|
||
display_label = "Search: " + app.search.query
|
||
max_w = WIDTH - (BACK_X + BACK_W + px(8)) - px(8)
|
||
if display.measure_text(display_label, scale=TEXT_SCALE) > max_w:
|
||
suffix = "..."
|
||
display_q = app.search.query
|
||
while (display.measure_text("Search: " + display_q + suffix, scale=TEXT_SCALE) > max_w
|
||
and len(display_q) > 0):
|
||
display_q = display_q[:-1]
|
||
display_label = "Search: " + (display_q + suffix if display_q else suffix)
|
||
tw = display.measure_text(display_label, scale=TEXT_SCALE)
|
||
display.text(display_label, (WIDTH - tw) // 2,
|
||
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2, scale=TEXT_SCALE)
|
||
|
||
|
||
def _draw_search_record_list(app):
|
||
"""Draw the scrollable search results list, mirroring day-view scroll."""
|
||
max_offset = _search_max_scroll_offset(app)
|
||
app.search.scroll_offset = min(max(0, app.search.scroll_offset), max_offset)
|
||
|
||
# Up arrow if scrolled down
|
||
if app.search.scroll_offset > 0:
|
||
display.set_pen(_pen_arrow)
|
||
display.set_font("bitmap14_outline")
|
||
display.text("^", WIDTH // 2 - px(8), RECORD_START_Y - px(28), scale=TEXT_SCALE)
|
||
|
||
cy = RECORD_START_Y - app.search.scroll_offset
|
||
for rec in app.search.results:
|
||
row_h = rec.get("_row_height", THUMB_SIZE + px(8))
|
||
row_bottom = cy + row_h
|
||
|
||
if row_bottom <= RECORD_START_Y:
|
||
cy = row_bottom
|
||
continue
|
||
|
||
if cy >= HEIGHT - px(28):
|
||
break
|
||
|
||
_draw_record_row(app, rec, cy)
|
||
cy = row_bottom
|
||
|
||
# Down arrow if more records below
|
||
if app.search.scroll_offset < max_offset:
|
||
display.set_pen(_pen_arrow)
|
||
display.set_font("bitmap14_outline")
|
||
display.text("v", WIDTH // 2 - px(8), HEIGHT - px(24), scale=TEXT_SCALE)
|
||
|
||
|
||
def _search_max_scroll_offset(app):
|
||
"""Return the maximum pixel scroll offset for search results."""
|
||
viewport_h = HEIGHT - RECORD_START_Y - px(28)
|
||
return max(0, app.search.content_height - viewport_h)
|
||
|
||
|
||
def _draw_search_empty():
|
||
"""Show 'No records found' message."""
|
||
display.set_pen(_pen_dim_text)
|
||
display.set_font("bitmap8")
|
||
msg = "No records found"
|
||
mw = display.measure_text(msg, scale=TEXT_SCALE)
|
||
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - px(10), scale=TEXT_SCALE)
|
||
|
||
|
||
def _draw_search_error():
|
||
"""Show search error message with retry hint."""
|
||
display.set_pen(_pen_error)
|
||
display.set_font("bitmap8")
|
||
msg = "Could not reach server"
|
||
mw = display.measure_text(msg, scale=TEXT_SCALE)
|
||
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - px(20), scale=TEXT_SCALE)
|
||
|
||
display.set_pen(_pen_dim_text)
|
||
hint = "Tap to retry"
|
||
hw = display.measure_text(hint, scale=TEXT_SCALE)
|
||
display.text(hint, (WIDTH - hw) // 2, HEIGHT // 2 + px(10), scale=TEXT_SCALE)
|
||
|
||
|
||
# ============================================================================
|
||
# DRAWING: DAY VIEW (records list)
|
||
# ============================================================================
|
||
|
||
def draw_day_view(app):
|
||
"""Render the day view showing records for the selected date."""
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
if app.day.error:
|
||
_draw_day_error()
|
||
_draw_day_header(app)
|
||
presto.update()
|
||
return
|
||
|
||
if not app.day.records:
|
||
_draw_day_empty()
|
||
_draw_day_header(app)
|
||
presto.update()
|
||
return
|
||
|
||
_draw_record_list(app)
|
||
_draw_day_header(app)
|
||
presto.update()
|
||
|
||
|
||
def _draw_day_header(app):
|
||
"""Draw the header with formatted date and back button."""
|
||
# Background bar
|
||
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)
|
||
|
||
# Formatted date
|
||
date_str = "{} {}, {}".format(
|
||
MONTH_NAMES[app.view_month - 1], app.day.selected_day, app.view_year
|
||
)
|
||
display.set_pen(_pen_header_text)
|
||
display.set_font("bitmap14_outline")
|
||
tw = display.measure_text(date_str, scale=TEXT_SCALE)
|
||
display.text(
|
||
date_str,
|
||
(WIDTH - tw) // 2,
|
||
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2,
|
||
scale=TEXT_SCALE
|
||
)
|
||
|
||
# Record count
|
||
record_count = len(app.day.records)
|
||
count_str = "{} record{}".format(record_count, "s" if record_count != 1 else "")
|
||
display.set_pen(_pen_dim_text)
|
||
display.set_font("bitmap8")
|
||
cw = display.measure_text(count_str, scale=TEXT_SCALE)
|
||
display.text(
|
||
count_str,
|
||
WIDTH - cw - px(11),
|
||
DAY_HEADER_Y + (DAY_HEADER_H - DAY_COUNT_TEXT_H) // 2,
|
||
scale=TEXT_SCALE
|
||
)
|
||
|
||
|
||
def _draw_day_error():
|
||
"""Show error message when API call failed."""
|
||
display.set_pen(_pen_error)
|
||
display.set_font("bitmap8")
|
||
msg = "Could not reach server"
|
||
mw = display.measure_text(msg, scale=TEXT_SCALE)
|
||
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - px(20), scale=TEXT_SCALE)
|
||
|
||
display.set_pen(_pen_dim_text)
|
||
hint = "Tap < Back to return"
|
||
hw = display.measure_text(hint, scale=TEXT_SCALE)
|
||
display.text(hint, (WIDTH - hw) // 2, HEIGHT // 2 + px(10), scale=TEXT_SCALE)
|
||
|
||
|
||
def _draw_day_empty():
|
||
"""Show message when no records exist for the selected date."""
|
||
display.set_pen(_pen_dim_text)
|
||
display.set_font("bitmap8")
|
||
msg = "No records on this day"
|
||
mw = display.measure_text(msg, scale=TEXT_SCALE)
|
||
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - px(10), scale=TEXT_SCALE)
|
||
|
||
|
||
def _draw_record_list(app):
|
||
"""Draw the scrollable list of record rows with cover thumbnails.
|
||
Uses dynamic row heights so wrapped text doesn't break layout."""
|
||
max_offset = _max_scroll_offset(app)
|
||
app.day.scroll_offset = min(max(0, app.day.scroll_offset), max_offset)
|
||
|
||
# Up arrow if scrolled down
|
||
if app.day.scroll_offset > 0:
|
||
display.set_pen(_pen_arrow)
|
||
display.set_font("bitmap14_outline")
|
||
display.text("^", WIDTH // 2 - px(8), RECORD_START_Y - px(28), scale=TEXT_SCALE)
|
||
|
||
cy = RECORD_START_Y - app.day.scroll_offset
|
||
|
||
for rec in app.day.records:
|
||
row_h = rec.get("_row_height", THUMB_SIZE + px(8))
|
||
row_bottom = cy + row_h
|
||
|
||
if row_bottom <= RECORD_START_Y:
|
||
cy = row_bottom
|
||
continue
|
||
|
||
if cy >= HEIGHT - px(28):
|
||
break
|
||
|
||
_draw_record_row(app, rec, cy)
|
||
cy = row_bottom
|
||
|
||
# Down arrow if more records below
|
||
if app.day.scroll_offset < max_offset:
|
||
display.set_pen(_pen_arrow)
|
||
display.set_font("bitmap14_outline")
|
||
display.text("v", WIDTH // 2 - px(8), HEIGHT - px(24), scale=TEXT_SCALE)
|
||
|
||
def _max_scroll_offset(app):
|
||
"""Return the maximum pixel scroll offset for the current records."""
|
||
viewport_h = HEIGHT - RECORD_START_Y - px(28)
|
||
return max(0, app.day.content_height - viewport_h)
|
||
|
||
|
||
def prepare_record_list(app, recs):
|
||
"""Cache display fields, row heights, and thumbnails for a record list.
|
||
|
||
Returns the list content height for the caller's view state.
|
||
"""
|
||
display.set_font("bitmap8")
|
||
|
||
content_height = 0
|
||
for rec in recs:
|
||
_prepare_record_for_display(rec)
|
||
content_height += rec.get("_row_height", THUMB_SIZE + px(8))
|
||
|
||
preload_record_thumbnails(app, recs)
|
||
gc.collect()
|
||
return content_height
|
||
|
||
|
||
def _prepare_record_for_display(rec):
|
||
"""Prepare one record's display text and row height."""
|
||
title = display_text(rec.get("title", "Unknown Title"))
|
||
artists = rec.get("artists", [])
|
||
artist_str = display_text(", ".join(artists)) if artists else ""
|
||
meta_text = _record_meta_text(rec)
|
||
|
||
rec["_display_title"] = title
|
||
rec["_display_artists"] = artist_str
|
||
rec["_display_meta"] = meta_text
|
||
rec["_display_title_lines"] = _wrapped_lines(title, TEXT_W, scale=TEXT_SCALE)
|
||
rec["_display_artist_lines"] = (
|
||
_wrapped_lines(artist_str, TEXT_W, scale=TEXT_SCALE) if artist_str else []
|
||
)
|
||
rec["_display_meta_lines"] = (
|
||
_wrapped_lines(meta_text, TEXT_W, scale=TEXT_SCALE) if meta_text else []
|
||
)
|
||
rec["_thumb_url"] = _record_thumbnail_url(rec)
|
||
rec["_row_height"] = _record_row_height(rec)
|
||
|
||
|
||
def preload_record_thumbnails(app, recs):
|
||
"""Fetch thumbnail bytes before the first draw of a record list."""
|
||
for rec in recs:
|
||
if rec.get("micro_cover_url", ""):
|
||
_record_thumbnail_data(app, rec)
|
||
|
||
|
||
def _record_row_height(rec):
|
||
"""Calculate a record row height without drawing it."""
|
||
min_h = THUMB_SIZE + ROW_PAD_Y * 2 + ROW_SEPARATOR_H
|
||
title_lines = rec.get("_display_title_lines", None)
|
||
if title_lines is None:
|
||
title = rec.get("_display_title", display_text(rec.get("title", "Unknown Title")))
|
||
title_lines = _wrapped_lines(title, TEXT_W, scale=TEXT_SCALE)
|
||
ty = max(1, len(title_lines)) * TEXT_LINE_H
|
||
|
||
artist_lines = rec.get("_display_artist_lines", None)
|
||
if artist_lines is None:
|
||
artist_str = rec.get("_display_artists", "")
|
||
artist_lines = _wrapped_lines(artist_str, TEXT_W, scale=TEXT_SCALE) if artist_str else []
|
||
if artist_lines:
|
||
ty += px(2) + len(artist_lines) * TEXT_LINE_H
|
||
|
||
meta_lines = rec.get("_display_meta_lines", None)
|
||
if meta_lines is None:
|
||
meta_text = rec.get("_display_meta", _record_meta_text(rec))
|
||
meta_lines = _wrapped_lines(meta_text, TEXT_W, scale=TEXT_SCALE) if meta_text else []
|
||
if meta_lines:
|
||
ty += px(2) + len(meta_lines) * TEXT_LINE_H
|
||
|
||
content_h = max(THUMB_SIZE, ty) + ROW_PAD_Y * 2 + ROW_SEPARATOR_H
|
||
return max(min_h, content_h)
|
||
|
||
|
||
def _record_meta_text(rec):
|
||
"""Return compact format/date metadata for a record row."""
|
||
record_format = rec.get("format", "")
|
||
release_year = _release_year(rec.get("release_date", ""))
|
||
|
||
if record_format and release_year:
|
||
return "{} | {}".format(record_format, release_year)
|
||
if record_format:
|
||
return record_format
|
||
return release_year
|
||
|
||
|
||
def _release_year(release_date):
|
||
"""Extract a display year from an API release_date value."""
|
||
if not release_date:
|
||
return ""
|
||
return str(release_date)[:4]
|
||
|
||
|
||
def _record_thumbnail_data(app, rec):
|
||
"""Fetch thumbnail bytes once per record and reuse them on redraw."""
|
||
if rec.get("_thumb_failed", False):
|
||
return None
|
||
|
||
cached = rec.get("_thumb_data", None)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
thumb_url = rec.get("_thumb_url", "") or _record_thumbnail_url(rec)
|
||
if not thumb_url:
|
||
return None
|
||
|
||
data = fetch_thumbnail(app, thumb_url)
|
||
if data is None:
|
||
rec["_thumb_failed"] = True
|
||
else:
|
||
rec["_thumb_data"] = data
|
||
|
||
return data
|
||
|
||
|
||
def _record_thumbnail_url(rec):
|
||
"""Return the preferred cover URL for a record row."""
|
||
return (
|
||
rec.get("mini_cover_url", "")
|
||
or rec.get("micro_cover_url", "")
|
||
or rec.get("thumb_url", "")
|
||
)
|
||
|
||
|
||
def _draw_record_row(app, rec, y):
|
||
"""Draw a single record row using its cached layout height."""
|
||
row_h = rec.get("_row_height", None)
|
||
if row_h is None:
|
||
row_h = _record_row_height(rec)
|
||
row_bottom = y + row_h
|
||
|
||
# Fetch and draw thumbnail (top-aligned in row)
|
||
thumb_y = y + ROW_PAD_Y
|
||
if app.touch.dragging:
|
||
_draw_placeholder(THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE)
|
||
else:
|
||
jpeg_data = _record_thumbnail_data(app, rec)
|
||
if jpeg_data is not None:
|
||
draw_jpeg(jpeg_data, THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE)
|
||
else:
|
||
_draw_placeholder(THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE)
|
||
|
||
# Title (starts at same height as thumbnail)
|
||
display.set_font("bitmap8")
|
||
ty = y + ROW_PAD_Y
|
||
title_lines = rec.get("_display_title_lines", None)
|
||
if title_lines is None:
|
||
title = rec.get("_display_title", display_text(rec.get("title", "Unknown Title")))
|
||
ty = draw_wrapped(title, TEXT_X, ty, TEXT_W, _pen_title, scale=TEXT_SCALE)
|
||
else:
|
||
ty = draw_wrapped_lines(title_lines, TEXT_X, ty, _pen_title, scale=TEXT_SCALE)
|
||
|
||
# Artists
|
||
artist_lines = rec.get("_display_artist_lines", None)
|
||
if artist_lines is None:
|
||
artist_str = rec.get("_display_artists", "")
|
||
artist_lines = _wrapped_lines(artist_str, TEXT_W, scale=TEXT_SCALE) if artist_str else []
|
||
if artist_lines:
|
||
ty += px(2)
|
||
ty = draw_wrapped_lines(artist_lines, TEXT_X, ty, _pen_artist, scale=TEXT_SCALE)
|
||
|
||
meta_lines = rec.get("_display_meta_lines", None)
|
||
if meta_lines is None:
|
||
meta_text = rec.get("_display_meta", _record_meta_text(rec))
|
||
meta_lines = _wrapped_lines(meta_text, TEXT_W, scale=TEXT_SCALE) if meta_text else []
|
||
if meta_lines:
|
||
ty += px(2)
|
||
ty = draw_wrapped_lines(meta_lines, TEXT_X, ty, _pen_dim_text, scale=TEXT_SCALE)
|
||
|
||
# Separator line at the computed bottom
|
||
display.set_pen(_pen_cell_other)
|
||
display.line(
|
||
THUMB_MARGIN,
|
||
row_bottom - ROW_SEPARATOR_H,
|
||
WIDTH - THUMB_MARGIN,
|
||
row_bottom - ROW_SEPARATOR_H
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# DRAWING: RECORD DETAIL
|
||
# ============================================================================
|
||
|
||
def _max_detail_scroll_offset(app):
|
||
"""Return the maximum pixel scroll offset for the detail view."""
|
||
if app.detail.content_height == 0:
|
||
return 0
|
||
content_bottom = DETAIL_COVER_Y + app.detail.content_height
|
||
return max(0, content_bottom - HEIGHT)
|
||
|
||
|
||
def _measure_detail_text_height(text, font="bitmap8", scale=TEXT_SCALE, 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 = _line_height_for_font(font, scale)
|
||
return len(lines) * line_height
|
||
|
||
|
||
def _line_height_for_font(font, scale=TEXT_SCALE):
|
||
"""Return scaled line height for the bitmap font used by a detail section."""
|
||
if font == "bitmap14_outline":
|
||
return 14 * scale
|
||
return _text_line_height(scale)
|
||
|
||
|
||
def _line_metrics(lines, font="bitmap8", scale=TEXT_SCALE):
|
||
"""Pair pre-wrapped text lines with their measured widths."""
|
||
display.set_font(font)
|
||
metrics = []
|
||
for line in lines:
|
||
metrics.append((line, display.measure_text(line, scale=scale)))
|
||
return metrics
|
||
|
||
|
||
def _measure_detail_content(app, rec):
|
||
"""Compute and cache the total height of the scrollable detail content."""
|
||
h = 0
|
||
|
||
# Title (above cover)
|
||
title = rec.get("_display_title",
|
||
display_text(rec.get("title", "Unknown Title")))
|
||
display.set_font("bitmap14_outline")
|
||
title_lines = _wrapped_lines(title, DETAIL_TEXT_W, scale=TEXT_SCALE)
|
||
rec["_detail_title_lines"] = _line_metrics(
|
||
title_lines, font="bitmap14_outline", scale=TEXT_SCALE
|
||
)
|
||
h += len(title_lines) * TITLE_LINE_H
|
||
h += px(6)
|
||
|
||
# Artists (above cover)
|
||
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:
|
||
display.set_font("bitmap8")
|
||
artist_lines = _wrapped_lines(artist_str, DETAIL_TEXT_W, scale=TEXT_SCALE)
|
||
rec["_detail_artist_lines"] = _line_metrics(artist_lines, scale=TEXT_SCALE)
|
||
h += len(artist_lines) * TEXT_LINE_H
|
||
h += px(4)
|
||
else:
|
||
rec["_detail_artist_lines"] = []
|
||
|
||
h += DETAIL_INFO_GAP # gap before cover
|
||
|
||
# Cover
|
||
h += DETAIL_COVER_SIZE
|
||
h += DETAIL_INFO_GAP
|
||
|
||
# Genres (below cover)
|
||
genres = rec.get("genres", [])
|
||
if genres:
|
||
genre_str = display_text(", ".join(genres))
|
||
display.set_font("bitmap8")
|
||
genre_lines = _wrapped_lines(genre_str, DETAIL_TEXT_W, scale=TEXT_SCALE)
|
||
rec["_detail_genre_lines"] = _line_metrics(genre_lines, scale=TEXT_SCALE)
|
||
h += len(genre_lines) * TEXT_LINE_H
|
||
h += px(4)
|
||
else:
|
||
rec["_detail_genre_lines"] = []
|
||
|
||
h += px(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)
|
||
display.set_font("bitmap8")
|
||
meta_lines = _wrapped_lines(meta_str, DETAIL_TEXT_W, scale=TEXT_SCALE)
|
||
rec["_detail_meta_lines"] = _line_metrics(meta_lines, scale=TEXT_SCALE)
|
||
h += len(meta_lines) * TEXT_LINE_H
|
||
h += px(4)
|
||
else:
|
||
rec["_detail_meta_lines"] = []
|
||
|
||
# Purchased at
|
||
purchased_at = rec.get("purchased_at", "")
|
||
if purchased_at:
|
||
purch_str = "Purchased: " + display_text(str(purchased_at)[:10])
|
||
display.set_font("bitmap8")
|
||
purchased_lines = _wrapped_lines(purch_str, DETAIL_TEXT_W, scale=TEXT_SCALE)
|
||
rec["_detail_purchased_lines"] = _line_metrics(purchased_lines, scale=TEXT_SCALE)
|
||
h += len(purchased_lines) * TEXT_LINE_H
|
||
else:
|
||
rec["_detail_purchased_lines"] = []
|
||
|
||
# Scrobble button (at bottom, only if scrobble-eligible)
|
||
if rec.get("selected_release_id"):
|
||
h += SCROBBLE_BUTTON_GAP
|
||
h += SCROBBLE_BUTTON_H
|
||
|
||
app.detail.content_height = 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(app):
|
||
"""Render the individual record detail view. Layout order:
|
||
title, artists, large cover, genres, metadata, purchased at."""
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
if app.detail.source_screen == STATE_SEARCH_RESULTS:
|
||
rec = app.search.results[app.detail.selected_index]
|
||
else:
|
||
rec = app.day.records[app.detail.selected_index]
|
||
|
||
# Measure content height before normal draws; drag redraws reuse it.
|
||
if app.detail.content_height == 0 or not app.touch.dragging:
|
||
_measure_detail_content(app, rec)
|
||
|
||
# Clamp scroll offset
|
||
max_off = _max_detail_scroll_offset(app)
|
||
if app.detail.scroll_offset > max_off:
|
||
app.detail.scroll_offset = max_off
|
||
if app.detail.scroll_offset < 0:
|
||
app.detail.scroll_offset = 0
|
||
|
||
offset = app.detail.scroll_offset
|
||
|
||
_draw_detail_header()
|
||
|
||
# Clip scrolling content below the header
|
||
_set_clip_below_header()
|
||
|
||
# Start drawing below the header
|
||
y = DETAIL_COVER_Y - offset
|
||
|
||
# Title and artists (above cover)
|
||
y = _draw_detail_title_artists(rec, y)
|
||
y += DETAIL_INFO_GAP # gap before cover
|
||
|
||
# Cover image
|
||
_draw_detail_cover(app, rec, y)
|
||
|
||
# Genres, metadata, purchased at (below cover)
|
||
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
|
||
+ app.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(app, rec, button_y)
|
||
|
||
# Scroll indicators
|
||
display.set_font("bitmap14_outline")
|
||
if offset > 0:
|
||
display.set_pen(_pen_arrow)
|
||
display.text("^", WIDTH // 2 - px(8), DAY_HEADER_H + px(4), scale=TEXT_SCALE)
|
||
if offset < max_off:
|
||
display.set_pen(_pen_arrow)
|
||
display.text("v", WIDTH // 2 - px(8), HEIGHT - px(24), scale=TEXT_SCALE)
|
||
|
||
_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=TEXT_SCALE)
|
||
display.text(
|
||
label,
|
||
(WIDTH - tw) // 2,
|
||
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2,
|
||
scale=TEXT_SCALE
|
||
)
|
||
|
||
|
||
def _draw_detail_title_artists(rec, y):
|
||
"""Draw title and artists above the cover. Returns y after drawing."""
|
||
# Title
|
||
title_lines = rec.get("_detail_title_lines", None)
|
||
if title_lines is None:
|
||
title = rec.get("_display_title",
|
||
display_text(rec.get("title", "Unknown Title")))
|
||
y = _draw_detail_text_line(title, y, _pen_title,
|
||
font="bitmap14_outline", scale=TEXT_SCALE)
|
||
else:
|
||
y = _draw_detail_text_lines(title_lines, y, _pen_title,
|
||
font="bitmap14_outline", scale=TEXT_SCALE)
|
||
y += px(6)
|
||
|
||
# Artists
|
||
artist_lines = rec.get("_detail_artist_lines", None)
|
||
if artist_lines is None:
|
||
artist_str = rec.get("_display_artists", "")
|
||
if not artist_str:
|
||
artists = rec.get("artists", [])
|
||
artist_str = display_text(", ".join(artists)) if artists else ""
|
||
artist_lines = _wrapped_lines(artist_str, DETAIL_TEXT_W, scale=TEXT_SCALE) if artist_str else []
|
||
if artist_lines:
|
||
y = _draw_detail_text_lines(artist_lines, y, _pen_artist)
|
||
y += px(4)
|
||
|
||
return y
|
||
|
||
|
||
def _draw_detail_cover(app, rec, y):
|
||
"""Fetch and draw the cover art at the given y position."""
|
||
# Skip if entirely outside viewport
|
||
if y + DETAIL_COVER_SIZE <= DAY_HEADER_Y + DAY_HEADER_H:
|
||
return
|
||
if y >= 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, y,
|
||
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
|
||
return
|
||
|
||
# Use cached data or fetch
|
||
data = rec.get("_detail_thumb_data", None)
|
||
if data is None and app.touch.dragging:
|
||
_draw_placeholder(DETAIL_COVER_X, y,
|
||
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
|
||
return
|
||
|
||
if data is None and not rec.get("_detail_thumb_failed", False):
|
||
data = fetch_thumbnail(app, 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, y,
|
||
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
|
||
else:
|
||
_draw_placeholder(DETAIL_COVER_X, y,
|
||
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
|
||
|
||
|
||
def _draw_detail_info_below_cover(rec, y):
|
||
"""Draw genres, metadata line, and purchased-at below the cover."""
|
||
# Genres
|
||
genre_lines = rec.get("_detail_genre_lines", None)
|
||
if genre_lines is None:
|
||
genres = rec.get("genres", [])
|
||
if genres:
|
||
genre_str = display_text(", ".join(genres))
|
||
genre_lines = _wrapped_lines(genre_str, DETAIL_TEXT_W, scale=TEXT_SCALE)
|
||
else:
|
||
genre_lines = []
|
||
if genre_lines:
|
||
y = _draw_detail_text_lines(genre_lines, y, _pen_dim_text)
|
||
y += px(4)
|
||
|
||
y += px(6)
|
||
|
||
# Metadata: record_type | format | year
|
||
meta_lines = rec.get("_detail_meta_lines", None)
|
||
if meta_lines is None:
|
||
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])
|
||
meta_lines = _wrapped_lines(" | ".join(meta_parts), DETAIL_TEXT_W, scale=TEXT_SCALE) if meta_parts else []
|
||
|
||
if meta_lines:
|
||
y = _draw_detail_text_lines(meta_lines, y, _pen_dim_text)
|
||
y += px(4)
|
||
|
||
# Purchased at
|
||
purchased_lines = rec.get("_detail_purchased_lines", None)
|
||
if purchased_lines is None:
|
||
purchased_at = rec.get("purchased_at", "")
|
||
if purchased_at:
|
||
purch_str = "Purchased: " + display_text(str(purchased_at)[:10])
|
||
purchased_lines = _wrapped_lines(purch_str, DETAIL_TEXT_W, scale=TEXT_SCALE)
|
||
else:
|
||
purchased_lines = []
|
||
if purchased_lines:
|
||
y = _draw_detail_text_lines(purchased_lines, y, _pen_dim_text)
|
||
|
||
return y
|
||
|
||
|
||
def _draw_detail_text_line(text, y, pen, font="bitmap8", scale=TEXT_SCALE):
|
||
"""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 = _line_height_for_font(font, scale)
|
||
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
|
||
|
||
|
||
def _draw_detail_text_lines(lines, y, pen, font="bitmap8", scale=TEXT_SCALE):
|
||
"""Draw pre-wrapped detail text centered horizontally."""
|
||
display.set_pen(pen)
|
||
display.set_font(font)
|
||
|
||
line_height = _line_height_for_font(font, scale)
|
||
cy = y
|
||
for item in lines:
|
||
if isinstance(item, tuple):
|
||
line, tw = item
|
||
else:
|
||
line = item
|
||
tw = display.measure_text(line, scale=scale)
|
||
display.text(line, (WIDTH - tw) // 2, cy, scale=scale)
|
||
cy += line_height
|
||
|
||
return cy
|
||
|
||
|
||
def _draw_scrobble_button(app, rec, y):
|
||
"""Draw the scrobble button at (x, y). Button is horizontally centered.
|
||
Visual state depends on app detail scrobble state."""
|
||
|
||
bx = (WIDTH - SCROBBLE_BUTTON_W) // 2
|
||
|
||
if app.detail.scrobble_state == "done":
|
||
bg_pen = _pen_scrobble_done_bg
|
||
text_pen = _pen_scrobble_done_text
|
||
label = "Done"
|
||
elif app.detail.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, TEXT_H)
|
||
|
||
|
||
# ============================================================================
|
||
# TOUCH HANDLING
|
||
# ============================================================================
|
||
def read_touch():
|
||
"""Return the current touch as (x, y), or None when not pressed."""
|
||
try:
|
||
touch.poll()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
if touch.state:
|
||
return int(touch.x), int(touch.y)
|
||
except Exception:
|
||
pass
|
||
|
||
for method_name in ("read", "get_touch"):
|
||
method = getattr(touch, method_name, None)
|
||
if method is None:
|
||
continue
|
||
|
||
try:
|
||
point = method()
|
||
except Exception:
|
||
continue
|
||
|
||
normalised = _normalise_touch(point)
|
||
if normalised is not None:
|
||
return normalised
|
||
|
||
return None
|
||
|
||
|
||
def _normalise_touch(point):
|
||
"""Convert common touch return shapes to (x, y)."""
|
||
if not point:
|
||
return None
|
||
|
||
if isinstance(point, (tuple, list)):
|
||
if len(point) == 2:
|
||
return int(point[0]), int(point[1])
|
||
if len(point) >= 3 and point[0] is not None:
|
||
return int(point[1]), int(point[2])
|
||
return None
|
||
|
||
try:
|
||
if hasattr(point, "state") and not point.state:
|
||
return None
|
||
return int(point.x), int(point.y)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def wait_for_touch_release():
|
||
"""Block until the active touch is released."""
|
||
start = time.ticks_ms()
|
||
while read_touch() is not None:
|
||
if time.ticks_diff(time.ticks_ms(), start) >= TOUCH_RELEASE_TIMEOUT_MS:
|
||
return False
|
||
time.sleep(0.02)
|
||
return True
|
||
|
||
|
||
def touch_to_calendar_cell(x, y):
|
||
"""Map a touch (x, y) to (row, col) in the calendar grid, or None
|
||
if the touch is outside the grid."""
|
||
if y < CALENDAR_TOP:
|
||
return None
|
||
|
||
col = (x - GRID_LEFT) // (CELL_SIZE + CELL_GAP)
|
||
row = (y - CALENDAR_TOP) // (CELL_SIZE + CELL_GAP)
|
||
|
||
# Check within cell (not in gap)
|
||
cx = GRID_LEFT + col * (CELL_SIZE + CELL_GAP)
|
||
cy = CALENDAR_TOP + row * (CELL_SIZE + CELL_GAP)
|
||
if cx <= x < cx + CELL_SIZE and cy <= y < cy + CELL_SIZE:
|
||
if 0 <= col < CELLS_PER_ROW and 0 <= row < MAX_ROWS:
|
||
return row, col
|
||
return None
|
||
|
||
|
||
def cell_to_day(app, row, col):
|
||
"""Convert a calendar cell (row, col) to a day number (1-based)
|
||
for the current view month. Returns None if the cell is outside
|
||
the current month."""
|
||
first_dow = day_of_week(app.view_year, app.view_month, 1)
|
||
day_num = row * CELLS_PER_ROW + col - first_dow + 1
|
||
days = days_in_month(app.view_year, app.view_month)
|
||
if 1 <= day_num <= days:
|
||
return day_num
|
||
return None
|
||
|
||
|
||
def touch_to_record_index(app, y, recs=None, offset=None):
|
||
"""Map a touch y-coordinate to a record list index, accounting for
|
||
scroll offset. Use recs/offset params for search results, or defaults
|
||
for day view."""
|
||
if recs is None:
|
||
recs = app.day.records
|
||
if offset is None:
|
||
offset = app.day.scroll_offset
|
||
elif offset is None:
|
||
offset = app.search.scroll_offset
|
||
|
||
if not recs:
|
||
return None
|
||
|
||
cy = RECORD_START_Y - offset
|
||
for i, rec in enumerate(recs):
|
||
row_h = rec.get("_row_height", THUMB_SIZE + px(8))
|
||
if cy <= y < cy + row_h:
|
||
return i
|
||
cy += row_h
|
||
return None
|
||
|
||
|
||
def _scrobble_button_hit_test(app, 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)."""
|
||
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 - app.detail.scroll_offset
|
||
+ app.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(app, 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"."""
|
||
rec_id = rec.get("id")
|
||
if not rec_id:
|
||
return
|
||
|
||
# Set loading state and redraw immediately so the user sees "..."
|
||
app.detail.scrobble_state = "loading"
|
||
draw_record_detail(app)
|
||
|
||
# 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(app))
|
||
if resp.status_code == 200:
|
||
app.detail.scrobble_state = "done"
|
||
else:
|
||
app.detail.scrobble_state = "idle"
|
||
resp.close()
|
||
except Exception:
|
||
app.detail.scrobble_state = "idle"
|
||
|
||
gc.collect()
|
||
draw_record_detail(app)
|
||
|
||
|
||
def handle_home_touch(app, x, y):
|
||
"""Process a touch event on the home/splash screen."""
|
||
# "Search Collection" button
|
||
if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and
|
||
HOME_BUTTON1_Y <= y <= HOME_BUTTON1_Y + HOME_BUTTON_H):
|
||
app.search.query = ""
|
||
show_search_input(app, reset_keyboard=True)
|
||
return
|
||
|
||
# "Today's Records" button → month view with today highlighted
|
||
if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and
|
||
HOME_BUTTON2_Y <= y <= HOME_BUTTON2_Y + HOME_BUTTON_H):
|
||
open_month_for_today(app)
|
||
return
|
||
|
||
|
||
def handle_search_input_touch(app, x, y):
|
||
"""Process a touch event in search input view."""
|
||
# Header Back → Home
|
||
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
|
||
go_home(app)
|
||
return
|
||
|
||
action = _keyboard_hit_test(app, x, y)
|
||
if action is None:
|
||
return
|
||
|
||
if action == "toggle":
|
||
if app.search.keyboard_mode == "alpha":
|
||
app.search.keyboard_mode = "numbers"
|
||
else:
|
||
app.search.keyboard_mode = "alpha"
|
||
draw_search_input(app)
|
||
return
|
||
|
||
if action == "space":
|
||
if len(app.search.query) < 50:
|
||
app.search.query += " "
|
||
draw_search_input(app)
|
||
return
|
||
|
||
if action == "backspace":
|
||
app.search.query = app.search.query[:-1]
|
||
draw_search_input(app)
|
||
return
|
||
|
||
if action == "ok":
|
||
if not app.search.query.strip():
|
||
return
|
||
draw_status("Searching for:\n" + app.search.query)
|
||
if not ensure_wifi_connected(app):
|
||
show_search_results(app, [], True)
|
||
return
|
||
recs, had_error = fetch_search_results(app, app.search.query)
|
||
show_search_results(app, recs, had_error)
|
||
return
|
||
|
||
# Character key
|
||
if len(app.search.query) < 50 and len(action) == 1:
|
||
app.search.query += action
|
||
draw_search_input(app)
|
||
|
||
|
||
def handle_search_results_touch(app, x, y):
|
||
"""Process a tap event in search results view.
|
||
Back button, error retry, and record row taps.
|
||
Row scroll is handled by the drag logic in the main loop."""
|
||
# Back button → search input (preserves search_query so user can refine)
|
||
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
|
||
show_search_input(app)
|
||
return
|
||
|
||
# Error state: retry on tap anywhere
|
||
if app.search.error:
|
||
draw_status("Retrying search...")
|
||
if not ensure_wifi_connected(app):
|
||
show_search_results(app, [], True)
|
||
return
|
||
recs, had_error = fetch_search_results(app, app.search.query)
|
||
show_search_results(app, recs, had_error)
|
||
return
|
||
|
||
# Record row tap → detail view
|
||
if not app.search.error and app.search.results:
|
||
rec_idx = touch_to_record_index(
|
||
app, y, recs=app.search.results, offset=app.search.scroll_offset
|
||
)
|
||
|
||
if rec_idx is not None:
|
||
open_record(app, STATE_SEARCH_RESULTS, rec_idx)
|
||
|
||
|
||
def handle_month_touch(app, x, y):
|
||
"""Process a touch event in month view state."""
|
||
# Check Home button
|
||
hx, hy = HEADER_SIDE_MARGIN, HOME_BTN_Y
|
||
if hx <= x <= hx + HOME_BTN_W and hy <= y <= hy + HOME_BTN_H:
|
||
go_home(app)
|
||
return
|
||
|
||
# Check left arrow (shifted right to make room for Home)
|
||
lx = HEADER_SIDE_MARGIN + HOME_BTN_W + px(4)
|
||
ly = HEADER_BUTTON_Y
|
||
if lx <= x <= lx + ARROW_W and ly <= y <= ly + ARROW_H:
|
||
app.view_year, app.view_month = previous_month(app.view_year, app.view_month)
|
||
draw_month_view(app)
|
||
return
|
||
|
||
# Check right arrow
|
||
rx, ry = WIDTH - ARROW_W - HEADER_SIDE_MARGIN, HEADER_BUTTON_Y
|
||
if rx <= x <= rx + ARROW_W and ry <= y <= ry + ARROW_H:
|
||
app.view_year, app.view_month = next_month(app.view_year, app.view_month)
|
||
draw_month_view(app)
|
||
return
|
||
|
||
# Check calendar cells
|
||
cell = touch_to_calendar_cell(x, y)
|
||
if cell is None:
|
||
return
|
||
|
||
row, col = cell
|
||
day_num = cell_to_day(app, row, col)
|
||
if day_num is None:
|
||
return
|
||
|
||
open_day(app, day_num)
|
||
|
||
|
||
def handle_day_touch(app, x, y):
|
||
"""Process a tap event in day view state.
|
||
Back button and scroll are handled by drag logic in the main loop."""
|
||
# Tap in error state: retry fetching records
|
||
if app.day.error:
|
||
draw_status("Retrying...")
|
||
if not ensure_wifi_connected(app):
|
||
set_day_records(app, [], True)
|
||
draw_day_view(app)
|
||
return
|
||
|
||
recs, had_error = fetch_records(
|
||
app, app.view_year, app.view_month, app.day.selected_day
|
||
)
|
||
set_day_records(app, recs, had_error)
|
||
draw_day_view(app)
|
||
return
|
||
|
||
# Row taps are handled by the main loop before this function is called.
|
||
|
||
|
||
def handle_vertical_drag(app, start_y, get_offset, set_offset, max_offset, redraw,
|
||
use_placeholders):
|
||
"""Handle a throttled vertical drag for any scrollable view.
|
||
|
||
The hot path only updates offsets and redraws; layout measurement and
|
||
network image fetches must already be avoided by the redraw path.
|
||
"""
|
||
drag_start_y = start_y
|
||
last_drag_y = start_y
|
||
pending_delta = 0
|
||
dragged = False
|
||
did_redraw = False
|
||
app.touch.dragging = False
|
||
app.touch.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, app.touch.last_drag_redraw) >= DRAG_REDRAW_MS
|
||
if abs(pending_delta) >= DRAG_REDRAW_PX and redraw_due:
|
||
new_offset = min(max(0, get_offset() + pending_delta), max_offset())
|
||
if new_offset != get_offset():
|
||
set_offset(new_offset)
|
||
app.touch.dragging = use_placeholders
|
||
redraw()
|
||
did_redraw = True
|
||
app.touch.last_drag_redraw = now
|
||
pending_delta = 0
|
||
|
||
last_drag_y = current_y
|
||
time.sleep(0.01)
|
||
|
||
if dragged and pending_delta:
|
||
new_offset = min(max(0, get_offset() + pending_delta), max_offset())
|
||
if new_offset != get_offset():
|
||
set_offset(new_offset)
|
||
app.touch.dragging = use_placeholders
|
||
did_redraw = True
|
||
|
||
if did_redraw:
|
||
app.touch.dragging = False
|
||
redraw()
|
||
|
||
return dragged
|
||
|
||
|
||
# ============================================================================
|
||
# MAIN LOOP
|
||
# ============================================================================
|
||
|
||
def init_display():
|
||
"""Initialise pens (display is already set up at module level)."""
|
||
try:
|
||
display.set_font("bitmap8")
|
||
except Exception:
|
||
pass
|
||
_init_pens()
|
||
|
||
|
||
def main():
|
||
"""Application entry point. Runs the boot sequence then enters the
|
||
main event loop."""
|
||
app = AppState()
|
||
|
||
# -- Init display --
|
||
init_display()
|
||
try:
|
||
presto.set_backlight(DISPLAY_BRIGHTNESS)
|
||
except Exception:
|
||
pass
|
||
draw_status("Music Library\nStarting up...")
|
||
time.sleep(0.5)
|
||
|
||
# -- WiFi --
|
||
if not connect_wifi(app):
|
||
# Show persistent error; try to reconnect in a loop
|
||
while not connect_wifi(app):
|
||
time.sleep(10)
|
||
gc.collect()
|
||
|
||
# -- NTP sync --
|
||
sync_time()
|
||
set_today(app)
|
||
|
||
# Start on splash screen — no auto-fetch
|
||
app.view_year = app.today_year
|
||
app.view_month = app.today_month
|
||
app.day.selected_day = app.today_day
|
||
app.screen = STATE_HOME
|
||
app.day.error = False
|
||
app.day.scroll_offset = 0
|
||
|
||
draw_home_screen(app)
|
||
app.touch.last_activity = time.ticks_ms()
|
||
|
||
while True:
|
||
touch_point = read_touch()
|
||
now = time.ticks_ms()
|
||
|
||
if (app.touch.display_awake
|
||
and time.ticks_diff(now, app.touch.last_activity) >= DISPLAY_SLEEP_MS):
|
||
sleep_display(app)
|
||
|
||
if touch_point is None:
|
||
time.sleep(0.03)
|
||
continue
|
||
|
||
if not app.touch.display_awake:
|
||
wake_display(app)
|
||
app.touch.last_activity = now
|
||
app.touch.last_touch = now
|
||
wait_for_touch_release()
|
||
continue
|
||
|
||
x, y = touch_point
|
||
if time.ticks_diff(now, app.touch.last_touch) < DEBOUNCE_MS:
|
||
time.sleep(0.02)
|
||
continue
|
||
app.touch.last_touch = now
|
||
app.touch.last_activity = now
|
||
|
||
# -- STATE_HOME: splash screen --
|
||
if app.screen == STATE_HOME:
|
||
handle_home_touch(app, x, y)
|
||
wait_for_touch_release()
|
||
|
||
# -- STATE_SEARCH_INPUT: on-screen keyboard --
|
||
elif app.screen == STATE_SEARCH_INPUT:
|
||
handle_search_input_touch(app, x, y)
|
||
wait_for_touch_release()
|
||
|
||
# -- STATE_SEARCH_RESULTS: search results list --
|
||
elif app.screen == STATE_SEARCH_RESULTS:
|
||
# Check back button immediately (no drag needed)
|
||
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
|
||
show_search_input(app)
|
||
wait_for_touch_release()
|
||
continue
|
||
|
||
# Error state: retry on tap anywhere (before drag)
|
||
if app.search.error:
|
||
handle_search_results_touch(app, x, y)
|
||
wait_for_touch_release()
|
||
continue
|
||
|
||
dragged = handle_vertical_drag(
|
||
app,
|
||
y,
|
||
lambda: app.search.scroll_offset,
|
||
lambda value: setattr(app.search, "scroll_offset", value),
|
||
lambda: _search_max_scroll_offset(app),
|
||
lambda: draw_search_results(app),
|
||
True
|
||
)
|
||
|
||
if not dragged:
|
||
# Record row tap handled by handle_search_results_touch
|
||
handle_search_results_touch(app, x, y)
|
||
|
||
elif app.screen == STATE_MONTH:
|
||
handle_month_touch(app, x, y)
|
||
wait_for_touch_release()
|
||
|
||
elif app.screen == STATE_DAY:
|
||
# Check back button immediately (no drag needed)
|
||
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
|
||
app.screen = STATE_MONTH
|
||
draw_month_view(app)
|
||
wait_for_touch_release()
|
||
continue
|
||
|
||
dragged = handle_vertical_drag(
|
||
app,
|
||
y,
|
||
lambda: app.day.scroll_offset,
|
||
lambda value: setattr(app.day, "scroll_offset", value),
|
||
lambda: _max_scroll_offset(app),
|
||
lambda: draw_day_view(app),
|
||
True
|
||
)
|
||
|
||
if not dragged:
|
||
# Check for record row tap -> detail view
|
||
if not app.day.error and app.day.records:
|
||
rec_idx = touch_to_record_index(app, y)
|
||
if rec_idx is not None:
|
||
open_record(app, STATE_DAY, rec_idx)
|
||
continue
|
||
|
||
# Otherwise handle as normal tap
|
||
handle_day_touch(app, x, y)
|
||
|
||
elif app.screen == 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:
|
||
back_from_record(app)
|
||
wait_for_touch_release()
|
||
continue
|
||
|
||
dragged = handle_vertical_drag(
|
||
app,
|
||
y,
|
||
lambda: app.detail.scroll_offset,
|
||
lambda value: setattr(app.detail, "scroll_offset", value),
|
||
lambda: _max_detail_scroll_offset(app),
|
||
lambda: draw_record_detail(app),
|
||
True
|
||
)
|
||
|
||
if not dragged:
|
||
# Determine which record is being viewed
|
||
if app.detail.source_screen == STATE_SEARCH_RESULTS:
|
||
rec = app.search.results[app.detail.selected_index]
|
||
else:
|
||
rec = app.day.records[app.detail.selected_index]
|
||
|
||
# Check scrobble button tap
|
||
if _scrobble_button_hit_test(app, x, y, rec):
|
||
handle_scrobble_touch(app, rec)
|
||
|
||
|
||
# ============================================================================
|
||
# STARTUP
|
||
# ============================================================================
|
||
|
||
if os.environ.get("PRESTO_TEST_MODE") != "1":
|
||
main()
|