change the layout and fix f string bug on raspberry pi python 3

This commit is contained in:
William Bell
2025-12-26 19:47:30 +00:00
parent d069150c54
commit d86587e526
6 changed files with 371 additions and 253 deletions

392
app.py
View File

@@ -1,27 +1,23 @@
import pyray as pr import pyray as pr
import math import math
from ctypes import c_float from ctypes import c_float
from gapless_player import GaplessPlayer, song_data_to_Song, server, client, Song from gapless_player import GaplessPlayer, Song, song_data_to_Song
from scrolling_text import ScrollingText from scrolling_text import ScrollingText
import numpy as np
import threading
import time
import os
from jelly import server, client
# --- Configuration Constants --- # --- Configuration Constants ---
INITIAL_SCREEN_WIDTH = 240 INITIAL_SCREEN_WIDTH = 240
INITIAL_SCREEN_HEIGHT = 240 INITIAL_SCREEN_HEIGHT = 240
TARGET_FPS = 60 TARGET_FPS =60
# --- State Variables --- # --- State Variables ---
state = { state = {
"screen_width": INITIAL_SCREEN_WIDTH, "screen_width": INITIAL_SCREEN_WIDTH,
"screen_height": INITIAL_SCREEN_HEIGHT, "screen_height": INITIAL_SCREEN_HEIGHT,
"current_time": 120.0,
"total_time": 300.0,
"is_playing": True,
# 3D Camera State
"camera": None,
"render_texture": None,
# Assets
"album_texture": None,
"album_model": None,
} }
# --- Utility Functions --- # --- Utility Functions ---
@@ -37,7 +33,7 @@ def format_time_mm_ss(seconds):
def get_progress_bar_rect(screen_width, screen_height): def get_progress_bar_rect(screen_width, screen_height):
width = screen_width width = screen_width
height = 5 height = screen_height*0.021
x = (screen_width - width) / 2 x = (screen_width - width) / 2
y = screen_height - height y = screen_height - height
return pr.Rectangle(x, y, width, height) return pr.Rectangle(x, y, width, height)
@@ -53,7 +49,7 @@ def draw_progress_bar(rect, current_time, total_time):
progress_width = rect.width * progress_ratio progress_width = rect.width * progress_ratio
pr.draw_rectangle( pr.draw_rectangle(
int(rect.x), int(rect.x),
int(rect.y), int(rect.y)+1,
int(progress_width), int(progress_width),
int(rect.height), int(rect.height),
pr.Color(200, 50, 50, 255), pr.Color(200, 50, 50, 255),
@@ -70,10 +66,9 @@ def draw_progress_bar(rect, current_time, total_time):
# pr.WHITE, # pr.WHITE,
# ) # )
# Initialization
pr.set_config_flags(pr.ConfigFlags.FLAG_WINDOW_RESIZABLE) pr.set_config_flags(pr.ConfigFlags.FLAG_WINDOW_RESIZABLE)
# pr.set_config_flags(pr.FLAG_MSAA_4X_HINT) pr.set_config_flags(pr.FLAG_MSAA_4X_HINT)
#pr.set_config_flags(pr.FLAG_FULLSCREEN_MODE)
pr.init_window(state["screen_width"], state["screen_height"], "UgPod") pr.init_window(state["screen_width"], state["screen_height"], "UgPod")
pr.set_target_fps(TARGET_FPS) pr.set_target_fps(TARGET_FPS)
@@ -81,149 +76,54 @@ player = GaplessPlayer()
print("add queue") print("add queue")
# player.add_to_queue(build_jellyfin_audio_url(server["address"], , server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("58822c0fc47ec63ba798ba4f04ea3cf3")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("6382005f9dbae8d187d80a5cdca3e7a6")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("a5d2453e07a4998ea20e957c44f90be6")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("398d481a7b85287ad200578b5ab997b0")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("f9f32ca67be7f83139cee3c66e1e4965")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("2f651e103b1fd22ea2f202d6f3398b36")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("164b95968ab1a725fff060fa8c351cc8")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("38a6c21561f54d284a6acad89a3ea8b0")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("631aeddb0557fef65f49463abb20ad7f")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("3d611c8664c5b2072edbf46da2a76c89")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("66559c40d5904944a3f97198d0297894")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("84b75eeb5c8e862d002bae05d2671b1b")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("7ef66992426093252696e1d8666a22e4")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("f37982227942d3df031381e653ec5790")["Id"], server["AccessToken"], server["UserId"]))
# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("0e8fc5fcf119de0439f5a15a4f255c5c")["Id"], server["AccessToken"], server["UserId"]))
print(client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376"))
# player.add_to_queue( # player.add_to_queue(
# Song( # Song(
# "music/pink floyd/dark side of the moon/01 Speak to Me.flac", # "/mnt/HDD/Downloads/72 Pantera.wav",
# "Speak to Me", # "Bullseye",
# "The Dark Side Of The Moon", # 1,
# "music/albumcover.png", # "KDrew",
# "Pink Floyd", # "",
# "KDrew",
# ) # )
# ) # )
player.add_to_queue(
song_data_to_Song( albums = client.jellyfin.user_items(
client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f"), server params={
) "IncludeItemTypes": "MusicAlbum",
) "SearchTerm": "Dawn FM", # album name
player.add_to_queue( "Recursive": True,
song_data_to_Song( },
client.jellyfin.get_item("916eda422f48efd8705f29e0600a3e60"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("5e1067d59ed98979ad12a58548b27b83"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("8bcf8240d12aa5c3b14dc3b57f32fef7"), server
)
) )
album = albums["Items"][0] # pick the album you want
album_id = album["Id"]
player.add_to_queue(
song_data_to_Song( tracks = client.jellyfin.user_items(
client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376"), server params={
) "ParentId": album_id,
) "IncludeItemTypes": "Audio",
player.add_to_queue( "SortBy": "IndexNumber",
song_data_to_Song( "SortOrder": "Ascending",
client.jellyfin.get_item("58822c0fc47ec63ba798ba4f04ea3cf3"), server },
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("6382005f9dbae8d187d80a5cdca3e7a6"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("a5d2453e07a4998ea20e957c44f90be6"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("398d481a7b85287ad200578b5ab997b0"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("f9f32ca67be7f83139cee3c66e1e4965"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("2f651e103b1fd22ea2f202d6f3398b36"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("164b95968ab1a725fff060fa8c351cc8"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("38a6c21561f54d284a6acad89a3ea8b0"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("631aeddb0557fef65f49463abb20ad7f"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("3d611c8664c5b2072edbf46da2a76c89"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("66559c40d5904944a3f97198d0297894"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("84b75eeb5c8e862d002bae05d2671b1b"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("7ef66992426093252696e1d8666a22e4"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("f37982227942d3df031381e653ec5790"), server
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("0e8fc5fcf119de0439f5a15a4f255c5c"), server
)
) )
# player.add_to_queue('music/pink floyd/dark side of the moon/01 Speak to Me.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f")["Id"], server["AccessToken"], server["UserId"])) for track in tracks["Items"]:
# player.add_to_queue('music/pink floyd/dark side of the moon/02 Breathe (In the Air).flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("916eda422f48efd8705f29e0600a3e60")["Id"], server["AccessToken"], server["UserId"])) player.add_to_queue(
# player.add_to_queue('music/pink floyd/dark side of the moon/03 On the Run.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("5e1067d59ed98979ad12a58548b27b83")["Id"], server["AccessToken"], server["UserId"])) song_data_to_Song(
# player.add_to_queue('music/pink floyd/dark side of the moon/04 Time.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("8bcf8240d12aa5c3b14dc3b57f32fef7")["Id"], server["AccessToken"], server["UserId"])) track, server
# player.add_to_queue('music/pink floyd/dark side of the moon/05 The Great Gig in the Sky.flac') )
# player.add_to_queue('music/pink floyd/dark side of the moon/06 Money.flac') )
# player.add_to_queue('music/pink floyd/dark side of the moon/07 Us and Them.flac')
# player.add_to_queue('music/pink floyd/dark side of the moon/08 Any Colour You Like.flac')
# player.add_to_queue('music/pink floyd/dark side of the moon/09 Brain Damage.flac')
# player.add_to_queue('music/pink floyd/dark side of the moon/10 Eclipse.flac')
print("add queue done") print("add queue done")
player.load_state("data/player.json")
close_event = threading.Event()
def save_state_loop():
while not close_event.wait(10):
player.save_state("data/player.lock.json")
os.rename("data/player.lock.json", "data/player.json")
# save_state_thread = threading.Thread(target=save_state_loop)
# save_state_thread.start()
current_path = None current_path = None
texture = None texture = None
@@ -255,7 +155,6 @@ def draw_play_pause_button(pos: pr.Vector2, size: pr.Vector2, is_playing: bool)
pr.draw_rectangle_rec(rect, pr.fade(pr.BLACK, 0.4)) pr.draw_rectangle_rec(rect, pr.fade(pr.BLACK, 0.4))
if pr.is_mouse_button_pressed(pr.MOUSE_LEFT_BUTTON): if pr.is_mouse_button_pressed(pr.MOUSE_LEFT_BUTTON):
clicked = True clicked = True
cx = pos.x + size.x / 2 cx = pos.x + size.x / 2
cy = pos.y + size.y / 2 cy = pos.y + size.y / 2
@@ -269,7 +168,7 @@ def draw_play_pause_button(pos: pr.Vector2, size: pr.Vector2, is_playing: bool)
left_x = cx - bar_width - bar_width * 0.4 left_x = cx - bar_width - bar_width * 0.4
right_x = cx + bar_width * 0.4 right_x = cx + bar_width * 0.4
top_y = cy - bar_height / 2 top_y = 1+cy - bar_height / 2
pr.draw_rectangle( pr.draw_rectangle(
int(left_x), int(left_x),
@@ -297,10 +196,8 @@ def draw_play_pause_button(pos: pr.Vector2, size: pr.Vector2, is_playing: bool)
title = ScrollingText( title = ScrollingText(
text="Aphex Twin - Xtal", "",
font_size=15, 15
speed=35,
pause_time=1.2,
) )
# --- Main Game Loop --- # --- Main Game Loop ---
@@ -309,6 +206,8 @@ while not pr.window_should_close():
current_width = pr.get_screen_width() current_width = pr.get_screen_width()
current_height = pr.get_screen_height() current_height = pr.get_screen_height()
if pr.is_key_pressed(pr.KEY_F11):
pr.toggle_fullscreen()
if pr.is_key_pressed(pr.KeyboardKey.KEY_SPACE): if pr.is_key_pressed(pr.KeyboardKey.KEY_SPACE):
if player.playing: if player.playing:
player.pause() player.pause()
@@ -325,41 +224,44 @@ while not pr.window_should_close():
progress_rect = get_progress_bar_rect(current_width, current_height) progress_rect = get_progress_bar_rect(current_width, current_height)
pr.draw_text( # pr.draw_text(
"UgPod", # "UgPod",
int(current_width * 0.05), # int(current_width * 0.05),
int(current_height * 0.05), # int(current_height * 0.05),
int(current_height * 0.05), # int(current_height * 0.05),
pr.SKYBLUE, # pr.SKYBLUE,
) # )
current_song = player.get_current_song() current_song = player.get_current_song()
max_size = int(current_height * 0.075)
draw_progress_bar(
progress_rect,
player.position,
(current_song and current_song.duration) or 0.0,
)
if current_song: if current_song:
load_texture(current_song.album_cover_path) load_texture(current_song.album_cover_path)
title_size = pr.Vector2(current_width-int(current_width * 0.05 + max_size * 1.1), 30) title_font_size = int(current_height*0.05)
album_cover_size = int(min(current_width, current_height*0.7))
title.speed = title_font_size*2.5
title_size = pr.Vector2(current_width-int(current_height * 0.01)*2, title_font_size)
title.update(dt,title_size) title.update(dt,title_size)
title.set_text(f"{current_song.name} - {current_song.artist_name}") title.set_text(f"{current_song.name} - {current_song.artist_name}", title_font_size)
title.draw(pr.Vector2(int(current_width * 0.05 + max_size * 1.1),int(current_height * 0.8)),title_size) title.draw(pr.Vector2(int(current_height * 0.01),int(current_height * 0.8)),title_size)
# pr.draw_text( # pr.draw_text(
# , # ,
# , # ,
# int(current_height * 0.03), # int(current_height * 0.03),
# pr.WHITE, # pr.WHITE,
# ) # )
draw_progress_bar( points = player.oscilloscope_data_points
progress_rect,
player.position,
current_song.duration,
)
if texture is not None: if texture is not None:
scale = min(max_size / texture.width, max_size / texture.height) scale = min(album_cover_size / texture.width, album_cover_size / texture.height)
dest_rect = pr.Rectangle( dest_rect = pr.Rectangle(
int(current_width * 0.05), current_width//2 - album_cover_size//2,
int(current_height * 0.8), (current_height*0.8)//2 - album_cover_size//2,
texture.width * scale, texture.width * scale,
texture.height * scale, texture.height * scale,
) )
@@ -369,6 +271,146 @@ while not pr.window_should_close():
pr.draw_texture_pro( pr.draw_texture_pro(
texture, src_rect, dest_rect, pr.Vector2(0, 0), 0.0, pr.WHITE texture, src_rect, dest_rect, pr.Vector2(0, 0), 0.0, pr.WHITE
) )
else:
clip = pr.Rectangle(int(current_width//2 - album_cover_size//2),
int((current_height*0.8)//2 - album_cover_size//2),
int(album_cover_size),
int(album_cover_size))
pr.begin_scissor_mode(
int(clip.x),
int(clip.y),
int(clip.width),
int(clip.height),
)
pr.draw_rectangle(
int(clip.x),
int(clip.y),
int(clip.width),
int(clip.height), pr.BLACK)
# cx = current_width * 0.5+1
# cy = current_height * 0.4+1
# MAX_LEN = album_cover_size * 0.25 # tune this
# MIN_ALPHA = 10
# MAX_ALPHA = 255
# for i in range(len(points) - 1):
# x1 = cx + points[i][0] * album_cover_size * 0.5
# y1 = cy + -points[i][1] * album_cover_size * 0.5
# x2 = cx + points[i+1][0] * album_cover_size * 0.5
# y2 = cy + -points[i+1][1] * album_cover_size * 0.5
# dx = x2 - x1
# dy = y2 - y1
# length = (dx * dx + dy * dy) ** 0.5
# # 1.0 = short line, 0.0 = long line
# t = max(0.0, min(1.0, 1.0 - (length / MAX_LEN)))*math.pow(i/len(points), 2)
# alpha = int(MIN_ALPHA + t * (MAX_ALPHA - MIN_ALPHA))
# color = pr.Color(255, 255, 255, alpha)
# pr.draw_line(int(x1), int(y1), int(x2), int(y2), color)
# draw background square
if len(points) >= 2:
samples = np.fromiter(
((p[0] + p[1]) * 0.5 for p in points),
dtype=np.float32
)
# Guard: FFT must have meaningful size
if samples.size > 128:
rect_x = int(current_width // 2 - album_cover_size // 2)
rect_y = int((current_height * 0.8) // 2 - album_cover_size // 2)
# ---- FFT ----
FFT_SIZE = min(samples.size, 2048)
window = np.hanning(FFT_SIZE)
fft = np.fft.rfft(samples[:FFT_SIZE] * window)
magnitudes = np.abs(fft)
# remove DC component (important for visuals)
magnitudes[0] = 0.0
# ---- LOG BINNING ----
num_bars = album_cover_size//10
num_bins = magnitudes.size
# logarithmic bin edges (low end stretched)
log_min = 1
log_max = math.log10(num_bins)
log_edges = np.logspace(
math.log10(log_min),
log_max,
num_bars + 1
).astype(int)
bar_values = np.zeros(num_bars, dtype=np.float32)
for i in range(num_bars):
start = log_edges[i]
end = log_edges[i + 1]
if end <= start:
continue
bar_values[i] = np.mean(magnitudes[start:end])
# ---- STATIC SCALING ----
# Instead of normalizing to the max of the frame, we scale by the FFT size.
# For a Hanning windowed FFT, dividing by (FFT_SIZE / 4) maps
# maximum possible volume roughly to 1.0.
bar_values = bar_values / (FFT_SIZE / 4.0)
# ---- DRAW ----
def map_to_screen(val):
return rect_x + (math.log10(max(1, val)) / log_max) * album_cover_size
spacing = 0
for i in range(num_bars):
# 1. Calculate integer pixel boundaries first
# This ensures the right edge of one bar is exactly the left edge of the next
x_start_int = int(map_to_screen(log_edges[i]))
x_end_int = int(map_to_screen(log_edges[i+1]))
# 2. Width is the difference between these fixed integer points
w = (x_end_int - x_start_int) - spacing
value = bar_values[i]
h = int(min(1.0, value) * album_cover_size)
# 3. Anchor to bottom
y = (rect_y + album_cover_size) - h
alpha = min(1.0, ((value+1)**2)-1)
r = 255
g = 0
b = 0
# Keep alpha at 255 (fully opaque)
color = pr.Color(r, g, b, int(255 * alpha))
# 4. Draw the bar
# Use max(1, w) to ensure high-frequency bars don't disappear on small screens
pr.draw_rectangle(
x_start_int,
int(y),
max(1, int(w)),
h,
color
)
pr.end_scissor_mode()
pos = pr.Vector2(current_width * 0.5 - current_height * 0.05, current_height * 0.9-progress_rect.height) pos = pr.Vector2(current_width * 0.5 - current_height * 0.05, current_height * 0.9-progress_rect.height)
size = pr.Vector2(current_height * 0.1, current_height * 0.1) size = pr.Vector2(current_height * 0.1, current_height * 0.1)
@@ -385,3 +427,5 @@ if texture is not None:
pr.unload_texture(texture) pr.unload_texture(texture)
pr.close_window() pr.close_window()
close_event.set()
# save_state_thread.join()

View File

@@ -1,14 +1,11 @@
from jellyfin_apiclient_python import JellyfinClient
import json import json
import uuid import uuid
import subprocess import subprocess
from dotenv import load_dotenv
import os import os
import time import time
import ffmpeg import ffmpeg
import requests import requests
import threading import threading
from urllib.parse import urlencode, urljoin
import subprocess import subprocess
import numpy as np import numpy as np
import sounddevice as sd import sounddevice as sd
@@ -18,17 +15,67 @@ import sys
import io import io
import fcntl import fcntl
from dataclasses import dataclass from dataclasses import dataclass
import numpy as np
from collections import deque
from jelly import server, client
from urllib.parse import urlencode, urljoin
import requests import requests
from pathlib import Path from pathlib import Path
import mimetypes import mimetypes
os.makedirs("logs", exist_ok=True) def song_data_to_Song(data, client_data) -> Song:
# """
# Build a Jellyfin audio stream URL using urllib.parse.
# """
item_id = data["Id"]
path = f"/Audio/{item_id}/universal"
params = {
"UserId": client_data["UserId"],
"Container": "flac",
"AudioCodec": "flac", # <-- IMPORTANT
"api_key": client_data["AccessToken"],
}
query = urlencode(params)
url = urljoin(client_data["address"], path) + "?" + query
album_cover_url = urljoin(
client_data["address"], f"/Items/{data['AlbumId']}/Images/Primary"
)
r = requests.get(album_cover_url)
r.raise_for_status()
content_type = r.headers.get("Content-Type") # e.g. "image/jpeg"
ext = mimetypes.guess_extension(content_type) # ".jpg"
if ext is None:
ext = ".jpg" # safe fallback for album art
saved_path = Path("data", "images", data["AlbumId"] + ext).as_posix()
with open(saved_path, "wb") as f:
f.write(r.content)
return Song(
item_id,
url,
data["Name"],
data["RunTimeTicks"] / 10_000_000,
data["Album"],
saved_path,
data["AlbumArtist"],
)
#os.makedirs("logs", exist_ok=True)
os.makedirs("data", exist_ok=True) os.makedirs("data", exist_ok=True)
os.makedirs("data/images", exist_ok=True) os.makedirs("data/images", exist_ok=True)
@dataclass @dataclass
class Song: class Song:
id: str
url: str url: str
name: str name: str
duration: float duration: float
@@ -38,18 +85,16 @@ class Song:
class GaplessPlayer: class GaplessPlayer:
def __init__(self, samplerate: int = 44100, channels: int = 2): def __init__(self, samplerate: int = 96000, channels: int = 2):
self.samplerate = samplerate self.samplerate = samplerate
self.channels = channels self.channels = channels
self.next_preload_state = 0
self.closed = False self.closed = False
self.playing = False self.playing = False
self.position = 0.0 self.position = 0.0
self.song_list = [] self.song_list: list[Song] = []
self.current_song_in_list = -1 self.current_song_in_list = -1
@@ -62,6 +107,7 @@ class GaplessPlayer:
callback=self._callback, callback=self._callback,
) )
self.stream.start() self.stream.start()
self.oscilloscope_data_points = deque(maxlen=samplerate//60)
def get_current_song(self): def get_current_song(self):
if self.current_song_in_list >= 0 and self.current_song_in_list < len( if self.current_song_in_list >= 0 and self.current_song_in_list < len(
@@ -92,6 +138,8 @@ class GaplessPlayer:
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
print("yo")
# --- make stdout non-blocking --- # --- make stdout non-blocking ---
fd = proc.stdout.fileno() fd = proc.stdout.fileno()
flags = fcntl.fcntl(fd, fcntl.F_GETFL) flags = fcntl.fcntl(fd, fcntl.F_GETFL)
@@ -200,6 +248,13 @@ class GaplessPlayer:
self.position += len(data) / (self.samplerate * self.channels * 2) self.position += len(data) / (self.samplerate * self.channels * 2)
if self.position >= current_song.duration - 10: if self.position >= current_song.duration - 10:
self.preload_next_threaded() self.preload_next_threaded()
else:
next_song = self.get_next_song()
if next_song and next_song.ffmpeg:
if next_song.ffmpeg.poll() is None:
next_song.ffmpeg.kill()
next_song.ffmpeg = None
next_song.preload_state = 0
if current_song.ffmpeg.poll() is not None and len(data) < needed: if current_song.ffmpeg.poll() is not None and len(data) < needed:
if round(self.position, 2) >= current_song.duration - 0.1: if round(self.position, 2) >= current_song.duration - 0.1:
self._start_next() self._start_next()
@@ -223,75 +278,51 @@ class GaplessPlayer:
) )
data += new_data data += new_data
else: else:
# if current_song.ffmpeg and current_song.ffmpeg.poll() is not None:
# current_song.ffmpeg.kill()
# current_song.ffmpeg = None
current_song.ffmpeg = self._open_ffmpeg( current_song.ffmpeg = self._open_ffmpeg(
current_song, self.position current_song, self.position
) )
samples = np.frombuffer(data, dtype=np.int16)
left = samples[0::2]
right = samples[1::2]
norm = 32769.0
x = left / norm
y = right / norm
points = list(zip(x, y))
# step = max(1, len(points) // 1000)
# points = points[::step]
self.oscilloscope_data_points.extend(points)
outdata[: len(data)] = data outdata[: len(data)] = data
outdata[len(data) :] = b"\x00" * (needed - len(data)) outdata[len(data) :] = b"\x00" * (needed - len(data))
def save_state(self, path):
with open(path,"w") as f:
album_covers = [] data = {
"queue": [song.id for song in self.song_list],
"current_song": self.current_song_in_list,
def song_data_to_Song(data, client_data) -> Song: "position": self.position
# """
# Build a Jellyfin audio stream URL using urllib.parse.
# """
item_id = data["Id"]
path = f"/Audio/{item_id}/universal"
params = {
"UserId": client_data["UserId"],
"Container": "flac",
"AudioCodec": "flac", # <-- IMPORTANT
"api_key": client_data["AccessToken"],
} }
json.dump(data, f)
def load_state(self, path):
try:
with open(path,"r") as f:
data = json.load(f)
self.song_list = []
for song in data["queue"]:
songOBJ = song_data_to_Song(client.jellyfin.get_item(song), server)
songOBJ.ffmpeg = None
songOBJ.preload_state = 0
self.song_list.append(songOBJ)
query = urlencode(params) self.current_song_in_list = data['current_song']
url = urljoin(client_data["address"], path) + "?" + query self.seek(data['position'])
except:
return
album_cover_url = urljoin(
client_data["address"], f"/Items/{data["AlbumId"]}/Images/Primary"
)
r = requests.get(album_cover_url)
r.raise_for_status()
content_type = r.headers.get("Content-Type") # e.g. "image/jpeg"
ext = mimetypes.guess_extension(content_type) # ".jpg"
if ext is None:
ext = ".jpg" # safe fallback for album art
saved_path = Path("data", "images", data["AlbumId"] + ext).as_posix()
with open(saved_path, "wb") as f:
f.write(r.content)
return Song(
url,
data["Name"],
data["RunTimeTicks"] / 10_000_000,
data["Album"],
saved_path,
data["AlbumArtist"],
)
client = JellyfinClient()
load_dotenv()
client.config.app("UgPod", "0.0.1", "UgPod prototype", "UgPod_prototype_1")
client.config.data["auth.ssl"] = True
client.auth.connect_to_address(os.getenv("host"))
client.auth.login(os.getenv("URL"), os.getenv("username"), os.getenv("password"))
credentials = client.auth.credentials.get_credentials()
server = credentials["Servers"][0]
print(json.dumps(server))
# while True: # while True:

41
jelly.py Normal file
View File

@@ -0,0 +1,41 @@
from jellyfin_apiclient_python import JellyfinClient
import os
from dotenv import load_dotenv
import json
load_dotenv()
album_covers = {}
client = JellyfinClient()
client.config.app("UgPod", "0.0.1", "UgPod prototype", "UgPod_prototype_1")
client.config.data["auth.ssl"] = True
try:
with open("data/auth.json", "r") as f:
credentials = json.load(f)
client.authenticate(credentials, discover=False)
# 🔴 THIS IS THE MISSING STEP
server = credentials["Servers"][0]
client.config.data["auth.server"] = server["Id"]
client.config.data["auth.servers"] = credentials["Servers"]
client.start()
server = credentials["Servers"][0]
assert server["Address"].startswith("http")
print("Server address:", server["Address"])
print("Server ID:", server["Id"])
except:
print("authenticating")
client.auth.connect_to_address(os.getenv("host"))
client.auth.login(os.getenv("URL"), os.getenv("username"), os.getenv("password"))
credentials = client.auth.credentials.get_credentials()
# with open("data/auth.json", 'w') as f:
# json.dump(credentials, f)
server = credentials["Servers"][0]

View File

@@ -11,6 +11,7 @@ ffmpeg-python==0.2.0
frozenlist==1.8.0 frozenlist==1.8.0
future==1.0.0 future==1.0.0
idna==3.11 idna==3.11
inflection==0.5.1
jellyfin-apiclient-python==1.11.0 jellyfin-apiclient-python==1.11.0
multidict==6.7.0 multidict==6.7.0
numpy==2.3.5 numpy==2.3.5
@@ -18,8 +19,7 @@ propcache==0.4.1
pycparser==2.23 pycparser==2.23
pyee==13.0.0 pyee==13.0.0
python-dotenv==1.2.1 python-dotenv==1.2.1
python-vlc==3.0.21203 raylib_drm==5.5.0.4
raylib==5.5.0.3
requests==2.32.5 requests==2.32.5
sounddevice==0.5.3 sounddevice==0.5.3
typing_extensions==4.15.0 typing_extensions==4.15.0

2
run-fb.sh Executable file
View File

@@ -0,0 +1,2 @@
export LIBGL_ALWAYS_SOFTWARE=1
python3 app.py

View File

@@ -15,13 +15,13 @@ class ScrollingText:
self.pause_time = pause_time self.pause_time = pause_time
self.color = color self.color = color
self.text = None self.text = None
self.set_text(text) self.set_text(text, font_size)
def set_text(self, text: str, font_size: int):
def set_text(self, text: str): if text == self.text and font_size == self.font_size:
if text == self.text:
return return
self.text = text self.text = text
self.font_size = font_size
self.text_width = pr.measure_text(self.text, self.font_size) self.text_width = pr.measure_text(self.text, self.font_size)
self.reset() self.reset()
@@ -32,7 +32,7 @@ class ScrollingText:
def update(self, dt: float, size: pr.Vector2): def update(self, dt: float, size: pr.Vector2):
if self.text_width <= size.x: if self.text_width <= size.x:
return return self.reset()
self.timer += dt self.timer += dt
@@ -43,7 +43,7 @@ class ScrollingText:
else: else:
self.offset += self.speed * dt self.offset += self.speed * dt
if self.offset >= self.text_width + 20: if self.offset >= self.text_width + self.font_size*2.5:
self.reset() self.reset()
def draw(self, pos: pr.Vector2, size: pr.Vector2): def draw(self, pos: pr.Vector2, size: pr.Vector2):
@@ -69,7 +69,7 @@ class ScrollingText:
if self.text_width > size.x: if self.text_width > size.x:
pr.draw_text( pr.draw_text(
self.text, self.text,
int(pos.x - self.offset + self.text_width + 20), int(pos.x - self.offset + self.text_width + self.font_size*2.5),
int(y), int(y),
self.font_size, self.font_size,
self.color, self.color,