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