#!/usr/bin/env python3 import socket import time import tkinter as tk from tkinter import ttk, scrolledtext import threading import subprocess import re import tempfile import os import sys ANDROID_HOST = os.environ.get("ANDROID_HOST_OVERRIDE", "192.168.0.190") ANDROID_PORT = 8023 WINDOWS_IP = os.environ.get("WINDOWS_IP_OVERRIDE", "192.168.0.122") AUDIO_STREAM_PORT = 12345 COL_BG = "#fff5f7" COL_PANEL = "#ffe4ed" COL_PANEL_LIGHT = "#ffd9e4" COL_ACCENT = "#ff69b4" COL_ACCENT_DARK = "#ff1493" COL_TEXT = "#4a2c3a" COL_SUBTEXT = "#8b5e72" COL_SUCCESS = "#2ecc71" COL_WARNING = "#f39c12" COL_ERROR = "#e74c3c" COL_INFO = "#3498db" class AudioStreamer: def __init__(self): self.process = None self.sock = None self.conn = None self.log_callback = None self.total_bytes = 0 self.stop_flag = False self.stream_id = None self.lock = threading.Lock() def set_log_callback(self, callback): self.log_callback = callback def start_receiver(self, stream_id): """Receive entire file into memory, save to temp file, then play with ffplay.""" self.stream_id = stream_id temp_path = None try: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.settimeout(1.0) self.sock.bind(('0.0.0.0', AUDIO_STREAM_PORT)) self.sock.listen(1) self.conn = None while not self.stop_flag and self.conn is None: try: self.conn, addr = self.sock.accept() print(f"[Audio {stream_id}] Connected from {addr}") except socket.timeout: continue except Exception as e: if not self.stop_flag: print(f"[Audio {stream_id}] Accept error: {e}") break if self.stop_flag or self.conn is None: print(f"[Audio {stream_id}] Stopped before connection") return None # Receive all data into memory print(f"[Audio {stream_id}] Receiving file...") file_data = bytearray() bytes_received = 0 last_log = time.time() while not self.stop_flag: try: data = self.conn.recv(65536) if not data: print(f"[Audio {stream_id}] Connection closed by sender") break file_data.extend(data) bytes_received += len(data) self.total_bytes = bytes_received if time.time() - last_log >= 1: mb = bytes_received / (1024 * 1024) if self.log_callback: self.log_callback(mb, stream_id) last_log = time.time() except socket.timeout: continue except Exception as e: if not self.stop_flag: print(f"[Audio {stream_id}] Receive error: {e}") break print(f"[Audio {stream_id}] Received {bytes_received/(1024*1024):.2f} MB") if self.stop_flag or bytes_received == 0: print(f"[Audio {stream_id}] Stopped or no data") return None # Save to temp file suffix = ".mp3" fd, temp_path = tempfile.mkstemp(suffix=suffix) try: os.write(fd, file_data) finally: os.close(fd) print(f"[Audio {stream_id}] Saved to {temp_path}") # Play the file self.process = subprocess.Popen( ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet', temp_path], stdin=subprocess.DEVNULL ) # Wait for ffplay to finish while not self.stop_flag and self.process.poll() is None: time.sleep(0.5) print(f"[Audio {stream_id}] Playback finished") return temp_path except Exception as e: print(f"AudioStreamer error: {e}") return None finally: self.cleanup() if temp_path and os.path.exists(temp_path): try: os.remove(temp_path) print(f"[Audio {stream_id}] Removed temp file") except: pass def cleanup(self): with self.lock: if self.process: try: self.process.terminate() self.process.wait(timeout=2) except: pass self.process = None if self.conn: try: self.conn.close() except: pass self.conn = None if self.sock: try: self.sock.close() except: pass self.sock = None def stop(self): print(f"[Audio {self.stream_id}] Stop requested") self.stop_flag = True if self.conn: try: self.conn.shutdown(socket.SHUT_RDWR) except: pass class PowerampApp: def __init__(self, root): self.root = root self.root.title("Poweramp Streamer") self.root.geometry("900x750") self.root.configure(bg=COL_BG) self.current_song_path = None self.current_folder = None self.playlist = [] self.playlist_index = -1 self.is_playing = False self.auto_advance = tk.BooleanVar(value=True) self.streamer = None self.current_mb = 0 self.stream_generation = 0 self.active_stream_thread = None self.playing_lock = threading.Lock() self.android_ip_var = tk.StringVar(value=ANDROID_HOST) self.windows_ip_var = tk.StringVar(value=WINDOWS_IP) self.ticker_enabled = tk.BooleanVar(value=False) self.ticker_window = None self.ticker_after_id = None self.ticker_title_label = None self.ticker_song_label = None self.setup_styles() self.setup_ui() self.setup_bindings() self.root.after(500, self.get_song_and_folder) def setup_styles(self): style = ttk.Style() try: style.theme_use("clam") except tk.TclError: pass default_font = ("Segoe UI", 10) bold_font = ("Segoe UI", 10, "bold") style.configure(".", background=COL_BG, foreground=COL_TEXT, font=default_font) style.configure("TFrame", background=COL_BG) style.configure("TLabel", background=COL_BG, foreground=COL_TEXT, font=default_font) style.configure("TLabelframe", background=COL_PANEL, foreground=COL_TEXT, borderwidth=1, relief="flat") style.configure("TLabelframe.Label", background=COL_PANEL, foreground=COL_ACCENT_DARK, font=bold_font) style.configure("Pill.TButton", background=COL_ACCENT, foreground="#ffffff", font=("Segoe UI", 10, "bold"), borderwidth=0, focusthickness=0, focuscolor=COL_ACCENT, padding=(14, 8), relief="flat") style.map("Pill.TButton", background=[("active", COL_ACCENT_DARK), ("pressed", COL_ACCENT_DARK)]) style.configure("Accent.TButton", background=COL_ACCENT_DARK, foreground="#ffffff", font=("Segoe UI", 10, "bold"), borderwidth=0, focusthickness=0, focuscolor=COL_ACCENT_DARK, padding=(16, 8), relief="flat") style.map("Accent.TButton", background=[("active", "#db0f8b"), ("pressed", "#db0f8b")]) style.configure("Danger.TButton", background="#d44c7a", foreground="#ffffff", font=("Segoe UI", 10, "bold"), borderwidth=0, focusthickness=0, focuscolor="#d44c7a", padding=(14, 8), relief="flat") style.map("Danger.TButton", background=[("active", "#c41e6a"), ("pressed", "#c41e6a")]) style.configure("Nav.TButton", background=COL_ACCENT, foreground="#ffffff", font=("Segoe UI", 12, "bold"), borderwidth=0, focusthickness=0, focuscolor=COL_ACCENT, padding=(10, 6), relief="flat") style.map("Nav.TButton", background=[("active", COL_ACCENT_DARK), ("pressed", COL_ACCENT_DARK)]) style.configure("TCheckbutton", background=COL_BG, foreground=COL_SUBTEXT, font=default_font) style.map("TCheckbutton", foreground=[("active", COL_TEXT)]) style.configure("Accent.Horizontal.TProgressbar", troughcolor=COL_PANEL_LIGHT, background=COL_ACCENT, borderwidth=0, lightcolor=COL_ACCENT, darkcolor=COL_ACCENT, thickness=14) def setup_ui(self): main_frame = ttk.Frame(self.root, padding="12") main_frame.pack(fill=tk.BOTH, expand=True) # --- Address override panel (Ctrl+1 / Ctrl+2 to focus) --- addr_frame = ttk.LabelFrame(main_frame, text="Connection Addresses", padding="10") addr_frame.pack(fill=tk.X, pady=(0, 8)) ttk.Label(addr_frame, text="Android IP (Ctrl+1):").grid(row=0, column=0, sticky=tk.W, padx=(0, 6), pady=4) self.android_ip_entry = ttk.Entry(addr_frame, textvariable=self.android_ip_var, width=18) self.android_ip_entry.grid(row=0, column=1, sticky=tk.W, padx=(0, 16), pady=4) ttk.Label(addr_frame, text="Windows IP (Ctrl+2):").grid(row=0, column=2, sticky=tk.W, padx=(0, 6), pady=4) self.windows_ip_entry = ttk.Entry(addr_frame, textvariable=self.windows_ip_var, width=18) self.windows_ip_entry.grid(row=0, column=3, sticky=tk.W, padx=(0, 16), pady=4) ttk.Button(addr_frame, text="Restart script with new addresses", style="Pill.TButton", command=self.restart_with_new_addresses).grid(row=0, column=4, sticky=tk.W, pady=4) self.status_label = ttk.Label(main_frame, text="Status: Auto-loading...", foreground=COL_WARNING, font=("Segoe UI", 11, "bold")) self.status_label.pack(anchor=tk.W, pady=(0, 8)) btn_frame = ttk.Frame(main_frame) btn_frame.pack(pady=5, fill=tk.X) ttk.Button(btn_frame, text="Get Current Song & Folder", style="Pill.TButton", command=self.get_song_and_folder).pack(side=tk.LEFT, padx=4) ttk.Button(btn_frame, text="▶ Play", style="Accent.TButton", command=self.play_song).pack(side=tk.LEFT, padx=4) ttk.Button(btn_frame, text="■ Stop", style="Danger.TButton", command=self.stop_song).pack(side=tk.LEFT, padx=4) ttk.Button(btn_frame, text="⏮", width=3, style="Nav.TButton", command=self.prev_song).pack(side=tk.LEFT, padx=(16, 2)) ttk.Button(btn_frame, text="⏭", width=3, style="Nav.TButton", command=self.next_song).pack(side=tk.LEFT, padx=2) ttk.Checkbutton(btn_frame, text="Auto-advance", variable=self.auto_advance).pack(side=tk.LEFT, padx=16) ttk.Checkbutton(btn_frame, text="Ticker", variable=self.ticker_enabled).pack(side=tk.LEFT, padx=8) progress_frame = ttk.LabelFrame(main_frame, text="Stream Progress", padding="10") progress_frame.pack(fill=tk.X, pady=8) self.progress_bar = ttk.Progressbar(progress_frame, mode='determinate', style="Accent.Horizontal.TProgressbar") self.progress_bar.pack(fill=tk.X, pady=4) self.progress_label = ttk.Label(progress_frame, text="Waiting...", foreground=COL_SUBTEXT) self.progress_label.pack(pady=2) playlist_frame = ttk.LabelFrame(main_frame, text="Playlist (Double-click to play)", padding="10") playlist_frame.pack(fill=tk.BOTH, expand=True, pady=10) list_frame = ttk.Frame(playlist_frame) list_frame.pack(fill=tk.BOTH, expand=True) scrollbar = ttk.Scrollbar(list_frame) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.playlist_box = tk.Listbox(list_frame, yscrollcommand=scrollbar.set, font=("Consolas", 9), bg=COL_PANEL_LIGHT, fg=COL_TEXT, selectbackground=COL_ACCENT, selectforeground="#ffffff", borderwidth=0, highlightthickness=0, activestyle="none") self.playlist_box.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.config(command=self.playlist_box.yview) self.playlist_box.bind('', self.on_playlist_double_click) song_frame = ttk.LabelFrame(main_frame, text="Current Song", padding="10") song_frame.pack(fill=tk.X, pady=5) self.song_text = tk.Text(song_frame, height=2, wrap=tk.WORD, bg=COL_PANEL_LIGHT, fg=COL_TEXT, insertbackground=COL_TEXT, borderwidth=0, highlightthickness=0, font=("Consolas", 10)) self.song_text.pack(fill=tk.X) console_frame = ttk.LabelFrame(main_frame, text="Console", padding="10") console_frame.pack(fill=tk.BOTH, expand=True) self.console = scrolledtext.ScrolledText(console_frame, height=8, font=("Consolas", 9), bg=COL_PANEL_LIGHT, fg=COL_SUBTEXT, insertbackground=COL_TEXT, borderwidth=0, highlightthickness=0) self.console.pack(fill=tk.BOTH, expand=True) def setup_bindings(self): # Ctrl+1 / Ctrl+2 focus the address textboxes self.root.bind_all("", lambda e: self.android_ip_entry.focus_set()) self.root.bind_all("", lambda e: self.windows_ip_entry.focus_set()) # Media keys: play/pause toggles play/stop, next/previous move in playlist media_bindings = { "": lambda e: self.toggle_play_pause(), "": lambda e: self.toggle_play_pause(), "": lambda e: self.next_song(), "": lambda e: self.prev_song(), "": lambda e: self.toggle_play_pause(), "": lambda e: self.next_song(), "": lambda e: self.prev_song(), } for keysym, handler in media_bindings.items(): try: self.root.bind_all(keysym, handler) except tk.TclError: pass def restart_with_new_addresses(self): android_ip = self.android_ip_var.get().strip() windows_ip = self.windows_ip_var.get().strip() if not android_ip or not windows_ip: self.log("Please enter both IP addresses before restarting.") return self.log(f"Restarting with Android IP={android_ip}, Windows IP={windows_ip}...") try: if self.streamer: self.streamer.stop() except Exception: pass env = os.environ.copy() env["ANDROID_HOST_OVERRIDE"] = android_ip env["WINDOWS_IP_OVERRIDE"] = windows_ip python = sys.executable args = [python] + sys.argv os.execve(python, args, env) def toggle_play_pause(self): if self.is_playing: self.stop_song() else: self.play_song() def show_ticker(self, song_name): if not self.ticker_enabled.get(): return if self.ticker_after_id: try: self.root.after_cancel(self.ticker_after_id) except Exception: pass self.ticker_after_id = None if self.ticker_window is None or not self.ticker_window.winfo_exists(): self.ticker_window = tk.Toplevel(self.root) self.ticker_window.overrideredirect(True) try: self.ticker_window.attributes("-topmost", True) except Exception: pass try: self.ticker_window.attributes("-alpha", 0.95) except Exception: pass self.ticker_window.configure(bg=COL_ACCENT_DARK) frame = tk.Frame(self.ticker_window, bg=COL_ACCENT_DARK, padx=12, pady=8) frame.pack() self.ticker_title_label = tk.Label(frame, text="Now Playing", bg=COL_ACCENT_DARK, fg="#ffffff", font=("Segoe UI", 8, "bold")) self.ticker_title_label.pack(anchor=tk.W) self.ticker_song_label = tk.Label(frame, text="", bg=COL_ACCENT_DARK, fg="#ffffff", font=("Segoe UI", 10, "bold"), wraplength=280, justify=tk.LEFT) self.ticker_song_label.pack(anchor=tk.W) else: self.ticker_window.deiconify() self.ticker_song_label.config(text=song_name) self.ticker_window.update_idletasks() self._position_ticker() self.ticker_window.lift() self.ticker_after_id = self.root.after(4000, self._hide_ticker) def _position_ticker(self): if not self.ticker_window or not self.ticker_window.winfo_exists(): return self.ticker_window.update_idletasks() w = self.ticker_window.winfo_width() h = self.ticker_window.winfo_height() screen_w = self.ticker_window.winfo_screenwidth() screen_h = self.ticker_window.winfo_screenheight() x = screen_w - w - 20 y = screen_h - h - 60 self.ticker_window.geometry(f"+{x}+{y}") def _hide_ticker(self): self.ticker_after_id = None if self.ticker_window and self.ticker_window.winfo_exists(): self.ticker_window.withdraw() def log(self, msg): def _do_log(): self.console.insert(tk.END, f"[{time.strftime('%H:%M:%S')}] {msg}\n") self.console.see(tk.END) try: if threading.current_thread() is threading.main_thread(): _do_log() else: self.root.after(0, _do_log) except Exception: pass def update_progress(self, mb, stream_id): if stream_id != self.stream_generation: return self.current_mb = mb self.progress_label.config(text=f"Receiving: {mb:.2f} MB") percent = min(int((mb / 5) * 100), 100) self.progress_bar['value'] = percent def clean_filename(self, filename): if not filename: return "" filename = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', filename) filename = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', filename) filename = filename.strip() return filename def send_telnet_command(self, cmd): sock = None try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) sock.connect((ANDROID_HOST, ANDROID_PORT)) time.sleep(0.5) try: sock.recv(4096) except: pass sock.send(f"{cmd}\n".encode()) time.sleep(1) response = b'' sock.settimeout(2) try: while True: data = sock.recv(4096) if not data: break response += data if b'~ $' in data or b'#' in data: break except socket.timeout: pass return response.decode('utf-8', errors='ignore') except Exception as e: self.log(f"Telnet error: {e}") return "" finally: if sock: try: sock.close() except: pass def _get_poweramp_pid(self): response = self.send_telnet_command('su -c "pidof com.maxmpz.audioplayer"') cleaned = self.clean_filename(response) match = re.search(r'\d+', cleaned) if match: return match.group(0) return None def get_song_and_folder(self): self.status_label.config(text="Status: Getting song and folder...", foreground=COL_WARNING) threading.Thread(target=self._get_song_and_folder, daemon=True).start() def _get_song_and_folder(self): try: self.log("Getting Poweramp PID...") pid = self._get_poweramp_pid() if not pid: self.log("Could not find Poweramp PID") self.status_label.config(text="Status: No PID", foreground=COL_ERROR) return self.log(f"Poweramp PID: {pid}") self.log("Getting current song path...") response = self.send_telnet_command( f'su -c "ls -la /proc/{pid}/fd/ 2>/dev/null | grep /mobile/ | sed \'s/.*-> //\' | tail -1"' ) response = self.clean_filename(response) start = response.find('/storage/') if start != -1: end = response.find('.mp3', start) if end != -1: self.current_song_path = response[start:end + 4] self.current_folder = '/'.join(self.current_song_path.split('/')[:-1]) + '/' filename = self.current_song_path.split('/')[-1] self.log(f"Found: {filename}") self.log(f"Folder: {self.current_folder}") self.log("Listing folder contents...") ls_cmd = f'ls -1 "{self.current_folder}" | grep -E "\\.(mp3|flac|wav|m4a|ogg|opus)"' ls_response = self.send_telnet_command(ls_cmd) self.playlist = [] for line in ls_response.split('\n'): line = self.clean_filename(line) if line and re.search(r'\.(mp3|flac|wav|m4a|ogg|opus)$', line, re.IGNORECASE): self.playlist.append(line) self.playlist = sorted(self.playlist) clean_filename = self.clean_filename(filename) for i, song in enumerate(self.playlist): if song == clean_filename: self.playlist_index = i break self.playlist_box.delete(0, tk.END) for song in self.playlist: self.playlist_box.insert(tk.END, song) if self.playlist_index >= 0: self.playlist_box.selection_set(self.playlist_index) self.playlist_box.see(self.playlist_index) self.log(f"Found {len(self.playlist)} songs in folder") self.song_text.delete(1.0, tk.END) self.song_text.insert(1.0, self.current_song_path) self.status_label.config(text="Status: Ready", foreground=COL_SUCCESS) return self.log("No song found") self.status_label.config(text="Status: No song", foreground=COL_ERROR) except Exception as e: self.log(f"ERROR: {e}") self.status_label.config(text="Status: Error", foreground=COL_ERROR) def _wait_for_receiver_stop(self, timeout=5): if self.active_stream_thread and self.active_stream_thread.is_alive(): self.active_stream_thread.join(timeout=timeout) def _do_stop_current_stream(self): """Stop current stream. Must be called WITH playing_lock held.""" if self.streamer: old = self.streamer self.streamer = None old.stop() self.playing_lock.release() try: self._wait_for_receiver_stop(timeout=3) time.sleep(0.5) finally: self.playing_lock.acquire() def play_song(self): if not self.current_song_path: self.log("No song loaded. Click 'Get Current Song & Folder' first.") return threading.Thread(target=self._play_song_worker, daemon=True).start() def _play_song_worker(self): with self.playing_lock: self.root.after(0, lambda: self.progress_bar.configure(value=0)) self.root.after(0, lambda: self.progress_label.config(text="Starting...")) self.stream_generation += 1 my_gen = self.stream_generation self.current_mb = 0 if self.streamer: self.log("Stopping previous stream...") old = self.streamer self.streamer = None old.stop() self.playing_lock.release() try: self._wait_for_receiver_stop(timeout=3) time.sleep(0.5) finally: self.playing_lock.acquire() self.log(f"Starting audio receiver (stream {my_gen})...") self.streamer = AudioStreamer() self.streamer.set_log_callback(lambda mb, sid: self.root.after(0, self.update_progress, mb, sid)) def run_receiver(): try: self.streamer.start_receiver(my_gen) except Exception as e: self.log(f"Receiver error: {e}") finally: self.root.after(100, lambda: self.on_song_ended(my_gen)) self.active_stream_thread = threading.Thread(target=run_receiver, daemon=True) self.active_stream_thread.start() # Wait for receiver to be listening ready = False for _ in range(50): if self.streamer and self.streamer.sock: try: self.streamer.sock.getsockname() ready = True break except: pass time.sleep(0.1) if not ready: self.log("ERROR: Receiver did not start in time!") self.root.after(0, lambda: self.status_label.config(text="Status: Receiver failed", foreground=COL_ERROR)) self.streamer = None return clean_filename = self.clean_filename(self.current_song_path.split('/')[-1]) self.log(f"Streaming: {clean_filename} (stream {my_gen})") self.root.after(0, lambda: self.show_ticker(clean_filename)) escaped_filename = clean_filename.replace("'", "'\\''") cmd = f"cat '{self.current_folder}{escaped_filename}' | nc {WINDOWS_IP} {AUDIO_STREAM_PORT}" self.is_playing = True self.root.after(0, lambda: self.status_label.config(text="Status: Playing", foreground=COL_SUCCESS)) # Send command OUTSIDE lock so we don't block try: self.send_telnet_command(cmd) except Exception as e: self.log(f"Stream send error: {e}") def on_song_ended(self, gen): if gen != self.stream_generation: self.log(f"Ignoring end of stale stream {gen} (current is {self.stream_generation})") return with self.playing_lock: mb = self.current_mb if mb > 0: self.log(f"Song ended - Total: {mb:.2f} MB") else: self.log("Song ended") self.progress_bar['value'] = 0 self.progress_label.config(text="Complete") self.current_mb = 0 self.is_playing = False self.streamer = None should_advance = (gen == self.stream_generation and self.auto_advance.get()) if should_advance: self.log("Auto-advancing...") self.root.after(500, self._safe_next_song) def _safe_next_song(self): with self.playing_lock: should_advance = self.auto_advance.get() and not self.is_playing if should_advance: self.next_song() def play_song_at_index(self, index): with self.playing_lock: if index < 0 or index >= len(self.playlist): self.log("End of playlist reached") self.playing_lock.release() try: self.stop_song() finally: self.playing_lock.acquire() return False if self.streamer: old = self.streamer self.streamer = None old.stop() self.playing_lock.release() try: self._wait_for_receiver_stop(timeout=3) time.sleep(0.5) finally: self.playing_lock.acquire() self.playlist_index = index clean_song = self.clean_filename(self.playlist[index]) self.current_song_path = self.current_folder + clean_song self.song_text.delete(1.0, tk.END) self.song_text.insert(1.0, self.current_song_path) self.playlist_box.selection_clear(0, tk.END) self.playlist_box.selection_set(index) self.playlist_box.see(index) self.play_song() return True def next_song(self): if not self.playlist: return next_index = self.playlist_index + 1 if next_index < len(self.playlist): self.play_song_at_index(next_index) else: self.log("End of playlist - stopping") self.stop_song() def prev_song(self): if not self.playlist: return prev_index = self.playlist_index - 1 if prev_index >= 0: self.play_song_at_index(prev_index) else: self.log("Beginning of playlist") def stop_song(self): with self.playing_lock: self.stream_generation += 1 if self.streamer: old = self.streamer self.streamer = None old.stop() self.playing_lock.release() try: self._wait_for_receiver_stop(timeout=3) finally: self.playing_lock.acquire() self.is_playing = False self.current_mb = 0 self.status_label.config(text="Status: Stopped", foreground=COL_INFO) self.log("Stopped") self.progress_bar['value'] = 0 self.progress_label.config(text="Waiting...") def on_playlist_double_click(self, event): selection = self.playlist_box.curselection() if selection: self.play_song_at_index(selection[0]) def main(): root = tk.Tk() app = PowerampApp(root) root.mainloop() if __name__ == "__main__": main()