add scrolling text and change ui layout

This commit is contained in:
William Bell
2025-12-19 04:23:28 +00:00
parent 27e4c13b7e
commit d069150c54
3 changed files with 434 additions and 309 deletions

475
app.py
View File

@@ -1,11 +1,12 @@
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 from gapless_player import GaplessPlayer, song_data_to_Song, server, client, Song
from scrolling_text import ScrollingText
# --- Configuration Constants --- # --- Configuration Constants ---
INITIAL_SCREEN_WIDTH = 800 INITIAL_SCREEN_WIDTH = 240
INITIAL_SCREEN_HEIGHT = 600 INITIAL_SCREEN_HEIGHT = 240
TARGET_FPS = 60 TARGET_FPS = 60
# --- State Variables --- # --- State Variables ---
@@ -16,15 +17,16 @@ state = {
"total_time": 300.0, "total_time": 300.0,
"is_playing": True, "is_playing": True,
# 3D Camera State # 3D Camera State
"camera": None, "camera": None,
"render_texture": None, "render_texture": None,
# Assets # Assets
"album_texture": None, "album_texture": None,
"album_model": None "album_model": None,
} }
# --- Utility Functions --- # --- Utility Functions ---
def format_time_mm_ss(seconds): def format_time_mm_ss(seconds):
"""Converts a time in seconds to an 'MM:SS' string format.""" """Converts a time in seconds to an 'MM:SS' string format."""
seconds = int(seconds) seconds = int(seconds)
@@ -32,35 +34,15 @@ def format_time_mm_ss(seconds):
seconds_remainder = seconds % 60 seconds_remainder = seconds % 60
return f"{minutes:02d}:{seconds_remainder:02d}" return f"{minutes:02d}:{seconds_remainder:02d}"
# --- Dynamic Layout Functions ---
def get_3d_render_area(screen_width, screen_height):
ASPECT_WIDTH = 2.0
ASPECT_HEIGHT = 1.0
ASPECT_RATIO = ASPECT_WIDTH / ASPECT_HEIGHT
max_available_width = screen_width * 0.7
max_available_height = screen_height * 0.5
if (max_available_width / max_available_height) > ASPECT_RATIO:
height = max_available_height
width = height * ASPECT_RATIO
else:
width = max_available_width
height = width / ASPECT_RATIO
x = (screen_width - width) / 2
y = screen_height * 0.15
return pr.Rectangle(x, y, width, height)
def get_progress_bar_rect(screen_width, screen_height): def get_progress_bar_rect(screen_width, screen_height):
width = screen_width * 0.7 width = screen_width
height = screen_height * 0.03 height = 5
x = (screen_width - width) / 2 x = (screen_width - width) / 2
y = screen_height * 0.75 y = screen_height - height
return pr.Rectangle(x, y, width, height) return pr.Rectangle(x, y, width, height)
def draw_progress_bar(rect, current_time, total_time): def draw_progress_bar(rect, current_time, total_time):
if total_time > 0: if total_time > 0:
progress_ratio = current_time / total_time progress_ratio = current_time / total_time
@@ -69,107 +51,29 @@ def draw_progress_bar(rect, current_time, total_time):
pr.draw_rectangle_rec(rect, pr.Color(100, 100, 100, 255)) pr.draw_rectangle_rec(rect, pr.Color(100, 100, 100, 255))
progress_width = rect.width * progress_ratio progress_width = rect.width * progress_ratio
pr.draw_rectangle(int(rect.x), int(rect.y), int(progress_width), int(rect.height), pr.Color(200, 50, 50, 255)) pr.draw_rectangle(
pr.draw_rectangle_lines_ex(rect, 2, pr.Color(50, 50, 50, 255)) int(rect.x),
int(rect.y),
time_text = f"{format_time_mm_ss(current_time)} / {format_time_mm_ss(total_time)}" int(progress_width),
int(rect.height),
pr.Color(200, 50, 50, 255),
)
# pr.draw_rectangle_lines_ex(rect, 2, pr.Color(50, 50, 50, 255))
time_text = f"{format_time_mm_ss(current_time)} / {format_time_mm_ss(total_time)}"
text_width = pr.measure_text(time_text, int(rect.height * 0.7)) text_width = pr.measure_text(time_text, int(rect.height * 0.7))
pr.draw_text(time_text, # pr.draw_text(
int(rect.x + rect.width / 2 - text_width / 2), # time_text,
int(rect.y + rect.height * 0.15), # int(rect.x + rect.width / 2 - text_width / 2),
int(rect.height * 0.7), # int(rect.y + rect.height * 0.15),
pr.WHITE) # int(rect.height * 0.7),
# pr.WHITE,
# )
# --- ASSET MANAGEMENT ---
def load_album_assets():
"""Loads the texture, creates the 3D model, and applies the flipped texture."""
# 1. Load Image
try:
image = pr.load_image("music/albumcover.png")
except:
print("WARNING: 'music/albumcover.png' not found. Using placeholder.")
image = pr.gen_image_checked(512, 512, 32, 32, pr.DARKGRAY, pr.WHITE)
# --- THE FIX: FLIP THE IMAGE VERTICALLY ---
pr.image_flip_vertical(image)
# 2. Create Texture
texture = pr.load_texture_from_image(image)
pr.unload_image(image)
# 3. Generate Mesh (CD Case)
mesh = pr.gen_mesh_cube(1.5, 1.5, 0.0)
# 4. Load Model
model = pr.load_model_from_mesh(mesh)
# 5. Apply Texture
# We use index 0 for the Albedo/Diffuse map
map_index = 0
# Use MATERIAL_MAP_ALBEDO if the binding is modern enough
if hasattr(pr.MaterialMapIndex, 'MATERIAL_MAP_ALBEDO'):
map_index = pr.MaterialMapIndex.MATERIAL_MAP_ALBEDO
model.materials[0].maps[map_index].texture = texture
return texture, model
# --- CORE 3D RENDERING ---
def setup_3d_environment(render_width, render_height):
camera = pr.Camera3D()
camera.position = pr.Vector3(0.0, -0.35, 4.0) # Moved back slightly to fit the new models
camera.target = pr.Vector3(0.0, 0.0, 0.0)
camera.up = pr.Vector3(0.0, 1.0, 0.0)
camera.fovy = 45.0
camera.projection = pr.CameraProjection.CAMERA_PERSPECTIVE
return camera
def draw_3d_cover_flow(camera, model):
"""
Draws the textured model using the existing Matrix logic.
"""
pr.begin_mode_3d(camera)
# We use pr.WHITE as the tint so the texture shows its original colors.
# If you use pr.RED, the album cover will look red-tinted.
# --------------------------------------------------------
# 2. CURRENT ALBUM (Center)
# --------------------------------------------------------
# Draw model at (0,0,0) with 1.0 scale
pr.rl_push_matrix()
pr.rl_translatef(0.0, -0.0, 1.5) # Spaced out slightly more
pr.rl_rotatef(5.0, 1.0, 0.0, 0.0) # Sharper angle
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.WHITE)
pr.rl_pop_matrix()
for i in range(-5, 0):
pr.rl_push_matrix()
pr.rl_translatef(-1.5+0.15*i, 0.0, 0.5) # Added slight Z offset for depth
pr.rl_rotatef(50.0, 0.0, 1.0, 0.0)
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.WHITE)
pr.rl_pop_matrix()
for i in range(1,6):
pr.rl_push_matrix()
pr.rl_translatef(1.5+0.15*i, 0.0, 0.5)
pr.rl_rotatef(-50.0, 0.0, 1.0, 0.0)
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.WHITE)
pr.rl_pop_matrix()
pr.end_mode_3d()
# --- Main Setup and Loop ---
# Initialization # 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.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)
@@ -196,22 +100,118 @@ print("add queue")
# 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("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"])) # player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("0e8fc5fcf119de0439f5a15a4f255c5c")["Id"], server["AccessToken"], server["UserId"]))
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376"), server)) print(client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376"))
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("58822c0fc47ec63ba798ba4f04ea3cf3"), server)) # player.add_to_queue(
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("6382005f9dbae8d187d80a5cdca3e7a6"), server)) # Song(
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("a5d2453e07a4998ea20e957c44f90be6"), server)) # "music/pink floyd/dark side of the moon/01 Speak to Me.flac",
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("398d481a7b85287ad200578b5ab997b0"), server)) # "Speak to Me",
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("f9f32ca67be7f83139cee3c66e1e4965"), server)) # "The Dark Side Of The Moon",
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("2f651e103b1fd22ea2f202d6f3398b36"), server)) # "music/albumcover.png",
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("164b95968ab1a725fff060fa8c351cc8"), server)) # "Pink Floyd",
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(
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("66559c40d5904944a3f97198d0297894"), server)) song_data_to_Song(
player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("84b75eeb5c8e862d002bae05d2671b1b"), server)) client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f"), 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(
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
)
)
player.add_to_queue(
song_data_to_Song(
client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376"), server
)
)
player.add_to_queue(
song_data_to_Song(
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"])) # 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"]))
# 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('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"]))
@@ -225,17 +225,10 @@ player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("0e8fc5fcf119de04
# player.add_to_queue('music/pink floyd/dark side of the moon/10 Eclipse.flac') # player.add_to_queue('music/pink floyd/dark side of the moon/10 Eclipse.flac')
print("add queue done") print("add queue done")
# Initial setup
render_rect = get_3d_render_area(state["screen_width"], state["screen_height"])
state["render_texture"] = pr.load_render_texture(int(render_rect.width), int(render_rect.height))
state["camera"] = setup_3d_environment(int(render_rect.width), int(render_rect.height))
# LOAD THE ASSETS
state["album_texture"], state["album_model"] = load_album_assets()
current_path = None current_path = None
texture = None texture = None
def load_texture(path): def load_texture(path):
global texture, current_path global texture, current_path
@@ -250,109 +243,145 @@ def load_texture(path):
texture = pr.load_texture(path) texture = pr.load_texture(path)
current_path = path current_path = path
def draw_play_pause_button(pos: pr.Vector2, size: pr.Vector2, is_playing: bool) -> bool:
clicked = False
rect = pr.Rectangle(pos.x, pos.y, size.x, size.y)
# Optional hover background
if pr.check_collision_point_rec(pr.get_mouse_position(), rect):
pr.draw_rectangle_rec(rect, pr.fade(pr.BLACK, 0.4))
if pr.is_mouse_button_pressed(pr.MOUSE_LEFT_BUTTON):
clicked = True
cx = pos.x + size.x / 2
cy = pos.y + size.y / 2
icon_padding = size.x * 0.25
icon_size = size.x - icon_padding * 2
if is_playing:
# PAUSE (two bars centered, same visual weight as play)
bar_width = icon_size * 0.25
bar_height = icon_size
left_x = cx - bar_width - bar_width * 0.4
right_x = cx + bar_width * 0.4
top_y = cy - bar_height / 2
pr.draw_rectangle(
int(left_x),
int(top_y),
int(bar_width),
int(bar_height),
pr.WHITE,
)
pr.draw_rectangle(
int(right_x),
int(top_y),
int(bar_width),
int(bar_height),
pr.WHITE,
)
else:
# PLAY (centered triangle)
p1 = pr.Vector2(cx - icon_size / 2, cy - icon_size / 2)
p2 = pr.Vector2(cx - icon_size / 2, cy + icon_size / 2)
p3 = pr.Vector2(cx + icon_size / 2, cy)
pr.draw_triangle(p1, p2, p3, pr.WHITE)
return clicked
title = ScrollingText(
text="Aphex Twin - Xtal",
font_size=15,
speed=35,
pause_time=1.2,
)
# --- Main Game Loop --- # --- Main Game Loop ---
while not pr.window_should_close(): while not pr.window_should_close():
# 1. Update # 1. Update
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_window_resized():
state["screen_width"] = current_width
state["screen_height"] = current_height
render_rect = get_3d_render_area(current_width, current_height)
pr.unload_render_texture(state["render_texture"])
state["render_texture"] = pr.load_render_texture(int(render_rect.width), int(render_rect.height))
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()
else: else:
player.play() player.play()
if pr.is_key_pressed(pr.KeyboardKey.KEY_LEFT): if pr.is_key_pressed(pr.KeyboardKey.KEY_LEFT):
player.seek(player.position-5) player.seek(player.position - 5)
if pr.is_key_pressed(pr.KeyboardKey.KEY_RIGHT): if pr.is_key_pressed(pr.KeyboardKey.KEY_RIGHT):
player.seek(player.position+5) player.seek(player.position + 5)
# ----------------------------------------------------
# 2. DRAW 3D SCENE
# ----------------------------------------------------
render_rect = get_3d_render_area(current_width, current_height)
pr.begin_texture_mode(state["render_texture"])
pr.clear_background(pr.Color(20, 20, 20, 255))
# Pass the loaded model to the draw function
draw_3d_cover_flow(state["camera"], state["album_model"])
pr.end_texture_mode()
# ----------------------------------------------------
# 3. DRAW 2D GUI
# ----------------------------------------------------
pr.begin_drawing() pr.begin_drawing()
pr.clear_background(pr.Color(40, 40, 40, 255)) pr.clear_background(pr.Color(40, 40, 40, 255))
dt = pr.get_frame_time()
progress_rect = get_progress_bar_rect(current_width, current_height) progress_rect = get_progress_bar_rect(current_width, current_height)
title_size = int(current_height * 0.05)
pr.draw_text("UgPod", int(current_width * 0.05), int(current_height * 0.05), title_size, pr.SKYBLUE)
source_rect = pr.Rectangle(0, 0, state["render_texture"].texture.width, -state["render_texture"].texture.height)
pr.draw_texture_pro(state["render_texture"].texture,
source_rect, render_rect, pr.Vector2(0, 0), 0.0, pr.WHITE)
pr.draw_rectangle_lines_ex(render_rect, 3, pr.WHITE) pr.draw_text(
"UgPod",
int(current_width * 0.05),
int(current_height * 0.05),
int(current_height * 0.05),
pr.SKYBLUE,
)
current_song = player.get_current_song() current_song = player.get_current_song()
load_texture(current_song and current_song.album_cover_path)
max_size = int(current_height * 0.075) max_size = int(current_height * 0.075)
pr.draw_text((current_song and f"{current_song.name} - {current_song.artist_name}") or "",
int(current_width * 0.05+max_size*1.1), int(current_height * 0.8), int(current_height * 0.03), pr.WHITE)
draw_progress_bar(progress_rect, player.position, player.song_to_duration(player.get_current_song())) if current_song:
load_texture(current_song.album_cover_path)
title_size = pr.Vector2(current_width-int(current_width * 0.05 + max_size * 1.1), 30)
pr.draw_text(f"Status: {'Playing' if player.playing else 'Paused'} (SPACE)", title.update(dt,title_size)
int(current_width * 0.05), int(current_height * 0.9), int(current_height * 0.03), pr.WHITE) title.set_text(f"{current_song.name} - {current_song.artist_name}")
title.draw(pr.Vector2(int(current_width * 0.05 + max_size * 1.1),int(current_height * 0.8)),title_size)
# pr.draw_text(
if texture is not None: # ,
# ,
scale = min( # int(current_height * 0.03),
max_size / texture.width, # pr.WHITE,
max_size / texture.height # )
draw_progress_bar(
progress_rect,
player.position,
current_song.duration,
) )
if texture is not None:
scale = min(max_size / texture.width, max_size / texture.height)
dest_rect = pr.Rectangle( dest_rect = pr.Rectangle(
int(current_width * 0.05), int(current_width * 0.05),
int(current_height * 0.8), int(current_height * 0.8),
texture.width * scale, texture.width * scale,
texture.height * scale texture.height * scale,
) )
src_rect = pr.Rectangle(0, 0, texture.width, texture.height) src_rect = pr.Rectangle(0, 0, texture.width, texture.height)
pr.draw_texture_pro( pr.draw_texture_pro(
texture, texture, src_rect, dest_rect, pr.Vector2(0, 0), 0.0, pr.WHITE
src_rect, )
dest_rect,
pr.Vector2(0, 0), pos = pr.Vector2(current_width * 0.5 - current_height * 0.05, current_height * 0.9-progress_rect.height)
0.0, size = pr.Vector2(current_height * 0.1, current_height * 0.1)
pr.WHITE
) if draw_play_pause_button(pos, size, player.playing):
if player.playing:
player.pause()
else:
player.play()
pr.end_drawing() pr.end_drawing()
# Cleanup # Cleanup
if texture is not None: if texture is not None:
pr.unload_texture(texture) pr.unload_texture(texture)
# --- De-initialization --- pr.close_window()
pr.unload_texture(state["album_texture"]) # Unload the texture
pr.unload_model(state["album_model"]) # Unload the model/mesh
pr.unload_render_texture(state["render_texture"])
pr.close_window()

View File

@@ -26,23 +26,25 @@ 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:
url: str url: str
name: str name: str
album_name:str duration: float
album_cover_path:str album_name: str
artist_name:str album_cover_path: str
artist_name: str
class GaplessPlayer: class GaplessPlayer:
def __init__(self, samplerate:int=44100, channels:int=2): def __init__(self, samplerate: int = 44100, channels: int = 2):
self.samplerate = samplerate self.samplerate = samplerate
self.channels = channels self.channels = channels
self.next_preload_state = 0 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
@@ -57,12 +59,14 @@ class GaplessPlayer:
samplerate=self.samplerate, samplerate=self.samplerate,
channels=self.channels, channels=self.channels,
dtype="int16", dtype="int16",
callback=self._callback callback=self._callback,
) )
self.stream.start() self.stream.start()
def get_current_song(self): def get_current_song(self):
if self.current_song_in_list >= 0 and self.current_song_in_list<len(self.song_list): if self.current_song_in_list >= 0 and self.current_song_in_list < len(
self.song_list
):
return self.song_list[self.current_song_in_list] return self.song_list[self.current_song_in_list]
def _open_ffmpeg(self, song, seek=0): def _open_ffmpeg(self, song, seek=0):
@@ -70,16 +74,22 @@ class GaplessPlayer:
[ [
"ffmpeg", "ffmpeg",
# "-re", # "-re",
"-ss", str(seek), "-ss",
"-i", song.url, str(seek),
"-f", "s16le", "-i",
"-ac", str(self.channels), song.url,
"-ar", str(self.samplerate), "-f",
"-loglevel", "verbose", "s16le",
"-" "-ac",
str(self.channels),
"-ar",
str(self.samplerate),
"-loglevel",
"verbose",
"-",
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE stderr=subprocess.PIPE,
) )
# --- make stdout non-blocking --- # --- make stdout non-blocking ---
@@ -88,23 +98,25 @@ class GaplessPlayer:
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
return proc return proc
def seek(self, pos): def seek(self, pos):
with self.lock: with self.lock:
song = self.get_current_song() song = self.get_current_song()
if song: if song:
pos = min(max(0,pos), self.song_to_duration(song)) pos = min(max(0, pos), song.duration)
if song.ffmpeg: if song.ffmpeg:
song.ffmpeg.kill() song.ffmpeg.kill()
song.ffmpeg = self._open_ffmpeg(song, pos) song.ffmpeg = None
if self.playing:
song.ffmpeg = self._open_ffmpeg(song, pos)
self.position = pos self.position = pos
def close(self): def close(self):
self.closed=True self.closed = True
self.stream.close() self.stream.close()
def add_to_queue(self, song:Song):
def add_to_queue(self, song: Song):
song.ffmpeg = None song.ffmpeg = None
song.playback_info = None
song.preload_state = 0 song.preload_state = 0
self.song_list.append(song) self.song_list.append(song)
@@ -129,103 +141,101 @@ class GaplessPlayer:
current_song = self.get_current_song() current_song = self.get_current_song()
if current_song and current_song.ffmpeg: if current_song and current_song.ffmpeg:
current_song.ffmpeg.kill() current_song.ffmpeg.kill()
current_song.ffmpeg = None
# Move next pipeline into active # Move next pipeline into active
self.position = 0.0 self.position = 0.0
self.current_song_in_list+=1 self.current_song_in_list += 1
def get_next_song(self): def get_next_song(self):
if self.current_song_in_list+1 >= 0 and self.current_song_in_list +1 < len(self.song_list): if self.current_song_in_list + 1 >= 0 and self.current_song_in_list + 1 < len(
return self.song_list[self.current_song_in_list +1] self.song_list
):
return self.song_list[self.current_song_in_list + 1]
return None return None
def load_song(self, song:Song):
def forward_song(self):
current_song = self.get_current_song()
if current_song and current_song.ffmpeg:
current_song.ffmpeg.kill()
current_song.ffmpeg = None
if self.current_song_in_list < len(
self.song_list
):
self.current_song_in_list += 1
def load_song(self, song: Song):
if song: if song:
song.playback_info = self.get_stream_info(song.url)
song.ffmpeg = self._open_ffmpeg(song) song.ffmpeg = self._open_ffmpeg(song)
song.preload_state = 2 song.preload_state = 2
def preload_next_threaded(self): def preload_next_threaded(self):
next_song = self.get_next_song() next_song = self.get_next_song()
if not next_song or next_song.preload_state: return if not next_song or next_song.preload_state:
return
next_song.preload_state = 1 next_song.preload_state = 1
threading.Thread(target=self.load_song, args=(next_song,)).start() threading.Thread(target=self.load_song, args=(next_song,)).start()
def get_stream_info(self, url):
"""Return duration in seconds for the track"""
try:
result = subprocess.run(
[
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
url
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
return json.loads(result.stdout)
except Exception as e:
print("ffprobe error:", e)
return None return None
def song_to_duration(self, song):
if not song or not song.playback_info: return 0.0
if "streams" in song.playback_info:
for s in song.playback_info["streams"]:
if "duration" in s:
return float(s["duration"])
if "format" in song.playback_info and "duration" in song.playback_info["format"]:
return float(song.playback_info["format"]["duration"])
return 0.0
def _callback(self, outdata, frames, t, status): def _callback(self, outdata, frames, t, status):
with self.lock: with self.lock:
needed = frames * self.channels * 2 needed = frames * self.channels * 2
data = b'' data = b""
if self.playing: if self.playing:
current_song = self.get_current_song() current_song = self.get_current_song()
if not current_song or current_song.ffmpeg is None: if not current_song or current_song.ffmpeg is None:
next_song = self.get_next_song() next_song = self.get_next_song()
if next_song: if next_song:
if next_song.preload_state==2: if next_song.preload_state == 2:
self._start_next() self._start_next()
elif next_song.preload_state == 0: elif next_song.preload_state == 0:
self.preload_next_threaded() self.preload_next_threaded()
elif current_song: elif current_song:
try: try:
data = current_song.ffmpeg.stdout.read(needed) or b'' data = current_song.ffmpeg.stdout.read(needed) or b""
except BlockingIOError: except BlockingIOError:
pass pass
self.position += len(data) / (self.samplerate * self.channels * 2) self.position += len(data) / (self.samplerate * self.channels * 2)
if self.position >= self.song_to_duration(current_song)-10: if self.position >= current_song.duration - 10:
self.preload_next_threaded() self.preload_next_threaded()
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) >= self.song_to_duration(current_song)-0.1: if round(self.position, 2) >= current_song.duration - 0.1:
self._start_next() self._start_next()
current_song = self.get_current_song() current_song = self.get_current_song()
if current_song and current_song.ffmpeg is not None and current_song.ffmpeg.poll() is None: if (
current_song
and current_song.ffmpeg is not None
and current_song.ffmpeg.poll() is None
):
try: try:
new_data = current_song.ffmpeg.stdout.read(needed-len(data)) or b'' new_data = (
current_song.ffmpeg.stdout.read(
needed - len(data)
)
or b""
)
except BlockingIOError: except BlockingIOError:
new_data = b'' new_data = b""
self.position += len(new_data) / (self.samplerate * self.channels * 2) self.position += len(new_data) / (
self.samplerate * self.channels * 2
)
data += new_data data += new_data
else: else:
current_song.ffmpeg = self._open_ffmpeg(current_song, self.position) current_song.ffmpeg = self._open_ffmpeg(
current_song, self.position
)
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 song_data_to_Song( album_covers = []
data,
client_data
) -> Song: def song_data_to_Song(data, client_data) -> Song:
# """ # """
# Build a Jellyfin audio stream URL using urllib.parse. # Build a Jellyfin audio stream URL using urllib.parse.
# """ # """
@@ -234,14 +244,16 @@ def song_data_to_Song(
params = { params = {
"UserId": client_data["UserId"], "UserId": client_data["UserId"],
"Container": "flac", "Container": "flac",
"AudioCodec": "flac", # <-- IMPORTANT "AudioCodec": "flac", # <-- IMPORTANT
"api_key": client_data["AccessToken"], "api_key": client_data["AccessToken"],
} }
query = urlencode(params) query = urlencode(params)
url = urljoin(client_data["address"], path) + "?" + query url = urljoin(client_data["address"], path) + "?" + query
album_cover_url = urljoin(client_data["address"], f"/Items/{data["AlbumId"]}/Images/Primary") album_cover_url = urljoin(
client_data["address"], f"/Items/{data["AlbumId"]}/Images/Primary"
)
r = requests.get(album_cover_url) r = requests.get(album_cover_url)
r.raise_for_status() r.raise_for_status()
@@ -253,18 +265,25 @@ def song_data_to_Song(
if ext is None: if ext is None:
ext = ".jpg" # safe fallback for album art ext = ".jpg" # safe fallback for album art
saved_path = Path("data","images", data["AlbumId"] + ext).as_posix() saved_path = Path("data", "images", data["AlbumId"] + ext).as_posix()
with open(saved_path, "wb") as f: with open(saved_path, "wb") as f:
f.write(r.content) f.write(r.content)
return Song(url,data["Name"],data["Album"], saved_path, data["AlbumArtist"]) return Song(
url,
data["Name"],
data["RunTimeTicks"] / 10_000_000,
data["Album"],
saved_path,
data["AlbumArtist"],
)
client = JellyfinClient() client = JellyfinClient()
load_dotenv() load_dotenv()
client.config.app('UgPod', '0.0.1', 'UgPod prototype', 'UgPod_prototype_1') client.config.app("UgPod", "0.0.1", "UgPod prototype", "UgPod_prototype_1")
client.config.data["auth.ssl"] = True client.config.data["auth.ssl"] = True
client.auth.connect_to_address(os.getenv("host")) client.auth.connect_to_address(os.getenv("host"))
@@ -275,8 +294,7 @@ server = credentials["Servers"][0]
print(json.dumps(server)) print(json.dumps(server))
# while True: # while True:
# duration = player.playback_info_to_duration(player.playback_info) # duration = player.playback_info_to_duration(player.playback_info)
# print("pos:", str(round((player.position*100)/(duration or 1.0)))+"%", player.position, '/', duration) # print("pos:", str(round((player.position*100)/(duration or 1.0)))+"%", player.position, '/', duration)
# time.sleep(1) # time.sleep(1)

78
scrolling_text.py Normal file
View File

@@ -0,0 +1,78 @@
import pyray as pr
class ScrollingText:
def __init__(
self,
text: str,
font_size: int,
speed: float = 40.0, # pixels per second
pause_time: float = 1.0, # seconds before scrolling
color=pr.WHITE,
):
self.font_size = font_size
self.speed = speed
self.pause_time = pause_time
self.color = color
self.text = None
self.set_text(text)
def set_text(self, text: str):
if text == self.text:
return
self.text = text
self.text_width = pr.measure_text(self.text, self.font_size)
self.reset()
def reset(self):
self.offset = 0.0
self.timer = 0.0
self.scrolling = False
def update(self, dt: float, size: pr.Vector2):
if self.text_width <= size.x:
return
self.timer += dt
if not self.scrolling:
if self.timer >= self.pause_time:
self.scrolling = True
self.timer = 0.0
else:
self.offset += self.speed * dt
if self.offset >= self.text_width + 20:
self.reset()
def draw(self, pos: pr.Vector2, size: pr.Vector2):
clip = pr.Rectangle(pos.x, pos.y, size.x, size.y)
pr.begin_scissor_mode(
int(clip.x),
int(clip.y),
int(clip.width),
int(clip.height),
)
y = pos.y + (size.y - self.font_size) / 2
pr.draw_text(
self.text,
int(pos.x - self.offset),
int(y),
self.font_size,
self.color,
)
# Second copy for seamless loop
if self.text_width > size.x:
pr.draw_text(
self.text,
int(pos.x - self.offset + self.text_width + 20),
int(y),
self.font_size,
self.color,
)
pr.end_scissor_mode()