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:
1 parent
716c5499ff
commit
1845f03c41
184 files changed
+24648
No files matched your search
@@ -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`).
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .app import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -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 |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user