make the GUI in raylib
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -174,3 +174,5 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
music
|
||||||
|
logs
|
||||||
4
LICENSE
4
LICENSE
@@ -208,7 +208,7 @@ If you develop a new program, and you want it to be of the greatest possible use
|
|||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
FinPod
|
UgPod
|
||||||
Copyright (C) 2025 Ugric
|
Copyright (C) 2025 Ugric
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
|
|||||||
|
|
||||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
FinPod Copyright (C) 2025 Ugric
|
UgPod Copyright (C) 2025 Ugric
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -1,2 +1,13 @@
|
|||||||
# FinPod
|
# UgPod
|
||||||
|
|
||||||
|
A desktop music player for Jellyfin with gapless transitions between songs.
|
||||||
|
|
||||||
|
This whole project is extremely early in development, there is and will be bugs for a while, but I hope to make it usable.
|
||||||
|
|
||||||
|
I'm mainly working on this because I haven't found a good desktop Jellyfin client that has gapless transitions. I also wanna make my own mp3 player for online and offline Jellyfin use, however I thought about it and wanted to turn it into a desktop app too.
|
||||||
|
|
||||||
|
# Image(s)
|
||||||
|
|
||||||
|
(sorry extremely early development screenshots)
|
||||||
|
|
||||||
|

|
||||||
471
app.py
471
app.py
@@ -1,233 +1,308 @@
|
|||||||
from jellyfin_apiclient_python import JellyfinClient
|
import pyray as pr
|
||||||
import json
|
import math
|
||||||
import uuid
|
from ctypes import c_float
|
||||||
import subprocess
|
from player import FFQueuePlayer, build_jellyfin_audio_url, server, client
|
||||||
from dotenv import load_dotenv
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import ffmpeg
|
|
||||||
import requests
|
|
||||||
import threading
|
|
||||||
from urllib.parse import urlencode, urljoin
|
|
||||||
import subprocess
|
|
||||||
import numpy as np
|
|
||||||
import sounddevice as sd
|
|
||||||
import threading
|
|
||||||
import queue
|
|
||||||
import sys
|
|
||||||
import io
|
|
||||||
|
|
||||||
class FFQueuePlayer:
|
# --- Configuration Constants ---
|
||||||
def __init__(self, samplerate=44100, channels=2):
|
INITIAL_SCREEN_WIDTH = 800
|
||||||
self.samplerate = samplerate
|
INITIAL_SCREEN_HEIGHT = 600
|
||||||
self.channels = channels
|
TARGET_FPS = 60
|
||||||
|
|
||||||
self.proc = None
|
# --- State Variables ---
|
||||||
self.next_proc = None
|
state = {
|
||||||
|
"screen_width": INITIAL_SCREEN_WIDTH,
|
||||||
|
"screen_height": INITIAL_SCREEN_HEIGHT,
|
||||||
|
"current_time": 120.0,
|
||||||
|
"total_time": 300.0,
|
||||||
|
"is_playing": True,
|
||||||
|
# 3D Camera State
|
||||||
|
"camera": None,
|
||||||
|
"render_texture": None,
|
||||||
|
# Assets
|
||||||
|
"album_texture": None,
|
||||||
|
"album_model": None
|
||||||
|
}
|
||||||
|
|
||||||
self.current_file = None
|
# --- Utility Functions ---
|
||||||
self.next_file = None
|
|
||||||
|
|
||||||
self.next_preloaded = False
|
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}"
|
||||||
|
|
||||||
self.closed=False
|
# --- Dynamic Layout Functions ---
|
||||||
|
|
||||||
self.playing = False
|
def get_3d_render_area(screen_width, screen_height):
|
||||||
self.position = 0.0
|
ASPECT_WIDTH = 2.0
|
||||||
self.duration = 1.0
|
ASPECT_HEIGHT = 1.0
|
||||||
self.next_duration=1.0
|
ASPECT_RATIO = ASPECT_WIDTH / ASPECT_HEIGHT
|
||||||
|
|
||||||
self.song = 0
|
max_available_width = screen_width * 0.7
|
||||||
|
max_available_height = screen_height * 0.5
|
||||||
|
|
||||||
self.song_queue = queue.Queue()
|
if (max_available_width / max_available_height) > ASPECT_RATIO:
|
||||||
self.swap_pending = False
|
height = max_available_height
|
||||||
|
width = height * ASPECT_RATIO
|
||||||
|
else:
|
||||||
|
width = max_available_width
|
||||||
|
height = width / ASPECT_RATIO
|
||||||
|
|
||||||
self.lock = threading.Lock()
|
x = (screen_width - width) / 2
|
||||||
|
y = screen_height * 0.15
|
||||||
self.stream = sd.RawOutputStream(
|
|
||||||
samplerate=self.samplerate,
|
|
||||||
channels=self.channels,
|
|
||||||
dtype="int16",
|
|
||||||
callback=self._callback
|
|
||||||
)
|
|
||||||
self.stream.start()
|
|
||||||
|
|
||||||
def _open_ffmpeg(self, url, seek=0):
|
|
||||||
self.song+=1
|
|
||||||
return subprocess.Popen(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-ss", str(seek),
|
|
||||||
"-i", url,
|
|
||||||
"-f", "s16le",
|
|
||||||
"-ac", str(self.channels),
|
|
||||||
"-ar", str(self.samplerate),
|
|
||||||
"-loglevel", "verbose",
|
|
||||||
"-"
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=open(str(self.song)+".txt", "wb")
|
|
||||||
)
|
|
||||||
|
|
||||||
def seek(self, pos):
|
return pr.Rectangle(x, y, width, height)
|
||||||
with self.lock:
|
|
||||||
self.proc = self._open_ffmpeg(self.current_file, pos)
|
def get_progress_bar_rect(screen_width, screen_height):
|
||||||
self.position = pos
|
width = screen_width * 0.7
|
||||||
|
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):
|
||||||
|
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), 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))
|
||||||
|
|
||||||
def close(self):
|
time_text = f"{format_time_mm_ss(current_time)} / {format_time_mm_ss(total_time)}"
|
||||||
self.closed=True
|
text_width = pr.measure_text(time_text, int(rect.height * 0.7))
|
||||||
self.stream.close()
|
pr.draw_text(time_text,
|
||||||
def get_duration(self, url):
|
int(rect.x + rect.width / 2 - text_width / 2),
|
||||||
"""Return duration in seconds for the track"""
|
int(rect.y + rect.height * 0.15),
|
||||||
try:
|
int(rect.height * 0.7),
|
||||||
result = subprocess.run(
|
pr.WHITE)
|
||||||
[
|
|
||||||
"ffprobe",
|
|
||||||
"-v", "quiet",
|
|
||||||
"-print_format", "json",
|
|
||||||
"-show_format",
|
|
||||||
"-show_streams",
|
|
||||||
url
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
|
|
||||||
info = json.loads(result.stdout)
|
# --- ASSET MANAGEMENT ---
|
||||||
|
|
||||||
# Prefer stream duration → fallback to format duration
|
def load_album_assets():
|
||||||
if "streams" in info:
|
"""Loads the texture, creates the 3D model, and applies the flipped texture."""
|
||||||
for s in info["streams"]:
|
|
||||||
if "duration" in s:
|
# 1. Load Image
|
||||||
return float(s["duration"])
|
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)
|
||||||
|
|
||||||
if "format" in info and "duration" in info["format"]:
|
# --- THE FIX: FLIP THE IMAGE VERTICALLY ---
|
||||||
return float(info["format"]["duration"])
|
pr.image_flip_vertical(image)
|
||||||
|
|
||||||
except Exception as e:
|
# 2. Create Texture
|
||||||
print("ffprobe error:", e)
|
texture = pr.load_texture_from_image(image)
|
||||||
|
pr.unload_image(image)
|
||||||
return None
|
|
||||||
def add_to_queue(self, url):
|
# 3. Generate Mesh (CD Case)
|
||||||
self.song_queue.put_nowait(url)
|
mesh = pr.gen_mesh_cube(1.5, 1.5, 0.0)
|
||||||
|
|
||||||
def play(self):
|
# 4. Load Model
|
||||||
with self.lock:
|
model = pr.load_model_from_mesh(mesh)
|
||||||
if not self.playing:
|
|
||||||
self.playing = True
|
# 5. Apply Texture
|
||||||
|
# We use index 0 for the Albedo/Diffuse map
|
||||||
def pause(self):
|
map_index = 0
|
||||||
with self.lock:
|
# Use MATERIAL_MAP_ALBEDO if the binding is modern enough
|
||||||
self.playing = False
|
if hasattr(pr.MaterialMapIndex, 'MATERIAL_MAP_ALBEDO'):
|
||||||
|
map_index = pr.MaterialMapIndex.MATERIAL_MAP_ALBEDO
|
||||||
def _start_next(self):
|
|
||||||
# Kill old pipeline
|
|
||||||
if self.proc:
|
|
||||||
self.proc.kill()
|
|
||||||
|
|
||||||
# Move next pipeline into active
|
model.materials[0].maps[map_index].texture = texture
|
||||||
self.position = 0.0
|
|
||||||
self.proc = self.next_proc
|
|
||||||
self.current_file = self.next_file
|
|
||||||
self.duration = self.next_duration
|
|
||||||
self.next_proc=None
|
|
||||||
self.next_preloaded = False
|
|
||||||
|
|
||||||
def preload_next(self):
|
return texture, model
|
||||||
self.next_file = self.song_queue.get()
|
|
||||||
self.next_duration = self.get_duration(self.next_file)
|
|
||||||
self.next_proc = self._open_ffmpeg(self.next_file)
|
|
||||||
self.next_preloaded = True
|
|
||||||
|
|
||||||
def preload_next_threaded(self):
|
# --- CORE 3D RENDERING ---
|
||||||
if self.next_preloaded: return
|
|
||||||
self.next_preloaded = True
|
|
||||||
threading.Thread(target=self.preload_next).start()
|
|
||||||
|
|
||||||
def _callback(self, outdata, frames, t, status):
|
def setup_3d_environment(render_width, render_height):
|
||||||
with self.lock:
|
camera = pr.Camera3D()
|
||||||
needed = frames * self.channels * 2
|
camera.position = pr.Vector3(0.0, 0.0, 4.0) # Moved back slightly to fit the new models
|
||||||
data = b''
|
camera.target = pr.Vector3(0.0, 0.0, 0.0)
|
||||||
if self.proc is None:
|
camera.up = pr.Vector3(0.0, 1.0, 0.0)
|
||||||
self.preload_next()
|
camera.fovy = 45.0
|
||||||
self._start_next()
|
camera.projection = pr.CameraProjection.CAMERA_PERSPECTIVE
|
||||||
else:
|
return camera
|
||||||
data = self.proc.stdout.read(needed) or b''
|
|
||||||
self.position += len(data) / (self.samplerate * self.channels * 2)
|
|
||||||
if self.position >= self.duration-10:
|
|
||||||
self.preload_next_threaded()
|
|
||||||
if self.proc.poll() is not None and len(data)<needed:
|
|
||||||
self._start_next()
|
|
||||||
new_data = self.proc.stdout.read(needed-len(data)) or b''
|
|
||||||
self.position += len(new_data) / (self.samplerate * self.channels * 2)
|
|
||||||
data += new_data
|
|
||||||
outdata[:len(data)]=data
|
|
||||||
outdata[len(data):] = b"\x00" * (needed-len(data))
|
|
||||||
|
|
||||||
|
def draw_3d_cover_flow(camera, model):
|
||||||
def build_jellyfin_audio_url(
|
|
||||||
base_url: str,
|
|
||||||
item_id: str,
|
|
||||||
api_key: str,
|
|
||||||
user_id: str,
|
|
||||||
container: str = "flac",
|
|
||||||
audio_codec: str = "flac",
|
|
||||||
bitrate: int | None = None,
|
|
||||||
media_source_id: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
"""
|
||||||
Build a Jellyfin audio stream URL using urllib.parse.
|
Draws the textured model using the existing Matrix logic.
|
||||||
"""
|
"""
|
||||||
path = f"/Items/{item_id}/Download"
|
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(0.0, 0.0, 1.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()
|
||||||
|
|
||||||
params = {
|
# --------------------------------------------------------
|
||||||
"UserId": user_id,
|
# 3. PREVIOUS ALBUM (Far Far Left)
|
||||||
"Container": container,
|
# --------------------------------------------------------
|
||||||
"AudioCodec": audio_codec, # <-- IMPORTANT
|
pr.rl_push_matrix()
|
||||||
"api_key": api_key,
|
pr.rl_translatef(-3.5, 0.0, 0.0) # Spaced out slightly more
|
||||||
}
|
pr.rl_rotatef(90.0, 0.0, 1.0, 0.0) # Sharper angle
|
||||||
|
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.LIGHTGRAY) # Slightly darkened
|
||||||
|
pr.rl_pop_matrix()
|
||||||
|
|
||||||
if bitrate is not None:
|
# --------------------------------------------------------
|
||||||
params["Bitrate"] = bitrate
|
# 3. PREVIOUS ALBUM (Far Left)
|
||||||
|
# --------------------------------------------------------
|
||||||
|
pr.rl_push_matrix()
|
||||||
|
pr.rl_translatef(-2.5, 0.0, 0.0) # Spaced out slightly more
|
||||||
|
pr.rl_rotatef(90.0, 0.0, 1.0, 0.0) # Sharper angle
|
||||||
|
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.LIGHTGRAY) # Slightly darkened
|
||||||
|
pr.rl_pop_matrix()
|
||||||
|
|
||||||
if media_source_id is not None:
|
# --------------------------------------------------------
|
||||||
params["MediaSourceId"] = media_source_id
|
# 3. PREVIOUS ALBUM (Near Left)
|
||||||
|
# --------------------------------------------------------
|
||||||
|
pr.rl_push_matrix()
|
||||||
|
pr.rl_translatef(-1.5, 0.0, 0.5) # Added slight Z offset for depth
|
||||||
|
pr.rl_rotatef(65.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()
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 4. NEXT ALBUM (Near Right)
|
||||||
|
# --------------------------------------------------------
|
||||||
|
pr.rl_push_matrix()
|
||||||
|
pr.rl_translatef(1.5, 0.0, 0.5)
|
||||||
|
pr.rl_rotatef(-65.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()
|
||||||
|
|
||||||
query = urlencode(params)
|
# --------------------------------------------------------
|
||||||
return urljoin(base_url, path) + "?" + query
|
# 4. NEXT ALBUM (Far Right)
|
||||||
|
# --------------------------------------------------------
|
||||||
|
pr.rl_push_matrix()
|
||||||
|
pr.rl_translatef(2.5, 0.0, 0.0)
|
||||||
|
pr.rl_rotatef(-90.0, 0.0, 1.0, 0.0)
|
||||||
|
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.LIGHTGRAY)
|
||||||
|
pr.rl_pop_matrix()
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 4. NEXT ALBUM (Far Far Right)
|
||||||
|
# --------------------------------------------------------
|
||||||
|
pr.rl_push_matrix()
|
||||||
|
pr.rl_translatef(3.5, 0.0, 0.0)
|
||||||
|
pr.rl_rotatef(-90.0, 0.0, 1.0, 0.0)
|
||||||
|
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.LIGHTGRAY)
|
||||||
|
pr.rl_pop_matrix()
|
||||||
|
|
||||||
|
pr.end_mode_3d()
|
||||||
|
|
||||||
|
# --- Main Setup and Loop ---
|
||||||
|
|
||||||
client = JellyfinClient()
|
# Initialization
|
||||||
load_dotenv()
|
pr.set_config_flags(pr.ConfigFlags.FLAG_WINDOW_RESIZABLE)
|
||||||
|
pr.set_config_flags(pr.FLAG_MSAA_4X_HINT)
|
||||||
client.config.app('FinPod', '0.0.1', 'FinPod prototype', 'FinPod_prototype_1')
|
pr.init_window(state["screen_width"], state["screen_height"], "UgPod")
|
||||||
client.config.data["auth.ssl"] = True
|
pr.set_target_fps(TARGET_FPS)
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
player = FFQueuePlayer()
|
player = FFQueuePlayer()
|
||||||
|
|
||||||
# Build Jellyfin URLs
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
print("add queue")
|
print("add queue")
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f")["Id"], server["AccessToken"], server["UserId"]))
|
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("38a6c21561f54d284a6acad89a3ea8b0")["Id"], server["AccessToken"], server["UserId"]))
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("916eda422f48efd8705f29e0600a3e60")["Id"], server["AccessToken"], server["UserId"]))
|
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("631aeddb0557fef65f49463abb20ad7f")["Id"], server["AccessToken"], server["UserId"]))
|
||||||
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("5e1067d59ed98979ad12a58548b27b83")["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(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/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")
|
print("add queue done")
|
||||||
|
|
||||||
player.play()
|
player.play()
|
||||||
|
|
||||||
while True:
|
# Initial setup
|
||||||
print("pos:", str(round((player.position*100)/player.duration))+"%", player.position, '/', player.duration)
|
render_rect = get_3d_render_area(state["screen_width"], state["screen_height"])
|
||||||
time.sleep(1)
|
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))
|
||||||
|
|
||||||
player.close()
|
# LOAD THE ASSETS
|
||||||
|
state["album_texture"], state["album_model"] = load_album_assets()
|
||||||
|
|
||||||
|
# --- 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))
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 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()
|
||||||
BIN
images/screenshot002.png
Normal file
BIN
images/screenshot002.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
244
player.py
Normal file
244
player.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
from jellyfin_apiclient_python import JellyfinClient
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import subprocess
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import ffmpeg
|
||||||
|
import requests
|
||||||
|
import threading
|
||||||
|
from urllib.parse import urlencode, urljoin
|
||||||
|
import subprocess
|
||||||
|
import numpy as np
|
||||||
|
import sounddevice as sd
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
|
os.makedirs("logs", exist_ok=True)
|
||||||
|
|
||||||
|
class FFQueuePlayer:
|
||||||
|
def __init__(self, samplerate=44100, channels=2):
|
||||||
|
self.samplerate = samplerate
|
||||||
|
self.channels = channels
|
||||||
|
|
||||||
|
self.proc = None
|
||||||
|
self.next_proc = None
|
||||||
|
|
||||||
|
self.current_file = None
|
||||||
|
self.next_file = None
|
||||||
|
|
||||||
|
self.next_preload_state = 0
|
||||||
|
|
||||||
|
self.last_sample = 0.0
|
||||||
|
self.samples_since_last_sample = 0
|
||||||
|
|
||||||
|
self.closed=False
|
||||||
|
|
||||||
|
self.playing = False
|
||||||
|
self.position = 0.0
|
||||||
|
self.playback_info = None
|
||||||
|
self.next_playback_info=None
|
||||||
|
|
||||||
|
self.song = 0
|
||||||
|
|
||||||
|
self.song_queue = queue.Queue()
|
||||||
|
self.swap_pending = False
|
||||||
|
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
self.stream = sd.RawOutputStream(
|
||||||
|
samplerate=self.samplerate,
|
||||||
|
channels=self.channels,
|
||||||
|
dtype="int16",
|
||||||
|
callback=self._callback
|
||||||
|
)
|
||||||
|
self.stream.start()
|
||||||
|
|
||||||
|
def _open_ffmpeg(self, url, seek=0):
|
||||||
|
self.song+=1
|
||||||
|
return subprocess.Popen(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-ss", str(seek),
|
||||||
|
"-i", url,
|
||||||
|
"-f", "s16le",
|
||||||
|
"-ac", str(self.channels),
|
||||||
|
"-ar", str(self.samplerate),
|
||||||
|
"-loglevel", "verbose",
|
||||||
|
"-"
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=open('logs/'+str(self.song)+".txt", "wb")
|
||||||
|
)
|
||||||
|
|
||||||
|
def seek(self, pos):
|
||||||
|
with self.lock:
|
||||||
|
pos = min(max(0,pos), self.playback_info_to_duration(self.playback_info))
|
||||||
|
if self.proc:
|
||||||
|
self.proc.kill()
|
||||||
|
self.proc = self._open_ffmpeg(self.current_file, pos)
|
||||||
|
self.position = pos
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed=True
|
||||||
|
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)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("ffprobe error:", e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
def add_to_queue(self, url):
|
||||||
|
self.song_queue.put_nowait(url)
|
||||||
|
|
||||||
|
def play(self):
|
||||||
|
with self.lock:
|
||||||
|
if not self.playing:
|
||||||
|
if not self.proc and self.current_file:
|
||||||
|
self.proc = self._open_ffmpeg(self.current_file, self.position)
|
||||||
|
self.playing = True
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.proc:
|
||||||
|
self.proc.kill()
|
||||||
|
self.proc = None
|
||||||
|
self.playing = False
|
||||||
|
|
||||||
|
def _start_next(self):
|
||||||
|
# Kill old pipeline
|
||||||
|
if self.proc:
|
||||||
|
self.proc.kill()
|
||||||
|
|
||||||
|
# Move next pipeline into active
|
||||||
|
self.position = 0.0
|
||||||
|
self.proc = self.next_proc
|
||||||
|
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):
|
||||||
|
self.next_file = self.song_queue.get()
|
||||||
|
self.next_playback_info = self.get_stream_info(self.next_file)
|
||||||
|
self.next_proc = self._open_ffmpeg(self.next_file)
|
||||||
|
self.next_preload_state = 2
|
||||||
|
|
||||||
|
def preload_next_threaded(self):
|
||||||
|
if self.next_preload_state: return
|
||||||
|
self.next_preload_state = 1
|
||||||
|
threading.Thread(target=self.preload_next).start()
|
||||||
|
|
||||||
|
def playback_info_to_duration(self, info):
|
||||||
|
if info is None: return 0.0
|
||||||
|
if "streams" in info:
|
||||||
|
for s in info["streams"]:
|
||||||
|
if "duration" in s:
|
||||||
|
return float(s["duration"])
|
||||||
|
|
||||||
|
if "format" in info and "duration" in info["format"]:
|
||||||
|
return float(info["format"]["duration"])
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _callback(self, outdata, frames, t, status):
|
||||||
|
with self.lock:
|
||||||
|
needed = frames * self.channels * 2
|
||||||
|
data = b''
|
||||||
|
if self.playing:
|
||||||
|
if self.proc is None:
|
||||||
|
if self.next_preload_state==2:
|
||||||
|
self._start_next()
|
||||||
|
elif self.next_preload_state == 0:
|
||||||
|
self.preload_next_threaded()
|
||||||
|
else:
|
||||||
|
data = self.proc.stdout.read(needed) or b''
|
||||||
|
self.position += len(data) / (self.samplerate * self.channels * 2)
|
||||||
|
if self.position >= self.playback_info_to_duration(self.playback_info)-10:
|
||||||
|
self.preload_next_threaded()
|
||||||
|
if self.proc.poll() is not None and len(data)<needed:
|
||||||
|
self._start_next()
|
||||||
|
if self.proc is not None and self.proc.poll() is None:
|
||||||
|
new_data = self.proc.stdout.read(needed-len(data)) or b''
|
||||||
|
self.position += len(new_data) / (self.samplerate * self.channels * 2)
|
||||||
|
data += new_data
|
||||||
|
else:
|
||||||
|
print("bruh")
|
||||||
|
|
||||||
|
outdata[:len(data)] = data
|
||||||
|
|
||||||
|
outdata[len(data):] = b'\x00'*(needed-len(data))
|
||||||
|
|
||||||
|
|
||||||
|
def build_jellyfin_audio_url(
|
||||||
|
base_url: str,
|
||||||
|
item_id: str,
|
||||||
|
api_key: str,
|
||||||
|
user_id: str,
|
||||||
|
container: str = "flac",
|
||||||
|
audio_codec: str = "flac",
|
||||||
|
bitrate: int | None = None,
|
||||||
|
media_source_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build a Jellyfin audio stream URL using urllib.parse.
|
||||||
|
"""
|
||||||
|
path = f"/Items/{item_id}/Download"
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"UserId": user_id,
|
||||||
|
"Container": container,
|
||||||
|
"AudioCodec": audio_codec, # <-- IMPORTANT
|
||||||
|
"api_key": api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
if bitrate is not None:
|
||||||
|
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('FinPod', '0.0.1', 'FinPod prototype', 'FinPod_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:
|
||||||
|
# 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)
|
||||||
@@ -1,9 +1,28 @@
|
|||||||
|
aiohappyeyeballs==2.6.1
|
||||||
|
aiohttp==3.13.2
|
||||||
|
aiosignal==1.4.0
|
||||||
|
attrs==25.4.0
|
||||||
certifi==2025.11.12
|
certifi==2025.11.12
|
||||||
|
cffi==2.0.0
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
dotenv==0.9.9
|
dotenv==0.9.9
|
||||||
|
ffmpeg==1.4
|
||||||
|
ffmpeg-python==0.2.0
|
||||||
|
frozenlist==1.8.0
|
||||||
|
future==1.0.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
jellyfin-apiclient-python==1.11.0
|
jellyfin-apiclient-python==1.11.0
|
||||||
|
multidict==6.7.0
|
||||||
|
numpy==2.3.5
|
||||||
|
propcache==0.4.1
|
||||||
|
pycparser==2.23
|
||||||
|
pyee==13.0.0
|
||||||
python-dotenv==1.2.1
|
python-dotenv==1.2.1
|
||||||
|
python-vlc==3.0.21203
|
||||||
|
raylib==5.5.0.3
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
|
sounddevice==0.5.3
|
||||||
|
typing_extensions==4.15.0
|
||||||
urllib3==2.6.1
|
urllib3==2.6.1
|
||||||
websocket-client==1.9.0
|
websocket-client==1.9.0
|
||||||
|
yarl==1.22.0
|
||||||
|
|||||||
Reference in New Issue
Block a user