Merge branch 'main' of https://git.wbell.dev/Ugric/FinPod
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -175,4 +175,5 @@ cython_debug/
|
|||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
music
|
music
|
||||||
logs
|
logs
|
||||||
|
data
|
||||||
386
app.py
386
app.py
@@ -1,286 +1,164 @@
|
|||||||
import pyray as pr
|
# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
|
||||||
import math
|
# SPDX-License-Identifier: MIT
|
||||||
from ctypes import c_float
|
|
||||||
from gapless_player import GaplessPlayer, build_jellyfin_audio_url, server, client
|
|
||||||
|
|
||||||
# --- Configuration Constants ---
|
# Copyright (c) 2017 Adafruit Industries
|
||||||
INITIAL_SCREEN_WIDTH = 800
|
# Author: James DeVito
|
||||||
INITIAL_SCREEN_HEIGHT = 600
|
# Ported to RGB Display by Melissa LeBlanc-Williams
|
||||||
TARGET_FPS = 60
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
|
||||||
# --- State Variables ---
|
# This example is for use on (Linux) computers that are using CPython with
|
||||||
state = {
|
# Adafruit Blinka to support CircuitPython libraries. CircuitPython does
|
||||||
"screen_width": INITIAL_SCREEN_WIDTH,
|
# not support PIL/pillow (python imaging library)!
|
||||||
"screen_height": INITIAL_SCREEN_HEIGHT,
|
"""
|
||||||
"current_time": 120.0,
|
This example is for use on (Linux) computers that are using CPython with
|
||||||
"total_time": 300.0,
|
Adafruit Blinka to support CircuitPython libraries. CircuitPython does
|
||||||
"is_playing": True,
|
not support PIL/pillow (python imaging library)!
|
||||||
# 3D Camera State
|
"""
|
||||||
"camera": None,
|
|
||||||
"render_texture": None,
|
|
||||||
# Assets
|
|
||||||
"album_texture": None,
|
|
||||||
"album_model": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Utility Functions ---
|
import random
|
||||||
|
import time
|
||||||
|
from colorsys import hsv_to_rgb
|
||||||
|
|
||||||
def format_time_mm_ss(seconds):
|
import board
|
||||||
"""Converts a time in seconds to an 'MM:SS' string format."""
|
from digitalio import DigitalInOut, Direction
|
||||||
seconds = int(seconds)
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
minutes = seconds // 60
|
|
||||||
seconds_remainder = seconds % 60
|
|
||||||
return f"{minutes:02d}:{seconds_remainder:02d}"
|
|
||||||
|
|
||||||
# --- Dynamic Layout Functions ---
|
from adafruit_rgb_display import st7789
|
||||||
|
import old_app
|
||||||
|
|
||||||
def get_3d_render_area(screen_width, screen_height):
|
# Create the display
|
||||||
ASPECT_WIDTH = 2.0
|
cs_pin = DigitalInOut(board.CE0)
|
||||||
ASPECT_HEIGHT = 1.0
|
dc_pin = DigitalInOut(board.D25)
|
||||||
ASPECT_RATIO = ASPECT_WIDTH / ASPECT_HEIGHT
|
reset_pin = DigitalInOut(board.D24)
|
||||||
|
BAUDRATE = 24000000
|
||||||
|
|
||||||
max_available_width = screen_width * 0.7
|
spi = board.SPI()
|
||||||
max_available_height = screen_height * 0.5
|
disp = st7789.ST7789(
|
||||||
|
spi,
|
||||||
|
height=240,
|
||||||
|
y_offset=80,
|
||||||
|
rotation=180,
|
||||||
|
cs=cs_pin,
|
||||||
|
dc=dc_pin,
|
||||||
|
rst=reset_pin,
|
||||||
|
baudrate=BAUDRATE,
|
||||||
|
)
|
||||||
|
|
||||||
if (max_available_width / max_available_height) > ASPECT_RATIO:
|
# Input pins:
|
||||||
height = max_available_height
|
button_A = DigitalInOut(board.D5)
|
||||||
width = height * ASPECT_RATIO
|
button_A.direction = Direction.INPUT
|
||||||
else:
|
|
||||||
width = max_available_width
|
|
||||||
height = width / ASPECT_RATIO
|
|
||||||
|
|
||||||
x = (screen_width - width) / 2
|
button_B = DigitalInOut(board.D6)
|
||||||
y = screen_height * 0.15
|
button_B.direction = Direction.INPUT
|
||||||
|
|
||||||
return pr.Rectangle(x, y, width, height)
|
|
||||||
|
|
||||||
def get_progress_bar_rect(screen_width, screen_height):
|
button_L = DigitalInOut(board.D27)
|
||||||
width = screen_width * 0.7
|
button_L.direction = Direction.INPUT
|
||||||
height = screen_height * 0.03
|
|
||||||
x = (screen_width - width) / 2
|
|
||||||
y = screen_height * 0.75
|
|
||||||
return pr.Rectangle(x, y, width, height)
|
|
||||||
|
|
||||||
def draw_progress_bar(rect, current_time, total_time):
|
button_R = DigitalInOut(board.D23)
|
||||||
if total_time > 0:
|
button_R.direction = Direction.INPUT
|
||||||
progress_ratio = current_time / total_time
|
|
||||||
else:
|
|
||||||
progress_ratio = 0.0
|
|
||||||
|
|
||||||
pr.draw_rectangle_rec(rect, pr.Color(100, 100, 100, 255))
|
button_U = DigitalInOut(board.D17)
|
||||||
progress_width = rect.width * progress_ratio
|
button_U.direction = Direction.INPUT
|
||||||
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)
|
|
||||||
|
|
||||||
# --- ASSET MANAGEMENT ---
|
button_D = DigitalInOut(board.D22)
|
||||||
|
button_D.direction = Direction.INPUT
|
||||||
|
|
||||||
def load_album_assets():
|
button_C = DigitalInOut(board.D4)
|
||||||
"""Loads the texture, creates the 3D model, and applies the flipped texture."""
|
button_C.direction = Direction.INPUT
|
||||||
|
|
||||||
# 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 ---
|
# Turn on the Backlight
|
||||||
pr.image_flip_vertical(image)
|
backlight = DigitalInOut(board.D26)
|
||||||
|
backlight.switch_to_output()
|
||||||
# 2. Create Texture
|
backlight.value = True
|
||||||
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
|
# Create blank image for drawing.
|
||||||
|
# Make sure to create image with mode 'RGB' for color.
|
||||||
|
width = disp.width
|
||||||
|
height = disp.height
|
||||||
|
image = Image.new("RGB", (width, height))
|
||||||
|
|
||||||
# --- CORE 3D RENDERING ---
|
# Get drawing object to draw on image.
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
def setup_3d_environment(render_width, render_height):
|
# Clear display.
|
||||||
camera = pr.Camera3D()
|
draw.rectangle((0, 0, width, height), outline=0, fill=(255, 0, 0))
|
||||||
camera.position = pr.Vector3(0.0, -0.35, 4.0) # Moved back slightly to fit the new models
|
disp.image(image)
|
||||||
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):
|
# Get drawing object to draw on image.
|
||||||
"""
|
draw = ImageDraw.Draw(image)
|
||||||
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()
|
|
||||||
|
|
||||||
|
# Draw a black filled box to clear the image.
|
||||||
|
draw.rectangle((0, 0, width, height), outline=0, fill=0)
|
||||||
|
|
||||||
for i in range(-5, 0):
|
udlr_fill = "#00FF00"
|
||||||
pr.rl_push_matrix()
|
udlr_outline = "#00FFFF"
|
||||||
pr.rl_translatef(-1.5+0.15*i, 0.0, 0.5) # Added slight Z offset for depth
|
button_fill = "#FF00FF"
|
||||||
pr.rl_rotatef(50.0, 0.0, 1.0, 0.0)
|
button_outline = "#FFFFFF"
|
||||||
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.WHITE)
|
|
||||||
pr.rl_pop_matrix()
|
|
||||||
|
|
||||||
|
|
||||||
|
fnt = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 30)
|
||||||
|
|
||||||
for i in range(1,6):
|
while True:
|
||||||
pr.rl_push_matrix()
|
up_fill = 0
|
||||||
pr.rl_translatef(1.5+0.15*i, 0.0, 0.5)
|
if not button_U.value: # up pressed
|
||||||
pr.rl_rotatef(-50.0, 0.0, 1.0, 0.0)
|
up_fill = udlr_fill
|
||||||
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.WHITE)
|
draw.polygon([(40, 40), (60, 4), (80, 40)], outline=udlr_outline, fill=up_fill) # Up
|
||||||
pr.rl_pop_matrix()
|
|
||||||
|
|
||||||
pr.end_mode_3d()
|
|
||||||
|
|
||||||
# --- Main Setup and Loop ---
|
|
||||||
|
|
||||||
# Initialization
|
down_fill = 0
|
||||||
pr.set_config_flags(pr.ConfigFlags.FLAG_WINDOW_RESIZABLE)
|
if not button_D.value: # down pressed
|
||||||
pr.set_config_flags(pr.FLAG_MSAA_4X_HINT)
|
down_fill = udlr_fill
|
||||||
pr.init_window(state["screen_width"], state["screen_height"], "UgPod")
|
draw.polygon([(60, 120), (80, 84), (40, 84)], outline=udlr_outline, fill=down_fill) # down
|
||||||
pr.set_target_fps(TARGET_FPS)
|
|
||||||
|
|
||||||
player = GaplessPlayer()
|
left_fill = 0
|
||||||
|
if not button_L.value: # left pressed
|
||||||
|
left_fill = udlr_fill
|
||||||
|
draw.polygon([(0, 60), (36, 42), (36, 81)], outline=udlr_outline, fill=left_fill) # left
|
||||||
|
|
||||||
print("add queue")
|
right_fill = 0
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376")["Id"], server["AccessToken"], server["UserId"]))
|
if not button_R.value: # right pressed
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("58822c0fc47ec63ba798ba4f04ea3cf3")["Id"], server["AccessToken"], server["UserId"]))
|
right_fill = udlr_fill
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("6382005f9dbae8d187d80a5cdca3e7a6")["Id"], server["AccessToken"], server["UserId"]))
|
draw.polygon([(120, 60), (84, 42), (84, 82)], outline=udlr_outline, fill=right_fill) # right
|
||||||
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"]))
|
center_fill = 0
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("631aeddb0557fef65f49463abb20ad7f")["Id"], server["AccessToken"], server["UserId"]))
|
if not button_C.value: # center pressed
|
||||||
|
center_fill = button_fill
|
||||||
|
draw.rectangle((40, 44, 80, 80), outline=button_outline, fill=center_fill) # center
|
||||||
|
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("3d611c8664c5b2072edbf46da2a76c89")["Id"], server["AccessToken"], server["UserId"]))
|
A_fill = 0
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("66559c40d5904944a3f97198d0297894")["Id"], server["AccessToken"], server["UserId"]))
|
if not button_A.value: # left pressed
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("84b75eeb5c8e862d002bae05d2671b1b")["Id"], server["AccessToken"], server["UserId"]))
|
A_fill = button_fill
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("7ef66992426093252696e1d8666a22e4")["Id"], server["AccessToken"], server["UserId"]))
|
draw.ellipse((140, 80, 180, 120), outline=button_outline, fill=A_fill) # A button
|
||||||
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('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/03 On the Run.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("5e1067d59ed98979ad12a58548b27b83")["Id"], server["AccessToken"], server["UserId"]))
|
|
||||||
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"]))
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Initial setup
|
B_fill = 0
|
||||||
render_rect = get_3d_render_area(state["screen_width"], state["screen_height"])
|
if not button_B.value: # left pressed
|
||||||
state["render_texture"] = pr.load_render_texture(int(render_rect.width), int(render_rect.height))
|
B_fill = button_fill
|
||||||
state["camera"] = setup_3d_environment(int(render_rect.width), int(render_rect.height))
|
draw.ellipse((190, 40, 230, 80), outline=button_outline, fill=B_fill) # B button
|
||||||
|
|
||||||
# LOAD THE ASSETS
|
# make a random color and print text
|
||||||
state["album_texture"], state["album_model"] = load_album_assets()
|
rcolor = tuple(int(x * 255) for x in hsv_to_rgb(random.random(), 1, 1))
|
||||||
|
draw.text((20, 150), "Hello World", font=fnt, fill=rcolor)
|
||||||
|
rcolor = tuple(int(x * 255) for x in hsv_to_rgb(random.random(), 1, 1))
|
||||||
|
draw.text((20, 180), "Hello World", font=fnt, fill=rcolor)
|
||||||
|
rcolor = tuple(int(x * 255) for x in hsv_to_rgb(random.random(), 1, 1))
|
||||||
|
draw.text((20, 210), "Hello World", font=fnt, fill=rcolor)
|
||||||
|
|
||||||
# --- Main Game Loop ---
|
# Display the Image
|
||||||
while not pr.window_should_close():
|
disp.image(image)
|
||||||
# 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))
|
|
||||||
|
|
||||||
delta_time = pr.get_frame_time()
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 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()
|
time.sleep(0.1)
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 3. DRAW 2D GUI
|
|
||||||
# ----------------------------------------------------
|
|
||||||
pr.begin_drawing()
|
|
||||||
pr.clear_background(pr.Color(40, 40, 40, 255))
|
|
||||||
|
|
||||||
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.LIME)
|
|
||||||
|
|
||||||
draw_progress_bar(progress_rect, player.position, player.playback_info_to_duration(player.playback_info))
|
|
||||||
|
|
||||||
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.LIME)
|
|
||||||
|
|
||||||
pr.end_drawing()
|
|
||||||
|
|
||||||
# --- 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()
|
|
||||||
@@ -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,38 +15,87 @@ import sys
|
|||||||
import io
|
import io
|
||||||
import fcntl
|
import fcntl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import numpy as np
|
||||||
os.makedirs("logs", exist_ok=True)
|
from collections import deque
|
||||||
|
from jelly import server, client
|
||||||
|
from urllib.parse import urlencode, urljoin
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Song:
|
class Song:
|
||||||
|
id: str
|
||||||
url: str
|
url: str
|
||||||
duration: float
|
|
||||||
name: str
|
name: str
|
||||||
album_name:str
|
duration: float
|
||||||
album_cover:str
|
album_name: str
|
||||||
artist_name:str
|
album_cover_path: str
|
||||||
|
artist_name: str
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
ext = None
|
||||||
|
|
||||||
|
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/images", exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
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.proc = None
|
self.closed = False
|
||||||
self.next_proc = None
|
|
||||||
|
|
||||||
self.current_song: Song = None
|
|
||||||
self.next_file: Song = None
|
|
||||||
|
|
||||||
self.next_preload_state = 0
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -59,207 +105,228 @@ 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()
|
||||||
|
self.oscilloscope_data_points = deque(maxlen=samplerate//60)
|
||||||
|
|
||||||
def _open_ffmpeg(self, url, seek=0):
|
def get_current_song(self):
|
||||||
self.song+=1
|
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]
|
||||||
|
|
||||||
|
def _open_ffmpeg(self, song, seek=0):
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
# "-re",
|
# "-re",
|
||||||
"-ss", str(seek),
|
"-ss",
|
||||||
"-i", 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
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:
|
||||||
pos = min(max(0,pos), self.playback_info_to_duration(self.playback_info))
|
song = self.get_current_song()
|
||||||
if self.proc:
|
if song:
|
||||||
self.proc.kill()
|
pos = min(max(0, pos), song.duration)
|
||||||
self.proc = self._open_ffmpeg(self.current_file, pos)
|
if song.ffmpeg:
|
||||||
self.position = pos
|
song.ffmpeg.kill()
|
||||||
|
song.ffmpeg = None
|
||||||
|
if self.playing:
|
||||||
|
song.ffmpeg = self._open_ffmpeg(song, pos)
|
||||||
|
self.position = pos
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.closed=True
|
self.closed = True
|
||||||
self.stream.close()
|
self.stream.close()
|
||||||
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)
|
def add_to_queue(self, song: Song):
|
||||||
|
song.ffmpeg = None
|
||||||
except Exception as e:
|
song.preload_state = 0
|
||||||
print("ffprobe error:", e)
|
self.song_list.append(song)
|
||||||
|
|
||||||
return None
|
|
||||||
def add_to_queue(self, song:Song):
|
|
||||||
self.current_song_in_list.append(song)
|
|
||||||
|
|
||||||
def play(self):
|
def play(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if not self.playing:
|
if not self.playing:
|
||||||
if not self.proc and self.current_file:
|
current_song = self.get_current_song()
|
||||||
self.proc = self._open_ffmpeg(self.current_file, self.position)
|
if current_song and not current_song.ffmpeg:
|
||||||
|
current_song.ffmpeg = self._open_ffmpeg(current_song, self.position)
|
||||||
self.playing = True
|
self.playing = True
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.proc:
|
# current_song = self.get_current_song()
|
||||||
self.proc.kill()
|
# if current_song and current_song.ffmpeg:
|
||||||
self.proc = None
|
# current_song.ffmpeg.kill()
|
||||||
|
# current_song.ffmpeg = None
|
||||||
self.playing = False
|
self.playing = False
|
||||||
|
|
||||||
def _start_next(self):
|
def _start_next(self):
|
||||||
# Kill old pipeline
|
# Kill old pipeline
|
||||||
if self.proc:
|
current_song = self.get_current_song()
|
||||||
self.proc.kill()
|
if current_song and current_song.ffmpeg:
|
||||||
|
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.proc = self.next_proc
|
self.current_song_in_list += 1
|
||||||
self.current_file = self.next_file
|
|
||||||
self.playback_info = self.next_playback_info
|
|
||||||
self.next_proc=None
|
|
||||||
self.next_playback_info = None
|
|
||||||
self.next_preload_state = 0
|
|
||||||
|
|
||||||
def preload_next(self):
|
def get_next_song(self):
|
||||||
self.next_file = self.song_queue.get()
|
if self.current_song_in_list + 1 >= 0 and self.current_song_in_list + 1 < len(
|
||||||
self.next_playback_info = self.get_stream_info(self.next_file)
|
self.song_list
|
||||||
self.next_proc = self._open_ffmpeg(self.next_file)
|
):
|
||||||
self.next_preload_state = 2
|
return self.song_list[self.current_song_in_list + 1]
|
||||||
|
return None
|
||||||
def preload_next_threaded(self):
|
|
||||||
if self.next_preload_state: return
|
def forward_song(self):
|
||||||
self.next_preload_state = 1
|
current_song = self.get_current_song()
|
||||||
threading.Thread(target=self.preload_next).start()
|
if current_song and current_song.ffmpeg:
|
||||||
|
current_song.ffmpeg.kill()
|
||||||
def playback_info_to_duration(self, info):
|
current_song.ffmpeg = None
|
||||||
if info is None: return 0.0
|
if self.current_song_in_list < len(
|
||||||
if "streams" in info:
|
self.song_list
|
||||||
for s in info["streams"]:
|
):
|
||||||
if "duration" in s:
|
self.current_song_in_list += 1
|
||||||
return float(s["duration"])
|
|
||||||
|
|
||||||
if "format" in info and "duration" in info["format"]:
|
|
||||||
return float(info["format"]["duration"])
|
|
||||||
|
|
||||||
return 0.0
|
|
||||||
|
def load_song(self, song: Song):
|
||||||
|
if song:
|
||||||
|
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
|
||||||
|
next_song.preload_state = 1
|
||||||
|
threading.Thread(target=self.load_song, args=(next_song,)).start()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
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:
|
||||||
if self.proc is None:
|
current_song = self.get_current_song()
|
||||||
if self.next_preload_state==2:
|
if not current_song or current_song.ffmpeg is None:
|
||||||
self._start_next()
|
next_song = self.get_next_song()
|
||||||
elif self.next_preload_state == 0:
|
if next_song:
|
||||||
self.preload_next_threaded()
|
if next_song.preload_state == 2:
|
||||||
else:
|
self._start_next()
|
||||||
|
elif next_song.preload_state == 0:
|
||||||
|
self.preload_next_threaded()
|
||||||
|
elif current_song:
|
||||||
try:
|
try:
|
||||||
data = self.proc.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.playback_info_to_duration(self.playback_info)-10:
|
if self.position >= current_song.duration - 10:
|
||||||
self.preload_next_threaded()
|
self.preload_next_threaded()
|
||||||
if self.proc.poll() is not None and len(data)<needed:
|
else:
|
||||||
if round(self.position, 2) >= self.playback_info_to_duration(self.playback_info)-0.1:
|
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 round(self.position, 2) >= current_song.duration - 0.1:
|
||||||
self._start_next()
|
self._start_next()
|
||||||
if self.proc is not None and self.proc.poll() is None:
|
current_song = self.get_current_song()
|
||||||
|
if (
|
||||||
|
current_song
|
||||||
|
and current_song.ffmpeg is not None
|
||||||
|
and current_song.ffmpeg.poll() is None
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
new_data = self.proc.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:
|
||||||
self.proc = self._open_ffmpeg(self.current_file, self.position)
|
# 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, 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
|
||||||
|
|
||||||
outdata[:len(data)] = data
|
points = list(zip(x, y))
|
||||||
|
# step = max(1, len(points) // 1000)
|
||||||
|
# points = points[::step]
|
||||||
|
self.oscilloscope_data_points.extend(points)
|
||||||
|
|
||||||
outdata[len(data):] = b'\x00'*(needed-len(data))
|
outdata[: len(data)] = data
|
||||||
|
|
||||||
|
outdata[len(data) :] = b"\x00" * (needed - len(data))
|
||||||
def build_jellyfin_audio_url(
|
def save_state(self, path):
|
||||||
base_url: str,
|
with open(path,"w") as f:
|
||||||
item_id: str,
|
data = {
|
||||||
api_key: str,
|
"queue": [song.id for song in self.song_list],
|
||||||
user_id: str,
|
"current_song": self.current_song_in_list,
|
||||||
container: str = "flac",
|
"position": self.position
|
||||||
audio_codec: str = "flac",
|
}
|
||||||
bitrate: int | None = None,
|
json.dump(data, f)
|
||||||
media_source_id: str | None = None,
|
def load_state(self, path):
|
||||||
) -> str:
|
try:
|
||||||
"""
|
with open(path,"r") as f:
|
||||||
Build a Jellyfin audio stream URL using urllib.parse.
|
data = json.load(f)
|
||||||
"""
|
self.song_list = []
|
||||||
path = f"/Audio/{item_id}/universal"
|
for song in data["queue"]:
|
||||||
|
songOBJ = song_data_to_Song(client.jellyfin.get_item(song), server)
|
||||||
params = {
|
songOBJ.ffmpeg = None
|
||||||
"UserId": user_id,
|
songOBJ.preload_state = 0
|
||||||
"Container": container,
|
self.song_list.append(songOBJ)
|
||||||
"AudioCodec": audio_codec, # <-- IMPORTANT
|
|
||||||
"api_key": api_key,
|
self.current_song_in_list = data['current_song']
|
||||||
}
|
self.seek(data['position'])
|
||||||
|
except:
|
||||||
if bitrate is not None:
|
return
|
||||||
params["Bitrate"] = bitrate
|
|
||||||
|
|
||||||
if media_source_id is not None:
|
|
||||||
params["MediaSourceId"] = media_source_id
|
|
||||||
|
|
||||||
query = urlencode(params)
|
|
||||||
return urljoin(base_url, path) + "?" + query
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
# 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)
|
||||||
|
|||||||
44
jelly.py
Normal file
44
jelly.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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]
|
||||||
|
|
||||||
|
client = None
|
||||||
|
server = None
|
||||||
451
old_app.py
Normal file
451
old_app.py
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
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(
|
||||||
|
"bruhh",
|
||||||
|
"music/pink floyd/dark side of the moon/06 Money.flac",
|
||||||
|
"Money",
|
||||||
|
382.200000,
|
||||||
|
"The Dark Side Of The Moon",
|
||||||
|
"",
|
||||||
|
"Pink Floyd",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
player.add_to_queue(
|
||||||
|
Song(
|
||||||
|
"bruhh",
|
||||||
|
"music/pink floyd/dark side of the moon/07 Us and Them.flac",
|
||||||
|
"Us and Them",
|
||||||
|
470.333333,
|
||||||
|
"The Dark Side Of The Moon",
|
||||||
|
"",
|
||||||
|
"Pink Floyd",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
player.play()
|
||||||
|
# while True:
|
||||||
|
# time.sleep(1)
|
||||||
@@ -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==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
|
||||||
|
|||||||
78
scrolling_text.py
Normal file
78
scrolling_text.py
Normal 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, font_size)
|
||||||
|
|
||||||
|
def set_text(self, text: str, font_size: int):
|
||||||
|
if text == self.text and font_size == self.font_size:
|
||||||
|
return
|
||||||
|
self.text = text
|
||||||
|
self.font_size = font_size
|
||||||
|
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.reset()
|
||||||
|
|
||||||
|
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 + self.font_size*2.5:
|
||||||
|
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 + self.font_size*2.5),
|
||||||
|
int(y),
|
||||||
|
self.font_size,
|
||||||
|
self.color,
|
||||||
|
)
|
||||||
|
|
||||||
|
pr.end_scissor_mode()
|
||||||
Reference in New Issue
Block a user