import pyray as pr import math from ctypes import c_float from gapless_player import GaplessPlayer, Song, song_data_to_Song from scrolling_text import ScrollingText import numpy as np import threading import time import os from jelly import server, client # --- Configuration Constants --- INITIAL_SCREEN_WIDTH = 240 INITIAL_SCREEN_HEIGHT = 240 TARGET_FPS =60 # --- State Variables --- state = { "screen_width": INITIAL_SCREEN_WIDTH, "screen_height": INITIAL_SCREEN_HEIGHT, } # --- Utility Functions --- def format_time_mm_ss(seconds): """Converts a time in seconds to an 'MM:SS' string format.""" seconds = int(seconds) minutes = seconds // 60 seconds_remainder = seconds % 60 return f"{minutes:02d}:{seconds_remainder:02d}" def get_progress_bar_rect(screen_width, screen_height): width = screen_width height = screen_height*0.021 x = (screen_width - width) / 2 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 else: progress_ratio = 0.0 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)+1, 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.set_config_flags(pr.ConfigFlags.FLAG_WINDOW_RESIZABLE) 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.set_target_fps(TARGET_FPS) player = GaplessPlayer() print("add queue") # player.add_to_queue( # Song( # "/mnt/HDD/Downloads/72 Pantera.wav", # "Bullseye", # 1, # "KDrew", # "", # "KDrew", # ) # ) albums = client.jellyfin.user_items( params={ "IncludeItemTypes": "MusicAlbum", "SearchTerm": "Dawn FM", # album name "Recursive": True, }, ) album = albums["Items"][0] # pick the album you want album_id = album["Id"] tracks = client.jellyfin.user_items( params={ "ParentId": album_id, "IncludeItemTypes": "Audio", "SortBy": "IndexNumber", "SortOrder": "Ascending", }, ) for track in tracks["Items"]: player.add_to_queue( song_data_to_Song( track, server ) ) 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 texture = None def load_texture(path): global texture, current_path if not path: return if path == current_path: return if texture is not None: pr.unload_texture(texture) 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 = 1+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( "", 15 ) # --- 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_key_pressed(pr.KEY_F11): pr.toggle_fullscreen() 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) if pr.is_key_pressed(pr.KeyboardKey.KEY_RIGHT): player.seek(player.position + 5) 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) # 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() draw_progress_bar( progress_rect, player.position, (current_song and current_song.duration) or 0.0, ) if current_song: load_texture(current_song.album_cover_path) 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.set_text(f"{current_song.name} - {current_song.artist_name}", title_font_size) title.draw(pr.Vector2(int(current_height * 0.01),int(current_height * 0.8)),title_size) # pr.draw_text( # , # , # int(current_height * 0.03), # pr.WHITE, # ) points = player.oscilloscope_data_points if texture is not None: scale = min(album_cover_size / texture.width, album_cover_size / texture.height) dest_rect = pr.Rectangle( current_width//2 - album_cover_size//2, (current_height*0.8)//2 - album_cover_size//2, texture.width * scale, texture.height * scale, ) 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 ) 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) 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) pr.close_window() close_event.set() # save_state_thread.join()