1041 lines
32 KiB
Python
1041 lines
32 KiB
Python
"""
|
|
Presto Music Library - Records On This Day Calendar
|
|
===================================================
|
|
A MicroPython application for the Pimoroni Presto (RP2350B, 720x720 touch
|
|
display, WiFi) that connects to the Music Library API and lets you browse
|
|
records by release date.
|
|
|
|
Features:
|
|
- WiFi auto-connect with retry
|
|
- NTP time synchronisation
|
|
- 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 json
|
|
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
|
|
presto = Presto()
|
|
display = presto.display
|
|
WIDTH, HEIGHT = display.get_bounds()
|
|
touch = presto.touch
|
|
|
|
# Calendar layout (computed from display bounds)
|
|
CELL_GAP = 3
|
|
CELLS_PER_ROW = 7
|
|
MAX_ROWS = 6
|
|
|
|
# Header
|
|
HEADER_Y = 0
|
|
HEADER_H = 46
|
|
MONTH_Y = HEADER_Y + 6
|
|
ARROW_W = 48
|
|
ARROW_H = 34
|
|
|
|
# Day-of-week labels
|
|
DOW_Y = HEADER_Y + HEADER_H + 2
|
|
DOW_H = 16
|
|
|
|
# Day cells: fit 7 across with comfortable side margins
|
|
CELL_SIZE = (WIDTH - 16 - (CELLS_PER_ROW - 1) * CELL_GAP) // CELLS_PER_ROW
|
|
if CELL_SIZE > 78:
|
|
CELL_SIZE = 78
|
|
GRID_LEFT = (WIDTH - (CELLS_PER_ROW * CELL_SIZE + (CELLS_PER_ROW - 1) * CELL_GAP)) // 2
|
|
|
|
# Grid starts after day-of-week labels with a small gap
|
|
CALENDAR_TOP = DOW_Y + DOW_H + 6
|
|
|
|
# Day view (proportional to cell size)
|
|
BACK_X = 8
|
|
BACK_Y = 8
|
|
BACK_W = 44
|
|
BACK_H = 32
|
|
RECORD_START_Y = BACK_Y + BACK_H + 12
|
|
THUMB_SIZE = 75
|
|
THUMB_MARGIN = 8
|
|
TEXT_X = THUMB_MARGIN + THUMB_SIZE + 12
|
|
TEXT_W = WIDTH - TEXT_X - 12
|
|
|
|
# Colors (RGB tuples, converted to pens at runtime)
|
|
BG = (22, 22, 36)
|
|
CELL_BG = (48, 48, 66)
|
|
CELL_OTHER_MONTH = (35, 35, 50)
|
|
TODAY_BG = (50, 90, 175)
|
|
TODAY_TEXT = (255, 255, 255)
|
|
NORMAL_TEXT = (215, 215, 235)
|
|
DIM_TEXT = (125, 125, 150)
|
|
HEADER_TEXT = (240, 240, 255)
|
|
ARROW_COLOR = (185, 185, 210)
|
|
BACK_COLOR = (210, 210, 235)
|
|
TITLE_COLOR = (240, 240, 255)
|
|
ARTIST_COLOR = (190, 190, 215)
|
|
ERROR_COLOR = (255, 115, 115)
|
|
PLACEHOLDER_BG = (65, 65, 85)
|
|
STATUS_TEXT = (170, 170, 195)
|
|
|
|
# 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
|
|
|
|
# WiFi connection timeout (seconds)
|
|
WIFI_TIMEOUT = 30
|
|
|
|
# ============================================================================
|
|
# GLOBAL STATE
|
|
# ============================================================================
|
|
|
|
api_token = ""
|
|
|
|
# Time state
|
|
today_year = 2026
|
|
today_month = 5
|
|
today_day = 1
|
|
view_year = 2026
|
|
view_month = 5
|
|
|
|
# View state
|
|
STATE_STARTUP = 0
|
|
STATE_MONTH = 1
|
|
STATE_DAY = 2
|
|
STATE_ERROR = 3
|
|
state = STATE_STARTUP
|
|
|
|
# Day view state
|
|
selected_day = 0 # 1-based day of month
|
|
records = [] # List of record dicts from API
|
|
records_error = False # True if API call failed
|
|
# Scroll state
|
|
scroll_offset = 0 # Scroll position for day view
|
|
_visible_count = 1 # How many records fit on screen (set during draw)
|
|
|
|
# Touch debounce
|
|
_last_touch = 0
|
|
|
|
# ============================================================================
|
|
# 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
|
|
|
|
|
|
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_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)
|
|
|
|
|
|
# ============================================================================
|
|
# HELPER: TEXT WRAPPING
|
|
# ============================================================================
|
|
|
|
def draw_wrapped(text, x, y, max_width, pen, scale=1):
|
|
"""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.
|
|
"""
|
|
words = text.split(" ")
|
|
lines = []
|
|
current_line = ""
|
|
|
|
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 a single word is too long, we draw it anyway (truncation
|
|
# would hide information).
|
|
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)
|
|
|
|
line_height = 8 * scale + 4 # bitmap8 is 8px tall, plus spacing
|
|
cy = y
|
|
display.set_pen(pen)
|
|
for line in lines:
|
|
display.text(line, x, cy, scale=scale)
|
|
cy += line_height
|
|
|
|
return cy
|
|
|
|
|
|
# ============================================================================
|
|
# 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():
|
|
"""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", "")
|
|
global api_token
|
|
api_token = getattr(secrets, "API_TOKEN", "")
|
|
|
|
if not ssid:
|
|
draw_status("ERROR: WIFI_SSID not set in secrets.py")
|
|
return False
|
|
if not 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():
|
|
"""Read current date from system clock into global today_* vars."""
|
|
global today_year, today_month, today_day
|
|
lt = time.localtime()
|
|
today_year = lt[0]
|
|
today_month = lt[1]
|
|
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)
|
|
|
|
|
|
# ============================================================================
|
|
# API CLIENT
|
|
# ============================================================================
|
|
|
|
def _auth_header():
|
|
"""Return the Authorization header dict for urequests."""
|
|
return {"Authorization": "Bearer " + api_token}
|
|
|
|
|
|
def fetch_records(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())
|
|
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 fetch_thumbnail(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())
|
|
if resp.status_code == 200:
|
|
data = resp.content
|
|
resp.close()
|
|
return data
|
|
resp.close()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# JPEG RENDERING
|
|
# ============================================================================
|
|
|
|
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:
|
|
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 first that fits
|
|
# (0=full, 2=half, 4=quarter, 8=eighth)
|
|
for s in (2, 4, 8):
|
|
if img_w // s <= max_w and img_h // s <= max_h:
|
|
scale = s
|
|
break
|
|
else:
|
|
scale = 0
|
|
sw = img_w // max(1, scale)
|
|
sh = img_h // max(1, scale)
|
|
ox = x + (max_w - sw) // 2
|
|
oy = y + (max_h - sh) // 2
|
|
jpeg.decode(ox, oy, scale)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: decode at quarter scale (works for most cover art)
|
|
jpeg.decode(x, y, 4)
|
|
return
|
|
except Exception:
|
|
pass
|
|
return
|
|
except Exception:
|
|
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=2) // 2
|
|
cy = y + h // 2 - 10
|
|
display.text("?", cx, cy, scale=2)
|
|
|
|
|
|
# ============================================================================
|
|
# 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 = 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=1)
|
|
x = (WIDTH - w) // 2
|
|
display.text(line, x, start_y + i * line_h, scale=1)
|
|
|
|
presto.update()
|
|
|
|
|
|
# ============================================================================
|
|
# DRAWING: MONTH CALENDAR
|
|
# ============================================================================
|
|
|
|
def draw_month_view():
|
|
"""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()
|
|
_draw_day_labels()
|
|
_draw_day_grid()
|
|
presto.update()
|
|
|
|
|
|
def _draw_month_header():
|
|
"""Draw the month/year title and navigation arrows at the top."""
|
|
# Background bar
|
|
display.set_pen(_pen_cell_bg)
|
|
display.rectangle(0, HEADER_Y, WIDTH, HEADER_H)
|
|
|
|
# Month and year text (centered)
|
|
display.set_pen(_pen_header_text)
|
|
display.set_font("bitmap14_outline")
|
|
title = "{} {}".format(MONTH_NAMES[view_month - 1], view_year)
|
|
tw = display.measure_text(title, scale=1)
|
|
tx = (WIDTH - tw) // 2
|
|
ty = MONTH_Y
|
|
display.text(title, tx, ty, scale=1)
|
|
|
|
# Left arrow
|
|
lx = 12
|
|
ly = HEADER_Y + (HEADER_H - ARROW_H) // 2
|
|
display.set_pen(_pen_cell_bg) # Match header bg
|
|
display.rectangle(lx, ly, ARROW_W, ARROW_H)
|
|
display.set_pen(_pen_arrow)
|
|
display.set_font("bitmap14_outline")
|
|
display.text("<", lx + 14, ly + 8, scale=1)
|
|
|
|
# Right arrow
|
|
rx = WIDTH - ARROW_W - 12
|
|
ry = HEADER_Y + (HEADER_H - ARROW_H) // 2
|
|
display.set_pen(_pen_cell_bg)
|
|
display.rectangle(rx, ry, ARROW_W, ARROW_H)
|
|
display.set_pen(_pen_arrow)
|
|
display.text(">", rx + 12, ry + 8, scale=1)
|
|
|
|
|
|
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=1)
|
|
display.text(name, cx - tw // 2, DOW_Y, scale=1)
|
|
|
|
|
|
def _draw_day_grid():
|
|
"""Draw the 7x6 grid of day cells for the current view month."""
|
|
days = days_in_month(view_year, view_month)
|
|
first_dow = day_of_week(view_year, 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 = (
|
|
view_year == today_year
|
|
and view_month == today_month
|
|
and day_num == today_day
|
|
)
|
|
is_selected = (state == STATE_DAY and day_num == 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=1)
|
|
display.text(text, cx - tw // 2, cy - 5, scale=1)
|
|
|
|
|
|
# ============================================================================
|
|
# DRAWING: DAY VIEW (records list)
|
|
# ============================================================================
|
|
|
|
def draw_day_view():
|
|
"""Render the day view showing records for the selected date."""
|
|
display.set_pen(_pen_bg)
|
|
display.clear()
|
|
|
|
_draw_day_header()
|
|
|
|
if records_error:
|
|
_draw_day_error()
|
|
presto.update()
|
|
return
|
|
|
|
if not records:
|
|
_draw_day_empty()
|
|
presto.update()
|
|
return
|
|
|
|
_draw_record_list()
|
|
presto.update()
|
|
|
|
|
|
def _draw_day_header():
|
|
"""Draw the header with formatted date and back button."""
|
|
# Background bar
|
|
display.set_pen(_pen_cell_bg)
|
|
display.rectangle(0, BACK_Y - 4, WIDTH, BACK_H + 8)
|
|
|
|
# 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")
|
|
display.text("< Back", bx + 10, by + 14, scale=1)
|
|
|
|
# Formatted date
|
|
date_str = "{} {}, {}".format(
|
|
MONTH_NAMES[view_month - 1], selected_day, view_year
|
|
)
|
|
display.set_pen(_pen_header_text)
|
|
display.set_font("bitmap14_outline")
|
|
tw = display.measure_text(date_str, scale=1)
|
|
display.text(date_str, (WIDTH - tw) // 2, BACK_Y + 8, scale=1)
|
|
|
|
# Record count
|
|
count_str = "{} record{}".format(len(records), "s" if len(records) != 1 else "")
|
|
display.set_pen(_pen_dim_text)
|
|
display.set_font("bitmap8")
|
|
cw = display.measure_text(count_str, scale=1)
|
|
# display.text(count_str, WIDTH - cw - 16, BACK_Y + BACK_H - 4, scale=1)
|
|
display.text(count_str, WIDTH - cw - 16, BACK_Y + 14, scale=1)
|
|
|
|
|
|
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=1)
|
|
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - 20, scale=1)
|
|
|
|
display.set_pen(_pen_dim_text)
|
|
hint = "Tap < Back to return"
|
|
hw = display.measure_text(hint, scale=1)
|
|
display.text(hint, (WIDTH - hw) // 2, HEIGHT // 2 + 10, scale=1)
|
|
|
|
|
|
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=1)
|
|
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - 10, scale=1)
|
|
|
|
|
|
def _draw_record_list():
|
|
"""Draw the scrollable list of record rows with cover thumbnails.
|
|
Uses dynamic row heights so wrapped text doesn't break layout."""
|
|
start_idx = scroll_offset
|
|
total = len(records)
|
|
|
|
# Up arrow if scrolled down
|
|
if scroll_offset > 0:
|
|
display.set_pen(_pen_arrow)
|
|
display.set_font("bitmap14_outline")
|
|
display.text("^", WIDTH // 2 - 8, RECORD_START_Y - 28, scale=1)
|
|
|
|
# Draw rows with dynamic heights
|
|
cy = RECORD_START_Y
|
|
visible_count = 0
|
|
for i in range(start_idx, total):
|
|
rec = records[i]
|
|
row_bottom = _draw_record_row(rec, cy)
|
|
# Stop if the row would overflow the display
|
|
if row_bottom > HEIGHT - 28:
|
|
break
|
|
cy = row_bottom
|
|
visible_count += 1
|
|
|
|
# Down arrow if more records below
|
|
if start_idx + visible_count < total:
|
|
display.set_pen(_pen_arrow)
|
|
display.set_font("bitmap14_outline")
|
|
display.text("v", WIDTH // 2 - 8, cy + 4, scale=1)
|
|
|
|
# Store how many fit for scroll logic
|
|
global _visible_count
|
|
_visible_count = max(1, visible_count)
|
|
|
|
|
|
def _draw_record_row(rec, y):
|
|
"""Draw a single record row. Returns the Y position after the row
|
|
(including separator), so the next row can be positioned correctly
|
|
even when title/artist text wraps to multiple lines."""
|
|
min_h = THUMB_SIZE + 8 # Minimum row height (thumbnail + padding)
|
|
|
|
# Fetch and draw thumbnail (top-aligned in row)
|
|
thumb_url = rec.get("mini_cover_url", "")
|
|
thumb_y = y + 4
|
|
if thumb_url:
|
|
jpeg_data = fetch_thumbnail(thumb_url)
|
|
draw_jpeg(jpeg_data, THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE)
|
|
gc.collect()
|
|
else:
|
|
_draw_placeholder(THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE)
|
|
|
|
# Title (starts at same height as thumbnail)
|
|
title = rec.get("title", "Unknown Title")
|
|
display.set_font("bitmap8")
|
|
ty = y + 4
|
|
ty = draw_wrapped(title, TEXT_X, ty, TEXT_W, _pen_title, scale=1)
|
|
|
|
# Artists
|
|
artists = rec.get("artists", [])
|
|
if artists:
|
|
artist_str = ", ".join(artists)
|
|
ty += 2
|
|
ty = draw_wrapped(artist_str, TEXT_X, ty, TEXT_W, _pen_artist, scale=1)
|
|
|
|
# Bottom of content: at least thumbnail bottom, at least min_h
|
|
content_bottom = max(thumb_y + THUMB_SIZE, ty) + 6
|
|
row_bottom = max(y + min_h, content_bottom)
|
|
|
|
# Separator line at the computed bottom
|
|
display.set_pen(_pen_cell_other)
|
|
display.line(THUMB_MARGIN, row_bottom - 2, WIDTH - THUMB_MARGIN, row_bottom - 2)
|
|
|
|
return row_bottom
|
|
|
|
|
|
# ============================================================================
|
|
# TOUCH HANDLING
|
|
# ============================================================================
|
|
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(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(view_year, view_month, 1)
|
|
day_num = row * CELLS_PER_ROW + col - first_dow + 1
|
|
days = days_in_month(view_year, view_month)
|
|
if 1 <= day_num <= days:
|
|
return day_num
|
|
return None
|
|
|
|
|
|
def handle_month_touch(x, y):
|
|
"""Process a touch event in month view state."""
|
|
global view_year, view_month, selected_day, state, records, records_error, scroll_offset
|
|
|
|
# Check left arrow
|
|
lx, ly = 12, HEADER_Y + (HEADER_H - ARROW_H) // 2
|
|
if lx <= x <= lx + ARROW_W and ly <= y <= ly + ARROW_H:
|
|
view_year, view_month = previous_month(view_year, view_month)
|
|
draw_month_view()
|
|
return
|
|
|
|
# Check right arrow
|
|
rx, ry = WIDTH - ARROW_W - 12, HEADER_Y + (HEADER_H - ARROW_H) // 2
|
|
if rx <= x <= rx + ARROW_W and ry <= y <= ry + ARROW_H:
|
|
view_year, view_month = next_month(view_year, view_month)
|
|
draw_month_view()
|
|
return
|
|
|
|
# Check calendar cells
|
|
cell = touch_to_calendar_cell(x, y)
|
|
if cell is None:
|
|
return
|
|
|
|
row, col = cell
|
|
day_num = cell_to_day(row, col)
|
|
if day_num is None:
|
|
return
|
|
|
|
# Valid day tapped — fetch records
|
|
selected_day = day_num
|
|
state = STATE_DAY
|
|
scroll_offset = 0
|
|
records_error = False
|
|
records = []
|
|
|
|
# Show loading state
|
|
draw_status("Loading records for\n{} {}, {}...".format(
|
|
MONTH_NAMES[view_month - 1], day_num, view_year
|
|
))
|
|
|
|
# Fetch from API
|
|
recs, had_error = fetch_records(view_year, view_month, day_num)
|
|
records = recs
|
|
records_error = had_error
|
|
|
|
# Render day view
|
|
draw_day_view()
|
|
|
|
|
|
def handle_day_touch(x, y):
|
|
"""Process a tap event in day view state.
|
|
Back button and scroll are handled by drag logic in the main loop."""
|
|
global records, records_error
|
|
|
|
# Tap in error state: retry fetching records
|
|
if records_error:
|
|
draw_status("Retrying...")
|
|
recs, had_error = fetch_records(view_year, view_month, selected_day)
|
|
records = recs
|
|
records_error = had_error
|
|
draw_day_view()
|
|
|
|
|
|
# ============================================================================
|
|
# 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."""
|
|
global state, view_year, view_month, _last_touch
|
|
global selected_day, records, records_error, scroll_offset
|
|
|
|
# -- Init display --
|
|
init_display()
|
|
draw_status("Music Library\nStarting up...")
|
|
time.sleep(0.5)
|
|
|
|
# -- WiFi --
|
|
if not connect_wifi():
|
|
# Show persistent error; try to reconnect in a loop
|
|
while not connect_wifi():
|
|
time.sleep(10)
|
|
gc.collect()
|
|
|
|
# -- NTP sync --
|
|
sync_time()
|
|
set_today()
|
|
|
|
# Start at current month and today's day view
|
|
view_year = today_year
|
|
view_month = today_month
|
|
selected_day = today_day
|
|
state = STATE_DAY
|
|
records_error = False
|
|
|
|
# Fetch today's records
|
|
draw_status("Loading records for\n{} {}, {}...".format(
|
|
MONTH_NAMES[view_month - 1], today_day, view_year
|
|
))
|
|
recs, had_error = fetch_records(view_year, view_month, today_day)
|
|
records = recs
|
|
records_error = had_error
|
|
|
|
# Enter main loop
|
|
draw_day_view()
|
|
|
|
while True:
|
|
touch.poll()
|
|
|
|
if not touch.state:
|
|
time.sleep(0.03)
|
|
continue
|
|
|
|
x, y = int(touch.x), int(touch.y)
|
|
now = time.ticks_ms()
|
|
if time.ticks_diff(now, _last_touch) < DEBOUNCE_MS:
|
|
continue
|
|
_last_touch = now
|
|
|
|
if state == STATE_MONTH:
|
|
handle_month_touch(x, y)
|
|
# Wait for release to avoid re-triggering
|
|
while touch.state:
|
|
touch.poll()
|
|
time.sleep(0.02)
|
|
|
|
elif state == 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:
|
|
state = STATE_MONTH
|
|
draw_month_view()
|
|
while touch.state:
|
|
touch.poll()
|
|
time.sleep(0.02)
|
|
continue
|
|
|
|
# Track vertical drag for scrolling
|
|
drag_start_y = y
|
|
dragged = False
|
|
while touch.state:
|
|
touch.poll()
|
|
time.sleep(0.02)
|
|
if touch.state:
|
|
dy = drag_start_y - int(touch.y)
|
|
if abs(dy) > 5:
|
|
dragged = True
|
|
# Every ~50px of drag = one record scroll
|
|
if abs(dy) >= 50:
|
|
total = len(records)
|
|
if dy > 0:
|
|
scroll_offset = min(total - _visible_count, scroll_offset + 1)
|
|
else:
|
|
scroll_offset = max(0, scroll_offset - 1)
|
|
draw_day_view()
|
|
drag_start_y = int(touch.y)
|
|
|
|
if not dragged:
|
|
# It was a tap — handle normally
|
|
handle_day_touch(x, y)
|
|
|
|
|
|
# ============================================================================
|
|
# STARTUP
|
|
# ============================================================================
|
|
|
|
main()
|
|
|