From d069150c54b024442e7acd70693ead7c3fff34ae Mon Sep 17 00:00:00 2001 From: William Bell <62452284+Ugric@users.noreply.github.com> Date: Fri, 19 Dec 2025 04:23:28 +0000 Subject: [PATCH] add scrolling text and change ui layout --- app.py | 475 ++++++++++++++++++++++++---------------------- gapless_player.py | 190 ++++++++++--------- scrolling_text.py | 78 ++++++++ 3 files changed, 434 insertions(+), 309 deletions(-) create mode 100644 scrolling_text.py diff --git a/app.py b/app.py index a569769..6139a97 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,12 @@ import pyray as pr import math 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 --- -INITIAL_SCREEN_WIDTH = 800 -INITIAL_SCREEN_HEIGHT = 600 +INITIAL_SCREEN_WIDTH = 240 +INITIAL_SCREEN_HEIGHT = 240 TARGET_FPS = 60 # --- State Variables --- @@ -16,15 +17,16 @@ state = { "total_time": 300.0, "is_playing": True, # 3D Camera State - "camera": None, + "camera": None, "render_texture": None, # Assets "album_texture": None, - "album_model": None + "album_model": None, } # --- Utility Functions --- + def format_time_mm_ss(seconds): """Converts a time in seconds to an 'MM:SS' string format.""" seconds = int(seconds) @@ -32,35 +34,15 @@ def format_time_mm_ss(seconds): seconds_remainder = seconds % 60 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): - width = screen_width * 0.7 - height = screen_height * 0.03 + width = screen_width + height = 5 x = (screen_width - width) / 2 - y = screen_height * 0.75 + y = screen_height - height return pr.Rectangle(x, y, width, height) + def draw_progress_bar(rect, current_time, total_time): if total_time > 0: 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)) 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_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)}" + pr.draw_rectangle( + int(rect.x), + int(rect.y), + 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)) - pr.draw_text(time_text, - int(rect.x + rect.width / 2 - text_width / 2), - int(rect.y + rect.height * 0.15), - int(rect.height * 0.7), - pr.WHITE) + # pr.draw_text( + # time_text, + # int(rect.x + rect.width / 2 - text_width / 2), + # int(rect.y + rect.height * 0.15), + # 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 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.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("0e8fc5fcf119de0439f5a15a4f255c5c")["Id"], server["AccessToken"], server["UserId"])) -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)) +print(client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376")) +# player.add_to_queue( +# Song( +# "music/pink floyd/dark side of the moon/01 Speak to Me.flac", +# "Speak to Me", +# "The Dark Side Of The Moon", +# "music/albumcover.png", +# "Pink Floyd", +# ) +# ) +player.add_to_queue( + song_data_to_Song( + client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f"), 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/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') 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 texture = None + def load_texture(path): global texture, current_path @@ -250,109 +243,145 @@ def load_texture(path): texture = pr.load_texture(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 --- while not pr.window_should_close(): # 1. Update current_width = pr.get_screen_width() 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 player.playing: player.pause() else: player.play() - 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): - 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"]) + player.seek(player.position + 5) - pr.end_texture_mode() - - # ---------------------------------------------------- - # 3. DRAW 2D GUI - # ---------------------------------------------------- pr.begin_drawing() pr.clear_background(pr.Color(40, 40, 40, 255)) + dt = pr.get_frame_time() 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() - load_texture(current_song and current_song.album_cover_path) - 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())) - - - pr.draw_text(f"Status: {'Playing' if player.playing else 'Paused'} (SPACE)", - int(current_width * 0.05), int(current_height * 0.9), int(current_height * 0.03), pr.WHITE) - - - if texture is not None: - - scale = min( - max_size / texture.width, - max_size / texture.height + 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) + title.update(dt,title_size) + 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( + # , + # , + # int(current_height * 0.03), + # pr.WHITE, + # ) + 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( - int(current_width * 0.05), - int(current_height * 0.8), - texture.width * scale, - texture.height * scale - ) + dest_rect = pr.Rectangle( + int(current_width * 0.05), + int(current_height * 0.8), + texture.width * 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( - texture, - src_rect, - dest_rect, - pr.Vector2(0, 0), - 0.0, - pr.WHITE - ) + pr.draw_texture_pro( + texture, src_rect, dest_rect, pr.Vector2(0, 0), 0.0, pr.WHITE + ) + + 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) + + if draw_play_pause_button(pos, size, player.playing): + if player.playing: + player.pause() + else: + player.play() pr.end_drawing() # Cleanup if texture is not None: pr.unload_texture(texture) -# --- De-initialization --- -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() \ No newline at end of file +pr.close_window() diff --git a/gapless_player.py b/gapless_player.py index 0b1c371..3143d40 100644 --- a/gapless_player.py +++ b/gapless_player.py @@ -26,23 +26,25 @@ os.makedirs("logs", exist_ok=True) os.makedirs("data", exist_ok=True) os.makedirs("data/images", exist_ok=True) + @dataclass class Song: url: str name: str - album_name:str - album_cover_path:str - artist_name:str + duration: float + album_name: str + album_cover_path: str + artist_name: str class GaplessPlayer: - def __init__(self, samplerate:int=44100, channels:int=2): + def __init__(self, samplerate: int = 44100, channels: int = 2): self.samplerate = samplerate self.channels = channels self.next_preload_state = 0 - self.closed=False + self.closed = False self.playing = False self.position = 0.0 @@ -57,12 +59,14 @@ class GaplessPlayer: samplerate=self.samplerate, channels=self.channels, dtype="int16", - callback=self._callback + callback=self._callback, ) self.stream.start() - + def get_current_song(self): - if self.current_song_in_list >= 0 and 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] def _open_ffmpeg(self, song, seek=0): @@ -70,16 +74,22 @@ class GaplessPlayer: [ "ffmpeg", # "-re", - "-ss", str(seek), - "-i", song.url, - "-f", "s16le", - "-ac", str(self.channels), - "-ar", str(self.samplerate), - "-loglevel", "verbose", - "-" + "-ss", + str(seek), + "-i", + song.url, + "-f", + "s16le", + "-ac", + str(self.channels), + "-ar", + str(self.samplerate), + "-loglevel", + "verbose", + "-", ], stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stderr=subprocess.PIPE, ) # --- make stdout non-blocking --- @@ -88,23 +98,25 @@ class GaplessPlayer: fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) return proc - + def seek(self, pos): with self.lock: song = self.get_current_song() if song: - pos = min(max(0,pos), self.song_to_duration(song)) + pos = min(max(0, pos), song.duration) if song.ffmpeg: 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 - + def close(self): - self.closed=True + self.closed = True self.stream.close() - def add_to_queue(self, song:Song): + + def add_to_queue(self, song: Song): song.ffmpeg = None - song.playback_info = None song.preload_state = 0 self.song_list.append(song) @@ -129,103 +141,101 @@ class GaplessPlayer: current_song = self.get_current_song() if current_song and current_song.ffmpeg: current_song.ffmpeg.kill() - + current_song.ffmpeg = None + # Move next pipeline into active self.position = 0.0 - self.current_song_in_list+=1 + self.current_song_in_list += 1 + def get_next_song(self): - if self.current_song_in_list+1 >= 0 and self.current_song_in_list +1 < len(self.song_list): - return self.song_list[self.current_song_in_list +1] + if self.current_song_in_list + 1 >= 0 and self.current_song_in_list + 1 < len( + self.song_list + ): + return self.song_list[self.current_song_in_list + 1] 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: - song.playback_info = self.get_stream_info(song.url) song.ffmpeg = self._open_ffmpeg(song) song.preload_state = 2 def preload_next_threaded(self): 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 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 - 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): with self.lock: needed = frames * self.channels * 2 - data = b'' + data = b"" if self.playing: current_song = self.get_current_song() if not current_song or current_song.ffmpeg is None: next_song = self.get_next_song() if next_song: - if next_song.preload_state==2: + if next_song.preload_state == 2: self._start_next() elif next_song.preload_state == 0: self.preload_next_threaded() elif current_song: try: - data = current_song.ffmpeg.stdout.read(needed) or b'' + data = current_song.ffmpeg.stdout.read(needed) or b"" except BlockingIOError: pass 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() - if current_song.ffmpeg.poll() is not None and len(data)= self.song_to_duration(current_song)-0.1: + if current_song.ffmpeg.poll() is not None and len(data) < needed: + if round(self.position, 2) >= current_song.duration - 0.1: self._start_next() 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: - 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: - new_data = b'' - self.position += len(new_data) / (self.samplerate * self.channels * 2) + new_data = b"" + self.position += len(new_data) / ( + self.samplerate * self.channels * 2 + ) data += new_data 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( - data, - client_data -) -> Song: +album_covers = [] + + +def song_data_to_Song(data, client_data) -> Song: # """ # Build a Jellyfin audio stream URL using urllib.parse. # """ @@ -234,14 +244,16 @@ def song_data_to_Song( params = { "UserId": client_data["UserId"], "Container": "flac", - "AudioCodec": "flac", # <-- IMPORTANT + "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") + + album_cover_url = urljoin( + client_data["address"], f"/Items/{data["AlbumId"]}/Images/Primary" + ) r = requests.get(album_cover_url) r.raise_for_status() @@ -253,18 +265,25 @@ def song_data_to_Song( if ext is None: 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: 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() 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.auth.connect_to_address(os.getenv("host")) @@ -275,8 +294,7 @@ server = credentials["Servers"][0] print(json.dumps(server)) - # while True: # duration = player.playback_info_to_duration(player.playback_info) # print("pos:", str(round((player.position*100)/(duration or 1.0)))+"%", player.position, '/', duration) -# time.sleep(1) \ No newline at end of file +# time.sleep(1) diff --git a/scrolling_text.py b/scrolling_text.py new file mode 100644 index 0000000..81e94e1 --- /dev/null +++ b/scrolling_text.py @@ -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()