Files
gti_radiostudio/playlist_widget.py
T

704 lines
24 KiB
Python

# playlist_widget.py
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFileDialog, QFrame, QSizePolicy,
QListWidget, QListWidgetItem, QAbstractItemView,
QMenu, QSplitter
)
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QFont, QColor, QAction
import json
import os
# ─────────────────────────────────────────
# PLAYLIST ITEM
# ─────────────────────────────────────────
class PlaylistItem(QListWidgetItem):
def __init__(self, filepath: str):
self.filepath = filepath
self.filename = os.path.basename(filepath)
display = os.path.splitext(self.filename)[0]
super().__init__(display)
self.setFont(QFont("Courier New", 10, QFont.Bold))
self.setForeground(QColor("#cccccc"))
self.setToolTip(filepath)
# ─────────────────────────────────────────
# HOUR DISPLAY + CLOCK
# ─────────────────────────────────────────
class HourDisplay(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self.setFrameStyle(QFrame.Box | QFrame.Sunken)
self.setFixedHeight(44)
self.setStyleSheet("""
QFrame {
background-color: #0a0a1a;
border: 1px solid #333;
border-radius: 3px;
}
""")
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 2, 8, 2)
self.hour_label = QLabel("HOUR --:--")
self.hour_label.setFont(QFont("Courier New", 12, QFont.Bold))
self.hour_label.setStyleSheet(
"color: #4488ff; background: transparent; border: none;"
)
self.hour_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.hour_label)
self.clock_label = QLabel("00:00:00")
self.clock_label.setFont(QFont("Courier New", 12, QFont.Bold))
self.clock_label.setStyleSheet(
"color: #aaaaaa; background: transparent; border: none;"
)
self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
layout.addWidget(self.clock_label)
self.clock_timer = QTimer(self)
self.clock_timer.setInterval(1000)
self.clock_timer.timeout.connect(self._update_clock)
self.clock_timer.start()
self._update_clock()
def _update_clock(self):
from datetime import datetime
now = datetime.now()
self.clock_label.setText(now.strftime("%H:%M:%S"))
self.hour_label.setText(f"HOUR {now.strftime('%H:00')}")
# ─────────────────────────────────────────
# DECK SELECTOR
# ─────────────────────────────────────────
class DeckSelector(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(36)
self.setStyleSheet("""
QFrame {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 3px;
}
""")
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 2, 6, 2)
layout.setSpacing(6)
label = QLabel("SEND TO:")
label.setFont(QFont("Arial", 9, QFont.Bold))
label.setStyleSheet("color: #888; background: transparent; border: none;")
layout.addWidget(label)
self.deck1_btn = QPushButton("DECK 1")
self.deck1_btn.setCheckable(True)
self.deck1_btn.setChecked(True)
self.deck1_btn.setFixedHeight(26)
self.deck1_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._style_btn(self.deck1_btn, "#00897b")
self.deck2_btn = QPushButton("DECK 2")
self.deck2_btn.setCheckable(True)
self.deck2_btn.setFixedHeight(26)
self.deck2_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._style_btn(self.deck2_btn, "#f9ca24")
self.deck1_btn.clicked.connect(lambda: self._select("deck1"))
self.deck2_btn.clicked.connect(lambda: self._select("deck2"))
layout.addWidget(self.deck1_btn)
layout.addWidget(self.deck2_btn)
layout.addStretch()
self._selected = "deck1"
def _style_btn(self, btn: QPushButton, accent: str):
btn.setStyleSheet(f"""
QPushButton {{
background-color: #2a2a2a;
color: #888;
border: 1px solid #444;
border-radius: 3px;
padding: 0 8px;
}}
QPushButton:checked {{
background-color: #1a1a1a;
color: {accent};
border: 1px solid {accent};
}}
QPushButton:hover {{ background-color: #333; }}
""")
def _select(self, deck: str):
self._selected = deck
self.deck1_btn.setChecked(deck == "deck1")
self.deck2_btn.setChecked(deck == "deck2")
@property
def selected_deck(self) -> str:
return self._selected
# ─────────────────────────────────────────
# BASE PLAYLIST PANEL
# ─────────────────────────────────────────
class BasePlaylistPanel(QWidget):
AUDIO_FILTER = (
"Audio Files "
"(*.wav *.flac *.mp3 *.m4a *.mp4 *.ogg *.opus *.aac *.aiff)"
)
AUDIO_EXTENSIONS = {
'.wav', '.flac', '.mp3', '.m4a', '.mp4',
'.ogg', '.opus', '.aac', '.aiff'
}
def __init__(self, parent=None):
super().__init__(parent)
def _make_list(self) -> QListWidget:
lst = QListWidget()
lst.setStyleSheet("""
QListWidget {
background-color: #0d0d0d;
color: #cccccc;
border: 1px solid #333;
border-radius: 3px;
outline: none;
}
QListWidget::item {
padding: 5px 6px;
border-bottom: 1px solid #1a1a1a;
}
QListWidget::item:selected {
background-color: #1a3a5a;
color: #ffffff;
}
QListWidget::item:hover {
background-color: #1e1e1e;
}
QScrollBar:vertical {
background: #1a1a1a;
width: 8px;
border-radius: 4px;
}
QScrollBar::handle:vertical {
background: #444;
border-radius: 4px;
}
""")
lst.setSelectionMode(QAbstractItemView.SingleSelection)
lst.setDragDropMode(QAbstractItemView.InternalMove)
lst.setContextMenuPolicy(Qt.CustomContextMenu)
return lst
def _make_btn(self, text: str, color: str, height: int = 28) -> QPushButton:
btn = QPushButton(text)
btn.setFixedHeight(height)
btn.setFont(QFont("Arial", 9, QFont.Bold))
btn.setStyleSheet(f"""
QPushButton {{
background-color: {color};
color: #cccccc;
border: 1px solid #444;
border-radius: 3px;
}}
QPushButton:hover {{ background-color: #555; }}
QPushButton:pressed {{ background-color: #222; }}
""")
return btn
def _add_files_to(self, lst: QListWidget):
paths, _ = QFileDialog.getOpenFileNames(
self, "Add Files",
os.path.expanduser("~"),
self.AUDIO_FILTER
)
for path in paths:
self._add_item_to(lst, path)
self._update_count(lst)
def _add_folder_to(self, lst: QListWidget):
folder = QFileDialog.getExistingDirectory(
self, "Add Folder",
os.path.expanduser("~")
)
if not folder:
return
for root_dir, dirs, files in os.walk(folder):
dirs.sort()
for filename in sorted(files):
ext = os.path.splitext(filename)[1].lower()
if ext in self.AUDIO_EXTENSIONS:
self._add_item_to(lst, os.path.join(root_dir, filename))
self._update_count(lst)
def _add_item_to(self, lst: QListWidget, filepath: str):
for i in range(lst.count()):
item = lst.item(i)
if isinstance(item, PlaylistItem) and item.filepath == filepath:
return
lst.addItem(PlaylistItem(filepath))
def _update_count(self, lst: QListWidget, label: QLabel = None):
n = lst.count()
if label:
label.setText(f"{n} track{'s' if n != 1 else ''}")
def _highlight(self, item: PlaylistItem,
active: bool = False, queued: bool = False):
if active:
item.setForeground(QColor("#00ff41"))
elif queued:
item.setForeground(QColor("#f9ca24"))
else:
item.setForeground(QColor("#cccccc"))
def get_filepaths(self, lst: QListWidget) -> list:
paths = []
for i in range(lst.count()):
item = lst.item(i)
if isinstance(item, PlaylistItem):
paths.append(item.filepath)
return paths
def clear_list(self, lst: QListWidget):
lst.clear()
# ─────────────────────────────────────────
# TRACK PLAYLIST PANEL
# ─────────────────────────────────────────
class TrackPlaylistPanel(BasePlaylistPanel):
def __init__(self, engine, deck_widgets: dict, parent=None):
super().__init__(parent)
self.engine = engine
self.deck_widgets = deck_widgets
self._build_ui()
def _build_ui(self):
self.setStyleSheet("""
QWidget {
background-color: #111111;
border: 2px solid #333333;
border-radius: 6px;
}
""")
root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6)
header = QLabel("PLAYLIST")
header.setFont(QFont("Arial", 12, QFont.Bold))
header.setStyleSheet(
"color: #aaaaaa; background: transparent; border: none; padding: 2px;"
)
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
self.hour_display = HourDisplay()
root.addWidget(self.hour_display)
self.deck_selector = DeckSelector()
root.addWidget(self.deck_selector)
self.playlist = self._make_list()
self.playlist.itemDoubleClicked.connect(self._send_to_deck)
self.playlist.customContextMenuRequested.connect(self._context_menu)
root.addWidget(self.playlist)
row1 = QHBoxLayout()
row1.setSpacing(4)
self.btn_add_files = self._make_btn("ADD FILES", "#2a5a8a")
self.btn_add_folder = self._make_btn("ADD FOLDER", "#2a4a6a")
row1.addWidget(self.btn_add_files)
row1.addWidget(self.btn_add_folder)
root.addLayout(row1)
row2 = QHBoxLayout()
row2.setSpacing(4)
self.btn_send_deck = self._make_btn("→ LOAD DECK", "#1a5a3a")
self.btn_send_queue = self._make_btn("→ QUEUE", "#1a3a2a")
row2.addWidget(self.btn_send_deck)
row2.addWidget(self.btn_send_queue)
root.addLayout(row2)
row3 = QHBoxLayout()
row3.setSpacing(4)
self.btn_remove = self._make_btn("REMOVE", "#5a1a1a")
self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a")
row3.addWidget(self.btn_remove)
row3.addWidget(self.btn_clear)
root.addLayout(row3)
self.count_label = QLabel("0 tracks")
self.count_label.setFont(QFont("Courier New", 9))
self.count_label.setStyleSheet(
"color: #555; background: transparent; border: none;"
)
self.count_label.setAlignment(Qt.AlignCenter)
root.addWidget(self.count_label)
self.btn_add_files.clicked.connect(
lambda: self._add_files_to(self.playlist)
)
self.btn_add_folder.clicked.connect(
lambda: self._add_folder_to(self.playlist)
)
self.btn_send_deck.clicked.connect(self._send_to_deck)
self.btn_send_queue.clicked.connect(self._send_to_queue)
self.btn_remove.clicked.connect(self._remove_selected)
self.btn_clear.clicked.connect(self._clear_all)
def _send_to_deck(self, item=None):
if item is None:
item = self.playlist.currentItem()
if not isinstance(item, PlaylistItem):
return
deck_key = self.deck_selector.selected_deck
self.engine.load_track(deck_key, item.filepath)
name = os.path.splitext(item.filename)[0]
if deck_key in self.deck_widgets:
self.deck_widgets[deck_key].set_track_name(name)
self._highlight(item, active=True)
def _send_to_queue(self):
item = self.playlist.currentItem()
if not isinstance(item, PlaylistItem):
return
deck_key = self.deck_selector.selected_deck
self.engine.load_queue(deck_key, item.filepath)
name = os.path.splitext(item.filename)[0]
if deck_key in self.deck_widgets:
self.deck_widgets[deck_key].set_queue_name(name)
self._highlight(item, queued=True)
def _remove_selected(self):
row = self.playlist.currentRow()
if row >= 0:
self.playlist.takeItem(row)
self._update_count(self.playlist, self.count_label)
def _clear_all(self):
self.playlist.clear()
self._update_count(self.playlist, self.count_label)
def _context_menu(self, position):
item = self.playlist.itemAt(position)
if not isinstance(item, PlaylistItem):
return
menu = QMenu(self)
menu.setStyleSheet("""
QMenu {
background-color: #1e1e1e;
color: #cccccc;
border: 1px solid #444;
}
QMenu::item:selected { background-color: #1a3a5a; }
""")
def load(deck_key):
self.engine.load_track(deck_key, item.filepath)
name = os.path.splitext(item.filename)[0]
if deck_key in self.deck_widgets:
self.deck_widgets[deck_key].set_track_name(name)
self._highlight(item, active=True)
def queue(deck_key):
self.engine.load_queue(deck_key, item.filepath)
name = os.path.splitext(item.filename)[0]
if deck_key in self.deck_widgets:
self.deck_widgets[deck_key].set_queue_name(name)
self._highlight(item, queued=True)
a1 = QAction("Load → Deck 1", self)
a2 = QAction("Load → Deck 2", self)
a3 = QAction("Queue → Deck 1", self)
a4 = QAction("Queue → Deck 2", self)
ar = QAction("Remove", self)
a1.triggered.connect(lambda: load("deck1"))
a2.triggered.connect(lambda: load("deck2"))
a3.triggered.connect(lambda: queue("deck1"))
a4.triggered.connect(lambda: queue("deck2"))
ar.triggered.connect(
lambda: self.playlist.takeItem(self.playlist.row(item))
)
menu.addAction(a1)
menu.addAction(a2)
menu.addSeparator()
menu.addAction(a3)
menu.addAction(a4)
menu.addSeparator()
menu.addAction(ar)
menu.exec(self.playlist.viewport().mapToGlobal(position))
self._update_count(self.playlist, self.count_label)
# ─────────────────────────────────────────
# CART PLAYLIST PANEL
# ─────────────────────────────────────────
class CartPlaylistPanel(BasePlaylistPanel):
def __init__(self, engine, cart_slots: list, parent=None):
super().__init__(parent)
self.engine = engine
self.cart_slots = cart_slots
self._build_ui()
def _build_ui(self):
self.setStyleSheet("""
QWidget {
background-color: #111111;
border: 2px solid #cc3300;
border-radius: 6px;
}
""")
root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6)
header = QLabel("CART PLAYLIST")
header.setFont(QFont("Arial", 12, QFont.Bold))
header.setStyleSheet(
"color: #cc3300; background: transparent; border: none; padding: 2px;"
)
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
self.playlist = self._make_list()
self.playlist.itemDoubleClicked.connect(self._send_to_next_free_cart)
self.playlist.customContextMenuRequested.connect(self._context_menu)
root.addWidget(self.playlist)
row1 = QHBoxLayout()
row1.setSpacing(4)
self.btn_add_files = self._make_btn("ADD FILES", "#5a2a00")
self.btn_add_folder = self._make_btn("ADD FOLDER", "#4a2000")
row1.addWidget(self.btn_add_files)
row1.addWidget(self.btn_add_folder)
root.addLayout(row1)
row2 = QHBoxLayout()
row2.setSpacing(4)
self.btn_remove = self._make_btn("REMOVE", "#5a1a1a")
self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a")
row2.addWidget(self.btn_remove)
row2.addWidget(self.btn_clear)
root.addLayout(row2)
self.count_label = QLabel("0 tracks")
self.count_label.setFont(QFont("Courier New", 9))
self.count_label.setStyleSheet(
"color: #555; background: transparent; border: none;"
)
self.count_label.setAlignment(Qt.AlignCenter)
root.addWidget(self.count_label)
self.btn_add_files.clicked.connect(
lambda: self._add_files_to(self.playlist)
)
self.btn_add_folder.clicked.connect(
lambda: self._add_folder_to(self.playlist)
)
self.btn_remove.clicked.connect(self._remove_selected)
self.btn_clear.clicked.connect(self._clear_all)
def _send_to_cart(self, item: PlaylistItem, cart_index: int):
if not isinstance(item, PlaylistItem):
return
slot = self.cart_slots[cart_index]
slot.load_track(item.filepath)
self._highlight(item, active=True)
def _send_to_next_free_cart(self, item=None):
if item is None:
item = self.playlist.currentItem()
if not isinstance(item, PlaylistItem):
return
for i in range(4):
cart_key = f"cart{i + 1}"
if self.engine.decks[cart_key].track is None:
self._send_to_cart(item, i)
return
self._send_to_cart(item, 0)
def _remove_selected(self):
row = self.playlist.currentRow()
if row >= 0:
self.playlist.takeItem(row)
self._update_count(self.playlist, self.count_label)
def _clear_all(self):
self.playlist.clear()
self._update_count(self.playlist, self.count_label)
def _context_menu(self, position):
item = self.playlist.itemAt(position)
if not isinstance(item, PlaylistItem):
return
menu = QMenu(self)
menu.setStyleSheet("""
QMenu {
background-color: #1e1e1e;
color: #cccccc;
border: 1px solid #444;
}
QMenu::item:selected { background-color: #1a3a5a; }
""")
for i in range(4):
action = QAction(f"Load → Cart {i + 1}", self)
action.triggered.connect(
lambda checked, idx=i: self._send_to_cart(item, idx)
)
menu.addAction(action)
menu.addSeparator()
ar = QAction("Remove", self)
ar.triggered.connect(
lambda: self.playlist.takeItem(self.playlist.row(item))
)
menu.addAction(ar)
menu.exec(self.playlist.viewport().mapToGlobal(position))
self._update_count(self.playlist, self.count_label)
# ─────────────────────────────────────────
# COMBINED PLAYLIST WIDGET
# ─────────────────────────────────────────
class PlaylistWidget(QWidget):
PLAYLIST_JSON_FILTER = "JSON Files (*.json)"
def __init__(self, engine, deck_widgets: dict,
cart_slots: list, parent=None):
super().__init__(parent)
self.engine = engine
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMinimumWidth(200)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(4)
# Toolbar
bar = QHBoxLayout()
bar.setSpacing(4)
self._save_btn = QPushButton("SAVE")
self._save_btn.setFixedHeight(28)
self._save_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._save_btn.setStyleSheet("""
QPushButton {
background-color: #2a4a2a;
color: #aaaaaa;
border: 1px solid #444;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #3a5a3a; }
""")
self._save_btn.clicked.connect(self._save_playlist)
self._load_btn = QPushButton("LOAD")
self._load_btn.setFixedHeight(28)
self._load_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._load_btn.setStyleSheet("""
QPushButton {
background-color: #3a2a2a;
color: #aaaaaa;
border: 1px solid #444;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #4a3a3a; }
""")
self._load_btn.clicked.connect(self._load_playlist)
bar.addWidget(self._save_btn)
bar.addWidget(self._load_btn)
bar.addStretch()
root.addLayout(bar)
splitter = QSplitter(Qt.Vertical)
splitter.setStyleSheet("""
QSplitter::handle {
background-color: #333;
height: 4px;
}
""")
self.track_panel = TrackPlaylistPanel(engine, deck_widgets)
self.cart_panel = CartPlaylistPanel(engine, cart_slots)
splitter.addWidget(self.track_panel)
splitter.addWidget(self.cart_panel)
splitter.setSizes([600, 400])
root.addWidget(splitter)
def _save_playlist(self):
path, _ = QFileDialog.getSaveFileName(
self, "Save Playlist",
os.path.expanduser("~/playlist.json"),
self.PLAYLIST_JSON_FILTER
)
if not path:
return
data = {
"version": 1,
"tracks": self.track_panel.get_filepaths(self.track_panel.playlist),
"cart_tracks": self.cart_panel.get_filepaths(self.cart_panel.playlist),
}
with open(path, 'w') as f:
json.dump(data, f, indent=2)
def _load_playlist(self):
path, _ = QFileDialog.getOpenFileName(
self, "Load Playlist",
os.path.expanduser("~"),
self.PLAYLIST_JSON_FILTER
)
if not path:
return
try:
with open(path) as f:
data = json.load(f)
except Exception:
return
tracks = data.get("tracks", [])
cart = data.get("cart_tracks", [])
self.track_panel.playlist.clear()
for fp in tracks:
if os.path.exists(fp):
self.track_panel._add_item_to(self.track_panel.playlist, fp)
self.track_panel._update_count(self.track_panel.playlist,
self.track_panel.count_label)
self.cart_panel.playlist.clear()
for fp in cart:
if os.path.exists(fp):
self.cart_panel._add_item_to(self.cart_panel.playlist, fp)
self.cart_panel._update_count(self.cart_panel.playlist,
self.cart_panel.count_label)