StemRoller: desktop stem separator, Android stems player, and server daemons

- stemroller/: PySide6/QML desktop app and player for separating music into stems
- android/: Plex music player that plays per-stem mixes, with Android Auto support,
  a graphic + parametric (Equalizer APO-style) EQ, and offline downloads
- server/: stemserve (serves stems to the app) and stemd (separates the library on
  the server's GPU, with "Stem Now" priority requests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClericalErrorandClaude Opus 5.5 committed 2026-10-14 21:17:17 -07:00
1 parent 716c5499ff
commit 1845f03c41
184 files changed
+24648

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
# StemRoller Player
The desktop counterpart to the phone app: your Plex library and its stems, with per-stem
faders, mute/solo, mix presets and a master volume. It is dressed as Microsoft Excel 2016.
```sh
stemroller-player # or "StemRoller Player" in the app grid (scripts/install-desktop.sh)
```
## First run
**Data → Connect to Plex** (or click **Sign in** on the title bar), then enter the 4-character
code at plex.tv/link. It picks the music library at `…/Flacs`. Until then the workbook is built
from the stem server's index alone (artist / album / song from the stems folders), so every
separated song is already playable.
## Where things are
| Excel | Here |
|---|---|
| Sheets: Songs, Artists, Albums, Playlists, Stems, Queue | The library. Double-click (or Enter) plays a song from that point, or opens an artist, album or playlist as a new sheet. Backspace / middle-click closes it |
| Row 1 filter buttons, Sort & Filter | Sort by any column |
| "Tell me what you want to do" | Search (Ctrl+F). Results open as a sheet |
| Status bar Average / Count / Sum | Select a range of times and it adds them up |
| Zoom slider | Master volume (scroll on it, too). Ctrl+scroll on the grid is the real zoom |
| Home → Cell Styles | Mix presets: Full Mix, Karaoke ("Bad"), A Cappella ("Good"), No Drums, Rhythm, No Guitar, No Bass |
| Home → Cells | Play Next, Add to Queue, Delete Rows (queue or playlist) |
| Insert → Playlists | New Plex playlist from the selection, or add the selection to one |
| AutoSum | AutoShuffle: shuffle the whole sheet |
| Quick Access Toolbar | Play/Pause, Undo = previous song, Redo = next song |
| Format Mix task pane | Now playing, a sparkline of the song to seek on, and the stems as a column chart: drag a column to set its level, double-click for 0 dB, M / S below |
| Green triangle in a cell | The song that's playing |
| `#GETTING_DATA` | A playlist loading from Plex |
| File → Info / Account / Options | Library and server status, Plex sign-in, server addresses and folders |
Keys: Space play/pause, Ctrl+←/→ previous/next, V vocals on/off, arrows/Shift+arrows select,
Ctrl+C copies cells as text, Ctrl+Q quits. The media keys, the desktop's media controls and
KDE Connect on the phone work through MPRIS.
## How it plays
- **Stems** come straight off the `Stems` share (`/var/mnt/unraid/Music/Stems`) when it's
mounted, else they're downloaded from stemserve once into `~/.cache/stemroller/player/files`
(4 GB, oldest first out). Songs without stems play the original from the music share, or from
Plex if the share isn't there.
- A decoder thread reads every stem about 1.5 s ahead of the playhead, so songs start at once
and nothing is decoded whole; the audio callback mixes with click-free gain ramps. The fader
taper matches the phone (0.8 = 0 dB, top = +3.9 dB).
- Plays are reported to Plex (now playing, and the play count at 90%).
- Album art: Plex's, or the album folder's `cover.*`, or the picture embedded in the file.
- The queue, position, mix and volume come back on the next launch (`~/.config/stemroller/`).
## Checking it without a window
```sh
QT_QPA_PLATFORM=offscreen stemroller-player --no-mpris --screenshot /tmp/p.png@5 --quit-after 6
```
`--eval 'JS@seconds'` runs JavaScript against the window (e.g. `library.activate(0, 0)@2`).
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
import sys
from .app import main
sys.exit(main())
+136
View File
@@ -0,0 +1,136 @@
"""StemRoller Player: the Plex library and its stems, in a workbook."""
import argparse
import os
import signal
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
def main():
os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic")
os.environ.setdefault("QT_QPA_PLATFORM", "wayland;xcb")
os.environ.setdefault("QSG_RENDER_LOOP", "threaded")
ap = argparse.ArgumentParser(prog="stemroller-player")
ap.add_argument("--screenshot", action="append", default=[], metavar="PNG@SECONDS",
help="grab the window to PNG after N seconds (repeatable; for headless checks)")
ap.add_argument("--quit-after", type=float, default=0, metavar="SECONDS")
ap.add_argument("--size", default="", metavar="WxH")
ap.add_argument("--no-mpris", action="store_true")
ap.add_argument("--eval", action="append", default=[], metavar="JS@SECONDS",
help="run a JavaScript snippet against the window after N seconds (dev)")
args = ap.parse_args()
from PySide6.QtCore import QTimer, QUrl
from PySide6.QtGui import QFontDatabase, QGuiApplication, QIcon, QSurfaceFormat
from PySide6.QtQml import QQmlApplicationEngine
fmt = QSurfaceFormat.defaultFormat()
fmt.setSwapInterval(1)
QSurfaceFormat.setDefaultFormat(fmt)
app = QGuiApplication(sys.argv)
app.setApplicationName("StemRoller Player")
app.setOrganizationName("stemroller")
app.setDesktopFileName("stemroller-player")
app.setWindowIcon(QIcon(os.path.join(HERE, "assets", "stemroller-player.svg")))
for d in (os.path.join(HERE, "assets", "fonts"), os.path.join(HERE, "..", "assets", "fonts")):
if os.path.isdir(d):
for f in sorted(os.listdir(d)):
if f.endswith((".ttf", ".otf")):
QFontDatabase.addApplicationFont(os.path.join(d, f))
families = set(QFontDatabase.families())
# Excel: Segoe UI for the chrome, Calibri (or its metric twin Carlito) in the cells.
ui_font = next((f for f in ("Segoe UI", "Selawik", "Inter") if f in families), "sans-serif")
cell_font = next((f for f in ("Aptos", "Calibri", "Carlito") if f in families), ui_font)
from . import config
from .engine import PlayerEngine
from .icons import IconProvider
from .library import Library
from .player import Player
from .plex import Plex
from .stems import StemServer
cfg = config.load()
plex = Plex(cfg)
stems = StemServer(cfg, config.CACHE_DIR)
engine = PlayerEngine()
library = Library(cfg, plex, stems)
player = Player(cfg, plex, stems, engine)
library.player = player
player.library = library
library.update_queue()
qml = QQmlApplicationEngine()
qml.addImageProvider("icon", IconProvider())
qml.addImportPath(os.path.join(HERE, "qml"))
ctx = qml.rootContext()
ctx.setContextProperty("library", library)
ctx.setContextProperty("sheetModel", library.model)
ctx.setContextProperty("player", player)
ctx.setContextProperty("engine", engine)
settings = _Settings(cfg)
ctx.setContextProperty("settings", settings)
ctx.setContextProperty("uiFont", ui_font)
ctx.setContextProperty("cellFont", cell_font)
qml.load(QUrl.fromLocalFile(os.path.join(HERE, "qml", "StemPlayer", "Main.qml")))
if not qml.rootObjects():
print("failed to load QML", file=sys.stderr)
return 1
window = qml.rootObjects()[0]
if args.size:
w, h = (int(v) for v in args.size.split("x"))
window.resize(w, h)
if not args.no_mpris:
from .mpris import Mpris
mpris = Mpris(player, engine, lambda: (window.show(), window.raise_(), window.requestActivate()), app.quit)
window.setProperty("mprisOk", mpris.ok)
for e in args.eval:
code, _, after = e.rpartition("@")
QTimer.singleShot(int(float(after) * 1000), lambda code=code: window.setProperty("devEval", code))
for shot in args.screenshot:
path, _, after = shot.rpartition("@")
QTimer.singleShot(int(float(after) * 1000),
lambda path=path: (window.grabWindow().save(path), print("screenshot saved", path, flush=True)))
if args.quit_after:
QTimer.singleShot(int(args.quit_after * 1000), app.quit)
signal.signal(signal.SIGINT, lambda *_: app.quit())
keepalive = QTimer()
keepalive.start(500)
keepalive.timeout.connect(lambda: None)
def shutdown():
player.shutdown()
engine.shutdown()
config.save(cfg)
app.aboutToQuit.connect(shutdown)
rc = app.exec()
del qml
return rc
def _Settings(cfg):
"""Editable settings for File → Options."""
from PySide6.QtCore import QObject, Slot
from . import config
class Settings(QObject):
@Slot(str, result="QVariant")
def get(self, key):
return cfg.get(key)
@Slot(str, "QVariant")
def set(self, key, value):
cfg[key] = value
config.save(cfg)
return Settings()
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.
@@ -0,0 +1,94 @@
Copyright 2013 The Carlito Project Authors (https://github.com/googlefonts/carlito), with Reserved Font Name "Carlito"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,91 @@
Copyright 2015, Microsoft Corporation (www.microsoft.com), with Reserved Font Name Selawik. All Rights Reserved. Selawik is a trademark of Microsoft Corporation in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="4" y="6" width="40" height="52" rx="4" fill="#217346"/>
<rect x="24" y="14" width="36" height="36" rx="3" fill="#ffffff"/>
<path d="M24 26h36M24 38h36M42 14v36" stroke="#217346" stroke-width="1.5"/>
<rect x="28" y="30" width="4" height="16" fill="#FF7AB6"/>
<rect x="34" y="22" width="4" height="24" fill="#FFB547"/>
<rect x="46" y="28" width="4" height="18" fill="#7C8CFF"/>
<rect x="52" y="19" width="4" height="27" fill="#4CD07D"/>
<path d="M9 20h7l4 7 4-7h7l-7.5 12L31 44h-7l-4-7-4 7H9l7.5-12z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 609 B

+47
View File
@@ -0,0 +1,47 @@
"""Player settings and saved state: ~/.config/stemroller/player.json."""
import json
import os
import uuid
CONFIG_DIR = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "stemroller")
CACHE_DIR = os.path.join(os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "stemroller", "player")
PATH = os.path.join(CONFIG_DIR, "player.json")
DEFAULTS = {
"plexUrl": "http://192.168.86.2:32400",
"plexToken": "",
"plexUser": "",
"musicSection": "",
"sectionLocations": [],
"stemUrl": "http://192.168.86.2:8765",
"stemToken": "",
# The unraid shares, mounted here; files play straight from them when present.
"stemsDir": "/var/mnt/unraid/Music/Stems",
"musicDir": "/var/mnt/unraid/Music/Actual music/Flacs",
"stemRoots": [],
"volume": 0.8,
"keepMix": False,
"showMixer": True,
"gridlines": True,
"zoom": 100,
}
def load() -> dict:
cfg = dict(DEFAULTS)
try:
with open(PATH) as f:
cfg.update(json.load(f))
except (OSError, ValueError):
pass
cfg.setdefault("clientId", str(uuid.uuid4()))
return cfg
def save(cfg: dict):
os.makedirs(CONFIG_DIR, exist_ok=True)
tmp = PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=1)
os.replace(tmp, PATH)
+448
View File
@@ -0,0 +1,448 @@
"""Streaming playback: a decoder thread reads every stem ahead of the playhead in chunks,
and the PortAudio callback mixes them with per-stem gains and the master volume.
Nothing is decoded whole, so a song starts as soon as its first chunk is read (stems sit on
the network share) and memory stays flat. The callback never takes a lock: chunks travel
through a deque tagged with a generation number, and a seek or a new song bumps the
generation so stale chunks are dropped on the floor.
"""
import collections
import json
import subprocess
import threading
import numpy as np
import sounddevice as sd
import soundfile as sf
from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot
CHUNK = 8192 # frames per decoder read
AHEAD = 1.5 # seconds buffered ahead of the playhead
BLOCK = 1024
LATENCY = 0.15 # roomy: Python shares the GIL with library loading
RING = 64
class SfReader:
"""libsndfile: FLAC, WAV, AIFF, Ogg, MP3."""
def __init__(self, path):
self.f = sf.SoundFile(path)
self.sr = self.f.samplerate
self.frames = self.f.frames
def read(self, n):
data = self.f.read(n, dtype="float32", always_2d=True)
if data.shape[1] == 1:
return np.repeat(data, 2, axis=1)
return data[:, :2]
def seek(self, frame):
self.f.seek(min(frame, self.frames))
def close(self):
self.f.close()
class FfmpegReader:
"""Anything else (AAC/ALAC in .m4a and friends), decoded by ffmpeg to float stereo."""
def __init__(self, path, sr=None):
self.path = path
info = json.loads(subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=sample_rate:format=duration",
"-of", "json", path], capture_output=True, check=True, timeout=20).stdout)
self.sr = sr or int(info["streams"][0]["sample_rate"])
self.frames = int(float(info["format"]["duration"]) * self.sr)
self.proc = None
self.seek(0)
def seek(self, frame):
self.close()
self.proc = subprocess.Popen(
["ffmpeg", "-v", "error", "-ss", f"{frame / self.sr:.6f}", "-i", self.path, "-vn",
"-f", "f32le", "-ac", "2", "-ar", str(self.sr), "-"],
stdout=subprocess.PIPE, stdin=subprocess.DEVNULL)
def read(self, n):
raw = self.proc.stdout.read(n * 8)
usable = len(raw) - len(raw) % 8
return np.frombuffer(raw[:usable], np.float32).reshape(-1, 2)
def close(self):
if self.proc:
self.proc.kill()
self.proc.stdout.close()
self.proc.wait()
self.proc = None
def open_reader(path, sr=None):
if sr is None:
try:
return SfReader(path)
except (sf.LibsndfileError, RuntimeError):
pass
return FfmpegReader(path, sr)
class Source:
"""One song: one reader per stem (or a single reader for the original file)."""
def __init__(self, names, paths):
self.names = list(names)
self.readers = []
try:
for p in paths:
self.readers.append(open_reader(p))
sr = self.readers[0].sr
# Stems always share a rate; if one didn't, resample it through ffmpeg.
for i, (r, p) in enumerate(zip(self.readers, paths)):
if r.sr != sr:
r.close()
self.readers[i] = open_reader(p, sr)
except Exception:
self.close()
raise
self.sr = sr
self.frames = min(r.frames for r in self.readers)
def read(self, n):
"""(stems, frames, 2) float32; fewer frames near the end, none at EOF."""
blocks = [r.read(n) for r in self.readers]
m = min(b.shape[0] for b in blocks)
return np.stack([b[:m] for b in blocks])
def seek(self, frame):
for r in self.readers:
r.seek(frame)
def close(self):
for r in self.readers:
try:
r.close()
except Exception:
pass
self.readers = []
class PlayerEngine(QObject):
playingChanged = Signal()
bufferingChanged = Signal()
volumeChanged = Signal()
durationChanged = Signal()
ended = Signal()
failed = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._src: Source | None = None
self._sr = 44100
self._frames = 0
self._gen = 0
self._gen_start = 0 # frame the current generation starts at
self._queue = collections.deque()
self._produced = 0
self._consumed = 0
self._eof = False
self._seek_to = None
self._to_close = []
self._cond = threading.Condition()
self._alive = True
self._playing = False
self._buffering = False
self._ended_flag = False
self._stream: sd.OutputStream | None = None
self._gains = np.ones(0, np.float32)
self._gains_cur = np.ones(0, np.float32)
self._volume = 0.8
self._master = self._volume ** 3
self._master_cur = self._master
# callback-owned
self._cb_gen = -1
self._cur = None
self._cur_off = 0
self._pos = 0
self._underrun = False
self._mix = np.zeros((BLOCK * 8, 2), np.float32)
self._ramp = np.zeros(0, np.float32)
self._ring = np.zeros((RING, 4), np.float64)
self._ring_i = 0
self._thread = threading.Thread(target=self._decode_loop, daemon=True, name="decoder")
self._thread.start()
# The callback only sets flags; this turns them into signals on the GUI thread.
self._tick = QTimer(self)
self._tick.setInterval(100)
self._tick.timeout.connect(self._poll)
self._tick.start()
# ---- decoder thread --------------------------------------------------------------
def _decode_loop(self):
while True:
with self._cond:
while self._alive and not self._to_close and self._seek_to is None and (
self._src is None or self._eof
or self._produced - self._consumed >= AHEAD * self._sr):
self._cond.wait(0.25)
if not self._alive:
return
closing, self._to_close = self._to_close, []
src, gen, seek = self._src, self._gen, self._seek_to
self._seek_to = None
for s in closing:
s.close()
if src is None:
continue
try:
if seek is not None:
src.seek(seek)
block = src.read(CHUNK)
except Exception as e: # unreadable file mid-song: treat as its end
print("decode error:", e, flush=True)
block = np.zeros((len(src.names), 0, 2), np.float32)
with self._cond:
if gen != self._gen:
continue
if block.shape[1] == 0:
self._eof = True
self._queue.append((gen, None))
else:
self._queue.append((gen, block))
self._produced += block.shape[1]
# ---- GUI thread API -----------------------------------------------------------------
def set_source(self, src: Source, start_seconds=0.0, play=True):
"""Replaces the playing song. Takes ownership of [src]."""
if self._stream is not None and self._stream.samplerate != src.sr:
self._close_stream()
with self._cond:
if self._src is not None:
self._to_close.append(self._src)
self._src = src
self._sr = src.sr
self._frames = src.frames
k = len(src.names)
if len(self._gains) != k:
self._gains = np.ones(k, np.float32)
self._gains_cur = self._gains.copy()
self._restart_locked(int(start_seconds * src.sr))
self._ended_flag = False
self.durationChanged.emit()
if play:
self.play()
def clear(self):
self.pause()
with self._cond:
if self._src is not None:
self._to_close.append(self._src)
self._src = None
self._frames = 0
self._restart_locked(0)
self._cond.notify()
self.durationChanged.emit()
def _restart_locked(self, frame):
frame = max(0, min(frame, max(0, self._frames - 1)))
self._gen += 1
self._gen_start = frame
self._queue.clear()
self._produced = self._consumed = 0
self._eof = False
self._seek_to = frame
self._ring[:] = 0
self._cond.notify()
@Slot()
def play(self):
if self._src is None or self._playing:
return
if self._stream is None:
self._stream = sd.OutputStream(samplerate=self._sr, channels=2, dtype="float32", blocksize=BLOCK,
latency=LATENCY, callback=self._callback)
self._playing = True
self._ring[:] = 0
self._stream.start()
self.playingChanged.emit()
@Slot()
def pause(self):
if not self._playing:
return
heard = self._heard_frame()
self._playing = False
if self._stream is not None:
self._stream.abort() # instant; what was queued but unheard is decoded again below
with self._cond:
self._restart_locked(int(heard))
self.playingChanged.emit()
@Slot()
def toggle(self):
self.pause() if self._playing else self.play()
@Slot(float)
def seek(self, seconds):
with self._cond:
self._restart_locked(int(seconds * self._sr))
self._ended_flag = False
def set_gains(self, gains):
g = np.asarray(gains, np.float32)
if len(g) == len(self._gains):
self._gains = g
@Slot(float)
def setVolume(self, v):
v = max(0.0, min(1.0, float(v)))
if v != self._volume:
self._volume = v
self._master = v ** 3 # cubic taper: even loudness steps along the slider
self.volumeChanged.emit()
def shutdown(self):
self._close_stream()
with self._cond:
self._alive = False
if self._src is not None:
self._src.close()
self._cond.notify()
def _close_stream(self):
if self._stream is not None:
try:
self._stream.abort()
self._stream.close()
except Exception:
pass
self._stream = None
if self._playing:
self._playing = False
self.playingChanged.emit()
def _poll(self):
# Wait for the device to play out what's queued, or the song's last moment is cut.
if self._ended_flag and self._playing and self._heard_frame() >= self._pos - BLOCK:
self._ended_flag = False
self._playing = False
if self._stream is not None:
self._stream.abort()
self.playingChanged.emit()
self.ended.emit()
return
b = self._playing and self._underrun and not self._eof
if b != self._buffering:
self._buffering = b
self.bufferingChanged.emit()
# ---- audio thread -----------------------------------------------------------------
def _callback(self, outdata, frames, time_info, status):
gen = self._gen
if gen != self._cb_gen:
self._cb_gen = gen
self._cur = None
self._pos = self._gen_start
mix = self._mix[:frames]
mix.fill(0)
start_pos = self._pos
filled = 0
g1 = self._gains
g0 = self._gains_cur if len(self._gains_cur) == len(g1) else g1
ramping = not np.array_equal(g0, g1)
if ramping and len(self._ramp) != frames:
self._ramp = np.linspace(0.0, 1.0, frames, dtype=np.float32)
q = self._queue
while self._playing and filled < frames:
if self._cur is None:
if not q:
break
item_gen, block = q.popleft()
if item_gen != gen:
continue
if block is None:
self._ended_flag = True
break
self._cur, self._cur_off = block, 0
cur, off = self._cur, self._cur_off
take = min(frames - filled, cur.shape[1] - off)
seg = cur[:, off:off + take]
if ramping:
r = self._ramp[filled:filled + take]
w = g0[:, None] + (g1 - g0)[:, None] * r[None, :]
mix[filled:filled + take] += np.einsum("kt,ktc->tc", w, seg)
else:
mix[filled:filled + take] += np.tensordot(g1, seg, axes=(0, 0))
filled += take
self._cur_off = off + take
if self._cur_off >= cur.shape[1]:
self._cur = None
self._gains_cur = g1
self._underrun = filled < frames and not self._ended_flag
self._pos = start_pos + filled
self._consumed += filled
m0, m1 = self._master_cur, self._master
if m0 != m1:
mix *= np.linspace(m0, m1, frames, dtype=np.float32)[:, None]
self._master_cur = m1
elif m1 != 1.0:
mix *= m1
outdata[:] = mix
if filled and len(q) < 3:
with self._cond:
self._cond.notify()
peak = np.abs(mix).max(axis=0)
dac = time_info.outputBufferDacTime
if dac <= 0:
dac = self._stream.time + self._stream.latency
r = self._ring[self._ring_i % RING]
r[0], r[1], r[2], r[3] = dac, start_pos, peak[0], peak[1]
self._ring_i += 1
def _heard_frame(self):
"""Interpolates the frame at the DAC from the per-block timestamps."""
if self._stream is None or not self._playing:
return float(self._gen_start if self._cb_gen != self._gen else self._pos)
if self._cb_gen != self._gen:
return float(self._gen_start)
now = self._stream.time
ring = self._ring
played = ring[(ring[:, 0] > 0) & (ring[:, 0] <= now)]
if not len(played):
return float(self._gen_start)
last = played[np.argmax(played[:, 0])]
return float(min(self._frames, last[1] + (now - last[0]) * self._sr))
# ---- UI reads ----------------------------------------------------------------------
@Slot(result="QVariantList")
def snapshot(self):
"""[position seconds, peakL, peakR] for this frame."""
pos = self._heard_frame() / self._sr if self._src is not None else 0.0
if not self._playing or self._stream is None:
return [pos, 0.0, 0.0]
ring = self._ring
played = ring[(ring[:, 0] > 0) & (ring[:, 0] <= self._stream.time)]
if not len(played):
return [pos, 0.0, 0.0]
last = played[np.argmax(played[:, 0])]
return [pos, float(last[2]), float(last[3])]
def position(self):
return self._heard_frame() / self._sr if self._src is not None else 0.0
def _get_playing(self):
return self._playing
def _get_buffering(self):
return self._buffering
def _get_duration(self):
return self._frames / self._sr if self._src is not None else 0.0
def _get_volume(self):
return self._volume
playing = Property(bool, _get_playing, notify=playingChanged)
buffering = Property(bool, _get_buffering, notify=bufferingChanged)
duration = Property(float, _get_duration, notify=durationChanged)
volume = Property(float, _get_volume, setVolume, notify=volumeChanged)
+104
View File
@@ -0,0 +1,104 @@
"""Office-style ribbon icons: flat, two-tone, drawn from SVG at whatever size QML asks for.
image://icon/<name>[/<hex colour>] — the colour overrides the dark-gray ink (for white-on-green chrome).
"""
from PySide6.QtCore import QByteArray, QRectF, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtQuick import QQuickImageProvider
from PySide6.QtSvg import QSvgRenderer
INK = "#444444"
GREEN = "#217346"
BLUE = "#2B7CD3"
RED = "#D13438"
ORANGE = "#E8912D"
LIGHT = "#FFFFFF"
# Each icon is SVG body markup on a 32×32 grid; {ink} is the main colour.
ICONS = {
"play": f'<path fill="{GREEN}" d="M9 5v22l18-11z"/>',
"pause": '<path fill="{ink}" d="M8 5h6v22H8zM18 5h6v22h-6z"/>',
"stop": '<rect fill="{ink}" x="7" y="7" width="18" height="18" rx="1"/>',
"previous": '<path fill="{ink}" d="M6 6h3v20H6z"/><path fill="{ink}" d="M27 6v20L11 16z"/>',
"next": '<path fill="{ink}" d="M23 6h3v20h-3z"/><path fill="{ink}" d="M5 6v20l16-10z"/>',
"shuffle": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M3 9h5c6 0 10 14 16 14h3M3 23h5c2.6 0 4.6-2.6 6.4-5.6M17.6 13.6C19.4 10.6 21.4 9 24 9h3"/>'
'<path fill="{ink}" d="M25 4l5 5-5 5zM25 18l5 5-5 5z"/>',
"repeat": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M6 15v-3c0-2.2 1.8-4 4-4h16M26 17v3c0 2.2-1.8 4-4 4H6"/>'
'<path fill="{ink}" d="M24 3l6 5-6 5zM8 19l-6 5 6 5z"/>',
"repeat_one": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M6 15v-3c0-2.2 1.8-4 4-4h16M26 17v3c0 2.2-1.8 4-4 4H6"/>'
f'<path fill="{{ink}}" d="M24 3l6 5-6 5zM8 19l-6 5 6 5z"/><path fill="{GREEN}" d="M15 11h3v10h-3v-7h-2z"/>',
"volume": '<path fill="{ink}" d="M4 12v8h6l7 6V6l-7 6z"/><path fill="none" stroke="{ink}" stroke-width="2.2" d="M21 11c1.6 1.4 1.6 8.6 0 10M24.5 7.5c4 3.5 4 13.5 0 17"/>',
"mute": f'<path fill="{{ink}}" d="M4 12v8h6l7 6V6l-7 6z"/><path stroke="{RED}" stroke-width="2.6" d="M21 12l8 8M29 12l-8 8"/>',
"vocals": '<rect fill="{ink}" x="12" y="3" width="8" height="15" rx="4"/><path fill="none" stroke="{ink}" stroke-width="2.2" d="M8 14c0 4.4 3.6 8 8 8s8-3.6 8-8M16 22v6M11 28h10"/>',
"vocals_off": f'<rect fill="{{ink}}" x="12" y="3" width="8" height="15" rx="4"/><path fill="none" stroke="{{ink}}" stroke-width="2.2" d="M8 14c0 4.4 3.6 8 8 8s8-3.6 8-8M16 22v6M11 28h10"/><path stroke="{RED}" stroke-width="2.8" d="M5 4l22 24"/>',
"mixer": f'<path stroke="{{ink}}" stroke-width="2" d="M8 4v24M16 4v24M24 4v24"/><rect fill="{GREEN}" x="4.5" y="17" width="7" height="5" rx="1"/><rect fill="{BLUE}" x="12.5" y="8" width="7" height="5" rx="1"/><rect fill="{ORANGE}" x="20.5" y="13" width="7" height="5" rx="1"/>',
"queue_add": f'<path fill="{{ink}}" d="M3 6h18v2.5H3zM3 12h18v2.5H3zM3 18h11v2.5H3z"/><path fill="{GREEN}" d="M22 16h3v5h5v3h-5v5h-3v-5h-5v-3h5z"/>',
"play_next": f'<path fill="{{ink}}" d="M3 14h16v2.5H3zM3 20h16v2.5H3zM3 26h11v2.5H3z"/><path fill="{GREEN}" d="M18 3v10l9-5z"/>',
"playlist_new": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M5 3h15l6 6v20H5z"/><path fill="{{ink}}" d="M9 13h12v2H9zM9 18h12v2H9zM9 23h7v2H9z"/><path fill="{GREEN}" d="M23 1h3v4h4v3h-4v4h-3V8h-4V5h4z"/>',
"playlist_add": f'<path fill="{{ink}}" d="M3 7h16v2.5H3zM3 13h16v2.5H3zM3 19h9v2.5H3z"/><circle fill="{BLUE}" cx="23" cy="22" r="7"/><path fill="#FFFFFF" d="M22 18h2v3h3v2h-3v3h-2v-3h-3v-2h3z"/>',
"delete_rows": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 6h26v20H3z"/><path fill="{{ink}}" d="M3 12.5h26v1.5H3zM3 18.5h26v1.5H3z"/><path fill="#FFC7CE" d="M3.8 14h24.4v4.5H3.8z"/><path stroke="{RED}" stroke-width="2.4" d="M12 11.5l8 9M20 11.5l-8 9"/>',
"refresh": f'<path fill="none" stroke="{GREEN}" stroke-width="3" d="M26 16a10 10 0 1 1-3-7.1"/><path fill="{GREEN}" d="M27 3v9h-9z"/>',
"plex": f'<path fill="{ORANGE}" d="M8 3h8l9 13-9 13H8l9-13z"/>',
"database": f'<ellipse fill="{{ink}}" cx="14" cy="7" rx="10" ry="4"/><path fill="{{ink}}" d="M4 9.5c0 2.2 4.5 4 10 4s10-1.8 10-4V14c0 2.2-4.5 4-10 4S4 16.2 4 14zM4 16.5c0 2.2 4.5 4 10 4s10-1.8 10-4V21c0 2.2-4.5 4-10 4S4 23.2 4 21z"/><path fill="{GREEN}" d="M22 18l8 6-8 6z"/>',
"server": f'<rect fill="{{ink}}" x="5" y="4" width="22" height="10" rx="1.5"/><rect fill="{{ink}}" x="5" y="17" width="22" height="10" rx="1.5"/><circle fill="{GREEN}" cx="22" cy="9" r="2"/><circle fill="{GREEN}" cx="22" cy="22" r="2"/>',
"sort_az": f'<text x="2" y="14" font-family="Arial" font-weight="bold" font-size="12" fill="{BLUE}">A</text><text x="2" y="29" font-family="Arial" font-weight="bold" font-size="12" fill="{{ink}}">Z</text><path fill="{{ink}}" d="M20 4h3v18h4l-5.5 7-5.5-7h4z"/>',
"sort_za": f'<text x="2" y="14" font-family="Arial" font-weight="bold" font-size="12" fill="{{ink}}">Z</text><text x="2" y="29" font-family="Arial" font-weight="bold" font-size="12" fill="{BLUE}">A</text><path fill="{{ink}}" d="M20 4h3v18h4l-5.5 7-5.5-7h4z"/>',
"filter": '<path fill="{ink}" d="M3 4h26l-10 12v10l-6 3V16z"/>',
"sigma": '<path fill="{ink}" d="M6 4h20v6h-3V7H11.5l7.5 9-7.5 9H23v-3h3v6H6v-2.4L14.8 16 6 6.4z"/>',
"search": '<circle fill="none" stroke="{ink}" stroke-width="2.6" cx="13" cy="13" r="8"/><path stroke="{ink}" stroke-width="3.4" stroke-linecap="round" d="M19 19l8 8"/>',
"lightbulb": '<path fill="none" stroke="{ink}" stroke-width="2" d="M11 22c0-4-5-6-5-12a10 10 0 0 1 20 0c0 6-5 8-5 12z"/><path fill="{ink}" d="M11 24h10v2H11zM12 27.5h8V30h-8z"/>',
"grid": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 4h26v24H3z"/><path stroke="{{ink}}" stroke-width="1" d="M3 10h26M3 16h26M3 22h26M11 4v24M20 4v24"/><path fill="{GREEN}" d="M3 4h8v6H3z"/>',
"freeze": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 4h26v24H3z"/><path fill="{BLUE}" d="M3 4h26v6H3z" opacity="0.35"/><path stroke="{BLUE}" stroke-width="2" d="M3 10h26"/><path stroke="{{ink}}" stroke-width="1" d="M3 16h26M3 22h26M11 4v24M20 4v24"/>',
"headings": '<path fill="#FFFFFF" stroke="{ink}" stroke-width="1.6" d="M3 4h26v24H3z"/><path fill="#E6E6E6" d="M3.8 4.8h24.4V9H3.8zM3.8 9h5v18.2h-5z"/><text x="13" y="8.6" font-family="Arial" font-size="5" fill="{ink}">A B</text><text x="5" y="16" font-family="Arial" font-size="5" fill="{ink}">1</text><text x="5" y="23" font-family="Arial" font-size="5" fill="{ink}">2</text>',
"pane": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 4h26v24H3z"/><path fill="{GREEN}" d="M20 4.8h8.2v22.4H20z" opacity="0.85"/><path stroke="#FFFFFF" stroke-width="1.6" d="M22 10h4M22 14h4M22 18h4"/>',
"zoom": '<circle fill="none" stroke="{ink}" stroke-width="2.6" cx="13" cy="13" r="8"/><path stroke="{ink}" stroke-width="3.4" stroke-linecap="round" d="M19 19l8 8"/><path stroke="{ink}" stroke-width="2.2" d="M9 13h8M13 9v8"/>',
"chart": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 4h26v24H3z"/><path fill="{BLUE}" d="M7 16h4v9H7z"/><path fill="{ORANGE}" d="M14 9h4v16h-4z"/><path fill="#A5A5A5" d="M21 13h4v12h-4z"/>',
"styles": '<rect fill="#C6EFCE" x="3" y="4" width="12" height="10"/><rect fill="#FFC7CE" x="17" y="4" width="12" height="10"/><rect fill="#FFEB9C" x="3" y="17" width="12" height="10"/><rect fill="#FFFFFF" stroke="{ink}" x="17" y="17" width="12" height="10"/>',
"info": f'<circle fill="{BLUE}" cx="16" cy="16" r="13"/><path fill="#FFFFFF" d="M14.5 13h3v11h-3zM14.5 7.5h3v3h-3z"/>',
"account": '<circle fill="{ink}" cx="16" cy="10" r="6"/><path fill="{ink}" d="M4 29c0-6.6 5.4-11 12-11s12 4.4 12 11z"/>',
"options": '<path fill="{ink}" d="M14 2h4l.7 3.6 2.5 1 3-2 2.8 2.8-2 3 1 2.5 3.6.7v4l-3.6.7-1 2.5 2 3-2.8 2.8-3-2-2.5 1L18 30h-4l-.7-3.6-2.5-1-3 2-2.8-2.8 2-3-1-2.5L2 18v-4l3.6-.7 1-2.5-2-3 2.8-2.8 3 2 2.5-1zM16 11a5 5 0 1 0 0 10 5 5 0 0 0 0-10z"/>',
"back": '<circle fill="none" stroke="{ink}" stroke-width="2" cx="16" cy="16" r="13"/><path fill="none" stroke="{ink}" stroke-width="2.6" d="M18 9l-7 7 7 7"/>',
"close": '<path stroke="{ink}" stroke-width="1.4" d="M10 10l12 12M22 10L10 22"/>',
"minimize": '<path stroke="{ink}" stroke-width="1.4" d="M10 16h12"/>',
"maximize": '<path fill="none" stroke="{ink}" stroke-width="1.4" d="M10.5 10.5h11v11h-11z"/>',
"restore": '<path fill="none" stroke="{ink}" stroke-width="1.4" d="M10.5 13.5h8v8h-8zM13.5 13.5v-3h8v8h-3"/>',
"save": '<path fill="{ink}" d="M5 4h18l4 4v19H5z"/><path fill="#FFFFFF" d="M9 4h12v8H9zM9 17h14v10H9z"/><path fill="{ink}" d="M17 5.5h3v5h-3z"/>',
"undo": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M8 12h12a6 6 0 0 1 0 12h-6"/><path fill="{ink}" d="M3 12l7-6v12z"/>',
"redo": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M24 12H12a6 6 0 0 0 0 12h6"/><path fill="{ink}" d="M29 12l-7-6v12z"/>',
"excel": f'<rect fill="{GREEN}" x="2" y="4" width="18" height="24" rx="1.5"/><rect fill="#FFFFFF" x="14" y="8" width="16" height="16" rx="1"/><path stroke="{GREEN}" stroke-width="1" d="M14 13.3h16M14 18.6h16M22 8v16"/><path fill="#FFFFFF" d="M5 10h3.6l2.4 4 2.4-4H17l-4.2 6 4.2 6h-3.6L11 18l-2.4 4H5l4.2-6z"/>',
"dropdown": '<path fill="{ink}" d="M9 12h14l-7 8z"/>',
"sort_up": '<path fill="{ink}" d="M16 9l7 8H9z"/><path fill="{ink}" d="M14.5 17h3v7h-3z"/>',
"sort_down": '<path fill="{ink}" d="M16 24l7-8H9z"/><path fill="{ink}" d="M14.5 8h3v8h-3z"/>',
"left": '<path fill="{ink}" d="M20 8v16l-10-8z"/>',
"right": '<path fill="{ink}" d="M12 8v16l10-8z"/>',
"plus": '<circle fill="none" stroke="{ink}" stroke-width="1.6" cx="16" cy="16" r="12"/><path stroke="{ink}" stroke-width="2" d="M16 10v12M10 16h12"/>',
"normal_view": '<path fill="none" stroke="{ink}" stroke-width="2" d="M5 7h22v18H5z"/><path stroke="{ink}" stroke-width="1.4" d="M5 13h22M5 19h22M12 7v18M19 7v18"/>',
"page_view": '<path fill="none" stroke="{ink}" stroke-width="2" d="M5 4h22v24H5z"/><path fill="{ink}" d="M9 9h14v2H9zM9 14h14v2H9zM9 19h9v2H9z"/>',
"break_view": '<path fill="none" stroke="{ink}" stroke-width="2" d="M5 4h22v24H5z"/><path stroke="{ink}" stroke-width="2" stroke-dasharray="3 2" d="M16 4v24M5 16h22"/>',
"function": '<text x="3" y="24" font-family="Times New Roman" font-style="italic" font-size="22" fill="{ink}">fx</text>',
"check": f'<path fill="none" stroke="{GREEN}" stroke-width="3" d="M6 16l7 7 13-14"/>',
"cancel": f'<path stroke="{RED}" stroke-width="3" d="M8 8l16 16M24 8L8 24"/>',
}
class IconProvider(QQuickImageProvider):
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Image)
def requestImage(self, id_, size, requested):
name, _, ink = id_.partition("/")
w = requested.width() if requested.width() > 0 else 32
h = requested.height() if requested.height() > 0 else w
body = ICONS.get(name, ICONS["grid"]).replace("{ink}", "#" + ink if ink else INK)
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">{body}</svg>'
img = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
img.fill(Qt.GlobalColor.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
QSvgRenderer(QByteArray(svg.encode())).render(p, QRectF(0, 0, w, h))
p.end()
if size is not None:
size.setWidth(w)
size.setHeight(h)
return img
+604
View File
@@ -0,0 +1,604 @@
"""The library as a workbook: Plex data cached on disk, refreshed in the background, shown as sheets."""
import json
import os
import random
import re
import threading
import time
from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot
from PySide6.QtGui import QGuiApplication
from . import config
from .plex import PlexError
from .sheets import ALBUM, ARTIST, PLAYLIST, SONG, Sheet, SheetModel, fmt, fmt_time, fold
BASE_TABS = ["Songs", "Artists", "Albums", "Playlists", "Stems", "Queue"]
_SONG_NAME = re.compile(r"^(?:.* - )?(?:\d{1,2}-)?(\d{1,3})\s*[-.]?\s+(.+)$")
class Library(QObject):
tabsChanged = Signal()
sheetChanged = Signal() # anything about the current sheet: data, sort, columns
sheetSwitched = Signal() # a different sheet is showing: selection and scroll start over
statusChanged = Signal()
toast = Signal(str)
_done = Signal(str, object) # worker results, delivered on the GUI thread
def __init__(self, cfg, plex, stems, parent=None):
super().__init__(parent)
self.cfg, self.plex, self.stems = cfg, plex, stems
self.player = None # set by the app; the queue sheet reads it
self.tracks, self.artists, self.albums, self.playlists = [], [], [], []
self._by_album, self._by_artist = {}, {}
self._tabs = [Sheet(t, "songs", []) for t in BASE_TABS]
self._tabs[1] = Sheet("Artists", "artists", [], ARTIST)
self._tabs[2] = Sheet("Albums", "albums", [], ALBUM)
self._tabs[3] = Sheet("Playlists", "playlists", [], PLAYLIST)
self._tabs[5] = Sheet("Queue", "queue", [])
self._current = 0
self.model = SheetModel(self)
self._status = ""
self._progress = -1.0
self._busy = False
self._done.connect(self._on_done)
self._cache = os.path.join(config.CACHE_DIR, "library.json")
self._load_cache()
self._rebuild()
self.model.set_sheet(self._tabs[0])
QTimer.singleShot(0, self.refresh)
# stemserve rescans every 30 s; pick up newly separated songs while open
self._stem_timer = QTimer(self, interval=120_000)
self._stem_timer.timeout.connect(self._refresh_stems)
self._stem_timer.start()
# ---- data ------------------------------------------------------------------------
def _load_cache(self):
try:
with open(self._cache) as f:
d = json.load(f)
if d.get("section") == self.cfg.get("musicSection"):
self.tracks, self.artists = d["tracks"], d["artists"]
self.albums, self.playlists = d["albums"], d["playlists"]
except (OSError, ValueError, KeyError):
pass
def _save_cache(self):
os.makedirs(config.CACHE_DIR, exist_ok=True)
tmp = self._cache + ".tmp"
with open(tmp, "w") as f:
json.dump({"section": self.cfg.get("musicSection"), "tracks": self.tracks, "artists": self.artists,
"albums": self.albums, "playlists": self.playlists}, f)
os.replace(tmp, self._cache)
def _work(self, tag, fn):
def run():
try:
self._done.emit(tag, fn())
except Exception as e: # noqa: BLE001 - shown in the status bar
self._done.emit(tag + ":error", e)
threading.Thread(target=run, daemon=True).start()
@Slot()
def refresh(self):
"""Data → Refresh All."""
self._refresh_stems()
if not self.plex.signed_in or not self.cfg.get("musicSection") or self._busy:
return
self._busy = True
self._set_status("Calculating: Plex library", 0.0)
def fetch():
def progress(n, total):
self._done.emit("progress", (n, total))
return {"tracks": self.plex.tracks(progress), "artists": self.plex.artists(),
"albums": self.plex.albums(), "playlists": self.plex.playlists()}
self._work("library", fetch)
def _refresh_stems(self):
self._work("stems", self.stems.refresh)
def _on_done(self, tag, result):
if tag == "pin":
self._poll_pin(result)
elif tag == "token":
self._signed_in(result)
elif tag == "sections":
self._sections(result)
elif tag == "progress":
n, total = result
self._set_status("Calculating: Plex library", n / total if total else 0.0)
elif tag == "library":
self._busy = False
self.tracks, self.artists = result["tracks"], result["artists"]
self.albums, self.playlists = result["albums"], result["playlists"]
self._rebuild()
self._set_status("", -1)
self._work("save", self._save_cache)
elif tag == "stems":
if result:
self._rebuild()
self.statusChanged.emit()
elif tag == "library:error":
self._busy = False
self._set_status("", -1)
self.toast.emit(str(result))
elif tag.endswith(":error"):
self.toast.emit(str(result))
for s in self._tabs:
if s.loading:
s.loading = False
self.sheetChanged.emit()
elif tag.startswith("playlist:"):
key = tag.split(":", 1)[1]
for s in self._tabs:
if s.sid == f"playlist:{key}":
s.rows, s.loading = self._song_rows(result), False
if s is self._tabs[self._current]:
self.model.set_sheet(s)
self.sheetChanged.emit()
elif tag == "playlists":
self.playlists = result
self._tabs[3].rows = self._playlist_rows()
if self._current == 3:
self.model.refresh()
def _stems_only(self):
"""Without Plex, the library is the stem server's index: artist/album/song from the folders."""
tracks, albums, artists = [], {}, {}
for e in self.stems.index.entries:
parts = e["key"].split("/")
artist = parts[0] if len(parts) > 2 else ""
album = parts[-2] if len(parts) > 1 else ""
m = _SONG_NAME.match(parts[-1])
title, index = (m.group(2), int(m.group(1))) if m else (parts[-1], 0)
ak, rk = "stem-album:" + "/".join(parts[:-1]), "stem-artist:" + artist
tracks.append({"key": "stem:" + e["key"], "title": title, "artist": artist, "albumArtist": artist,
"album": album, "albumKey": ak, "artistKey": rk, "year": 0, "index": index, "disc": 0,
"durationMs": int((e.get("duration") or 0) * 1000), "thumb": "", "partFile": "",
"partKey": "", "codec": "flac", "plays": 0, "addedAt": int(e.get("finished") or 0),
"stemKey": e["key"]})
albums.setdefault(ak, {"key": ak, "title": album, "artist": artist, "artistKey": rk, "year": 0,
"tracks": 0, "thumb": "", "addedAt": 0, "genre": ""})["tracks"] += 1
artists.setdefault(rk, {"key": rk, "title": artist, "sortTitle": artist, "albums": 0, "thumb": "", "genre": ""})
for a in albums.values():
artists[a["artistKey"]]["albums"] += 1
tracks.sort(key=lambda t: fold(t["title"]))
return tracks, sorted(artists.values(), key=lambda a: fold(a["title"])), \
sorted(albums.values(), key=lambda a: fold(a["title"]))
def _rebuild(self):
"""Derives stems flags, groupings and the base sheets from the raw lists."""
if not self.plex.signed_in or not self.cfg.get("musicSection"):
self.tracks, self.artists, self.albums = self._stems_only()
self.playlists = []
for t in self.tracks:
e = self._entry(t)
t["stemKey"] = e["key"] if e else ""
t["stems"] = len(e["stems"]) if e else 0
self._by_album, self._by_artist = {}, {}
for t in self.tracks:
self._by_album.setdefault(t["albumKey"], []).append(t)
self._by_artist.setdefault(t["artistKey"], []).append(t)
for lst in self._by_album.values():
lst.sort(key=lambda t: (t.get("disc") or 0, t.get("index") or 0))
for a in self.artists:
songs = self._by_artist.get(a["key"], [])
a["songs"] = len(songs)
a["stemSongs"] = sum(1 for t in songs if t["stems"])
for a in self.albums:
a["stemSongs"] = sum(1 for t in self._by_album.get(a["key"], []) if t["stems"])
self._tabs[0].rows = self._song_rows(self.tracks)
self._tabs[1].rows = [dict(a) for a in self.artists]
self._tabs[2].rows = [dict(a) for a in self.albums]
self._tabs[3].rows = self._playlist_rows()
self._tabs[4].rows = self._song_rows([t for t in self.tracks if t["stems"]])
for s in self._tabs[:5]:
if s.sort:
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
self.statusChanged.emit()
def _entry(self, t):
if t["key"].startswith("stem:"):
return self.stems.index.by_key.get(t.get("stemKey"))
return self.stems.find(t)
def _playlist_rows(self):
return [dict(p, kind="Smart" if p["smart"] else "Manual") for p in self.playlists]
def _song_rows(self, tracks):
rows = []
for t in tracks:
e = self._entry(t)
rows.append(dict(t, stemKey=e["key"] if e else "", stems=len(e["stems"]) if e else 0))
return rows
# ---- tabs -----------------------------------------------------------------------
def _get_tabs(self):
return [{"title": s.title, "base": i < len(BASE_TABS)} for i, s in enumerate(self._tabs)]
def _get_current(self):
return self._current
@Slot(int)
def setCurrent(self, i):
if 0 <= i < len(self._tabs) and i != self._current:
self._current = i
if self._tabs[i].layout == "queue":
self.update_queue()
self.sheetSwitched.emit()
self.model.set_sheet(self._tabs[i])
self.tabsChanged.emit()
self.sheetChanged.emit()
@Slot(int)
def closeTab(self, i):
if i >= len(BASE_TABS) and i < len(self._tabs):
del self._tabs[i]
cur = min(self._current, len(self._tabs) - 1) if self._current >= i else self._current
self._current = -1
self.tabsChanged.emit()
self.setCurrent(cur if cur != i or cur < len(self._tabs) else i - 1)
@Slot()
def back(self):
"""Backspace: closes the drilled-in sheet and returns to the one before it."""
if self._current >= len(BASE_TABS):
self.closeTab(self._current)
def _open(self, sheet):
"""Opens a drilled-in sheet after the current one, replacing what was to its right."""
for i, s in enumerate(self._tabs):
if s.sid == sheet.sid:
self._tabs[i] = sheet
self._current = -1
self.setCurrent(i)
return
cut = max(len(BASE_TABS), self._current + 1)
self._tabs = self._tabs[:cut] + [sheet]
self.tabsChanged.emit()
self.setCurrent(len(self._tabs) - 1)
@property
def sheet(self) -> Sheet:
return self._tabs[self._current]
# ---- rows -----------------------------------------------------------------------
@Slot(int, int)
def activate(self, row, mods=0):
"""Enter / double-click: play a song from here, or open an artist, album or playlist."""
s = self.sheet
if not (0 <= row < len(s.rows)):
return
kind, r = s.row_kind(row), s.rows[row]
if kind == SONG:
if s.layout == "queue":
self.player.skipTo(row)
return
songs = [x for i, x in enumerate(s.rows) if s.row_kind(i) == SONG]
self.player.play_tracks(songs, songs.index(r))
elif kind == ARTIST:
albums = sorted((a for a in self.albums if a["artistKey"] == r["key"]), key=lambda a: a.get("year") or 0)
self._open(Sheet(r["title"], "albums", [dict(a) for a in albums], ALBUM, sid=f"artist:{r['key']}"))
elif kind == ALBUM:
self._open(Sheet(r["title"], "albumSongs", self._song_rows(self._by_album.get(r["key"], [])),
sid=f"album:{r['key']}"))
elif kind == PLAYLIST:
self._open(Sheet(r["title"], "songs", [], sid=f"playlist:{r['key']}", loading=True))
self._work(f"playlist:{r['key']}", lambda: self.plex.playlist_tracks(r["key"]))
def rows_for_play(self, r0, r1):
s = self.sheet
out = []
for i in range(max(0, r0), min(len(s.rows), r1 + 1)):
k, r = s.row_kind(i), s.rows[i]
if k == SONG:
out.append(r)
elif k == ALBUM:
out += self._song_rows(self._by_album.get(r["key"], []))
elif k == ARTIST:
for a in sorted((a for a in self.albums if a["artistKey"] == r["key"]), key=lambda a: a.get("year") or 0):
out += self._song_rows(self._by_album.get(a["key"], []))
return out
@Slot(int, int)
def queueRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.add_to_queue(tracks)
self.toast.emit(f"Added {len(tracks)} song{'s' if len(tracks) != 1 else ''} to the queue")
@Slot(int, int)
def playNextRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.play_next(tracks)
self.toast.emit(f"{len(tracks)} song{'s' if len(tracks) != 1 else ''} will play next")
@Slot(int, int)
def playRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.play_tracks(tracks, 0)
@Slot()
def shuffleSheet(self):
"""AutoSum's spot on the ribbon: shuffle everything on this sheet."""
tracks = self.rows_for_play(0, len(self.sheet.rows) - 1)
if tracks:
self.player.play_tracks(tracks, random.randrange(len(tracks)), shuffle=True)
@Slot(int, int)
def removeRows(self, r0, r1):
s = self.sheet
if s.layout == "queue":
for i in range(min(r1, len(s.rows) - 1), r0 - 1, -1):
self.player.removeAt(i)
elif s.sid.startswith("playlist:"):
key = s.sid.split(":", 1)[1]
items = [s.rows[i] for i in range(r0, min(r1 + 1, len(s.rows)))]
def remove():
for it in items:
if it.get("playlistItemID"):
self.plex.remove_from_playlist(key, it["playlistItemID"])
return self.plex.playlist_tracks(key)
self._work(f"playlist:{key}", remove)
@Slot(str, int, int)
def newPlaylist(self, title, r0, r1):
keys = [t["key"] for t in self.rows_for_play(r0, r1)]
if not title.strip() or not keys:
return
def make():
self.plex.create_playlist(title.strip(), keys)
return self.plex.playlists()
self._work("playlists", make)
self.toast.emit(f"Created playlist “{title.strip()}”")
@Slot(str, int, int)
def addToPlaylist(self, title, r0, r1):
keys = [t["key"] for t in self.rows_for_play(r0, r1)]
p = next((p for p in self.playlists if p["title"] == title and not p["smart"]), None)
if not keys or p is None:
return
def add():
self.plex.add_to_playlist(p["key"], keys)
return self.plex.playlists()
self._work("playlists", add)
self.toast.emit(f"Added {len(keys)} song{'s' if len(keys) != 1 else ''} to “{p['title']}”")
def _get_playlist_names(self):
return [p["title"] for p in self.playlists if not p["smart"]]
# ---- queue sheet ------------------------------------------------------------------
def update_queue(self):
q = self._tabs[5]
q.rows = [dict(self._song_rows([t])[0], pos=i + 1) for i, t in enumerate(self.player.queue)]
if self._current == 5:
self.model.refresh()
self.model.set_playing(self.player.current_key(), self.player.index)
# ---- search, sort ------------------------------------------------------------------
@Slot(str)
def search(self, text):
q = fold(text.strip())
if not q:
return
rows = [dict(a, _kind=ARTIST, type="Artist") for a in self.artists if q in fold(a["title"])][:50]
rows += [dict(a, _kind=ALBUM, type="Album", album="") for a in self.albums if q in fold(a["title"])][:100]
hits = [t for t in self.tracks if q in fold(t["title"])]
hits += [t for t in self.tracks if q not in fold(t["title"]) and (q in fold(t["artist"]) or q in fold(t["album"]))]
rows += [dict(r, type="Song") for r in self._song_rows(hits[:2000])]
self._open(Sheet(f"Search “{text.strip()}”", "search", rows, SONG, sid="search"))
@Slot(int)
def sortBy(self, col):
s = self.sheet
desc = bool(s.sort and s.sort[0] == col and not s.sort[1])
s.sort = (col, desc)
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
@Slot(int, bool)
def sortTo(self, col, desc):
s = self.sheet
if 0 <= col < len(s.columns):
s.sort = (col, desc)
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
@staticmethod
def _apply_sort(s):
col, desc = s.sort
key, _, _, kind = s.columns[col]
if kind == "text":
s.rows.sort(key=lambda r: fold(str(r.get(key) or "")), reverse=desc)
else:
s.rows.sort(key=lambda r: r.get(key) or 0, reverse=desc)
def _get_sort_col(self):
return self.sheet.sort[0] if self.sheet.sort else -1
def _get_sort_desc(self):
return bool(self.sheet.sort and self.sheet.sort[1])
# ---- formula bar, status bar --------------------------------------------------------
@Slot(int, int, result=str)
def formula(self, row, col):
"""What the formula bar shows for a cell: the value, or a formula for the row's action."""
s = self.sheet
if not (0 <= row < len(s.rows) and 0 <= col < len(s.columns)):
return ""
r = s.rows[row]
key, _, _, kind = s.columns[col]
if kind == "stems":
return f"=STEMS(\"{r.get('title', '')}\")" if r.get(key) else "=FALSE"
if kind == "time" and r.get(key):
return f"=TIME(0,{int(r[key] / 60000)},{int(r[key] / 1000) % 60})"
if key == "title":
k = s.row_kind(row)
fn = {SONG: "PLAY", ARTIST: "ARTIST", ALBUM: "ALBUM", PLAYLIST: "PLAYLIST"}[k]
q = r["title"].replace('"', '""')
return f'={fn}("{q}")'
v = r.get(key)
return "" if v in (None, "", 0) and kind != "int" else str(v)
@Slot(int, int, int, int)
def copyCells(self, r0, c0, r1, c1):
"""Ctrl+C: the selection as tab-separated text, like copying from Excel."""
s = self.sheet
r0, r1 = max(0, min(r0, r1)), min(len(s.rows) - 1, max(r0, r1))
c0, c1 = max(0, min(c0, c1)), min(len(s.columns) - 1, max(c0, c1))
lines = ["\t".join(fmt(s.rows[r].get(s.columns[c][0]), s.columns[c][3]) for c in range(c0, c1 + 1))
for r in range(r0, r1 + 1)]
QGuiApplication.clipboard().setText("\n".join(lines))
@Slot(int, int, int, int, result="QVariantMap")
def stats(self, r0, c0, r1, c1):
"""Average / Count / Sum for a selection, like Excel's status bar."""
s = self.sheet
r0, r1 = max(0, min(r0, r1)), min(len(s.rows) - 1, max(r0, r1))
c0, c1 = max(0, min(c0, c1)), min(len(s.columns) - 1, max(c0, c1))
cells, nums, kinds = 0, [], set()
for r in range(r0, r1 + 1):
row = s.rows[r]
for c in range(c0, c1 + 1):
key, _, _, kind = s.columns[c]
v = row.get(key)
if v in (None, "") or (kind != "int" and not v):
continue
cells += 1
if kind in ("int", "time", "year") and isinstance(v, (int, float)):
nums.append(v)
kinds.add(kind)
if cells < 2:
return {}
out = {"count": cells}
if nums:
is_time = kinds == {"time"}
total = sum(nums)
out["sum"] = fmt_time(total) if is_time else f"{total:,}"
avg = total / len(nums)
out["average"] = fmt_time(avg) if is_time else f"{avg:,.1f}".rstrip("0").rstrip(".")
return out
def _set_status(self, text, progress):
self._status, self._progress = text, progress
self.statusChanged.emit()
def _get_status(self):
return self._status
def _get_progress(self):
return self._progress
def _get_sheet_title(self):
return self.sheet.title
def _get_loading(self):
return self.sheet.loading
def _get_sheet_id(self):
return self.sheet.sid
def _get_layout(self):
return self.sheet.layout
def _get_columns(self):
return [{"title": c[1], "width": c[2], "kind": c[3]} for c in self.sheet.columns]
def _get_counts(self):
return {"songs": len(self.tracks), "stems": sum(1 for t in self.tracks if t.get("stems")),
"stemIndex": len(self.stems.index), "stemOnline": self.stems.online}
tabs = Property("QVariantList", _get_tabs, notify=tabsChanged)
current = Property(int, _get_current, setCurrent, notify=tabsChanged)
columns = Property("QVariantList", _get_columns, notify=sheetChanged)
sheetTitle = Property(str, _get_sheet_title, notify=sheetChanged)
layout = Property(str, _get_layout, notify=sheetChanged)
sheetId = Property(str, _get_sheet_id, notify=sheetChanged)
loading = Property(bool, _get_loading, notify=sheetChanged)
sortColumn = Property(int, _get_sort_col, notify=sheetChanged)
sortDescending = Property(bool, _get_sort_desc, notify=sheetChanged)
status = Property(str, _get_status, notify=statusChanged)
progress = Property(float, _get_progress, notify=statusChanged)
counts = Property("QVariantMap", _get_counts, notify=statusChanged)
playlistNames = Property("QVariantList", _get_playlist_names, notify=sheetChanged)
# ---- Plex sign-in (File → Account) ---------------------------------------------------
pinChanged = Signal()
def _get_pin(self):
return getattr(self, "_pin_code", "")
pin = Property(str, _get_pin, notify=pinChanged)
@Slot()
def signIn(self):
def start():
return self.plex.create_pin()
self._work("pin", start)
def _poll_pin(self, pin):
self._pin_code = pin.get("code", "")
self.pinChanged.emit()
pin_id = pin["id"]
def wait():
for _ in range(300):
time.sleep(2)
p = self.plex.check_pin(pin_id)
if p.get("authToken"):
return p["authToken"]
if getattr(self, "_pin_code", "") != pin.get("code"):
return None
return None
self._work("token", wait)
def _signed_in(self, token):
self._pin_code = ""
self.pinChanged.emit()
if not token:
return
url = self.cfg["plexUrl"]
def finish():
if not self.plex.probe(url, token):
raise PlexError(f"Signed in, but the server at {url} didn't answer")
self.cfg["plexToken"] = token
self.cfg["plexUser"] = self.plex.username(token)
secs = self.plex.music_sections()
return secs
self._work("sections", finish)
def _sections(self, secs):
if not secs:
self.toast.emit("No music library on that Plex server")
return
# The phone uses the "Music" library; prefer the one at Flacs if there are several.
pick = next((s for s in secs if any("Flacs" in l for l in s["locations"])), secs[0])
self.cfg["musicSection"] = pick["key"]
self.cfg["sectionLocations"] = pick["locations"]
config.save(self.cfg)
self.toast.emit(f"Connected to Plex — {pick['title']}")
self.statusChanged.emit()
self.refresh()
@Slot()
def cancelSignIn(self):
self._pin_code = ""
self.pinChanged.emit()
def _get_signed_in(self):
return self.plex.signed_in and bool(self.cfg.get("musicSection"))
signedIn = Property(bool, _get_signed_in, notify=statusChanged)
+171
View File
@@ -0,0 +1,171 @@
"""MPRIS on the session bus: media keys, the desktop's media controls, and KDE Connect on the phone."""
import hashlib
from PySide6.QtCore import ClassInfo, Property, QObject, Slot
from PySide6.QtDBus import QDBusAbstractAdaptor, QDBusConnection, QDBusMessage, QDBusObjectPath
PATH = "/org/mpris/MediaPlayer2"
ROOT_IFACE = "org.mpris.MediaPlayer2"
PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"
@ClassInfo({"D-Bus Interface": ROOT_IFACE})
class _Root(QDBusAbstractAdaptor):
def __init__(self, parent, window_raise, quit_):
super().__init__(parent)
self._raise, self._quit = window_raise, quit_
@Slot()
def Raise(self):
self._raise()
@Slot()
def Quit(self):
self._quit()
CanQuit = Property(bool, lambda self: True)
CanRaise = Property(bool, lambda self: True)
HasTrackList = Property(bool, lambda self: False)
Identity = Property(str, lambda self: "StemRoller Player")
DesktopEntry = Property(str, lambda self: "stemroller-player")
SupportedUriSchemes = Property("QStringList", lambda self: [])
SupportedMimeTypes = Property("QStringList", lambda self: [])
@ClassInfo({"D-Bus Interface": PLAYER_IFACE})
class _Player(QDBusAbstractAdaptor):
def __init__(self, parent, mpris):
super().__init__(parent)
self.m = mpris
@Slot()
def Next(self):
self.m.player.next()
@Slot()
def Previous(self):
self.m.player.previous()
@Slot()
def Pause(self):
self.m.engine.pause()
@Slot()
def PlayPause(self):
self.m.player.toggle()
@Slot()
def Stop(self):
self.m.engine.pause()
@Slot()
def Play(self):
if not self.m.engine.playing:
self.m.player.toggle()
@Slot("qlonglong")
def Seek(self, offset_us):
self.m.player.seek(self.m.engine.position() + offset_us / 1e6)
@Slot(QDBusObjectPath, "qlonglong")
def SetPosition(self, track_id, position_us):
self.m.player.seek(position_us / 1e6)
@Slot(str)
def OpenUri(self, uri):
pass
def _status(self):
s = self.m.player._get_status()
return "Playing" if s in ("playing", "buffering") or (s == "loading" and self.m.engine.playing) else \
"Paused" if self.m.player.current() else "Stopped"
def _volume(self):
return float(self.m.engine.volume)
def _set_volume(self, v):
self.m.engine.setVolume(max(0.0, min(1.0, v)))
PlaybackStatus = Property(str, _status)
LoopStatus = Property(str, lambda self: {"off": "None", "all": "Playlist", "one": "Track"}[self.m.player.repeat])
Rate = Property(float, lambda self: 1.0)
Shuffle = Property(bool, lambda self: self.m.player.shuffle)
Metadata = Property("QVariantMap", lambda self: self.m.metadata())
Volume = Property(float, _volume, _set_volume)
Position = Property("qlonglong", lambda self: int(self.m.engine.position() * 1e6))
MinimumRate = Property(float, lambda self: 1.0)
MaximumRate = Property(float, lambda self: 1.0)
CanGoNext = Property(bool, lambda self: bool(self.m.player.queue))
CanGoPrevious = Property(bool, lambda self: bool(self.m.player.queue))
CanPlay = Property(bool, lambda self: bool(self.m.player.queue))
CanPause = Property(bool, lambda self: True)
CanSeek = Property(bool, lambda self: True)
CanControl = Property(bool, lambda self: True)
class Mpris(QObject):
def __init__(self, player, engine, window_raise, quit_, parent=None):
super().__init__(parent)
self.player, self.engine = player, engine
self._root = _Root(self, window_raise, quit_)
self._adaptor = _Player(self, self)
self.bus = QDBusConnection.sessionBus()
self.ok = self.bus.isConnected() and self.bus.registerObject(PATH, self) and \
self.bus.registerService("org.mpris.MediaPlayer2.stemroller_player")
self._last = {}
player.changed.connect(self._changed)
player.mixChanged.connect(self._changed)
engine.volumeChanged.connect(self._changed)
engine.playingChanged.connect(self._changed)
def metadata(self):
t = self.player.current()
if not t:
return {"mpris:trackid": QDBusObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")}
artist = t["artist"]
label = self.player._get_preset_label()
if label and label != "Full Mix":
artist = f"{artist} · {label}" # the mix rides on the artist line, as on the phone
md = {
"mpris:trackid": QDBusObjectPath("/stemroller/track/t" + hashlib.sha1(t["key"].encode()).hexdigest()[:16]),
"mpris:length": int((self.engine.duration or t.get("durationMs", 0) / 1000) * 1e6),
"xesam:title": t["title"],
"xesam:artist": [artist],
"xesam:album": t["album"],
"xesam:albumArtist": [t.get("albumArtist") or t["artist"]],
}
art = self.player.plex.art_url(t.get("thumb"), 600) or t.get("_cover", "")
if art:
md["mpris:artUrl"] = art
if t.get("index"):
md["xesam:trackNumber"] = t["index"]
return md
def _changed(self):
if not self.ok:
return
now = {
"PlaybackStatus": self._adaptor._status(),
"LoopStatus": self._adaptor.LoopStatus,
"Shuffle": self.player.shuffle,
"Volume": float(self.engine.volume),
"Metadata": self.metadata(),
"CanGoNext": bool(self.player.queue),
"CanGoPrevious": bool(self.player.queue),
"CanPlay": bool(self.player.queue),
}
diff = {k: v for k, v in now.items() if k == "Metadata" and _md_key(v) != _md_key(self._last.get(k)) or
k != "Metadata" and self._last.get(k) != v}
self._last = now
if not diff:
return
msg = QDBusMessage.createSignal(PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged")
msg.setArguments([PLAYER_IFACE, diff, []])
self.bus.send(msg)
def _md_key(md):
if not md:
return None
return tuple((k, str(v.path()) if isinstance(v, QDBusObjectPath) else repr(v)) for k, v in sorted(md.items()))
+601
View File
@@ -0,0 +1,601 @@
"""The queue and the mix: what plays, where its audio comes from, and how loud each stem is."""
import hashlib
import json
import math
import os
import random
import shutil
import subprocess
import threading
import urllib.request
from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot
from . import config
from .engine import Source
from .stems import COLORS, sort_names
UNITY = 0.8 # stem slider position for 0 dB (the phone uses the same taper)
WAVE_BUCKETS = 240
def stem_taper(v):
return (max(0.0, min(1.0, v)) / UNITY) ** 2
# One-click mixes, as on the phone. A preset is a function of the stems a song has, or None
# when the song lacks what it needs.
PRESETS = [
("Full Mix", lambda n: {}),
("Karaoke", lambda n: {"vocals": {"muted": True}} if "vocals" in n else None),
("A Cappella", lambda n: {"vocals": {"solo": True}} if "vocals" in n else None),
("No Drums", lambda n: {"drums": {"muted": True}} if "drums" in n else None),
("Rhythm", lambda n: {"drums": {"solo": True}, "bass": {"solo": True}} if "drums" in n and "bass" in n else None),
("No Guitar", lambda n: {"guitar": {"muted": True}} if "guitar" in n else None),
("No Bass", lambda n: {"bass": {"muted": True}} if "bass" in n else None),
]
def _setting(mix, name):
s = mix.get(name, {})
return s.get("level", UNITY), bool(s.get("muted")), bool(s.get("solo"))
def _normalized(mix):
out = {}
for name, s in mix.items():
level, muted, solo = s.get("level", UNITY), bool(s.get("muted")), bool(s.get("solo"))
if abs(level - UNITY) > 1e-3 or muted or solo:
out[name] = (round(level, 3), muted, solo)
return out
class Player(QObject):
changed = Signal() # track, queue, play state
mixChanged = Signal()
waveChanged = Signal()
toast = Signal(str)
_loaded = Signal(int, object, object) # token, Source or error, track
_wave = Signal(int, object)
def __init__(self, cfg, plex, stems, engine, parent=None):
super().__init__(parent)
self.cfg, self.plex, self.server, self.engine = cfg, plex, stems, engine
self.library = None
self.queue: list[dict] = []
self.index = -1
self._unshuffled = None
self.shuffle = False
self.repeat = "off" # off | all | one
self.mix: dict[str, dict] = {}
self.names: list[str] = []
self.source_kind = "" # stems | original
self._token = 0
self._loading = False
self._wave_raw = None # {"stems": {name: [..]}} or {"db": [..]}
self._scrobbled = False
self._pending_seek = 0.0
self._queue_dirty = False
self._loaded.connect(self._on_loaded)
self._wave.connect(self._on_wave)
engine.ended.connect(self._on_ended)
engine.playingChanged.connect(self._on_play_state)
engine.bufferingChanged.connect(self.changed)
engine.setVolume(cfg.get("volume", 0.8))
engine.volumeChanged.connect(self._save_soon)
self._report = QTimer(self, interval=10_000)
self._report.timeout.connect(self._tick)
self._report.start()
self._saver = QTimer(self, interval=1500, singleShot=True)
self._saver.timeout.connect(self.save)
self._restore()
# ---- state -------------------------------------------------------------------------
# The queue can be the whole library, so it lives in its own file, written off the GUI
# thread and only when it changes; the small state (position, mix) saves often.
QUEUE_PATH = os.path.join(config.CONFIG_DIR, "player-queue.json")
QUEUE_KEYS = ("key", "title", "artist", "albumArtist", "album", "albumKey", "artistKey", "year", "index", "disc",
"durationMs", "thumb", "partFile", "partKey", "codec", "stemKey", "_cover")
def _restore(self):
st = self.cfg.get("state") or {}
try:
with open(self.QUEUE_PATH) as f:
q = json.load(f)
self.queue, self._unshuffled = q.get("queue") or [], q.get("unshuffled")
except (OSError, ValueError):
self.queue, self._unshuffled = st.get("queue") or [], st.get("unshuffled")
self.index = st.get("index", -1) if self.queue else -1
self.shuffle = bool(st.get("shuffle"))
self.repeat = st.get("repeat", "off")
self.mix = st.get("mix") or {}
self._pending_seek = float(st.get("position") or 0)
if 0 <= self.index < len(self.queue):
QTimer.singleShot(0, lambda: self._load(self.index, play=False, start=self._pending_seek))
def save(self, wait=False):
self.cfg["volume"] = round(self.engine.volume, 3)
self.cfg["state"] = {
"index": self.index, "shuffle": self.shuffle, "repeat": self.repeat, "mix": self.mix,
"position": round(self.engine.position() if self.engine.duration else self._pending_seek, 2),
}
config.save(self.cfg)
if self._queue_dirty:
self._queue_dirty = False
slim = lambda ts: [{k: t[k] for k in self.QUEUE_KEYS if k in t} for t in ts] if ts is not None else None
data = {"queue": slim(self.queue), "unshuffled": slim(self._unshuffled)}
def write():
tmp = self.QUEUE_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f)
os.replace(tmp, self.QUEUE_PATH)
t = threading.Thread(target=write, daemon=not wait)
t.start()
if wait:
t.join()
def _save_soon(self):
self._saver.start()
def current(self):
return self.queue[self.index] if 0 <= self.index < len(self.queue) else None
def current_key(self):
t = self.current()
return t["key"] if t else ""
# ---- queue ---------------------------------------------------------------------------
def play_tracks(self, tracks, start=0, shuffle=False):
tracks = [dict(t) for t in tracks]
if not tracks:
return
self._unshuffled = list(tracks) if shuffle else None
if shuffle:
first = tracks.pop(start)
random.shuffle(tracks)
tracks.insert(0, first)
start = 0
self.shuffle = shuffle
self.queue = tracks
if not self.cfg.get("keepMix"):
self.mix = {}
self._load(start, play=True)
self._queue_changed()
def add_to_queue(self, tracks):
self.queue += [dict(t) for t in tracks]
if self.index < 0:
self._load(0, play=True)
self._queue_changed()
def play_next(self, tracks):
at = self.index + 1
self.queue[at:at] = [dict(t) for t in tracks]
if self.index < 0:
self._load(0, play=True)
self._queue_changed()
@Slot(int)
def removeAt(self, i):
if not (0 <= i < len(self.queue)):
return
del self.queue[i]
if i < self.index:
self.index -= 1
elif i == self.index:
if self.queue:
self._load(min(i, len(self.queue) - 1), play=self.engine.playing)
else:
self.index = -1
self.engine.clear()
self._queue_changed()
@Slot()
def clearQueue(self):
cur = self.current()
self.queue = [cur] if cur else []
self.index = 0 if cur else -1
self._unshuffled = None
self._queue_changed()
@Slot(int)
def skipTo(self, i):
if 0 <= i < len(self.queue):
self._load(i, play=True)
def _queue_changed(self):
self._queue_dirty = True
if self.library:
self.library.update_queue()
self.changed.emit()
self._save_soon()
# ---- transport -------------------------------------------------------------------------
@Slot()
def toggle(self):
if self.engine.duration:
self.engine.toggle()
elif self.current() and not self._loading:
self._load(self.index, play=True, start=self._pending_seek)
@Slot()
def next(self):
if not self.queue:
return
i = self.index + 1
if i >= len(self.queue):
if self.repeat != "all":
return
i = 0
self._load(i, play=True)
@Slot()
def previous(self):
if not self.queue:
return
if self.engine.position() > 3 or self.index <= 0:
self.engine.seek(0)
return
self._load(self.index - 1, play=True)
@Slot(float)
def seek(self, seconds):
if self.engine.duration:
self.engine.seek(max(0.0, min(seconds, self.engine.duration - 0.25)))
else:
self._pending_seek = seconds
@Slot()
def toggleShuffle(self):
cur = self.current()
if not self.shuffle:
self._unshuffled = list(self.queue)
rest = [t for i, t in enumerate(self.queue) if i != self.index]
random.shuffle(rest)
self.queue = ([cur] if cur else []) + rest
self.index = 0 if cur else -1
self.shuffle = True
else:
if self._unshuffled:
self.queue = self._unshuffled
keys = [t["key"] for t in self.queue]
self.index = keys.index(cur["key"]) if cur and cur["key"] in keys else 0
self._unshuffled = None
self.shuffle = False
self._queue_changed()
@Slot()
def cycleRepeat(self):
self.repeat = {"off": "all", "all": "one", "one": "off"}[self.repeat]
self.changed.emit()
self._save_soon()
def _on_ended(self):
cur = self.current()
if cur and not self._scrobbled:
self._scrobbled = True
threading.Thread(target=self.plex.scrobble, args=(cur["key"],), daemon=True).start()
if self.repeat == "one":
self._load(self.index, play=True)
elif self.index + 1 < len(self.queue) or self.repeat == "all":
self.next()
else:
self.engine.seek(0)
self.changed.emit()
def _on_play_state(self):
self._report_state("playing" if self.engine.playing else "paused")
self.changed.emit()
self._save_soon()
# ---- loading ------------------------------------------------------------------------------
def _load(self, i, play=True, start=0.0):
"""Opens queue[i] off the GUI thread; a newer request makes older ones moot."""
if not (0 <= i < len(self.queue)):
return
if self.engine.playing and self.current():
self._report_state("stopped")
self._token += 1
token = self._token
self.index = i
self._loading = True
self._scrobbled = False
self._wave_raw = None
track = self.queue[i]
if not self.cfg.get("keepMix") and start == 0.0:
self.mix = {}
self.engine.pause()
if self.library:
self.library.model.set_playing(track["key"], i)
self.changed.emit()
self.waveChanged.emit()
self._pending_seek = start
def work():
try:
src, entry = self._open(track)
self._loaded.emit(token, (src, play, start, entry), track)
except Exception as e: # noqa: BLE001 - reported, then skipped
self._loaded.emit(token, e, track)
threading.Thread(target=work, daemon=True).start()
def _open(self, track):
entry = self.server.index.by_key.get(track.get("stemKey") or "") or self.server.find(track)
if entry and "_cover" not in track:
track["_cover"] = self._cover(entry)
if entry:
stems = {s["name"]: s for s in entry["stems"]}
names = sort_names(list(stems))
paths = [self.server.local_path(entry, stems[n]) or self._download(self.server.file_url(entry, stems[n]), stems[n]["file"])
for n in names]
return Source(names, paths), entry
return Source(["mix"], [self._original_path(track)]), None
def _cover(self, entry):
"""The album folder's own cover on the music share: art for songs Plex doesn't know."""
root = self.cfg.get("musicDir", "")
folder = os.path.join(root, os.path.dirname(entry.get("source", ""))) if root else ""
try:
pics = [f for f in os.listdir(folder) if f.lower().endswith((".jpg", ".jpeg", ".png"))]
except OSError:
return ""
if pics:
pics.sort(key=lambda f: (not f.lower().startswith(("cover", "folder", "front")), f.lower()))
return "file://" + urllib.request.pathname2url(os.path.join(folder, pics[0]))
# no picture file: the art embedded in the song itself, pulled out once by ffmpeg
src = os.path.join(root, entry["source"])
out = os.path.join(config.CACHE_DIR, "art", hashlib.sha1(folder.encode()).hexdigest() + ".jpg")
if not os.path.exists(out) and os.path.exists(src) and shutil.which("ffmpeg"):
os.makedirs(os.path.dirname(out), exist_ok=True)
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", src, "-an", "-frames:v", "1", "-vf", "scale=600:-2", out],
stdin=subprocess.DEVNULL, capture_output=True, timeout=20)
return "file://" + urllib.request.pathname2url(out) if os.path.exists(out) else ""
def _original_path(self, track):
"""The file on the music share when it's mounted, else a download from Plex."""
pf = track.get("partFile") or ""
local = self.cfg.get("musicDir", "")
if pf and local and os.path.isdir(local):
for loc in self.cfg.get("sectionLocations") or []:
if pf.startswith(loc.rstrip("/") + "/"):
p = os.path.join(local, pf[len(loc.rstrip("/")) + 1:])
if os.path.exists(p):
return p
parts = pf.replace("\\", "/").split("/")
for i in range(1, len(parts)):
p = os.path.join(local, *parts[i:])
if os.path.exists(p):
return p
if not track.get("partKey"):
raise OSError(f"No file for {track.get('title')}")
return self._download(self.plex.part_url(track["partKey"]), os.path.basename(pf) or "track")
def _download(self, url, name):
d = os.path.join(config.CACHE_DIR, "files")
os.makedirs(d, exist_ok=True)
ext = os.path.splitext(name)[1] or ".bin"
path = os.path.join(d, hashlib.sha1(url.split("X-Plex-Token")[0].encode()).hexdigest() + ext)
if os.path.exists(path):
os.utime(path)
return path
tmp = path + ".part"
req = urllib.request.Request(url, headers={"X-Stem-Token": self.cfg["stemToken"]} if self.cfg.get("stemToken") else {})
with urllib.request.urlopen(req, timeout=30) as r, open(tmp, "wb") as f:
while chunk := r.read(1 << 20):
f.write(chunk)
os.replace(tmp, path)
self._trim_cache(d)
return path
@staticmethod
def _trim_cache(d, limit=4 << 30):
files = [os.path.join(d, f) for f in os.listdir(d) if not f.endswith(".part")]
files.sort(key=lambda p: os.path.getmtime(p))
total = sum(os.path.getsize(p) for p in files)
while files and total > limit:
p = files.pop(0)
total -= os.path.getsize(p)
os.remove(p)
def _on_loaded(self, token, result, track):
if token != self._token:
if not isinstance(result, Exception):
result[0].close()
return
self._loading = False
if isinstance(result, Exception):
self.toast.emit(f"Couldn't play “{track.get('title')}”: {result}")
self.changed.emit()
if self.index + 1 < len(self.queue):
QTimer.singleShot(800, lambda t=token: t == self._token and self.next())
return
src, play, start, entry = result
self.names = src.names
self.source_kind = "stems" if entry else "original"
self.engine.set_source(src, start, play=play)
self._push_mix()
self.changed.emit()
self._load_wave(token, track, entry)
self._save_soon()
def _load_wave(self, token, track, entry):
def work():
w = None
if entry:
st = self.server.peaks(entry, WAVE_BUCKETS)
w = {"stems": st} if st else None
if w is None:
try:
db = self.plex.loudness(track["key"], WAVE_BUCKETS)
w = {"db": db} if db else None
except Exception: # noqa: BLE001 - no waveform is fine
w = None
self._wave.emit(token, w)
threading.Thread(target=work, daemon=True).start()
def _on_wave(self, token, w):
if token == self._token:
self._wave_raw = w
self.waveChanged.emit()
# ---- mix -----------------------------------------------------------------------------------
def _push_mix(self):
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
gains = []
for n in self.names:
level, muted, solo = _setting(self.mix, n)
gains.append(0.0 if (any_solo and not solo) or muted else stem_taper(level))
self.engine.set_gains(gains)
self.mixChanged.emit()
self.waveChanged.emit()
self._save_soon()
def _edit(self, i, **kw):
if 0 <= i < len(self.names):
n = self.names[i]
self.mix[n] = {**self.mix.get(n, {}), **kw}
self._push_mix()
@Slot(int, float)
def setLevel(self, i, v):
self._edit(i, level=max(0.0, min(1.0, v)))
@Slot(int)
def toggleMute(self, i):
if 0 <= i < len(self.names):
self._edit(i, muted=not _setting(self.mix, self.names[i])[1])
@Slot(int)
def toggleSolo(self, i):
if 0 <= i < len(self.names):
self._edit(i, solo=not _setting(self.mix, self.names[i])[2])
@Slot(int)
def applyPreset(self, p):
m = PRESETS[p][1](self.names)
if m is not None:
self.mix = {k: dict(v) for k, v in m.items()}
self._push_mix()
@Slot()
def toggleVocals(self):
"""Karaoke switch: vocals off, or back to the full mix."""
if "vocals" not in self.names:
return
self.applyPreset(0 if self._preset_index() == 1 else 1)
def _preset_index(self):
m = _normalized(self.mix)
for i, (_, build) in enumerate(PRESETS):
b = build(self.names)
if b is not None and _normalized(b) == m:
return i
return -1
# ---- reporting to Plex (play counts, "now playing" on the server) -------------------------------
def _report_state(self, state):
cur = self.current()
if cur:
pos = self.engine.position() * 1000
threading.Thread(target=self.plex.timeline, args=(cur, state, pos), daemon=True).start()
def _tick(self):
if self.engine.playing:
self._report_state("playing")
cur, d = self.current(), self.engine.duration
if cur and d and not self._scrobbled and self.engine.position() > 0.9 * d:
self._scrobbled = True
threading.Thread(target=self.plex.scrobble, args=(cur["key"],), daemon=True).start()
self.save()
def shutdown(self):
self._report_state("stopped")
self.save(wait=True)
# ---- QML properties -------------------------------------------------------------------------
def _t(self, k, default=""):
t = self.current()
return t.get(k, default) if t else default
def _get_track(self):
t = self.current()
if not t:
return {}
return {"title": t["title"], "artist": t["artist"], "album": t["album"], "year": t.get("year") or 0,
"art": self.plex.art_url(t.get("thumb"), 600) or t.get("_cover", ""), "durationMs": t.get("durationMs", 0),
"codec": t.get("codec", ""), "key": t["key"]}
def _get_status(self):
if self._loading:
return "loading"
if self.engine.buffering:
return "buffering"
return "playing" if self.engine.playing else ("paused" if self.current() else "")
def _get_stems(self):
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
out = []
for n in self.names:
level, muted, solo = _setting(self.mix, n)
out.append({"name": n, "label": "Original mix" if n == "mix" else n.capitalize(),
"color": COLORS.get(n, "#8E8E93"), "level": level, "muted": muted, "solo": solo,
"audible": not muted and (solo or not any_solo), "db": 20 * math.log10(max(1e-6, stem_taper(level)))})
return out
def _get_presets(self):
cur = self._preset_index()
return [{"label": label, "usable": build(self.names) is not None and self.source_kind == "stems", "active": i == cur}
for i, (label, build) in enumerate(PRESETS)]
def _get_preset_label(self):
if self.source_kind != "stems":
return ""
i = self._preset_index()
return PRESETS[i][0] if i >= 0 else "Custom Mix"
def _get_wave(self):
"""0..1 loudness per bucket for the current mix: stems weighted by their faders."""
w = self._wave_raw
if not w:
return []
if "stems" in w:
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
gains = {}
for n in w["stems"]:
level, muted, solo = _setting(self.mix, n)
gains[n] = 0.0 if (any_solo and not solo) or muted else stem_taper(level)
size = max(len(a) for a in w["stems"].values())
out = []
for i in range(size):
e = sum((a[i] if i < len(a) else 0.0) ** 2 * gains.get(n, 1.0) ** 2 for n, a in w["stems"].items())
out.append(_norm_db(20 * math.log10(math.sqrt(e) + 1e-6)))
return out
return [_norm_db(v) for v in w["db"]]
def _get_queue_info(self):
return {"index": self.index, "count": len(self.queue)}
track = Property("QVariantMap", _get_track, notify=changed)
status = Property(str, _get_status, notify=changed)
stems = Property("QVariantList", _get_stems, notify=mixChanged)
presets = Property("QVariantList", _get_presets, notify=mixChanged)
presetLabel = Property(str, _get_preset_label, notify=mixChanged)
sourceKind = Property(str, lambda self: self.source_kind, notify=changed)
wave = Property("QVariantList", _get_wave, notify=waveChanged)
shuffleOn = Property(bool, lambda self: self.shuffle, notify=changed)
repeatMode = Property(str, lambda self: self.repeat, notify=changed)
queueInfo = Property("QVariantMap", _get_queue_info, notify=changed)
keepMix = Property(bool, lambda self: bool(self.cfg.get("keepMix")), notify=mixChanged)
@Slot(bool)
def setKeepMix(self, on):
self.cfg["keepMix"] = on
self.mixChanged.emit()
self._save_soon()
def _norm_db(db):
"""Loudness to bar height, as on the phone: -34 dB and below is a stub, -6 dB and up full."""
return max(0.06, min(1.0, (db + 34) / 28))
+275
View File
@@ -0,0 +1,275 @@
"""Plex Media Server and plex.tv calls (standard library only; call from worker threads)."""
import json
import platform
import urllib.error
import urllib.parse
import urllib.request
PRODUCT = "StemRoller Player"
PAGE = 1000
class PlexError(Exception):
pass
class NotSignedIn(PlexError):
def __init__(self):
super().__init__("Not signed in to Plex")
def _track(m: dict) -> dict:
"""A Plex track as the player's flat dict."""
media = (m.get("Media") or [{}])[0]
part = (media.get("Part") or [{}])[0]
artist = m.get("originalTitle") or m.get("grandparentTitle") or ""
return {
"key": str(m.get("ratingKey", "")),
"title": m.get("title", ""),
"artist": artist,
"albumArtist": m.get("grandparentTitle", ""),
"album": m.get("parentTitle", ""),
"albumKey": str(m.get("parentRatingKey", "")),
"artistKey": str(m.get("grandparentRatingKey", "")),
"year": m.get("parentYear") or m.get("year") or 0,
"index": m.get("index") or 0,
"disc": m.get("parentIndex") or 0,
"durationMs": m.get("duration") or media.get("duration") or part.get("duration") or 0,
"thumb": m.get("thumb") or m.get("parentThumb") or m.get("grandparentThumb") or "",
"partFile": part.get("file", ""),
"partKey": part.get("key", ""),
"codec": media.get("audioCodec", ""),
"plays": m.get("viewCount") or 0,
"addedAt": m.get("addedAt") or 0,
"playlistItemID": m.get("playlistItemID"),
}
def _album(m: dict) -> dict:
return {
"key": str(m.get("ratingKey", "")),
"title": m.get("title", ""),
"artist": m.get("parentTitle", ""),
"artistKey": str(m.get("parentRatingKey", "")),
"year": m.get("year") or 0,
"tracks": m.get("leafCount") or 0,
"thumb": m.get("thumb", ""),
"addedAt": m.get("addedAt") or 0,
"genre": ", ".join(g.get("tag", "") for g in (m.get("Genre") or [])[:2]),
}
def _artist(m: dict) -> dict:
return {
"key": str(m.get("ratingKey", "")),
"title": m.get("title", ""),
"sortTitle": m.get("titleSort") or m.get("title", ""),
"albums": m.get("childCount") or 0,
"thumb": m.get("thumb", ""),
"genre": ", ".join(g.get("tag", "") for g in (m.get("Genre") or [])[:2]),
}
def _playlist(m: dict) -> dict:
return {
"key": str(m.get("ratingKey", "")),
"title": m.get("title", ""),
"tracks": m.get("leafCount") or 0,
"durationMs": m.get("duration") or 0,
"thumb": m.get("composite") or m.get("thumb", ""),
"smart": bool(m.get("smart")),
"updatedAt": m.get("updatedAt") or 0,
}
class Plex:
def __init__(self, config):
self.cfg = config
@property
def base(self):
return self.cfg["plexUrl"].rstrip("/")
@property
def token(self):
return self.cfg.get("plexToken", "")
@property
def signed_in(self):
return bool(self.token)
@property
def section(self):
return self.cfg.get("musicSection", "")
def _headers(self, token=None):
h = {
"Accept": "application/json",
"X-Plex-Product": PRODUCT,
"X-Plex-Version": "1.0",
"X-Plex-Client-Identifier": self.cfg["clientId"],
"X-Plex-Platform": "Linux",
"X-Plex-Platform-Version": platform.release(),
"X-Plex-Device": "Desktop",
"X-Plex-Device-Name": f"{PRODUCT} ({platform.node()})",
}
if token:
h["X-Plex-Token"] = token
return h
def _call(self, url, method="GET", token=None, headers=None, timeout=20):
req = urllib.request.Request(url, method=method, headers={**self._headers(token), **(headers or {})},
data=b"" if method in ("POST", "PUT") else None)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
body = r.read()
except urllib.error.HTTPError as e:
raise PlexError(f"Plex answered {e.code}") from e
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise PlexError(f"Can't reach Plex: {getattr(e, 'reason', e)}") from e
if not body.strip() or not body.lstrip().startswith(b"{"):
return {}
return json.loads(body)
def _url(self, path, **params):
q = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
return f"{self.base}{path}" + (f"?{q}" if q else "")
def get(self, path, start=None, size=None, **params):
if not self.signed_in:
raise NotSignedIn()
extra = {}
if start is not None:
extra["X-Plex-Container-Start"] = str(start)
if size is not None:
extra["X-Plex-Container-Size"] = str(size)
return self._call(self._url(path, **params), token=self.token, headers=extra).get("MediaContainer", {})
def send(self, method, path, **params):
if not self.signed_in:
raise NotSignedIn()
return self._call(self._url(path, **params), method=method, token=self.token).get("MediaContainer", {})
def all(self, path, progress=None, **params):
"""Pages through a listing; Plex caps one response on big libraries."""
out, start = [], 0
while True:
page = self.get(path, start=start, size=PAGE, **params)
items = page.get("Metadata") or []
out += items
start += len(items)
total = page.get("totalSize") or page.get("size") or 0
if progress:
progress(start, total)
if not items or start >= total:
return out
# ---- server ------------------------------------------------------------------
def music_sections(self):
dirs = self.get("/library/sections").get("Directory") or []
return [{"key": d["key"], "title": d.get("title", ""), "locations": [l.get("path", "") for l in d.get("Location") or []]}
for d in dirs if d.get("type") == "artist"]
def machine_id(self):
mid = self._call(self._url("/identity")).get("MediaContainer", {}).get("machineIdentifier")
if not mid:
raise PlexError("Not a Plex server")
return mid
# ---- library -----------------------------------------------------------------
def tracks(self, progress=None):
return [_track(m) for m in self.all(f"/library/sections/{self.section}/all", progress, type=10, sort="titleSort")]
def albums(self):
return [_album(m) for m in self.all(f"/library/sections/{self.section}/all", type=9, sort="titleSort")]
def artists(self):
return [_artist(m) for m in self.all(f"/library/sections/{self.section}/all", type=8, sort="titleSort")]
def playlists(self):
return [_playlist(m) for m in self.get("/playlists", playlistType="audio").get("Metadata") or []]
def playlist_tracks(self, key):
return [_track(m) for m in self.all(f"/playlists/{key}/items")]
def album_tracks(self, key):
return [_track(m) for m in self.get(f"/library/metadata/{key}/children").get("Metadata") or []]
def artist_tracks(self, key):
return [_track(m) for m in self.get(f"/library/metadata/{key}/allLeaves").get("Metadata") or []]
def loudness(self, track_key, samples):
"""Plex's loudness analysis in dB (what Plexamp draws), or None if not analysed."""
meta = (self.get(f"/library/metadata/{track_key}").get("Metadata") or [{}])[0]
part = (((meta.get("Media") or [{}])[0]).get("Part") or [{}])[0]
stream = next((s for s in part.get("Stream") or [] if s.get("streamType") == 2), None)
if not stream:
return None
try:
levels = self.get(f"/library/streams/{stream['id']}/levels", subsample=samples).get("Level") or []
except PlexError:
return None
return [float(l.get("v", -60)) for l in levels] or None
# ---- playlists -----------------------------------------------------------------
def _library_uri(self, keys):
mid = self.cfg.get("plexMachineId") or self.machine_id()
self.cfg["plexMachineId"] = mid
return f"server://{mid}/com.plexapp.plugins.library/library/metadata/{','.join(keys)}"
def create_playlist(self, title, keys):
c = self.send("POST", "/playlists", type="audio", title=title, smart=0, uri=self._library_uri(keys))
items = c.get("Metadata") or []
return _playlist(items[0]) if items else None
def add_to_playlist(self, key, keys):
self.send("PUT", f"/playlists/{key}/items", uri=self._library_uri(keys))
def remove_from_playlist(self, key, item_id):
self.send("DELETE", f"/playlists/{key}/items/{item_id}")
# ---- reporting ---------------------------------------------------------------
def scrobble(self, key):
try:
self.get("/:/scrobble", key=key, identifier="com.plexapp.plugins.library")
except PlexError:
pass
def timeline(self, track, state, position_ms):
try:
self.get("/:/timeline", ratingKey=track["key"], key=f"/library/metadata/{track['key']}", state=state,
time=int(position_ms), duration=track.get("durationMs", 0), hasMDE=1)
except PlexError:
pass
# ---- URLs ----------------------------------------------------------------------
def art_url(self, thumb, px):
if not thumb or not self.signed_in:
return ""
return self._url("/photo/:/transcode", width=px, height=px, minSize=1, upscale=1, url=thumb,
**{"X-Plex-Token": self.token})
def part_url(self, part_key):
return f"{self.base}{part_key}?X-Plex-Token={urllib.parse.quote(self.token)}"
# ---- plex.tv sign-in -----------------------------------------------------------
def create_pin(self):
return self._call("https://plex.tv/api/v2/pins?strong=false", method="POST")
def check_pin(self, pin_id):
return self._call(f"https://plex.tv/api/v2/pins/{pin_id}")
def username(self, token):
try:
u = self._call("https://plex.tv/api/v2/user", token=token)
return u.get("username") or u.get("title") or ""
except PlexError:
return ""
def probe(self, url, token):
try:
self._call(url.rstrip("/") + "/library/sections", token=token, timeout=4)
return True
except PlexError:
return False
@@ -0,0 +1,192 @@
import QtQuick
// File: Excel's backstage view. Info, Account (Plex sign-in) and Options.
Rectangle {
id: bs
property var win: Window.window
property string page: "info"
color: "#FFFFFF"
Rectangle {
id: side
width: 190
height: parent.height
color: X.green
Column {
width: parent.width
Item {
width: parent.width
height: 64
Rectangle { anchors.fill: parent; color: bm.containsMouse ? X.greenHover : "transparent" }
Ico { x: 22; anchors.verticalCenter: parent.verticalCenter; name: "back"; ink: "#FFFFFF"; size: 30 }
MouseArea { id: bm; anchors.fill: parent; hoverEnabled: true; onClicked: bs.win.closeBackstage() }
}
Repeater {
model: [["info", "Info"], ["account", "Account"], ["options", "Options"]]
delegate: Item {
required property var modelData
width: side.width
height: 40
Rectangle {
anchors.fill: parent
color: bs.page === parent.modelData[0] ? X.greenDark : im.containsMouse ? X.greenHover : "transparent"
}
T { x: 26; height: parent.height; text: parent.modelData[1]; color: "#FFFFFF"; font.pixelSize: 15 }
MouseArea { id: im; anchors.fill: parent; hoverEnabled: true; onClicked: bs.page = parent.modelData[0] }
}
}
Item { width: 1; height: 20 }
Item {
width: side.width
height: 40
Rectangle { anchors.fill: parent; color: qm.containsMouse ? X.greenHover : "transparent" }
T { x: 26; height: parent.height; text: "Close"; color: "#FFFFFF"; font.pixelSize: 15 }
MouseArea { id: qm; anchors.fill: parent; hoverEnabled: true; onClicked: bs.win.close() }
}
}
}
Item {
x: side.width + 40
y: 24
width: parent.width - x - 40
height: parent.height - y
T {
id: heading
text: bs.page === "info" ? "Info" : bs.page === "account" ? "Account" : "Options"
font.pixelSize: 38
font.weight: Font.Light
color: X.ink
height: 56
}
// ---- Info ----
Column {
visible: bs.page === "info"
y: heading.height + 20
width: parent.width
spacing: 18
Row {
spacing: 40
Column {
width: 360
spacing: 8
T { text: "Now Playing"; font.pixelSize: 20; color: X.greenText; height: 30 }
Prop { k: "Title"; v: player.track.title || "—" }
Prop { k: "Artist"; v: player.track.artist || "—" }
Prop { k: "Album"; v: player.track.album || "—" }
Prop { k: "Plays from"; v: player.sourceKind === "stems" ? "Stems (" + player.stems.length + ")" : player.sourceKind === "original" ? "Original file" + (player.track.codec ? " (" + player.track.codec.toUpperCase() + ")" : "") : "—" }
Prop { k: "Mix"; v: player.presetLabel || "—" }
Prop { k: "Queue"; v: player.queueInfo.count ? (player.queueInfo.index + 1) + " of " + player.queueInfo.count : "Empty" }
}
Column {
width: 360
spacing: 8
T { text: "Properties"; font.pixelSize: 20; color: X.greenText; height: 30 }
Prop { k: "Songs"; v: library.counts.songs.toLocaleString(Qt.locale(), "f", 0) }
Prop { k: "With stems"; v: library.counts.stems.toLocaleString(Qt.locale(), "f", 0) }
Prop { k: "Stems index"; v: library.counts.stemIndex.toLocaleString(Qt.locale(), "f", 0) + " songs" }
Prop { k: "Stem server"; v: (library.counts.stemOnline === true ? "Online · " : library.counts.stemOnline === false ? "Offline · " : "Checking · ") + settings.get("stemUrl") }
Prop { k: "Plex"; v: (library.signedIn ? "Connected · " : "Not connected · ") + settings.get("plexUrl") }
Prop { k: "Media keys"; v: bs.win.mprisOk ? "On (MPRIS)" : "Off" }
}
}
}
// ---- Account ----
Column {
visible: bs.page === "account"
y: heading.height + 20
width: parent.width
spacing: 14
T { text: "Connected Services"; font.pixelSize: 20; color: X.greenText; height: 30 }
Row {
spacing: 14
Ico { name: "plex"; size: 40 }
Column {
spacing: 4
T { text: "Plex Media Server"; font.pixelSize: 15; font.bold: true }
T { text: library.signedIn ? "Connected to " + settings.get("plexUrl") : "Not connected"; color: X.inkSoft }
}
}
Rectangle {
visible: library.pin !== ""
width: 420
height: 150
color: "#F3F3F3"
border.color: X.ribbonLine
Column {
anchors.centerIn: parent
spacing: 6
T { anchors.horizontalCenter: parent.horizontalCenter; text: "Go to plex.tv/link and enter"; font.pixelSize: 14 }
T {
anchors.horizontalCenter: parent.horizontalCenter
text: library.pin
font.family: cellFont
font.pixelSize: 54
font.bold: true
font.letterSpacing: 10
color: X.greenText
}
T { anchors.horizontalCenter: parent.horizontalCenter; text: "Waiting for Plex…"; color: X.inkSoft }
}
}
Row {
spacing: 10
OfficeButton {
text: library.pin !== "" ? "Cancel" : library.signedIn ? "Sign in again" : "Sign in to Plex"
primary: library.pin === ""
onClicked: library.pin !== "" ? library.cancelSignIn() : library.signIn()
}
}
}
// ---- Options ----
Column {
visible: bs.page === "options"
y: heading.height + 20
width: Math.min(parent.width, 640)
spacing: 10
T { text: "Connections"; font.pixelSize: 20; color: X.greenText; height: 30 }
Opt { id: oPlex; label: "Plex server"; key: "plexUrl" }
Opt { id: oStem; label: "Stem server (stemserve)"; key: "stemUrl" }
T { text: "Folders"; font.pixelSize: 20; color: X.greenText; height: 40; verticalAlignment: Text.AlignBottom }
Opt { id: oStems; label: "Stems share (played directly when mounted)"; key: "stemsDir" }
Opt { id: oMusic; label: "Music share"; key: "musicDir" }
Item { width: 1; height: 8 }
Row {
spacing: 10
OfficeButton {
text: "Save"
primary: true
onClicked: {
oPlex.save(); oStem.save(); oStems.save(); oMusic.save()
library.refresh()
bs.win.closeBackstage()
}
}
OfficeButton { text: "Cancel"; onClicked: bs.win.closeBackstage() }
}
}
}
component Prop: Row {
property string k
property string v
spacing: 10
T { width: 110; text: parent.k; color: X.inkSoft }
T { width: 240; text: parent.v }
}
component Opt: Column {
id: o
property string label
property string key
function save() { settings.set(key, f.text.trim()) }
spacing: 3
width: parent.width
T { text: o.label; color: X.inkSoft }
XField { id: f; width: o.width; text: settings.get(o.key) || "" }
Connections { target: bs; function onVisibleChanged() { if (bs.visible) f.text = settings.get(o.key) || "" } }
}
}
@@ -0,0 +1,18 @@
import QtQuick
Item {
id: c
property string icon
property bool danger: false
property string tip: ""
signal clicked()
width: icon === "pane" ? 40 : 46
height: parent.height
Rectangle {
anchors.fill: parent
color: cm.pressed ? (c.danger ? "#F1707A" : X.greenDark) : cm.containsMouse ? (c.danger ? "#E81123" : X.greenHover) : "transparent"
}
Ico { anchors.centerIn: parent; name: c.icon; ink: "#FFFFFF"; size: c.icon === "pane" ? 16 : 24 }
MouseArea { id: cm; anchors.fill: parent; hoverEnabled: true; onClicked: c.clicked() }
Tip { target: cm; text: c.tip }
}
@@ -0,0 +1,51 @@
import QtQuick
// Name box, the ✕ ✓ fx buttons, and the formula of the active cell.
Rectangle {
id: fb
property var win: Window.window
height: 30
color: "#FFFFFF"
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.ribbonLine }
Rectangle {
id: nameBox
x: 4
y: 4
width: 110
height: 22
border.color: "#C6C6C6"
T {
x: 5
height: parent.height
width: parent.width - 20
text: fb.win.multiSel ? (fb.win.selBottom - fb.win.selTop + 1) + "R x " + (Math.abs(fb.win.curCol - fb.win.anchorCol) + 1) + "C"
: X.colName(fb.win.curCol) + (fb.win.curRow + 2)
}
Ico { anchors.right: parent.right; anchors.rightMargin: 4; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
}
Row {
x: nameBox.x + nameBox.width + 10
height: parent.height
spacing: 12
Ico { anchors.verticalCenter: parent.verticalCenter; name: "cancel"; size: 14; opacity: 0.35 }
Ico { anchors.verticalCenter: parent.verticalCenter; name: "check"; size: 14; opacity: 0.35 }
Ico { anchors.verticalCenter: parent.verticalCenter; name: "function"; size: 18 }
}
Rectangle {
x: nameBox.x + nameBox.width + 92
y: 4
width: parent.width - x - 6
height: 22
border.color: "#C6C6C6"
T {
x: 6
width: parent.width - 12
height: parent.height
font.family: cellFont
font.pixelSize: 14
color: "#000000"
text: fb.win.formulaText
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import QtQuick
// image://icon/<name>[/<hex ink>], rendered at device pixels.
Image {
property string name: "grid"
property string ink: ""
property int size: 16
width: size
height: size
source: "image://icon/" + name + (ink ? "/" + ink.replace("#", "") : "")
sourceSize.width: size * Screen.devicePixelRatio
sourceSize.height: size * Screen.devicePixelRatio
smooth: true
asynchronous: false
}
+205
View File
@@ -0,0 +1,205 @@
import QtQuick
import QtQuick.Controls.Basic
Window {
id: win
width: 1440
height: 900
minimumWidth: 960
minimumHeight: 600
visible: true
title: (player.track.title ? player.track.title + " - " + player.track.artist : "Book1") + ".xlsx - Excel"
color: "#FFFFFF"
flags: Qt.Window | Qt.FramelessWindowHint
readonly property bool maximized: visibility === Window.Maximized || visibility === Window.FullScreen
function toggleMaximized() { maximized ? showNormal() : showMaximized() }
// ---- selection: the active cell and the anchor of the range ----
property int curRow: 0
property int curCol: 0
property int anchorRow: 0
property int anchorCol: 0
readonly property int selTop: Math.min(curRow, anchorRow)
readonly property int selBottom: Math.max(curRow, anchorRow)
readonly property bool multiSel: curRow !== anchorRow || curCol !== anchorCol
property string formulaText: ""
property var selStats: ({})
function refreshCell() {
formulaText = library.formula(curRow, curCol)
selStats = multiSel ? library.stats(anchorRow, anchorCol, curRow, curCol) : ({})
}
onCurRowChanged: cellTimer.restart()
onCurColChanged: cellTimer.restart()
onAnchorRowChanged: cellTimer.restart()
Timer { id: cellTimer; interval: 0; onTriggered: win.refreshCell() }
Connections {
target: library
function onSheetSwitched() { win.curRow = win.anchorRow = 0; win.curCol = win.anchorCol = 0; win.refreshCell() }
function onSheetChanged() { cellTimer.restart() }
function onToast(msg) { messages.show(msg) }
}
Connections { target: player; function onToast(msg) { messages.show(msg) } }
Connections {
target: sheetModel
function onModelReset() {
// new data under the same sheet: keep the selection, inside the rows that exist
var last = Math.max(0, grid.rows - 1)
if (win.curRow > last) win.curRow = last
if (win.anchorRow > last) win.anchorRow = last
cellTimer.restart()
}
}
Component.onCompleted: refreshCell()
// ---- view options ----
property bool showMixer: settings.get("showMixer") !== false
property bool gridlines: settings.get("gridlines") !== false
property bool headings: settings.get("headings") !== false
property int zoom: settings.get("zoom") || 100
property bool mprisOk: false
readonly property bool inPlaylist: library.sheetId.indexOf("playlist:") === 0
function toggleMixer() { showMixer = !showMixer; settings.set("showMixer", showMixer) }
function setOption(k, v) { win[k] = v; settings.set(k, v) }
function setZoom(z) { zoom = Math.max(50, Math.min(200, z)); settings.set("zoom", zoom) }
function openBackstage(page) { backstage.page = page; backstage.visible = true }
function closeBackstage() { backstage.visible = false; focusGrid() }
function focusGrid() { grid.forceActiveFocus() }
function focusSearch() { ribbon.focusSearch() }
function selectAll() { anchorRow = 0; anchorCol = 0; curRow = Math.max(0, grid.rows - 1); curCol = Math.max(0, library.columns.length - 1) }
function copySelection() { library.copyCells(anchorRow, anchorCol, curRow, curCol); messages.show("Copied " + (selBottom - selTop + 1) + " rows") }
function sortCurrent(desc) { library.sortTo(curCol, desc) }
function sortColumn(c, desc) { library.sortTo(c, desc) }
function newPlaylist() { plDialog.open() }
function addToPlaylist(name) { library.addToPlaylist(name, selTop, selBottom) }
// ---- playhead, read once per frame while playing ----
property real playPos: 0
property real levelL: 0
property real levelR: 0
function seekTo(s) { player.seek(s); playPos = s }
FrameAnimation {
running: engine.playing
onTriggered: {
var s = engine.snapshot()
win.playPos = s[0]
// meters fall back smoothly between peaks
var k = Math.exp(-frameTime / 0.12)
win.levelL = Math.max(s[1], win.levelL * k)
win.levelR = Math.max(s[2], win.levelR * k)
}
}
Connections {
target: engine
function onPlayingChanged() { var s = engine.snapshot(); win.playPos = s[0]; if (!engine.playing) { win.levelL = 0; win.levelR = 0 } }
function onDurationChanged() { win.playPos = engine.snapshot()[0] }
}
Timer { interval: 500; running: !engine.playing; repeat: true; onTriggered: win.playPos = engine.snapshot()[0] }
// dev hook: --eval runs JavaScript against the window
property string devEval: ""
onDevEvalChanged: if (devEval) { try { console.log("eval:", eval(devEval)) } catch (e) { console.log("eval error:", e) } }
Shortcut { sequence: "Ctrl+F"; onActivated: win.focusSearch() }
Shortcut { sequence: "Ctrl+Q"; onActivated: win.close() }
Shortcut { sequence: "Media Play"; onActivated: player.toggle() }
Shortcut { sequence: "Ctrl+Right"; context: Qt.ApplicationShortcut; enabled: !grid.activeFocus; onActivated: player.next() }
Shortcut { sequence: "Ctrl+Left"; context: Qt.ApplicationShortcut; enabled: !grid.activeFocus; onActivated: player.previous() }
Shortcut { sequence: "Alt+Left"; onActivated: library.back() }
Shortcut { sequence: "F1"; onActivated: win.openBackstage("info") }
Column {
anchors.fill: parent
TitleBar { width: parent.width }
Ribbon { id: ribbon; width: parent.width }
MessageBar { id: messages; width: parent.width }
FormulaBar { width: parent.width }
Item {
width: parent.width
height: parent.height - y - tabs.height - status.height
Sheet {
id: grid
width: parent.width - (pane.visible ? pane.width : 0)
height: parent.height
focus: true
}
TaskPane {
id: pane
visible: win.showMixer
anchors.right: parent.right
width: 330
height: parent.height
}
}
SheetTabs { id: tabs; width: parent.width }
StatusBar { id: status; width: parent.width }
}
Backstage {
id: backstage
visible: false
y: 32
width: parent.width
height: parent.height - y
}
// New Playlist: an Office dialog
Popup {
id: plDialog
modal: true
anchors.centerIn: parent
width: 380
padding: 0
onOpened: { plName.text = ""; plName.forceActiveFocus() }
onClosed: win.focusGrid()
background: Rectangle { color: "#FFFFFF"; border.color: "#7A7A7A" }
Overlay.modal: Rectangle { color: Qt.rgba(0, 0, 0, 0.12) }
contentItem: Column {
Rectangle {
width: parent.width
height: 30
color: "#FFFFFF"
T { x: 10; height: parent.height; text: "New Playlist" }
Ico { anchors.right: parent.right; anchors.rightMargin: 8; anchors.verticalCenter: parent.verticalCenter; name: "close"; size: 18
MouseArea { anchors.fill: parent; onClicked: plDialog.close() } }
}
Column {
x: 14
width: parent.width - 28
spacing: 8
T { text: (win.selBottom - win.selTop + 1) + " selected row(s) go into a new Plex playlist."; color: X.inkSoft }
T { text: "Name:" }
XField {
id: plName
width: parent.width
onAccepted: okBtn.clicked()
}
Row {
anchors.right: parent.right
spacing: 8
bottomPadding: 14
topPadding: 6
OfficeButton {
id: okBtn
text: "OK"
primary: true
onClicked: { library.newPlaylist(plName.text, win.selTop, win.selBottom); plDialog.close() }
}
OfficeButton { text: "Cancel"; onClicked: plDialog.close() }
}
}
}
}
// frameless: resize from the edges
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge; x: 0; y: 6; width: 5; height: parent.height - 12 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge; x: parent.width - 5; y: 6; width: 5; height: parent.height - 12 }
ResizeEdge { visible: !win.maximized; edges: Qt.TopEdge; x: 6; y: 0; width: parent.width - 12; height: 4 }
ResizeEdge { visible: !win.maximized; edges: Qt.BottomEdge; x: 6; y: parent.height - 4; width: parent.width - 12; height: 4 }
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge | Qt.TopEdge; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge | Qt.TopEdge; x: parent.width - 8; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge | Qt.BottomEdge; y: parent.height - 8; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge | Qt.BottomEdge; x: parent.width - 8; y: parent.height - 8; width: 8; height: 8 }
Rectangle { anchors.fill: parent; color: "transparent"; border.color: X.green; border.width: win.maximized ? 0 : 1 }
}
@@ -0,0 +1,30 @@
import QtQuick
// Excel's yellow message bar, for notices.
Rectangle {
id: mb
property string text: ""
height: text ? 32 : 0
visible: text !== ""
color: "#FFF4CE"
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: "#E6D9A8" }
Behavior on height { NumberAnimation { duration: X.easeMs } }
function show(t) { text = t; hide.restart() }
Timer { id: hide; interval: 6000; onTriggered: mb.text = "" }
Row {
x: 12
height: parent.height
spacing: 10
Ico { anchors.verticalCenter: parent.verticalCenter; name: "info"; size: 16 }
T { height: parent.height; text: "NOTICE"; font.bold: true }
T { height: parent.height; text: mb.text }
}
Item {
anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
width: 22
height: 22
Rectangle { anchors.fill: parent; color: xm.containsMouse ? "#F2E2A6" : "transparent" }
Ico { anchors.centerIn: parent; name: "close"; size: 18 }
MouseArea { id: xm; anchors.fill: parent; hoverEnabled: true; onClicked: mb.text = "" }
}
}
@@ -0,0 +1,16 @@
import QtQuick
// Dialog button: gray with a border; the default one outlined in green.
Rectangle {
id: ob
property string text
property bool primary: false
signal clicked()
implicitWidth: Math.max(80, t.implicitWidth + 24)
implicitHeight: 26
color: m.pressed ? "#CCE4F7" : m.containsMouse ? "#E5F1FB" : "#FDFDFD"
border.color: m.containsMouse ? "#0078D7" : primary ? X.green : "#ADADAD"
border.width: primary ? 2 : 1
T { id: t; anchors.centerIn: parent; text: ob.text }
MouseArea { id: m; anchors.fill: parent; hoverEnabled: true; onClicked: ob.clicked() }
}
@@ -0,0 +1,59 @@
import QtQuick
// A ribbon command: large (icon over a label) or small (icon beside a label).
Item {
id: b
property string icon: ""
property string text: ""
property bool big: false
property bool checkable: false
property bool checked: false
property bool dropdown: false
property string tip: ""
signal clicked()
implicitWidth: big ? Math.max(44, lbl.implicitWidth + 12) : (icon ? 22 : 6) + (text ? lbl.implicitWidth + 8 : 0) + (dropdown ? 12 : 0)
implicitHeight: big ? 66 : 22
opacity: enabled ? 1 : 0.4
Rectangle {
anchors.fill: parent
color: b.checked ? X.checked : ma.pressed ? X.pressed : ma.containsMouse ? X.hover : "transparent"
border.color: b.checked ? X.checkedBorder : "transparent"
}
Ico {
id: ic
visible: b.icon !== ""
name: b.icon
size: b.big ? 32 : 16
x: b.big ? (parent.width - width) / 2 : 3
y: b.big ? 4 : 3
}
T {
id: lbl
text: b.text
font.pixelSize: X.fsUi
x: b.big ? (parent.width - width) / 2 : (b.icon ? 23 : 4)
y: b.big ? 38 : 0
height: b.big ? implicitHeight : parent.height
width: b.big ? Math.min(implicitWidth, 90) : implicitWidth
horizontalAlignment: Text.AlignHCenter
wrapMode: b.big ? Text.WordWrap : Text.NoWrap
maximumLineCount: 2
elide: Text.ElideNone
lineHeight: 0.95
}
Ico {
visible: b.dropdown
name: "dropdown"
size: 10
x: b.big ? (parent.width - width) / 2 : parent.width - 12
y: b.big ? parent.height - 13 : 6
}
MouseArea {
id: ma
anchors.fill: parent
hoverEnabled: true
onClicked: b.clicked()
}
Tip { target: ma; text: b.tip || b.text }
}
@@ -0,0 +1,30 @@
import QtQuick
// A ribbon group: its commands, a caption along the bottom, a rule on the right.
Item {
id: g
property string title: ""
default property alias content: row.data
implicitWidth: Math.max(row.implicitWidth, cap.implicitWidth) + 12
height: parent ? parent.height : 92
Row {
id: row
x: 6
y: 3
height: 68
spacing: 2
}
T {
id: cap
text: g.title
font.pixelSize: X.fsSmall
color: X.inkSoft
anchors.horizontalCenter: parent.horizontalCenter
y: parent.height - 18
}
Rectangle {
anchors { right: parent.right; top: parent.top; bottom: parent.bottom; topMargin: 4; bottomMargin: 4 }
width: 1
color: X.ribbonLine
}
}
@@ -0,0 +1,13 @@
import QtQuick
// Invisible edge/corner grip that hands resizing to the compositor.
MouseArea {
property int edges: 0
property var win: Window.window
hoverEnabled: true
acceptedButtons: Qt.LeftButton
cursorShape: edges === (Qt.LeftEdge | Qt.TopEdge) || edges === (Qt.RightEdge | Qt.BottomEdge) ? Qt.SizeFDiagCursor
: edges === (Qt.RightEdge | Qt.TopEdge) || edges === (Qt.LeftEdge | Qt.BottomEdge) ? Qt.SizeBDiagCursor
: (edges & (Qt.LeftEdge | Qt.RightEdge)) ? Qt.SizeHorCursor : Qt.SizeVerCursor
onPressed: win.startSystemResize(edges)
}
+290
View File
@@ -0,0 +1,290 @@
import QtQuick
import QtQuick.Controls.Basic
// Tabs on the green strip, commands on the gray ribbon below.
Column {
id: rib
property var win: Window.window
property int tab: 1 // 0 = File (backstage), 1 Home, 2 Insert, 3 Data, 4 View
readonly property var tabs: ["File", "Home", "Insert", "Data", "View"]
function focusSearch() { tell.startEditing() }
Rectangle {
width: parent.width
height: 28
color: X.green
Row {
x: 0
height: parent.height
Repeater {
model: rib.tabs
delegate: Item {
id: tabItem
required property int index
required property string modelData
readonly property bool file: index === 0
readonly property bool active: rib.tab === index && !file
width: tl.implicitWidth + (file ? 30 : 22)
height: 28
Rectangle {
anchors.fill: parent
anchors.topMargin: tabItem.active ? 2 : 0
color: tabItem.active ? X.ribbon : tabItem.file ? (tm.containsMouse ? "#0E4B2A" : X.greenDark)
: tm.containsMouse ? X.greenHover : "transparent"
}
T {
id: tl
anchors.centerIn: parent
text: tabItem.modelData
color: tabItem.active ? X.greenText : "#FFFFFF"
}
MouseArea {
id: tm
anchors.fill: parent
hoverEnabled: true
onClicked: tabItem.file ? rib.win.openBackstage("info") : rib.tab = tabItem.index
}
}
}
// "Tell me what you want to do": here it searches the library.
Item {
id: tell
width: 260
height: 28
function startEditing() { field.visible = true; field.forceActiveFocus(); field.selectAll() }
Ico { x: 12; anchors.verticalCenter: parent.verticalCenter; name: "lightbulb"; ink: "#FFFFFF"; size: 16 }
T {
x: 34
anchors.verticalCenter: parent.verticalCenter
visible: !field.visible
color: "#FFFFFF"
text: "Tell me what you want to listen to..."
}
MouseArea { anchors.fill: parent; cursorShape: Qt.IBeamCursor; onClicked: tell.startEditing() }
XField {
id: field
visible: false
x: 32
width: 220
anchors.verticalCenter: parent.verticalCenter
placeholderText: "Song, artist or album"
onAccepted: { library.search(text); visible = false; rib.win.focusGrid() }
onActiveFocusChanged: if (!activeFocus && text === "") visible = false
Keys.onEscapePressed: { text = ""; visible = false; rib.win.focusGrid() }
}
}
}
}
Rectangle {
width: parent.width
height: 94
color: X.ribbon
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.ribbonLine }
// ---- Home ----
Row {
visible: rib.tab === 1
height: parent.height - 2
RGroup {
title: "Playback"
RButton {
big: true
icon: player.status === "playing" ? "pause" : "play"
text: player.status === "playing" ? "Pause" : "Play"
tip: "Play or pause (Space)"
onClicked: player.toggle()
}
Column {
RButton { icon: "previous"; text: "Previous"; tip: "Previous song (Ctrl+Left)"; onClicked: player.previous() }
RButton { icon: "next"; text: "Next"; tip: "Next song (Ctrl+Right)"; onClicked: player.next() }
RButton { icon: "stop"; text: "Stop"; onClicked: { engine.pause(); player.seek(0) } }
}
Column {
RButton { icon: "shuffle"; text: "Shuffle"; checked: player.shuffleOn; onClicked: player.toggleShuffle() }
RButton {
icon: player.repeatMode === "one" ? "repeat_one" : "repeat"
text: player.repeatMode === "one" ? "Repeat One" : "Repeat"
checked: player.repeatMode !== "off"
onClicked: player.cycleRepeat()
}
}
}
RGroup {
title: "Karaoke"
RButton {
big: true
enabled: player.sourceKind === "stems"
icon: player.presetLabel === "Karaoke" ? "vocals_off" : "vocals"
text: player.presetLabel === "Karaoke" ? "Vocals\nOff" : "Vocals\nOn"
checked: player.presetLabel === "Karaoke"
tip: "Mute the vocals (V)"
onClicked: player.toggleVocals()
}
}
RGroup {
title: "Mix Styles"
Grid {
columns: 4
rowSpacing: 3
columnSpacing: 3
y: 4
Repeater {
model: player.presets
delegate: Rectangle {
id: sty
required property int index
required property var modelData
readonly property var s: X.styles[index]
width: 82
height: 28
opacity: modelData.usable ? 1 : 0.35
color: s.fill
border.width: modelData.active ? 2 : 1
border.color: modelData.active ? X.green : (sm.containsMouse ? "#8A8A8A" : s.border)
T {
anchors.fill: parent
anchors.leftMargin: 5
font.family: cellFont
font.pixelSize: 14
font.bold: sty.s.bold
color: sty.s.text
text: sty.modelData.label
}
MouseArea {
id: sm
anchors.fill: parent
hoverEnabled: true
enabled: sty.modelData.usable
onClicked: player.applyPreset(sty.index)
}
Tip { target: sm; text: "Style: " + sty.s.name + " — " + sty.modelData.label }
}
}
}
}
RGroup {
title: "Cells"
RButton { big: true; icon: "play_next"; text: "Play\nNext"; tip: "Play the selection after this song"; onClicked: library.playNextRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "queue_add"; text: "Add to\nQueue"; tip: "Add the selection to the end of the queue"; onClicked: library.queueRows(rib.win.selTop, rib.win.selBottom) }
RButton {
big: true
icon: "delete_rows"
text: "Delete\nRows"
enabled: library.layout === "queue" || library.sheetTitle !== "" && rib.win.inPlaylist
tip: "Remove the selected rows from the queue or playlist (Delete)"
onClicked: library.removeRows(rib.win.selTop, rib.win.selBottom)
}
}
RGroup {
title: "Editing"
Column {
RButton { icon: "sigma"; text: "AutoShuffle"; tip: "Shuffle everything on this sheet"; onClicked: library.shuffleSheet() }
RButton { icon: "sort_az"; text: "Sort & Filter"; dropdown: true; onClicked: sortMenu.popup() }
RButton { icon: "search"; text: "Find & Select"; tip: "Search the library (Ctrl+F)"; onClicked: rib.focusSearch() }
}
}
}
// ---- Insert ----
Row {
visible: rib.tab === 2
height: parent.height - 2
RGroup {
title: "Playlists"
RButton { big: true; icon: "playlist_new"; text: "New\nPlaylist"; tip: "A Plex playlist from the selected rows"; onClicked: rib.win.newPlaylist() }
RButton { big: true; icon: "playlist_add"; text: "Add to\nPlaylist"; dropdown: true; onClicked: plMenu.popup() }
}
RGroup {
title: "Queue"
RButton { big: true; icon: "play_next"; text: "Play\nNext"; onClicked: library.playNextRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "queue_add"; text: "Add to\nQueue"; onClicked: library.queueRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "play"; text: "Play\nSelection"; onClicked: library.playRows(rib.win.selTop, rib.win.selBottom) }
}
RGroup {
title: "Charts"
RButton { big: true; icon: "chart"; text: "Stem\nChart"; checked: rib.win.showMixer; tip: "Show the stem mixer pane"; onClicked: rib.win.toggleMixer() }
}
}
// ---- Data ----
Row {
visible: rib.tab === 3
height: parent.height - 2
RGroup {
title: "Get External Data"
RButton { big: true; icon: "plex"; text: library.signedIn ? "Plex\nAccount" : "Connect\nto Plex"; onClicked: rib.win.openBackstage("account") }
RButton { big: true; icon: "server"; text: "Connections"; tip: "Server addresses and folders"; onClicked: rib.win.openBackstage("options") }
}
RGroup {
title: "Connections"
RButton { big: true; icon: "refresh"; text: "Refresh\nAll"; tip: "Reload the Plex library and the stems index"; onClicked: library.refresh() }
Column {
y: 4
spacing: 2
T { text: "Songs: " + library.counts.songs.toLocaleString(Qt.locale(), "f", 0); height: 20 }
T { text: "With stems: " + library.counts.stems.toLocaleString(Qt.locale(), "f", 0); height: 20 }
T {
text: "Stem server: " + (library.counts.stemOnline === true ? "Online" : library.counts.stemOnline === false ? "Offline" : "Checking…")
color: library.counts.stemOnline === true ? X.goodText : library.counts.stemOnline === false ? X.badText : X.inkSoft
height: 20
}
}
}
RGroup {
title: "Sort & Filter"
Column {
RButton { icon: "sort_az"; text: "Sort A to Z"; onClicked: rib.win.sortCurrent(false) }
RButton { icon: "sort_za"; text: "Sort Z to A"; onClicked: rib.win.sortCurrent(true) }
RButton { icon: "filter"; text: "Stems Only"; tip: "Songs that have stems"; onClicked: library.current = 4 }
}
}
}
// ---- View ----
Row {
visible: rib.tab === 4
height: parent.height - 2
RGroup {
title: "Show"
Column {
y: 2
XCheck { text: "Gridlines"; checked: rib.win.gridlines; onToggled: rib.win.setOption("gridlines", !rib.win.gridlines) }
XCheck { text: "Headings"; checked: rib.win.headings; onToggled: rib.win.setOption("headings", !rib.win.headings) }
XCheck { text: "Mixer Pane"; checked: rib.win.showMixer; onToggled: rib.win.toggleMixer() }
}
}
RGroup {
title: "Zoom"
RButton { big: true; icon: "zoom"; text: "Zoom"; tip: "Ctrl+scroll on the grid"; onClicked: rib.win.setZoom(rib.win.zoom >= 150 ? 100 : rib.win.zoom + 25) }
RButton { big: true; icon: "grid"; text: "100%"; onClicked: rib.win.setZoom(100) }
}
RGroup {
title: "Window"
RButton { big: true; icon: "freeze"; text: "Freeze\nPanes"; checked: true; tip: "The header row is always frozen" }
}
}
}
XMenu {
id: sortMenu
Action { text: "Sort A to Z"; icon.name: "sort_az"; onTriggered: rib.win.sortCurrent(false) }
Action { text: "Sort Z to A"; icon.name: "sort_za"; onTriggered: rib.win.sortCurrent(true) }
MenuSeparator {}
Action { text: "Filter: Stems Only"; icon.name: "filter"; onTriggered: library.current = 4 }
}
XMenu {
id: plMenu
Instantiator {
model: library.playlistNames
delegate: MenuItem {
required property int index
required property string modelData
text: modelData
onTriggered: rib.win.addToPlaylist(modelData)
}
onObjectAdded: (i, o) => plMenu.insertItem(i, o)
onObjectRemoved: (i, o) => plMenu.removeItem(o)
}
}
}
+501
View File
@@ -0,0 +1,501 @@
import QtQuick
import QtQuick.Controls.Basic
// The worksheet. Row 1 is the frozen table header; data starts on row 2. Past the data the
// sheet carries on as empty gridlines, as Excel's does.
FocusScope {
id: g
property var win: Window.window
readonly property real z100: win.zoom / 100
readonly property int rowH: Math.round(20 * z100)
readonly property int headW: Math.max(30, Math.round(rowNumW.advanceWidth + 12))
readonly property int colHeadH: 20
readonly property var cols: library.columns
readonly property int rows: table.rows
readonly property int padCols: 26
property real extraColW: 64 * z100
function colX(c) {
var x = 0
for (var i = 0; i < c && i < cols.length; i++) x += cols[i].width * z100
if (c > cols.length) x += (c - cols.length) * extraColW
return x
}
function colW(c) { return c < cols.length ? cols[c].width * z100 : extraColW }
function colAt(x) {
var acc = 0
for (var i = 0; i < cols.length; i++) {
acc += cols[i].width * z100
if (x < acc) return i
}
return cols.length + Math.floor((x - acc) / extraColW)
}
function ensureVisible(r, c) {
var y = r * rowH
if (y < table.contentY) table.contentY = y
else if (y + rowH > table.contentY + table.height) table.contentY = y + rowH - table.height
var x = colX(c), w = colW(c)
if (x < table.contentX) table.contentX = x
else if (x + w > table.contentX + table.width) table.contentX = Math.min(x + w - table.width, Math.max(0, table.contentWidth - table.width))
}
function moveTo(r, c, extend) {
r = Math.max(0, Math.min(r, Math.max(0, rows - 1)))
c = Math.max(0, Math.min(c, cols.length - 1))
win.curRow = r
win.curCol = c
if (!extend) { win.anchorRow = r; win.anchorCol = c }
ensureVisible(r, c)
}
TextMetrics { id: rowNumW; font.family: uiFont; font.pixelSize: X.fsSmall; text: String(g.rows + 40) }
// ---- column letters ----
Rectangle {
id: colHead
visible: win.headings
x: g.headW
width: parent.width - x
height: visible ? g.colHeadH : 0
color: X.header
clip: true
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Repeater {
model: g.cols.length + g.padCols
delegate: Rectangle {
required property int index
readonly property bool sel: index >= Math.min(win.curCol, win.anchorCol) && index <= Math.max(win.curCol, win.anchorCol)
x: g.colX(index) - table.contentX
width: g.colW(index)
height: g.colHeadH
visible: x + width > 0 && x < colHead.width
color: sel ? X.headerSel : X.header
T {
anchors.centerIn: parent
text: X.colName(index)
font.pixelSize: X.fsSmall
color: parent.sel ? X.greenText : X.ink
}
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
Rectangle { visible: parent.sel; anchors.bottom: parent.bottom; width: parent.width; height: 2; color: X.green }
MouseArea {
anchors.fill: parent
onClicked: if (index < g.cols.length) { win.anchorRow = 0; win.curRow = Math.max(0, g.rows - 1); win.curCol = win.anchorCol = index }
}
}
}
}
// the select-all corner
Rectangle {
visible: win.headings
width: g.headW
height: g.colHeadH
color: X.header
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Canvas {
anchors { right: parent.right; bottom: parent.bottom; margins: 3 }
width: 10
height: 10
onPaint: {
var c = getContext("2d")
c.fillStyle = "#B4B4B4"
c.beginPath(); c.moveTo(10, 0); c.lineTo(10, 10); c.lineTo(0, 10); c.closePath(); c.fill()
}
}
MouseArea { anchors.fill: parent; onClicked: win.selectAll() }
}
// ---- row numbers ----
Rectangle {
id: rowHead
visible: win.headings
y: colHead.height
width: visible ? g.headW : 0
height: parent.height - y
color: X.header
clip: true
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
// row 1: the frozen table header
Rectangle {
width: parent.width
height: g.rowH
color: X.header
T { anchors.centerIn: parent; text: "1"; font.pixelSize: X.fsSmall }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
}
Item {
y: g.rowH
width: parent.width
height: parent.height - y
clip: true
Repeater {
model: Math.max(0, Math.ceil(parent.height / g.rowH) + 1)
delegate: Rectangle {
required property int index
readonly property int r: Math.floor(table.contentY / g.rowH) + index
readonly property bool sel: r >= win.selTop && r <= win.selBottom
y: r * g.rowH - table.contentY
width: rowHead.width
height: g.rowH
color: sel ? X.headerSel : X.header
T {
anchors.centerIn: parent
text: parent.r + 2
font.pixelSize: X.fsSmall
color: parent.sel ? X.greenText : X.ink
}
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Rectangle { anchors.right: parent.right; width: parent.sel ? 2 : 1; height: parent.height; color: parent.sel ? X.green : X.headerLine }
MouseArea {
anchors.fill: parent
onClicked: (m) => { if (parent.r < g.rows) { g.moveTo(parent.r, 0, m.modifiers & Qt.ShiftModifier); if (!(m.modifiers & Qt.ShiftModifier)) win.anchorCol = 0; win.curCol = g.cols.length - 1 } }
onDoubleClicked: if (parent.r < g.rows) library.activate(parent.r, 0)
}
}
}
}
}
// ---- sheet ----
Item {
id: sheet
x: rowHead.width
y: colHead.height
width: parent.width - x
height: parent.height - y
clip: true
Rectangle { anchors.fill: parent; color: "#FFFFFF" }
// the empty sheet: gridlines everywhere, under the cells
Item {
anchors.fill: parent
visible: win.gridlines
Repeater {
model: Math.max(0, Math.ceil(sheet.height / g.rowH) + 2)
delegate: Rectangle {
required property int index
y: g.rowH + (index + 1) * g.rowH - (table.contentY % g.rowH) - 1
width: sheet.width
height: 1
color: X.grid
}
}
Repeater {
model: g.cols.length + g.padCols
delegate: Rectangle {
required property int index
x: g.colX(index + 1) - table.contentX - 1
width: 1
height: sheet.height
color: X.grid
}
}
}
// row 1, frozen: the table header with its filter buttons
Item {
id: headRow
width: parent.width
height: g.rowH
z: 2
Repeater {
model: g.cols
delegate: Rectangle {
id: hc
required property int index
required property var modelData
x: g.colX(index) - table.contentX
width: g.colW(index)
height: g.rowH
color: X.tableHead
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: "#FFFFFF"; opacity: 0.5 }
T {
x: 4
width: parent.width - 26
height: parent.height
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
font.bold: true
color: "#FFFFFF"
text: hc.modelData.title
horizontalAlignment: hc.modelData.kind === "text" || hc.modelData.kind === "stems" ? Text.AlignLeft : Text.AlignRight
rightPadding: hc.modelData.kind === "text" || hc.modelData.kind === "stems" ? 0 : 2
}
// the AutoFilter button: sorts this column
Rectangle {
anchors { right: parent.right; rightMargin: 3; verticalCenter: parent.verticalCenter }
width: Math.round(15 * g.z100)
height: width
color: fm.containsMouse ? "#F0F0F0" : "#FFFFFF"
border.color: "#A0A0A0"
Ico {
anchors.centerIn: parent
size: Math.round(12 * g.z100)
name: library.sortColumn === hc.index ? (library.sortDescending ? "sort_down" : "sort_up") : "dropdown"
}
MouseArea {
id: fm
anchors.fill: parent
hoverEnabled: true
onClicked: { g.win.curCol = g.win.anchorCol = hc.index; filterMenu.col = hc.index; filterMenu.popup() }
}
}
MouseArea {
anchors { fill: parent; rightMargin: 20 }
onClicked: library.sortBy(hc.index)
}
}
}
}
TableView {
id: table
y: g.rowH
width: parent.width
height: parent.height - y
model: sheetModel
clip: true
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
columnWidthProvider: (c) => g.colW(c)
rowHeightProvider: (r) => g.rowH
interactive: false // the wheel and the scrollbars move it; dragging selects
ScrollBar.vertical: XScrollBar { id: vbar }
ScrollBar.horizontal: XScrollBar { id: hbar }
Connections {
target: g.win
function onZoomChanged() { Qt.callLater(table.forceLayout) }
}
delegate: Rectangle {
id: cell
required property int row
required property int column
required property string display
required property string align
required property string cellStyle
required property bool playing
readonly property bool bar: cellStyle.indexOf("bar:") === 0
color: cellStyle === "good" ? X.good : (row % 2 === 1 ? X.tableBand : "#FFFFFF")
Rectangle { // data bar
visible: cell.bar
x: 2
y: 2
height: parent.height - 4
width: cell.bar ? (parent.width - 4) * parseFloat(cell.cellStyle.slice(4)) : 0
color: X.dataBar
opacity: 0.85
}
Rectangle { visible: g.win.gridlines; anchors.right: parent.right; width: 1; height: parent.height; color: X.grid }
Rectangle { visible: g.win.gridlines; anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.grid }
Text {
anchors.fill: parent
leftPadding: 3
rightPadding: 4
verticalAlignment: Text.AlignBottom
bottomPadding: Math.round(2 * g.z100)
horizontalAlignment: cell.align === "right" ? Text.AlignRight : Text.AlignLeft
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
font.bold: cell.playing
color: cell.cellStyle === "good" ? X.goodText : X.cellText
text: cell.cellStyle === "good" ? cell.display + " stems" : cell.display
elide: cell.align === "right" ? Text.ElideLeft : Text.ElideRight
renderType: Text.NativeRendering
}
// Excel's green error triangle, here marking the song that's playing
Canvas {
visible: cell.playing && cell.column === 0
width: 7
height: 7
onPaint: {
var c = getContext("2d")
c.fillStyle = "#217346"
c.beginPath(); c.moveTo(0, 0); c.lineTo(7, 0); c.lineTo(0, 7); c.closePath(); c.fill()
}
}
}
// selection: range fill, green border, fill handle
Rectangle {
id: selBox
parent: table.contentItem
z: 50
visible: g.rows > 0
readonly property bool range: g.win.selTop !== g.win.selBottom || g.win.curCol !== g.win.anchorCol
x: g.colX(Math.min(g.win.curCol, g.win.anchorCol)) - 1
y: g.win.selTop * g.rowH - 1
width: g.colX(Math.max(g.win.curCol, g.win.anchorCol) + 1) - x + 1
height: (g.win.selBottom - g.win.selTop + 1) * g.rowH + 1
color: range ? X.selFill : "transparent"
border.color: X.green
border.width: 2
Rectangle {
// the active cell stays clear inside a range
visible: selBox.range
x: g.colX(g.win.curCol) - parent.x
y: g.win.curRow * g.rowH - parent.y
width: g.colW(g.win.curCol)
height: g.rowH
color: "#FFFFFF"
opacity: 0.6
}
Rectangle {
x: parent.width - 4
y: parent.height - 4
width: 7
height: 7
color: X.green
border.color: "#FFFFFF"
border.width: 1
}
}
MouseArea {
parent: table.contentItem
z: 60
width: Math.max(table.contentWidth, table.width)
height: Math.max(table.contentHeight, table.height)
acceptedButtons: Qt.LeftButton | Qt.RightButton
property bool dragging: false
function cellAt(m) { return [Math.floor(m.y / g.rowH), g.colAt(m.x)] }
onPressed: (m) => {
g.forceActiveFocus()
var rc = cellAt(m)
if (rc[0] >= g.rows) return
if (m.button === Qt.RightButton) {
if (rc[0] < g.win.selTop || rc[0] > g.win.selBottom) g.moveTo(rc[0], rc[1], false)
ctxMenu.popup()
return
}
g.moveTo(rc[0], rc[1], m.modifiers & Qt.ShiftModifier)
dragging = true
}
onPositionChanged: (m) => {
if (!dragging) return
var rc = cellAt(m)
g.moveTo(rc[0], rc[1], true)
}
onReleased: dragging = false
onDoubleClicked: (m) => { var rc = cellAt(m); if (rc[0] < g.rows) library.activate(rc[0], 0) }
onWheel: (w) => {
if (w.modifiers & Qt.ControlModifier) {
g.win.setZoom(g.win.zoom + (w.angleDelta.y > 0 ? 10 : -10))
return
}
var dy = w.pixelDelta.y !== 0 ? w.pixelDelta.y : w.angleDelta.y / 120 * 3 * g.rowH
var dx = w.pixelDelta.x !== 0 ? w.pixelDelta.x : w.angleDelta.x / 120 * 3 * g.rowH
if (w.modifiers & Qt.ShiftModifier) { dx = dy; dy = 0 }
table.contentY = Math.max(0, Math.min(table.contentY - dy, table.contentHeight - table.height))
table.contentX = Math.max(0, Math.min(table.contentX - dx, Math.max(0, table.contentWidth - table.width)))
}
}
}
// #GETTING_DATA: what Excel shows while a query runs
T {
visible: library.loading
x: 4
y: g.rowH
height: g.rowH
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
color: X.cellText
text: "#GETTING_DATA"
}
// a cell comment when there's nothing to show
Rectangle {
visible: g.rows === 0 && !library.loading
x: g.colX(1) + 30
y: g.rowH * 2
width: 300
height: note.implicitHeight + 16
color: X.note
border.color: "#B2B2B2"
Rectangle { x: -30; y: 6; width: 30; height: 1; color: "#000000" }
Text {
id: note
x: 8
y: 8
width: parent.width - 16
wrapMode: Text.WordWrap
font.family: uiFont
font.pixelSize: X.fsUi
textFormat: Text.StyledText
text: !library.signedIn ? "<b>StemRoller:</b><br>This workbook isn't connected yet. Go to <b>Data → Connect to Plex</b> and sign in with the code."
: library.layout === "queue" ? "<b>StemRoller:</b><br>The queue is empty. Double-click a song on any sheet to play it."
: library.status !== "" ? "<b>StemRoller:</b><br>Loading the library from Plex…"
: "<b>StemRoller:</b><br>Nothing on this sheet."
}
}
}
Keys.onPressed: (e) => {
var ext = e.modifiers & Qt.ShiftModifier
var ctrl = e.modifiers & Qt.ControlModifier
var page = Math.max(1, Math.floor(table.height / g.rowH) - 1)
switch (e.key) {
case Qt.Key_Down: g.moveTo(ctrl ? g.rows - 1 : win.curRow + 1, win.curCol, ext); break
case Qt.Key_Up: g.moveTo(ctrl ? 0 : win.curRow - 1, win.curCol, ext); break
case Qt.Key_Right: if (ctrl) { player.next(); break } g.moveTo(win.curRow, win.curCol + 1, ext); break
case Qt.Key_Left: if (ctrl) { player.previous(); break } g.moveTo(win.curRow, win.curCol - 1, ext); break
case Qt.Key_Tab: g.moveTo(win.curRow, win.curCol + 1, false); break
case Qt.Key_PageDown: g.moveTo(win.curRow + page, win.curCol, ext); break
case Qt.Key_PageUp: g.moveTo(win.curRow - page, win.curCol, ext); break
case Qt.Key_Home: g.moveTo(ctrl ? 0 : win.curRow, 0, ext); break
case Qt.Key_End: g.moveTo(ctrl ? g.rows - 1 : win.curRow, g.cols.length - 1, ext); break
case Qt.Key_Return:
case Qt.Key_Enter: library.activate(win.curRow, 0); break
case Qt.Key_Space: player.toggle(); break
case Qt.Key_Delete: library.removeRows(win.selTop, win.selBottom); break
case Qt.Key_Backspace: library.back(); break
case Qt.Key_A: if (ctrl) win.selectAll(); else return; break
case Qt.Key_C: if (ctrl) win.copySelection(); else return; break
case Qt.Key_V: if (!ctrl) player.toggleVocals(); else return; break
default: return
}
e.accepted = true
}
// A new sheet starts at the top; fresh data for the same sheet keeps its scroll position.
property real keptY: 0
property bool switching: false
Connections {
target: library
function onSheetSwitched() { g.switching = true; table.contentY = 0; table.contentX = 0 }
}
Connections {
target: sheetModel
function onModelAboutToBeReset() { g.keptY = table.contentY }
function onModelReset() {
if (g.switching) { g.switching = false; return }
Qt.callLater(() => { table.contentY = Math.min(g.keptY, Math.max(0, table.contentHeight - table.height)) })
}
}
XMenu {
id: ctxMenu
Action { text: "Play"; icon.name: "play"; onTriggered: library.playRows(g.win.selTop, g.win.selBottom) }
Action { text: "Play Next"; icon.name: "play_next"; onTriggered: library.playNextRows(g.win.selTop, g.win.selBottom) }
Action { text: "Add to Queue"; icon.name: "queue_add"; onTriggered: library.queueRows(g.win.selTop, g.win.selBottom) }
MenuSeparator {}
Action { text: "Open"; icon.name: "right"; onTriggered: library.activate(g.win.curRow, 0) }
Action { text: "New Playlist from Selection…"; icon.name: "playlist_new"; onTriggered: g.win.newPlaylist() }
Action { text: "Copy"; onTriggered: g.win.copySelection() }
MenuSeparator {}
Action {
text: "Delete Rows"
icon.name: "delete_rows"
enabled: library.layout === "queue" || g.win.inPlaylist
onTriggered: library.removeRows(g.win.selTop, g.win.selBottom)
}
}
XMenu {
id: filterMenu
property int col: 0
Action { text: "Sort A to Z"; icon.name: "sort_az"; onTriggered: g.win.sortColumn(filterMenu.col, false) }
Action { text: "Sort Z to A"; icon.name: "sort_za"; onTriggered: g.win.sortColumn(filterMenu.col, true) }
}
}
@@ -0,0 +1,89 @@
import QtQuick
// Worksheet tabs. The first six are fixed; opened artists, albums, playlists and searches follow.
Rectangle {
id: st
property var win: Window.window
height: 26
color: X.ribbon
Rectangle { width: parent.width; height: 1; color: X.ribbonLine }
Row {
id: nav
x: 6
height: parent.height
spacing: 2
Repeater {
model: ["left", "right"]
delegate: Item {
required property int index
required property string modelData
width: 18
height: parent.height
Ico { anchors.centerIn: parent; name: parent.modelData; size: 12; opacity: na.containsMouse ? 1 : 0.6 }
MouseArea {
id: na
anchors.fill: parent
hoverEnabled: true
onClicked: library.current = Math.max(0, Math.min(library.tabs.length - 1, library.current + (parent.index ? 1 : -1)))
}
}
}
}
Flickable {
id: flick
x: nav.x + nav.width + 8
width: parent.width - x - 8
height: parent.height
contentWidth: tabRow.width + 40
clip: true
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Row {
id: tabRow
height: parent.height
Repeater {
model: library.tabs
delegate: Item {
id: tb
required property int index
required property var modelData
readonly property bool active: library.current === index
width: lbl.implicitWidth + 28
height: st.height
Rectangle {
anchors.fill: parent
anchors.topMargin: 0
color: tb.active ? "#FFFFFF" : tm.containsMouse ? "#E5E5E5" : "transparent"
}
Rectangle { visible: !tb.active; anchors.right: parent.right; y: 6; width: 1; height: parent.height - 12; color: "#C8C8C8" }
Rectangle { visible: tb.active; anchors.bottom: parent.bottom; width: parent.width; height: 3; color: X.green }
T {
id: lbl
anchors.centerIn: parent
text: tb.modelData.title
color: tb.active ? X.greenText : X.ink
font.bold: tb.active
}
MouseArea {
id: tm
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: (m) => m.button === Qt.MiddleButton ? library.closeTab(tb.index) : (library.current = tb.index)
}
Tip { target: tm; text: tb.modelData.base ? "" : "Middle-click or Backspace to close" }
}
}
// ⊕ new sheet: here, the search box
Item {
width: 30
height: st.height
Ico { anchors.centerIn: parent; name: "plus"; size: 16; opacity: pm.containsMouse ? 1 : 0.6 }
MouseArea { id: pm; anchors.fill: parent; hoverEnabled: true; onClicked: st.win.focusSearch() }
Tip { target: pm; text: "New sheet from a search" }
}
}
}
}
@@ -0,0 +1,96 @@
import QtQuick
// Ready / Calculating on the left; Average, Count, Sum; view buttons; the zoom slider is the volume.
Rectangle {
id: sb
property var win: Window.window
height: 24
color: X.green
T {
x: 10
height: parent.height
color: "#FFFFFF"
text: library.status !== "" ? library.status + (library.progress >= 0 ? ": " + Math.round(library.progress * 100) + "%" : "")
: player.status === "loading" ? "Calculating..."
: player.status === "buffering" ? "Buffering..."
: player.status === "playing" ? "Playing" + (player.presetLabel && player.presetLabel !== "Full Mix" ? " · " + player.presetLabel : "")
: "Ready"
}
Rectangle { // progress for Calculating, like Excel's
visible: library.progress >= 0
x: 190
anchors.verticalCenter: parent.verticalCenter
width: 120
height: 8
color: "#FFFFFF"
opacity: 0.9
Rectangle { width: parent.width * Math.max(0, library.progress); height: parent.height; color: "#9FD2B4" }
}
Row {
anchors.right: parent.right
anchors.rightMargin: 10
height: parent.height
spacing: 18
Row {
height: parent.height
spacing: 18
visible: !!sb.win.selStats.count
T { color: "#FFFFFF"; height: parent.height; visible: !!sb.win.selStats.average; text: "Average: " + (sb.win.selStats.average || "") }
T { color: "#FFFFFF"; height: parent.height; text: "Count: " + (sb.win.selStats.count || "") }
T { color: "#FFFFFF"; height: parent.height; visible: !!sb.win.selStats.sum; text: "Sum: " + (sb.win.selStats.sum || "") }
}
Row {
height: parent.height
spacing: 2
Repeater {
model: ["normal_view", "page_view", "break_view"]
delegate: Item {
required property int index
required property string modelData
width: 24
height: sb.height
Rectangle { anchors.fill: parent; anchors.margins: 2; color: index === 0 ? X.greenDark : "transparent" }
Ico { anchors.centerIn: parent; name: parent.modelData; ink: "#FFFFFF"; size: 16 }
}
}
}
// the zoom slider, wired to the master volume
Row {
height: parent.height
spacing: 6
T { color: "#FFFFFF"; height: parent.height; text: "−"; font.pixelSize: 16
MouseArea { anchors.fill: parent; anchors.margins: -4; onClicked: engine.volume = Math.max(0, engine.volume - 0.05) } }
Item {
id: track
width: 110
height: parent.height
Rectangle { anchors.verticalCenter: parent.verticalCenter; width: parent.width; height: 2; color: "#FFFFFF"; opacity: 0.8 }
Rectangle { x: parent.width / 2 - 1; anchors.verticalCenter: parent.verticalCenter; width: 2; height: 8; color: "#FFFFFF"; opacity: 0.8 }
Rectangle {
x: engine.volume * (track.width - width)
anchors.verticalCenter: parent.verticalCenter
width: 6
height: 13
color: vm.pressed ? "#E0E0E0" : "#FFFFFF"
}
MouseArea {
id: vm
anchors.fill: parent
anchors.margins: -3
hoverEnabled: true
function set(m) { engine.volume = Math.max(0, Math.min(1, (m.x - 3) / (track.width))) }
onPressed: (m) => set(m)
onPositionChanged: (m) => { if (pressed) set(m) }
onWheel: (w) => engine.volume = Math.max(0, Math.min(1, engine.volume + (w.angleDelta.y > 0 ? 0.03 : -0.03)))
}
Tip { target: vm; text: "Volume" }
}
T { color: "#FFFFFF"; height: parent.height; text: "+"; font.pixelSize: 15
MouseArea { anchors.fill: parent; anchors.margins: -4; onClicked: engine.volume = Math.min(1, engine.volume + 0.05) } }
T { color: "#FFFFFF"; height: parent.height; width: 40; horizontalAlignment: Text.AlignRight; text: Math.round(engine.volume * 100) + "%" }
}
}
}
@@ -0,0 +1,136 @@
import QtQuick
// The stem faders as an Excel column chart. Drag a column to set its level; double-click
// for 0 dB. Below each category: M(ute) and S(olo).
Rectangle {
id: ch
color: "#FFFFFF"
border.color: "#D9D9D9"
readonly property var stems: player.stems
readonly property int n: stems.length
readonly property real unity: 0.8
T {
id: title
anchors.horizontalCenter: parent.horizontalCenter
y: 6
text: "Stem Mix"
font.family: cellFont
font.pixelSize: 17
color: X.chartText
}
Item {
id: plot
x: 34
y: title.y + title.height + 18
width: parent.width - x - 8
height: parent.height - y - 52
// value axis: fader position, 0 dB marked
Repeater {
model: [0, 0.25, 0.5, 0.8, 1.0]
delegate: Item {
required property var modelData
y: plot.height * (1 - modelData)
width: plot.width
Rectangle { width: parent.width; height: 1; color: modelData === 0.8 ? "#A6A6A6" : X.chartLine }
T {
x: -32
width: 28
y: -7
height: 14
horizontalAlignment: Text.AlignRight
font.family: cellFont
font.pixelSize: 11
color: X.chartText
text: modelData === 0 ? "-∞" : modelData === 0.8 ? "0 dB" : modelData === 1 ? "+4" : (40 * Math.log(modelData / 0.8) / Math.LN10).toFixed(0)
}
}
}
Repeater {
model: ch.stems
delegate: Item {
id: cat
required property int index
required property var modelData
readonly property real cw: plot.width / Math.max(1, ch.n)
x: index * cw
width: cw
height: plot.height
Rectangle {
id: bar
anchors.horizontalCenter: parent.horizontalCenter
width: cat.cw * 0.62
height: Math.max(1, plot.height * cat.modelData.level)
y: plot.height - height
color: cat.modelData.color
opacity: cat.modelData.audible ? 1 : 0.22
border.color: dm.containsMouse || dm.pressed ? "#404040" : "transparent"
Behavior on height { enabled: !dm.pressed; NumberAnimation { duration: X.easeMs; easing.type: Easing.OutCubic } }
}
T { // data label
anchors.horizontalCenter: parent.horizontalCenter
y: bar.y - 16
font.family: cellFont
font.pixelSize: 11
color: X.chartText
text: cat.modelData.muted ? "Muted" : !cat.modelData.audible ? "—" : cat.modelData.level <= 0.001 ? "-∞" : (cat.modelData.db >= 0 ? "+" : "") + cat.modelData.db.toFixed(1)
}
MouseArea {
id: dm
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SizeVerCursor
function set(m) { player.setLevel(cat.index, Math.max(0, Math.min(1, 1 - m.y / plot.height))) }
onPressed: (m) => set(m)
onPositionChanged: (m) => { if (pressed) set(m) }
onDoubleClicked: player.setLevel(cat.index, ch.unity)
onWheel: (w) => player.setLevel(cat.index, Math.max(0, Math.min(1, cat.modelData.level + (w.angleDelta.y > 0 ? 0.02 : -0.02))))
}
Tip { target: dm; text: cat.modelData.label + ": drag to set, double-click for 0 dB" }
// category axis label and the M / S toggles
Column {
y: plot.height + 4
anchors.horizontalCenter: parent.horizontalCenter
spacing: 3
T {
anchors.horizontalCenter: parent.horizontalCenter
font.family: cellFont
font.pixelSize: 12
color: X.chartText
text: cat.modelData.label
width: Math.min(implicitWidth, cat.cw)
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 2
Toggle { label: "M"; on: cat.modelData.muted; onFill: X.bad; onText: X.badText; tip: "Mute " + cat.modelData.label; onClicked: player.toggleMute(cat.index) }
Toggle { label: "S"; on: cat.modelData.solo; onFill: X.neutral; onText: X.neutralText; tip: "Solo " + cat.modelData.label; onClicked: player.toggleSolo(cat.index) }
}
}
}
}
Rectangle { y: plot.height; width: plot.width; height: 1; color: "#BFBFBF" }
}
component Toggle: Rectangle {
id: tg
property string label
property bool on: false
property color onFill
property color onText
property string tip
signal clicked()
width: 18
height: 18
color: on ? onFill : tgm.containsMouse ? "#F2F2F2" : "#FFFFFF"
border.color: on ? onText : "#BFBFBF"
T { anchors.centerIn: parent; text: tg.label; font.pixelSize: 11; font.bold: tg.on; color: tg.on ? tg.onText : X.inkSoft }
MouseArea { id: tgm; anchors.fill: parent; hoverEnabled: true; onClicked: tg.clicked() }
Tip { target: tgm; text: tg.tip }
}
}
+10
View File
@@ -0,0 +1,10 @@
import QtQuick
Text {
font.family: uiFont
font.pixelSize: X.fsUi
color: X.ink
renderType: Text.NativeRendering
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
@@ -0,0 +1,302 @@
import QtQuick
import QtQuick.Controls.Basic
// The "Format Mix" task pane: what's playing, a sparkline to seek with, and the stems as a chart.
Rectangle {
id: pane
property var win: Window.window
color: "#FFFFFF"
Rectangle { width: 1; height: parent.height; color: X.ribbonLine }
Item {
id: head
x: 1
width: parent.width - 1
height: 40
T { x: 14; height: parent.height; text: "Format Mix"; font.pixelSize: 17; color: X.ink }
Ico { anchors.right: parent.right; anchors.rightMargin: 34; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 12 }
Item {
anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter }
width: 24
height: 24
Rectangle { anchors.fill: parent; color: cm.containsMouse ? X.hover : "transparent" }
Ico { anchors.centerIn: parent; name: "close"; size: 20 }
MouseArea { id: cm; anchors.fill: parent; hoverEnabled: true; onClicked: pane.win.toggleMixer() }
}
}
Flickable {
id: flick
x: 1
y: head.height
width: parent.width - 1
height: parent.height - y
contentHeight: col.height + 20
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: XScrollBar { policy: flick.contentHeight > flick.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff }
Column {
id: col
x: 14
width: flick.width - 34
spacing: 10
// ---- now playing ----
Row {
width: parent.width
spacing: 12
Rectangle {
width: 128
height: 128
color: "#F2F2F2"
border.color: "#D9D9D9"
Image {
id: art
anchors.fill: parent
anchors.margins: 1
source: player.track.art || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
sourceSize.width: 256
sourceSize.height: 256
}
Ico { visible: art.status !== Image.Ready; anchors.centerIn: parent; name: "excel"; size: 48; opacity: 0.25 }
}
Column {
width: parent.width - 140
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: player.track.title || "Nothing playing"
font.family: uiFont
font.pixelSize: 16
font.bold: true
color: "#222222"
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
}
T { width: parent.width; text: player.track.artist || "Double-click a song to play it"; font.pixelSize: 13; color: X.inkSoft }
Text {
width: parent.width
visible: !!player.track.album
text: (player.track.album || "") + (player.track.year ? " · " + player.track.year : "")
font.family: uiFont
font.pixelSize: 12
color: X.inkFaint
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
}
}
// ---- the sparkline seek bar ----
Item {
id: spark
width: parent.width
height: 46
readonly property real dur: engine.duration > 0 ? engine.duration : (player.track.durationMs || 0) / 1000
readonly property real frac: dur > 0 ? Math.min(1, pane.win.playPos / dur) : 0
readonly property var bars: player.wave.length ? player.wave : []
readonly property int n: bars.length ? bars.length : 80
property real hoverFrac: -1
Rectangle { anchors.fill: parent; color: "#FFFFFF"; border.color: "#D9D9D9" }
Canvas {
id: sparkCanvas
anchors.fill: parent
anchors.margins: 3
property real frac: spark.frac
property var bars: spark.bars
onFracChanged: requestPaint()
onBarsChanged: requestPaint()
onWidthChanged: requestPaint()
onPaint: {
var c = getContext("2d")
c.reset()
var n = spark.n, w = width / n, h = height
for (var i = 0; i < n; i++) {
var v = bars.length ? bars[i] : 0.12
var bh = Math.max(2, v * h)
c.fillStyle = (i + 0.5) / n <= frac ? "#217346" : "#A9D08E"
c.fillRect(i * w + w * 0.15, h - bh, Math.max(1, w * 0.7), bh)
}
}
}
Rectangle { // playhead
visible: spark.dur > 0
x: 3 + spark.frac * (parent.width - 6) - 1
y: 2
width: 2
height: parent.height - 4
color: "#C00000"
}
Rectangle {
visible: spark.hoverFrac >= 0
x: Math.max(0, Math.min(parent.width - width, spark.hoverFrac * parent.width - width / 2))
y: -22
width: hv.implicitWidth + 10
height: 18
color: "#FFFFFF"
border.color: "#BEBEBE"
T { id: hv; anchors.centerIn: parent; font.pixelSize: X.fsSmall; text: X.time(spark.hoverFrac * spark.dur) }
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
function f(m) { return Math.max(0, Math.min(1, (m.x - 3) / (width - 6))) }
onPositionChanged: (m) => { spark.hoverFrac = f(m); if (pressed) pane.win.seekTo(f(m) * spark.dur) }
onExited: spark.hoverFrac = -1
onPressed: (m) => pane.win.seekTo(f(m) * spark.dur)
}
}
Item {
width: parent.width
height: 14
y: -6
T { font.pixelSize: X.fsSmall; color: X.inkSoft; text: X.time(pane.win.playPos) }
T {
anchors.right: parent.right
font.pixelSize: X.fsSmall
color: X.inkSoft
text: spark.dur > 0 ? "-" + X.time(spark.dur - pane.win.playPos) : ""
}
}
// ---- transport ----
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
PaneButton { icon: "shuffle"; checked: player.shuffleOn; tip: "Shuffle"; onClicked: player.toggleShuffle() }
PaneButton { icon: "previous"; tip: "Previous"; onClicked: player.previous() }
PaneButton {
icon: player.status === "playing" ? "pause" : "play"
size: 40
tip: "Play / Pause (Space)"
onClicked: player.toggle()
}
PaneButton { icon: "next"; tip: "Next"; onClicked: player.next() }
PaneButton {
icon: player.repeatMode === "one" ? "repeat_one" : "repeat"
checked: player.repeatMode !== "off"
tip: player.repeatMode === "off" ? "Repeat" : player.repeatMode === "all" ? "Repeat all" : "Repeat one"
onClicked: player.cycleRepeat()
}
}
// output meter, as a pair of data bars
Column {
width: parent.width
spacing: 2
Repeater {
model: 2
delegate: Rectangle {
required property int index
width: parent.width
height: 5
color: "#EDEDED"
Rectangle {
readonly property real lv: index === 0 ? pane.win.levelL : pane.win.levelR
readonly property real db: 20 * Math.log(Math.max(lv, 1e-5)) / Math.LN10
width: parent.width * Math.max(0, Math.min(1, (db + 48) / 48))
height: parent.height
color: db > -1 ? "#C00000" : db > -9 ? "#FFC000" : X.dataBar
}
}
}
}
// ---- section: Stem Mix ----
Row {
spacing: 6
height: 24
Ico { anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
T { anchors.verticalCenter: parent.verticalCenter; text: "Stem Mix"; font.bold: true; font.pixelSize: 13 }
}
Rectangle { // "comment" for songs without stems
visible: player.sourceKind === "original"
width: parent.width
height: noteTxt.implicitHeight + 16
color: X.note
border.color: "#B2B2B2"
Text {
id: noteTxt
x: 8
y: 8
width: parent.width - 16
wrapMode: Text.WordWrap
font.family: uiFont
font.pixelSize: X.fsUi
textFormat: Text.StyledText
text: "<b>StemRoller:</b><br>This song hasn't been separated yet, so it plays the original file. The server's stemd will get to it."
}
}
StemChart {
visible: player.sourceKind === "stems"
width: parent.width
height: 250
}
Row {
visible: player.sourceKind === "stems"
spacing: 8
T { height: 22; text: "Mix style:" }
Rectangle {
width: 150
height: 22
border.color: pm.containsMouse ? X.green : "#ABABAB"
T { x: 6; height: parent.height; width: parent.width - 24; text: player.presetLabel }
Ico { anchors.right: parent.right; anchors.rightMargin: 5; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
MouseArea { id: pm; anchors.fill: parent; hoverEnabled: true; onClicked: presetMenu.popup() }
}
}
XCheck {
text: "Keep the mix for the next song"
checked: player.keepMix
onToggled: player.setKeepMix(!player.keepMix)
}
}
}
XMenu {
id: presetMenu
Instantiator {
model: player.presets
delegate: MenuItem {
required property int index
required property var modelData
text: modelData.label
enabled: modelData.usable
checkable: true
checked: modelData.active
onTriggered: player.applyPreset(index)
}
onObjectAdded: (i, o) => presetMenu.insertItem(i, o)
onObjectRemoved: (i, o) => presetMenu.removeItem(o)
}
}
component PaneButton: Item {
id: pb
property string icon
property bool checked: false
property int size: 32
property string tip: ""
signal clicked()
width: size
height: size
Rectangle {
anchors.fill: parent
radius: pb.size / 2
color: pb.checked ? X.checked : pbm.pressed ? X.pressed : pbm.containsMouse ? X.hover : "transparent"
}
Ico { anchors.centerIn: parent; name: pb.icon; size: Math.round(pb.size * 0.55) }
MouseArea { id: pbm; anchors.fill: parent; hoverEnabled: true; onClicked: pb.clicked() }
Tip { target: pbm; text: pb.tip }
}
}
+22
View File
@@ -0,0 +1,22 @@
import QtQuick
import QtQuick.Controls.Basic
// Office-style tooltip: white box, gray border, after a short hover.
ToolTip {
id: tt
property var target
visible: target && target.containsMouse && text !== "" && hoverTimer.done
delay: 0
timeout: 6000
padding: 6
Timer {
id: hoverTimer
property bool done: false
interval: 600
running: tt.target && tt.target.containsMouse
onRunningChanged: if (!running) done = false
onTriggered: done = true
}
contentItem: T { text: tt.text; font.pixelSize: X.fsSmall; elide: Text.ElideNone }
background: Rectangle { color: "#FFFFFF"; border.color: "#BEBEBE" }
}
@@ -0,0 +1,65 @@
import QtQuick
// Excel's green title bar: Quick Access Toolbar, workbook name, account, caption buttons.
Rectangle {
id: bar
property var win: Window.window
height: 32
color: X.green
DragHandler {
target: null
grabPermissions: PointerHandler.CanTakeOverFromAnything
onActiveChanged: if (active) bar.win.startSystemMove()
}
TapHandler { onDoubleTapped: bar.win.toggleMaximized() }
Row {
x: 6
height: parent.height
spacing: 0
Ico { name: "excel"; size: 20; anchors.verticalCenter: parent.verticalCenter }
Item { width: 10; height: 1 }
QatButton { icon: player.status === "playing" ? "pause" : "play"; tip: player.status === "playing" ? "Pause (Space)" : "Play (Space)"; onClicked: player.toggle() }
QatButton { icon: "undo"; tip: "Undo — previous song (Ctrl+Left)"; onClicked: player.previous() }
QatButton { icon: "redo"; tip: "Redo — next song (Ctrl+Right)"; onClicked: player.next() }
QatButton { icon: "dropdown"; tip: "Customize Quick Access Toolbar"; width: 16 }
}
T {
anchors.centerIn: parent
width: Math.min(implicitWidth, parent.width - 560)
color: "#FFFFFF"
font.pixelSize: X.fsUi
text: (player.track.title ? player.track.title + " - " + player.track.artist : "Book1") + ".xlsx - Excel"
}
Row {
anchors.right: parent.right
height: parent.height
T {
anchors.verticalCenter: parent.verticalCenter
color: "#FFFFFF"
text: library.signedIn ? (settings.get("plexUser") || "Plex") : "Sign in"
rightPadding: 14
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: bar.win.openBackstage("account") }
}
CaptionButton { icon: "pane"; tip: "Show or hide the mixer pane"; onClicked: bar.win.toggleMixer() }
CaptionButton { icon: "minimize"; onClicked: bar.win.showMinimized() }
CaptionButton { icon: bar.win.maximized ? "restore" : "maximize"; onClicked: bar.win.toggleMaximized() }
CaptionButton { icon: "close"; danger: true; onClicked: bar.win.close() }
}
component QatButton: Item {
id: q
property string icon
property string tip
signal clicked()
width: 26
height: parent.height
Rectangle { anchors.fill: parent; color: qm.pressed ? X.greenDark : qm.containsMouse ? X.greenHover : "transparent" }
Ico { anchors.centerIn: parent; name: q.icon; ink: "#FFFFFF"; size: q.icon === "dropdown" ? 10 : 16 }
MouseArea { id: qm; anchors.fill: parent; hoverEnabled: true; onClicked: q.clicked() }
Tip { target: qm; text: q.tip }
}
}
+71
View File
@@ -0,0 +1,71 @@
pragma Singleton
import QtQuick
// Excel 2016, "Colorful" Office theme.
QtObject {
readonly property color green: "#217346"
readonly property color greenDark: "#185C37"
readonly property color greenHover: "#2A8754"
readonly property color greenText: "#217346"
readonly property color ribbon: "#F3F3F3"
readonly property color ribbonLine: "#D5D5D5"
readonly property color hover: "#DCDCDC"
readonly property color pressed: "#C6C6C6"
readonly property color checked: "#C6C6C6"
readonly property color checkedBorder: "#A5A5A5"
readonly property color ink: "#444444"
readonly property color inkSoft: "#666666"
readonly property color inkFaint: "#9A9A9A"
readonly property color white: "#FFFFFF"
readonly property color grid: "#D4D4D4"
readonly property color header: "#F3F3F3"
readonly property color headerSel: "#D2D2D2"
readonly property color headerLine: "#BDBDBD"
readonly property color selFill: Qt.rgba(0, 0, 0, 0.09)
readonly property color cellText: "#000000"
readonly property color tableHead: "#70AD47"
readonly property color tableBand: "#E2EFDA"
readonly property color good: "#C6EFCE"
readonly property color goodText: "#006100"
readonly property color bad: "#FFC7CE"
readonly property color badText: "#9C0006"
readonly property color neutral: "#FFEB9C"
readonly property color neutralText: "#9C5700"
readonly property color dataBar: "#63BE7B"
readonly property color note: "#FFFFE1"
readonly property color menuBorder: "#C6C6C6"
readonly property color chartLine: "#D9D9D9"
readonly property color chartText: "#595959"
// Excel's cell styles, used for the mix presets.
readonly property var styles: [
{ name: "Normal", fill: "#FFFFFF", text: "#000000", border: "#D4D4D4", bold: false },
{ name: "Bad", fill: "#FFC7CE", text: "#9C0006", border: "#FFC7CE", bold: false },
{ name: "Good", fill: "#C6EFCE", text: "#006100", border: "#C6EFCE", bold: false },
{ name: "Neutral", fill: "#FFEB9C", text: "#9C5700", border: "#FFEB9C", bold: false },
{ name: "Calculation", fill: "#F2F2F2", text: "#FA7D00", border: "#7F7F7F", bold: true },
{ name: "Check Cell", fill: "#A5A5A5", text: "#FFFFFF", border: "#3F3F3F", bold: true },
{ name: "Note", fill: "#FFFFCC", text: "#000000", border: "#B2B2B2", bold: false }
]
readonly property int fsSmall: 11
readonly property int fsUi: 12
readonly property int fsCell: 15 // Calibri 11 pt
readonly property int easeMs: 120
function colName(i) {
var s = ""
i += 1
while (i > 0) {
var m = (i - 1) % 26
s = String.fromCharCode(65 + m) + s
i = Math.floor((i - 1) / 26)
}
return s
}
function time(sec) {
if (!(sec > 0)) sec = 0
var s = Math.floor(sec), m = Math.floor(s / 60)
return m + ":" + ("0" + (s % 60)).slice(-2)
}
}
@@ -0,0 +1,24 @@
import QtQuick
// Office checkbox: square box, green tick.
Item {
id: c
property bool checked: false
property string text: ""
signal toggled()
implicitWidth: 18 + lbl.implicitWidth + 4
implicitHeight: 20
opacity: enabled ? 1 : 0.4
Rectangle {
id: box
width: 13
height: 13
y: (parent.height - height) / 2
x: 2
color: "#FFFFFF"
border.color: ma.containsMouse ? X.green : "#7A7A7A"
Ico { visible: c.checked; anchors.centerIn: parent; name: "check"; size: 12 }
}
T { id: lbl; x: 20; height: parent.height; text: c.text }
MouseArea { id: ma; anchors.fill: parent; hoverEnabled: true; onClicked: c.toggled() }
}
@@ -0,0 +1,17 @@
import QtQuick
import QtQuick.Controls.Basic
TextField {
font.family: uiFont
font.pixelSize: X.fsUi
color: X.ink
selectByMouse: true
selectionColor: "#B7D7C4"
selectedTextColor: "#000000"
leftPadding: 5
rightPadding: 5
topPadding: 2
bottomPadding: 2
implicitHeight: 22
background: Rectangle { color: "#FFFFFF"; border.color: parent.activeFocus ? X.green : "#ABABAB" }
}
@@ -0,0 +1,48 @@
import QtQuick
import QtQuick.Controls.Basic
// Office context menu: white, gray border, icon gutter on the left.
Menu {
id: m
padding: 2
delegate: MenuItem {
id: mi
implicitWidth: Math.max(200, lab.implicitWidth + 56)
implicitHeight: mi.text === "" ? 7 : 26
enabled: mi.action ? mi.action.enabled : true
contentItem: Item {
T {
id: lab
x: 28
anchors.verticalCenter: parent.verticalCenter
text: mi.text
color: mi.enabled ? X.ink : X.inkFaint
}
Ico {
visible: mi.action && mi.action.icon.name !== ""
name: mi.action ? mi.action.icon.name : ""
size: 16
x: 4
anchors.verticalCenter: parent.verticalCenter
}
Ico {
visible: mi.checkable && mi.checked
name: "check"
size: 14
x: 5
anchors.verticalCenter: parent.verticalCenter
}
}
background: Rectangle {
color: mi.highlighted && mi.enabled ? "#E0E0E0" : "transparent"
}
arrow: null
indicator: null
}
background: Rectangle {
implicitWidth: 200
color: "#FFFFFF"
border.color: X.menuBorder
Rectangle { x: 25; y: 1; width: 1; height: parent.height - 2; color: "#E7E7E7" }
}
}
@@ -0,0 +1,18 @@
import QtQuick
import QtQuick.Controls.Basic
// Office scrollbar: flat gray thumb on a light track.
ScrollBar {
id: sb
policy: ScrollBar.AlwaysOn
minimumSize: 0.04
padding: 2
implicitWidth: 15
implicitHeight: 15
contentItem: Rectangle {
implicitWidth: 9
implicitHeight: 9
color: sb.pressed ? "#7A7A7A" : sb.hovered ? "#A6A6A6" : "#C2C2C2"
}
background: Rectangle { color: "#F0F0F0"; border.color: "#E0E0E0" }
}
+25
View File
@@ -0,0 +1,25 @@
module StemPlayer
singleton X 1.0 X.qml
Backstage 1.0 Backstage.qml
CaptionButton 1.0 CaptionButton.qml
FormulaBar 1.0 FormulaBar.qml
Ico 1.0 Ico.qml
Main 1.0 Main.qml
MessageBar 1.0 MessageBar.qml
OfficeButton 1.0 OfficeButton.qml
RButton 1.0 RButton.qml
ResizeEdge 1.0 ResizeEdge.qml
RGroup 1.0 RGroup.qml
Ribbon 1.0 Ribbon.qml
Sheet 1.0 Sheet.qml
SheetTabs 1.0 SheetTabs.qml
StatusBar 1.0 StatusBar.qml
StemChart 1.0 StemChart.qml
TaskPane 1.0 TaskPane.qml
Tip 1.0 Tip.qml
TitleBar 1.0 TitleBar.qml
T 1.0 T.qml
XCheck 1.0 XCheck.qml
XField 1.0 XField.qml
XMenu 1.0 XMenu.qml
XScrollBar 1.0 XScrollBar.qml
+149
View File
@@ -0,0 +1,149 @@
"""The workbook: every view is a sheet (columns + rows) shown in the Excel grid."""
import time
import unicodedata
from PySide6.QtCore import QAbstractTableModel, QByteArray, QModelIndex, Qt
# Column formats: how a value is shown, aligned and summed in the status bar.
# text, int, year, time (ms -> m:ss), stems (count of stems, shown TRUE/FALSE-ish), date (unix s)
COLUMNS = {
"songs": [
("title", "Title", 300, "text"), ("artist", "Artist", 190, "text"), ("album", "Album", 230, "text"),
("year", "Year", 64, "year"), ("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"),
("plays", "Plays", 64, "int"),
],
"albumSongs": [
("index", "#", 44, "int"), ("title", "Title", 320, "text"), ("artist", "Artist", 200, "text"),
("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"), ("plays", "Plays", 64, "int"),
],
"artists": [
("title", "Artist", 280, "text"), ("albums", "Albums", 70, "int"), ("songs", "Songs", 70, "int"),
("stemSongs", "With stems", 90, "int"), ("genre", "Genre", 200, "text"),
],
"albums": [
("title", "Album", 300, "text"), ("artist", "Artist", 200, "text"), ("year", "Year", 64, "year"),
("tracks", "Songs", 64, "int"), ("stemSongs", "With stems", 90, "int"), ("genre", "Genre", 160, "text"),
("addedAt", "Added", 96, "date"),
],
"playlists": [
("title", "Playlist", 300, "text"), ("tracks", "Songs", 70, "int"), ("durationMs", "Length", 80, "time"),
("kind", "Type", 80, "text"), ("updatedAt", "Updated", 96, "date"),
],
"queue": [
("pos", "#", 44, "int"), ("title", "Title", 300, "text"), ("artist", "Artist", 190, "text"),
("album", "Album", 230, "text"), ("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"),
],
"search": [
("type", "Type", 70, "text"), ("title", "Name", 300, "text"), ("artist", "Artist", 190, "text"),
("album", "Album", 230, "text"), ("year", "Year", 64, "year"), ("durationMs", "Time", 64, "time"),
("stems", "Stems", 70, "stems"),
],
}
# Plex row kind -> what a double-click does
SONG, ARTIST, ALBUM, PLAYLIST = "song", "artist", "album", "playlist"
def fold(s: str) -> str:
"""Case- and accent-insensitive form for search and sorting."""
s = unicodedata.normalize("NFKD", s or "")
return "".join(c for c in s if not unicodedata.combining(c)).casefold()
def fmt_time(ms) -> str:
s = int(round((ms or 0) / 1000))
h, m = divmod(s // 60, 60)
return f"{h}:{m:02d}:{s % 60:02d}" if h else f"{m}:{s % 60:02d}"
def fmt(value, kind) -> str:
if kind == "time":
return fmt_time(value) if value else ""
if kind == "year":
return str(value) if value else ""
if kind == "int":
return "" if value in (None, "") else str(value)
if kind == "stems":
return f"{value}" if value else ""
if kind == "date":
return time.strftime("%Y-%m-%d", time.localtime(value)) if value else ""
return "" if value is None else str(value)
class Sheet:
"""A tab: its rows, columns and what the rows are."""
def __init__(self, title, columns, rows, kind=SONG, sid=None, loading=False):
self.title = title
self.columns = COLUMNS[columns]
self.layout = columns
self.rows = rows
self.kind = kind
self.sid = sid or title
self.loading = loading
self.sort = None # (column, descending)
def row_kind(self, r):
return self.rows[r].get("_kind", self.kind)
class SheetModel(QAbstractTableModel):
"""One sheet for the QML TableView. Roles: display, align, style, playing."""
ROLES = {Qt.ItemDataRole.DisplayRole: b"display", Qt.ItemDataRole.UserRole + 1: b"align",
Qt.ItemDataRole.UserRole + 2: b"cellStyle", Qt.ItemDataRole.UserRole + 3: b"playing",
Qt.ItemDataRole.UserRole + 4: b"bold"}
def __init__(self, parent=None):
super().__init__(parent)
self.sheet: Sheet | None = None
self.playing_key = ""
self.playing_pos = -1 # for the queue sheet, where the same song can appear twice
def set_sheet(self, sheet):
self.beginResetModel()
self.sheet = sheet
self.endResetModel()
def refresh(self):
self.beginResetModel()
self.endResetModel()
def set_playing(self, key, pos):
self.playing_key, self.playing_pos = key, pos
if self.sheet and self.sheet.rows:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.sheet.rows) - 1, len(self.sheet.columns) - 1),
[Qt.ItemDataRole.UserRole + 3, Qt.ItemDataRole.UserRole + 4])
def roleNames(self):
return {k: QByteArray(v) for k, v in self.ROLES.items()}
def rowCount(self, parent=QModelIndex()):
return len(self.sheet.rows) if self.sheet else 0
def columnCount(self, parent=QModelIndex()):
return len(self.sheet.columns) if self.sheet else 0
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
s = self.sheet
if s is None or not index.isValid():
return None
row = s.rows[index.row()]
key, _, _, kind = s.columns[index.column()]
if role == Qt.ItemDataRole.DisplayRole:
return fmt(row.get(key), kind)
if role == Qt.ItemDataRole.UserRole + 1:
return "left" if kind in ("text", "stems") else "right"
if role == Qt.ItemDataRole.UserRole + 2:
# Conditional formatting: "Good" for songs with stems, a data bar for stem counts.
if kind == "stems" and row.get(key):
return "good"
if key == "stemSongs" and row.get(key):
return "bar:%.3f" % (row[key] / max(1, row.get("tracks") or row.get("songs") or row[key]))
return ""
if role in (Qt.ItemDataRole.UserRole + 3, Qt.ItemDataRole.UserRole + 4):
if s.layout == "queue":
return row.get("pos") == self.playing_pos + 1
return s.row_kind(index.row()) == SONG and row.get("key") == self.playing_key and bool(self.playing_key)
return None
+153
View File
@@ -0,0 +1,153 @@
"""stemserve's index, matched to Plex tracks by path (a port of the phone app's StemIndex)."""
import json
import os
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
# Display order and colours, matching the desktop separator and the phone.
ORDER = ["vocals", "drums", "bass", "guitar", "piano", "other", "instrumental", "mix"]
COLORS = {
"vocals": "#FF7AB6", "drums": "#FFB547", "bass": "#7C8CFF", "other": "#4CD07D",
"guitar": "#FF8A5C", "piano": "#B48CFF", "instrumental": "#3DD6C6", "mix": "#8E8E93",
}
def sort_names(names):
return sorted(names, key=lambda n: ORDER.index(n) if n in ORDER else 99)
def norm(path: str) -> str:
"""NFC, forward slashes, no extension, lower case."""
p = unicodedata.normalize("NFC", path).replace("\\", "/")
slash, dot = p.rfind("/"), p.rfind(".")
if dot > slash + 1:
p = p[:dot]
return p.lower()
class StemIndex:
"""Maps Plex file paths to stem folders.
The stems folder mirrors the music folder, so the source path relative to the music root
identifies a track. Plex reports paths as its container sees them, so the root is unknown
up front: every suffix of the Plex path is tried, longest first. A bare file name, or a
suffix whose duration disagrees, only counts under a root learned from a longer match.
"""
def __init__(self, entries=(), roots=()):
self.entries = list(entries)
self.by_source = {}
self.by_key = {}
for e in self.entries:
self.by_source.setdefault(norm(e["source"]), e)
self.by_key[e["key"]] = e
self.roots = list(dict.fromkeys(roots))
def __len__(self):
return len(self.entries)
def find(self, plex_path, duration_ms=0):
if not plex_path or not self.entries:
return None
path = norm(plex_path)
for root in self.roots:
if path.startswith(root):
hit = self.by_source.get(path[len(root):])
if hit:
return hit
parts = path.split("/")
for i in range(1, len(parts)):
hit = self.by_source.get("/".join(parts[i:]))
if hit is None:
continue
if len(parts) - i >= 2 and self._duration_ok(hit, duration_ms):
self.roots.append("/".join(parts[:i]) + "/")
return hit
return None
@staticmethod
def _duration_ok(e, duration_ms):
d = e.get("duration")
return d is None or duration_ms <= 0 or abs(d * 1000 - duration_ms) < 3000
class StemServer:
"""Client for server/stemserve.py. Files come from the local mount when it's there."""
def __init__(self, config, cache_dir):
self.cfg = config
self.cache_dir = cache_dir
self.index = StemIndex(roots=config.get("stemRoots", []))
self.online = None # not checked yet
self._index_file = os.path.join(cache_dir, "stem_index.json")
self._etag_file = os.path.join(cache_dir, "stem_index.etag")
try:
with open(self._index_file) as f:
self.index = StemIndex(json.load(f).get("tracks", []), self.index.roots)
except (OSError, ValueError):
pass
@property
def base(self):
return self.cfg["stemUrl"].rstrip("/")
def _open(self, url, headers=None, timeout=15):
h = dict(headers or {})
if self.cfg.get("stemToken"):
h["X-Stem-Token"] = self.cfg["stemToken"]
return urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout)
def refresh(self):
"""Fetches the index (ETag-cached). Returns True if it changed."""
headers = {}
if os.path.exists(self._etag_file) and os.path.exists(self._index_file):
with open(self._etag_file) as f:
headers["If-None-Match"] = f.read().strip()
try:
with self._open(self.base + "/api/index", headers) as r:
body = r.read()
etag = r.headers.get("ETag")
except urllib.error.HTTPError as e:
self.online = e.code == 304
return False
except (urllib.error.URLError, TimeoutError, OSError):
self.online = False
return False
self.online = True
data = json.loads(body)
self.index = StemIndex(data.get("tracks", []), self.index.roots)
os.makedirs(self.cache_dir, exist_ok=True)
with open(self._index_file, "wb") as f:
f.write(body)
if etag:
with open(self._etag_file, "w") as f:
f.write(etag)
return True
def find(self, track):
hit = self.index.find(track.get("partFile"), track.get("durationMs", 0))
if self.index.roots != self.cfg.get("stemRoots"):
self.cfg["stemRoots"] = list(self.index.roots)
return hit
def local_path(self, entry, stem):
root = self.cfg.get("stemsDir", "")
p = os.path.join(root, entry["key"], stem["file"]) if root else ""
return p if p and os.path.exists(p) else None
def file_url(self, entry, stem):
path = "/".join(urllib.parse.quote(p) for p in entry["key"].split("/"))
return f"{self.base}/file/{path}/{urllib.parse.quote(stem['file'])}"
def peaks(self, entry, buckets):
"""Per-stem RMS envelopes (0..1), or None."""
url = f"{self.base}/api/peaks?" + urllib.parse.urlencode({"key": entry["key"], "n": buckets})
try:
with self._open(url, timeout=30) as r:
stems = json.loads(r.read()).get("stems", {})
return {name: [v / 1000 for v in arr] for name, arr in stems.items()}
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return None