Forked from Joabutt/spotify_playing_screen_landscape.py
Last active
February 21, 2026 00:19
-
-
Save pjosalgado/f8ee298ea113c40b9e9f497499c70aa9 to your computer and use it in GitHub Desktop.
Media Playing for UsbPCMonitor 3.5" [Landscape version]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Install dbus-python dependency | |
| import os | |
| import sys | |
| import io | |
| import time | |
| import tempfile | |
| import uuid | |
| import atexit | |
| import requests | |
| import dbus | |
| from PIL import Image, ImageDraw, ImageFont | |
| from library.lcd.lcd_comm import Orientation | |
| from library.lcd.lcd_comm_rev_a import LcdCommRevA | |
| SCRIPT_DIR = os.path.dirname(os.path.abspath(sys.argv[0])) | |
| os.chdir(SCRIPT_DIR) | |
| DISPLAY_WIDTH = 480 | |
| DISPLAY_HEIGHT = 320 | |
| COM_PORT = "AUTO" | |
| POLL_INTERVAL = 0.5 | |
| SCROLL_SPEED_PX = 36 | |
| lcd = LcdCommRevA( | |
| com_port=COM_PORT, | |
| display_width=DISPLAY_HEIGHT, | |
| display_height=DISPLAY_WIDTH | |
| ) | |
| lcd.Reset() | |
| lcd.InitializeComm() | |
| lcd.SetBrightness(level=25) | |
| lcd.SetOrientation(Orientation.LANDSCAPE) | |
| ROBOTO_FOLDER = os.path.join(SCRIPT_DIR, "res", "fonts", "roboto") | |
| def find_font(names): | |
| for n in names: | |
| p = os.path.join(ROBOTO_FOLDER, n) | |
| if os.path.exists(p): | |
| return p | |
| return None | |
| FONT_BOLD = find_font(["Roboto-Bold.ttf","roboto-bold.ttf"]) | |
| FONT_REG = find_font(["Roboto-Regular.ttf","roboto.ttf"]) or FONT_BOLD | |
| def load_font(path,size): | |
| return ImageFont.truetype(path,size) if path and os.path.exists(path) else ImageFont.load_default() | |
| _font_cache={} | |
| def get_font(size,bold=False): | |
| k=(size,bold) | |
| if k not in _font_cache: | |
| _font_cache[k]=load_font(FONT_BOLD if bold else FONT_REG,size) | |
| return _font_cache[k] | |
| def shutdown(): | |
| try: | |
| lcd.SetBrightness(0) | |
| lcd.ClearScreen() | |
| except: | |
| pass | |
| atexit.register(shutdown) | |
| session_bus=dbus.SessionBus() | |
| def get_player(): | |
| for s in session_bus.list_names(): | |
| if s.startswith("org.mpris.MediaPlayer2."): | |
| return s | |
| return None | |
| state={ | |
| "progress":0, | |
| "ts":time.monotonic(), | |
| "duration":0, | |
| "playing":False | |
| } | |
| def fetch_now_playing(): | |
| name=get_player() | |
| if not name: | |
| return None | |
| try: | |
| obj=session_bus.get_object(name,"/org/mpris/MediaPlayer2") | |
| prop=dbus.Interface(obj,"org.freedesktop.DBus.Properties") | |
| meta=prop.Get("org.mpris.MediaPlayer2.Player","Metadata") | |
| status=prop.Get("org.mpris.MediaPlayer2.Player","PlaybackStatus") | |
| pos=prop.Get("org.mpris.MediaPlayer2.Player","Position") | |
| duration=int(meta.get("mpris:length",0))//1000 | |
| progress=int(pos)//1000 | |
| state.update({ | |
| "progress":progress, | |
| "ts":time.monotonic(), | |
| "duration":duration, | |
| "playing":status=="Playing" | |
| }) | |
| art=None | |
| url=meta.get("mpris:artUrl") | |
| if url: | |
| url=str(url) | |
| if url.startswith("file://"): | |
| p=url.replace("file://","") | |
| if os.path.exists(p): | |
| with open(p,"rb") as f: art=f.read() | |
| else: | |
| try: art=requests.get(url,timeout=5).content | |
| except: pass | |
| track={ | |
| "title":str(meta.get("xesam:title","Unknown")), | |
| "artists":[str(a) for a in meta.get("xesam:artist",[])], | |
| "album":str(meta.get("xesam:album","Unknown")), | |
| "duration":duration | |
| } | |
| return track,status=="Playing",progress,duration,art | |
| except: | |
| return None | |
| def interpolated_progress(): | |
| if not state["playing"]: | |
| return state["progress"] | |
| e=(time.monotonic()-state["ts"])*1000 | |
| if state["duration"]: | |
| return min(state["progress"]+e,state["duration"]) | |
| return state["progress"]+e | |
| def dominant_color(b): | |
| try: | |
| i=Image.open(io.BytesIO(b)).convert("RGB").resize((50,50)) | |
| c=max(i.getcolors(2500),key=lambda x:x[0])[1] | |
| return tuple(int(v*0.4) for v in c) | |
| except: | |
| return (0,0,0) | |
| def rounded_album(b,size=180,r=10): | |
| try: | |
| a=Image.open(io.BytesIO(b)).convert("RGB").resize((size,size),Image.LANCZOS) | |
| m=Image.new("L",(size,size),0) | |
| ImageDraw.Draw(m).rounded_rectangle([0,0,size,size],r,fill=255) | |
| a.putalpha(m) | |
| bg=Image.new("RGB",(size,size),(0,0,0)) | |
| bg.paste(a,(0,0),a) | |
| return bg | |
| except: | |
| return None | |
| def format_time(ms): | |
| s=int(ms//1000) | |
| h=s//3600 | |
| m=(s%3600)//60 | |
| sec=s%60 | |
| return f"{h}:{m:02d}:{sec:02d}" if h>0 else f"{m}:{sec:02d}" | |
| def text_border(d,pos,t,f,c): | |
| x,y=pos | |
| for dx in(-1,0,1): | |
| for dy in(-1,0,1): | |
| if dx or dy: | |
| d.text((x+dx,y+dy),t,font=f,fill=(0,0,0)) | |
| d.text(pos,t,font=f,fill=c) | |
| def play_icon(d,x,y,s=18): | |
| d.polygon([(x,y),(x,y+s),(x+s,y+s//2)],fill=(255,255,255)) | |
| def pause_icon(d,x,y,s=18): | |
| b=s//3 | |
| d.rectangle([x,y,x+b,y+s],fill=(255,255,255)) | |
| d.rectangle([x+b*2,y,x+b*3,y+s],fill=(255,255,255)) | |
| scroll={} | |
| def scroll_state(k,t): | |
| if k not in scroll or scroll[k]["t"]!=t: | |
| scroll[k]={"t":t,"o":0,"d":False} | |
| return scroll[k] | |
| def draw_scroll(base,d,pos,t,f,c,w,s): | |
| tw=d.textlength(t,font=f) | |
| if tw<=w: | |
| text_border(d,pos,t,f,c) | |
| return | |
| if s["d"]: | |
| tr=t | |
| while d.textlength(tr+"…",font=f)>w: | |
| tr=tr[:-1] | |
| text_border(d,pos,tr+"…",f,c) | |
| return | |
| o=s["o"] | |
| img=Image.new("RGBA",(int(tw+100),80),(0,0,0,0)) | |
| td=ImageDraw.Draw(img) | |
| text_border(td,(0,0),t,f,c) | |
| if o>=tw-w: | |
| s["d"]=True | |
| tr=t | |
| while d.textlength(tr+"…",font=f)>w: | |
| tr=tr[:-1] | |
| text_border(d,pos,tr+"…",f,c) | |
| return | |
| crop=img.crop((o,0,o+w,80)) | |
| base.paste(crop,pos,crop) | |
| s["o"]+=SCROLL_SPEED_PX | |
| def render(track,playing,prog,dur,art,out): | |
| bg=dominant_color(art) if art else (0,0,0) | |
| img=Image.new("RGB",(DISPLAY_WIDTH,DISPLAY_HEIGHT),bg) | |
| d=ImageDraw.Draw(img) | |
| f_title=get_font(28,True) | |
| f_artist=get_font(24) | |
| f_album=get_font(22) | |
| f_time=get_font(20) | |
| alb=rounded_album(art) | |
| if alb: | |
| img.paste(alb,(20,(DISPLAY_HEIGHT-alb.height)//2)) | |
| tx=220 | |
| mw=DISPLAY_WIDTH-tx-20 | |
| st=scroll_state("t",track["title"]) | |
| sa=scroll_state("a",", ".join(track["artists"])) | |
| sl=scroll_state("l",track["album"]) | |
| draw_scroll(img,d,(tx,50),track["title"],f_title,(255,255,255),mw,st) | |
| draw_scroll(img,d,(tx,95),", ".join(track["artists"]),f_artist,(235,235,235),mw,sa) | |
| draw_scroll(img,d,(tx,135),track["album"],f_album,(215,215,215),mw,sl) | |
| bw=DISPLAY_WIDTH-tx-40 | |
| bx=tx | |
| by=200 | |
| bh=12 | |
| d.rounded_rectangle([bx,by,bx+bw,by+bh],6,fill=(90,90,90),outline=(0,0,0),width=2) | |
| if dur: | |
| pct=max(0,min(1,prog/dur)) | |
| d.rounded_rectangle([bx,by,bx+int(bw*pct),by+bh],6,fill=(255,255,255),outline=(0,0,0),width=2) | |
| time_txt=f"{format_time(prog)} / {format_time(dur)}" | |
| tw=d.textlength(time_txt,font=f_time) | |
| icon=18 | |
| gap=20 | |
| total=icon+gap+tw | |
| cx=bx+(bw//2) | |
| sx=cx-(total//2) | |
| y=by+30 | |
| play_icon(d,sx,y,icon) if playing else pause_icon(d,sx,y,icon) | |
| text_border(d,(sx+icon+gap,y-2),time_txt,f_time,(255,255,255)) | |
| img.save(out) | |
| while True: | |
| data=fetch_now_playing() | |
| if not data: | |
| time.sleep(POLL_INTERVAL) | |
| continue | |
| track,playing,prog,dur,art=data | |
| prog=interpolated_progress() | |
| tmp=os.path.join(tempfile.gettempdir(),f"turing_{uuid.uuid4().hex}.png") | |
| try: | |
| render(track,playing,prog,dur,art,tmp) | |
| lcd.DisplayBitmap(tmp) | |
| finally: | |
| if os.path.exists(tmp): | |
| os.remove(tmp) | |
| time.sleep(POLL_INTERVAL) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment