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

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

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

No files matched your search

@@ -0,0 +1,192 @@
import QtQuick
// File: Excel's backstage view. Info, Account (Plex sign-in) and Options.
Rectangle {
id: bs
property var win: Window.window
property string page: "info"
color: "#FFFFFF"
Rectangle {
id: side
width: 190
height: parent.height
color: X.green
Column {
width: parent.width
Item {
width: parent.width
height: 64
Rectangle { anchors.fill: parent; color: bm.containsMouse ? X.greenHover : "transparent" }
Ico { x: 22; anchors.verticalCenter: parent.verticalCenter; name: "back"; ink: "#FFFFFF"; size: 30 }
MouseArea { id: bm; anchors.fill: parent; hoverEnabled: true; onClicked: bs.win.closeBackstage() }
}
Repeater {
model: [["info", "Info"], ["account", "Account"], ["options", "Options"]]
delegate: Item {
required property var modelData
width: side.width
height: 40
Rectangle {
anchors.fill: parent
color: bs.page === parent.modelData[0] ? X.greenDark : im.containsMouse ? X.greenHover : "transparent"
}
T { x: 26; height: parent.height; text: parent.modelData[1]; color: "#FFFFFF"; font.pixelSize: 15 }
MouseArea { id: im; anchors.fill: parent; hoverEnabled: true; onClicked: bs.page = parent.modelData[0] }
}
}
Item { width: 1; height: 20 }
Item {
width: side.width
height: 40
Rectangle { anchors.fill: parent; color: qm.containsMouse ? X.greenHover : "transparent" }
T { x: 26; height: parent.height; text: "Close"; color: "#FFFFFF"; font.pixelSize: 15 }
MouseArea { id: qm; anchors.fill: parent; hoverEnabled: true; onClicked: bs.win.close() }
}
}
}
Item {
x: side.width + 40
y: 24
width: parent.width - x - 40
height: parent.height - y
T {
id: heading
text: bs.page === "info" ? "Info" : bs.page === "account" ? "Account" : "Options"
font.pixelSize: 38
font.weight: Font.Light
color: X.ink
height: 56
}
// ---- Info ----
Column {
visible: bs.page === "info"
y: heading.height + 20
width: parent.width
spacing: 18
Row {
spacing: 40
Column {
width: 360
spacing: 8
T { text: "Now Playing"; font.pixelSize: 20; color: X.greenText; height: 30 }
Prop { k: "Title"; v: player.track.title || "—" }
Prop { k: "Artist"; v: player.track.artist || "—" }
Prop { k: "Album"; v: player.track.album || "—" }
Prop { k: "Plays from"; v: player.sourceKind === "stems" ? "Stems (" + player.stems.length + ")" : player.sourceKind === "original" ? "Original file" + (player.track.codec ? " (" + player.track.codec.toUpperCase() + ")" : "") : "—" }
Prop { k: "Mix"; v: player.presetLabel || "—" }
Prop { k: "Queue"; v: player.queueInfo.count ? (player.queueInfo.index + 1) + " of " + player.queueInfo.count : "Empty" }
}
Column {
width: 360
spacing: 8
T { text: "Properties"; font.pixelSize: 20; color: X.greenText; height: 30 }
Prop { k: "Songs"; v: library.counts.songs.toLocaleString(Qt.locale(), "f", 0) }
Prop { k: "With stems"; v: library.counts.stems.toLocaleString(Qt.locale(), "f", 0) }
Prop { k: "Stems index"; v: library.counts.stemIndex.toLocaleString(Qt.locale(), "f", 0) + " songs" }
Prop { k: "Stem server"; v: (library.counts.stemOnline === true ? "Online · " : library.counts.stemOnline === false ? "Offline · " : "Checking · ") + settings.get("stemUrl") }
Prop { k: "Plex"; v: (library.signedIn ? "Connected · " : "Not connected · ") + settings.get("plexUrl") }
Prop { k: "Media keys"; v: bs.win.mprisOk ? "On (MPRIS)" : "Off" }
}
}
}
// ---- Account ----
Column {
visible: bs.page === "account"
y: heading.height + 20
width: parent.width
spacing: 14
T { text: "Connected Services"; font.pixelSize: 20; color: X.greenText; height: 30 }
Row {
spacing: 14
Ico { name: "plex"; size: 40 }
Column {
spacing: 4
T { text: "Plex Media Server"; font.pixelSize: 15; font.bold: true }
T { text: library.signedIn ? "Connected to " + settings.get("plexUrl") : "Not connected"; color: X.inkSoft }
}
}
Rectangle {
visible: library.pin !== ""
width: 420
height: 150
color: "#F3F3F3"
border.color: X.ribbonLine
Column {
anchors.centerIn: parent
spacing: 6
T { anchors.horizontalCenter: parent.horizontalCenter; text: "Go to plex.tv/link and enter"; font.pixelSize: 14 }
T {
anchors.horizontalCenter: parent.horizontalCenter
text: library.pin
font.family: cellFont
font.pixelSize: 54
font.bold: true
font.letterSpacing: 10
color: X.greenText
}
T { anchors.horizontalCenter: parent.horizontalCenter; text: "Waiting for Plex…"; color: X.inkSoft }
}
}
Row {
spacing: 10
OfficeButton {
text: library.pin !== "" ? "Cancel" : library.signedIn ? "Sign in again" : "Sign in to Plex"
primary: library.pin === ""
onClicked: library.pin !== "" ? library.cancelSignIn() : library.signIn()
}
}
}
// ---- Options ----
Column {
visible: bs.page === "options"
y: heading.height + 20
width: Math.min(parent.width, 640)
spacing: 10
T { text: "Connections"; font.pixelSize: 20; color: X.greenText; height: 30 }
Opt { id: oPlex; label: "Plex server"; key: "plexUrl" }
Opt { id: oStem; label: "Stem server (stemserve)"; key: "stemUrl" }
T { text: "Folders"; font.pixelSize: 20; color: X.greenText; height: 40; verticalAlignment: Text.AlignBottom }
Opt { id: oStems; label: "Stems share (played directly when mounted)"; key: "stemsDir" }
Opt { id: oMusic; label: "Music share"; key: "musicDir" }
Item { width: 1; height: 8 }
Row {
spacing: 10
OfficeButton {
text: "Save"
primary: true
onClicked: {
oPlex.save(); oStem.save(); oStems.save(); oMusic.save()
library.refresh()
bs.win.closeBackstage()
}
}
OfficeButton { text: "Cancel"; onClicked: bs.win.closeBackstage() }
}
}
}
component Prop: Row {
property string k
property string v
spacing: 10
T { width: 110; text: parent.k; color: X.inkSoft }
T { width: 240; text: parent.v }
}
component Opt: Column {
id: o
property string label
property string key
function save() { settings.set(key, f.text.trim()) }
spacing: 3
width: parent.width
T { text: o.label; color: X.inkSoft }
XField { id: f; width: o.width; text: settings.get(o.key) || "" }
Connections { target: bs; function onVisibleChanged() { if (bs.visible) f.text = settings.get(o.key) || "" } }
}
}
@@ -0,0 +1,18 @@
import QtQuick
Item {
id: c
property string icon
property bool danger: false
property string tip: ""
signal clicked()
width: icon === "pane" ? 40 : 46
height: parent.height
Rectangle {
anchors.fill: parent
color: cm.pressed ? (c.danger ? "#F1707A" : X.greenDark) : cm.containsMouse ? (c.danger ? "#E81123" : X.greenHover) : "transparent"
}
Ico { anchors.centerIn: parent; name: c.icon; ink: "#FFFFFF"; size: c.icon === "pane" ? 16 : 24 }
MouseArea { id: cm; anchors.fill: parent; hoverEnabled: true; onClicked: c.clicked() }
Tip { target: cm; text: c.tip }
}
@@ -0,0 +1,51 @@
import QtQuick
// Name box, the ✕ ✓ fx buttons, and the formula of the active cell.
Rectangle {
id: fb
property var win: Window.window
height: 30
color: "#FFFFFF"
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.ribbonLine }
Rectangle {
id: nameBox
x: 4
y: 4
width: 110
height: 22
border.color: "#C6C6C6"
T {
x: 5
height: parent.height
width: parent.width - 20
text: fb.win.multiSel ? (fb.win.selBottom - fb.win.selTop + 1) + "R x " + (Math.abs(fb.win.curCol - fb.win.anchorCol) + 1) + "C"
: X.colName(fb.win.curCol) + (fb.win.curRow + 2)
}
Ico { anchors.right: parent.right; anchors.rightMargin: 4; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
}
Row {
x: nameBox.x + nameBox.width + 10
height: parent.height
spacing: 12
Ico { anchors.verticalCenter: parent.verticalCenter; name: "cancel"; size: 14; opacity: 0.35 }
Ico { anchors.verticalCenter: parent.verticalCenter; name: "check"; size: 14; opacity: 0.35 }
Ico { anchors.verticalCenter: parent.verticalCenter; name: "function"; size: 18 }
}
Rectangle {
x: nameBox.x + nameBox.width + 92
y: 4
width: parent.width - x - 6
height: 22
border.color: "#C6C6C6"
T {
x: 6
width: parent.width - 12
height: parent.height
font.family: cellFont
font.pixelSize: 14
color: "#000000"
text: fb.win.formulaText
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import QtQuick
// image://icon/<name>[/<hex ink>], rendered at device pixels.
Image {
property string name: "grid"
property string ink: ""
property int size: 16
width: size
height: size
source: "image://icon/" + name + (ink ? "/" + ink.replace("#", "") : "")
sourceSize.width: size * Screen.devicePixelRatio
sourceSize.height: size * Screen.devicePixelRatio
smooth: true
asynchronous: false
}
+205
View File
@@ -0,0 +1,205 @@
import QtQuick
import QtQuick.Controls.Basic
Window {
id: win
width: 1440
height: 900
minimumWidth: 960
minimumHeight: 600
visible: true
title: (player.track.title ? player.track.title + " - " + player.track.artist : "Book1") + ".xlsx - Excel"
color: "#FFFFFF"
flags: Qt.Window | Qt.FramelessWindowHint
readonly property bool maximized: visibility === Window.Maximized || visibility === Window.FullScreen
function toggleMaximized() { maximized ? showNormal() : showMaximized() }
// ---- selection: the active cell and the anchor of the range ----
property int curRow: 0
property int curCol: 0
property int anchorRow: 0
property int anchorCol: 0
readonly property int selTop: Math.min(curRow, anchorRow)
readonly property int selBottom: Math.max(curRow, anchorRow)
readonly property bool multiSel: curRow !== anchorRow || curCol !== anchorCol
property string formulaText: ""
property var selStats: ({})
function refreshCell() {
formulaText = library.formula(curRow, curCol)
selStats = multiSel ? library.stats(anchorRow, anchorCol, curRow, curCol) : ({})
}
onCurRowChanged: cellTimer.restart()
onCurColChanged: cellTimer.restart()
onAnchorRowChanged: cellTimer.restart()
Timer { id: cellTimer; interval: 0; onTriggered: win.refreshCell() }
Connections {
target: library
function onSheetSwitched() { win.curRow = win.anchorRow = 0; win.curCol = win.anchorCol = 0; win.refreshCell() }
function onSheetChanged() { cellTimer.restart() }
function onToast(msg) { messages.show(msg) }
}
Connections { target: player; function onToast(msg) { messages.show(msg) } }
Connections {
target: sheetModel
function onModelReset() {
// new data under the same sheet: keep the selection, inside the rows that exist
var last = Math.max(0, grid.rows - 1)
if (win.curRow > last) win.curRow = last
if (win.anchorRow > last) win.anchorRow = last
cellTimer.restart()
}
}
Component.onCompleted: refreshCell()
// ---- view options ----
property bool showMixer: settings.get("showMixer") !== false
property bool gridlines: settings.get("gridlines") !== false
property bool headings: settings.get("headings") !== false
property int zoom: settings.get("zoom") || 100
property bool mprisOk: false
readonly property bool inPlaylist: library.sheetId.indexOf("playlist:") === 0
function toggleMixer() { showMixer = !showMixer; settings.set("showMixer", showMixer) }
function setOption(k, v) { win[k] = v; settings.set(k, v) }
function setZoom(z) { zoom = Math.max(50, Math.min(200, z)); settings.set("zoom", zoom) }
function openBackstage(page) { backstage.page = page; backstage.visible = true }
function closeBackstage() { backstage.visible = false; focusGrid() }
function focusGrid() { grid.forceActiveFocus() }
function focusSearch() { ribbon.focusSearch() }
function selectAll() { anchorRow = 0; anchorCol = 0; curRow = Math.max(0, grid.rows - 1); curCol = Math.max(0, library.columns.length - 1) }
function copySelection() { library.copyCells(anchorRow, anchorCol, curRow, curCol); messages.show("Copied " + (selBottom - selTop + 1) + " rows") }
function sortCurrent(desc) { library.sortTo(curCol, desc) }
function sortColumn(c, desc) { library.sortTo(c, desc) }
function newPlaylist() { plDialog.open() }
function addToPlaylist(name) { library.addToPlaylist(name, selTop, selBottom) }
// ---- playhead, read once per frame while playing ----
property real playPos: 0
property real levelL: 0
property real levelR: 0
function seekTo(s) { player.seek(s); playPos = s }
FrameAnimation {
running: engine.playing
onTriggered: {
var s = engine.snapshot()
win.playPos = s[0]
// meters fall back smoothly between peaks
var k = Math.exp(-frameTime / 0.12)
win.levelL = Math.max(s[1], win.levelL * k)
win.levelR = Math.max(s[2], win.levelR * k)
}
}
Connections {
target: engine
function onPlayingChanged() { var s = engine.snapshot(); win.playPos = s[0]; if (!engine.playing) { win.levelL = 0; win.levelR = 0 } }
function onDurationChanged() { win.playPos = engine.snapshot()[0] }
}
Timer { interval: 500; running: !engine.playing; repeat: true; onTriggered: win.playPos = engine.snapshot()[0] }
// dev hook: --eval runs JavaScript against the window
property string devEval: ""
onDevEvalChanged: if (devEval) { try { console.log("eval:", eval(devEval)) } catch (e) { console.log("eval error:", e) } }
Shortcut { sequence: "Ctrl+F"; onActivated: win.focusSearch() }
Shortcut { sequence: "Ctrl+Q"; onActivated: win.close() }
Shortcut { sequence: "Media Play"; onActivated: player.toggle() }
Shortcut { sequence: "Ctrl+Right"; context: Qt.ApplicationShortcut; enabled: !grid.activeFocus; onActivated: player.next() }
Shortcut { sequence: "Ctrl+Left"; context: Qt.ApplicationShortcut; enabled: !grid.activeFocus; onActivated: player.previous() }
Shortcut { sequence: "Alt+Left"; onActivated: library.back() }
Shortcut { sequence: "F1"; onActivated: win.openBackstage("info") }
Column {
anchors.fill: parent
TitleBar { width: parent.width }
Ribbon { id: ribbon; width: parent.width }
MessageBar { id: messages; width: parent.width }
FormulaBar { width: parent.width }
Item {
width: parent.width
height: parent.height - y - tabs.height - status.height
Sheet {
id: grid
width: parent.width - (pane.visible ? pane.width : 0)
height: parent.height
focus: true
}
TaskPane {
id: pane
visible: win.showMixer
anchors.right: parent.right
width: 330
height: parent.height
}
}
SheetTabs { id: tabs; width: parent.width }
StatusBar { id: status; width: parent.width }
}
Backstage {
id: backstage
visible: false
y: 32
width: parent.width
height: parent.height - y
}
// New Playlist: an Office dialog
Popup {
id: plDialog
modal: true
anchors.centerIn: parent
width: 380
padding: 0
onOpened: { plName.text = ""; plName.forceActiveFocus() }
onClosed: win.focusGrid()
background: Rectangle { color: "#FFFFFF"; border.color: "#7A7A7A" }
Overlay.modal: Rectangle { color: Qt.rgba(0, 0, 0, 0.12) }
contentItem: Column {
Rectangle {
width: parent.width
height: 30
color: "#FFFFFF"
T { x: 10; height: parent.height; text: "New Playlist" }
Ico { anchors.right: parent.right; anchors.rightMargin: 8; anchors.verticalCenter: parent.verticalCenter; name: "close"; size: 18
MouseArea { anchors.fill: parent; onClicked: plDialog.close() } }
}
Column {
x: 14
width: parent.width - 28
spacing: 8
T { text: (win.selBottom - win.selTop + 1) + " selected row(s) go into a new Plex playlist."; color: X.inkSoft }
T { text: "Name:" }
XField {
id: plName
width: parent.width
onAccepted: okBtn.clicked()
}
Row {
anchors.right: parent.right
spacing: 8
bottomPadding: 14
topPadding: 6
OfficeButton {
id: okBtn
text: "OK"
primary: true
onClicked: { library.newPlaylist(plName.text, win.selTop, win.selBottom); plDialog.close() }
}
OfficeButton { text: "Cancel"; onClicked: plDialog.close() }
}
}
}
}
// frameless: resize from the edges
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge; x: 0; y: 6; width: 5; height: parent.height - 12 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge; x: parent.width - 5; y: 6; width: 5; height: parent.height - 12 }
ResizeEdge { visible: !win.maximized; edges: Qt.TopEdge; x: 6; y: 0; width: parent.width - 12; height: 4 }
ResizeEdge { visible: !win.maximized; edges: Qt.BottomEdge; x: 6; y: parent.height - 4; width: parent.width - 12; height: 4 }
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge | Qt.TopEdge; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge | Qt.TopEdge; x: parent.width - 8; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge | Qt.BottomEdge; y: parent.height - 8; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge | Qt.BottomEdge; x: parent.width - 8; y: parent.height - 8; width: 8; height: 8 }
Rectangle { anchors.fill: parent; color: "transparent"; border.color: X.green; border.width: win.maximized ? 0 : 1 }
}
@@ -0,0 +1,30 @@
import QtQuick
// Excel's yellow message bar, for notices.
Rectangle {
id: mb
property string text: ""
height: text ? 32 : 0
visible: text !== ""
color: "#FFF4CE"
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: "#E6D9A8" }
Behavior on height { NumberAnimation { duration: X.easeMs } }
function show(t) { text = t; hide.restart() }
Timer { id: hide; interval: 6000; onTriggered: mb.text = "" }
Row {
x: 12
height: parent.height
spacing: 10
Ico { anchors.verticalCenter: parent.verticalCenter; name: "info"; size: 16 }
T { height: parent.height; text: "NOTICE"; font.bold: true }
T { height: parent.height; text: mb.text }
}
Item {
anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
width: 22
height: 22
Rectangle { anchors.fill: parent; color: xm.containsMouse ? "#F2E2A6" : "transparent" }
Ico { anchors.centerIn: parent; name: "close"; size: 18 }
MouseArea { id: xm; anchors.fill: parent; hoverEnabled: true; onClicked: mb.text = "" }
}
}
@@ -0,0 +1,16 @@
import QtQuick
// Dialog button: gray with a border; the default one outlined in green.
Rectangle {
id: ob
property string text
property bool primary: false
signal clicked()
implicitWidth: Math.max(80, t.implicitWidth + 24)
implicitHeight: 26
color: m.pressed ? "#CCE4F7" : m.containsMouse ? "#E5F1FB" : "#FDFDFD"
border.color: m.containsMouse ? "#0078D7" : primary ? X.green : "#ADADAD"
border.width: primary ? 2 : 1
T { id: t; anchors.centerIn: parent; text: ob.text }
MouseArea { id: m; anchors.fill: parent; hoverEnabled: true; onClicked: ob.clicked() }
}
@@ -0,0 +1,59 @@
import QtQuick
// A ribbon command: large (icon over a label) or small (icon beside a label).
Item {
id: b
property string icon: ""
property string text: ""
property bool big: false
property bool checkable: false
property bool checked: false
property bool dropdown: false
property string tip: ""
signal clicked()
implicitWidth: big ? Math.max(44, lbl.implicitWidth + 12) : (icon ? 22 : 6) + (text ? lbl.implicitWidth + 8 : 0) + (dropdown ? 12 : 0)
implicitHeight: big ? 66 : 22
opacity: enabled ? 1 : 0.4
Rectangle {
anchors.fill: parent
color: b.checked ? X.checked : ma.pressed ? X.pressed : ma.containsMouse ? X.hover : "transparent"
border.color: b.checked ? X.checkedBorder : "transparent"
}
Ico {
id: ic
visible: b.icon !== ""
name: b.icon
size: b.big ? 32 : 16
x: b.big ? (parent.width - width) / 2 : 3
y: b.big ? 4 : 3
}
T {
id: lbl
text: b.text
font.pixelSize: X.fsUi
x: b.big ? (parent.width - width) / 2 : (b.icon ? 23 : 4)
y: b.big ? 38 : 0
height: b.big ? implicitHeight : parent.height
width: b.big ? Math.min(implicitWidth, 90) : implicitWidth
horizontalAlignment: Text.AlignHCenter
wrapMode: b.big ? Text.WordWrap : Text.NoWrap
maximumLineCount: 2
elide: Text.ElideNone
lineHeight: 0.95
}
Ico {
visible: b.dropdown
name: "dropdown"
size: 10
x: b.big ? (parent.width - width) / 2 : parent.width - 12
y: b.big ? parent.height - 13 : 6
}
MouseArea {
id: ma
anchors.fill: parent
hoverEnabled: true
onClicked: b.clicked()
}
Tip { target: ma; text: b.tip || b.text }
}
@@ -0,0 +1,30 @@
import QtQuick
// A ribbon group: its commands, a caption along the bottom, a rule on the right.
Item {
id: g
property string title: ""
default property alias content: row.data
implicitWidth: Math.max(row.implicitWidth, cap.implicitWidth) + 12
height: parent ? parent.height : 92
Row {
id: row
x: 6
y: 3
height: 68
spacing: 2
}
T {
id: cap
text: g.title
font.pixelSize: X.fsSmall
color: X.inkSoft
anchors.horizontalCenter: parent.horizontalCenter
y: parent.height - 18
}
Rectangle {
anchors { right: parent.right; top: parent.top; bottom: parent.bottom; topMargin: 4; bottomMargin: 4 }
width: 1
color: X.ribbonLine
}
}
@@ -0,0 +1,13 @@
import QtQuick
// Invisible edge/corner grip that hands resizing to the compositor.
MouseArea {
property int edges: 0
property var win: Window.window
hoverEnabled: true
acceptedButtons: Qt.LeftButton
cursorShape: edges === (Qt.LeftEdge | Qt.TopEdge) || edges === (Qt.RightEdge | Qt.BottomEdge) ? Qt.SizeFDiagCursor
: edges === (Qt.RightEdge | Qt.TopEdge) || edges === (Qt.LeftEdge | Qt.BottomEdge) ? Qt.SizeBDiagCursor
: (edges & (Qt.LeftEdge | Qt.RightEdge)) ? Qt.SizeHorCursor : Qt.SizeVerCursor
onPressed: win.startSystemResize(edges)
}
+290
View File
@@ -0,0 +1,290 @@
import QtQuick
import QtQuick.Controls.Basic
// Tabs on the green strip, commands on the gray ribbon below.
Column {
id: rib
property var win: Window.window
property int tab: 1 // 0 = File (backstage), 1 Home, 2 Insert, 3 Data, 4 View
readonly property var tabs: ["File", "Home", "Insert", "Data", "View"]
function focusSearch() { tell.startEditing() }
Rectangle {
width: parent.width
height: 28
color: X.green
Row {
x: 0
height: parent.height
Repeater {
model: rib.tabs
delegate: Item {
id: tabItem
required property int index
required property string modelData
readonly property bool file: index === 0
readonly property bool active: rib.tab === index && !file
width: tl.implicitWidth + (file ? 30 : 22)
height: 28
Rectangle {
anchors.fill: parent
anchors.topMargin: tabItem.active ? 2 : 0
color: tabItem.active ? X.ribbon : tabItem.file ? (tm.containsMouse ? "#0E4B2A" : X.greenDark)
: tm.containsMouse ? X.greenHover : "transparent"
}
T {
id: tl
anchors.centerIn: parent
text: tabItem.modelData
color: tabItem.active ? X.greenText : "#FFFFFF"
}
MouseArea {
id: tm
anchors.fill: parent
hoverEnabled: true
onClicked: tabItem.file ? rib.win.openBackstage("info") : rib.tab = tabItem.index
}
}
}
// "Tell me what you want to do": here it searches the library.
Item {
id: tell
width: 260
height: 28
function startEditing() { field.visible = true; field.forceActiveFocus(); field.selectAll() }
Ico { x: 12; anchors.verticalCenter: parent.verticalCenter; name: "lightbulb"; ink: "#FFFFFF"; size: 16 }
T {
x: 34
anchors.verticalCenter: parent.verticalCenter
visible: !field.visible
color: "#FFFFFF"
text: "Tell me what you want to listen to..."
}
MouseArea { anchors.fill: parent; cursorShape: Qt.IBeamCursor; onClicked: tell.startEditing() }
XField {
id: field
visible: false
x: 32
width: 220
anchors.verticalCenter: parent.verticalCenter
placeholderText: "Song, artist or album"
onAccepted: { library.search(text); visible = false; rib.win.focusGrid() }
onActiveFocusChanged: if (!activeFocus && text === "") visible = false
Keys.onEscapePressed: { text = ""; visible = false; rib.win.focusGrid() }
}
}
}
}
Rectangle {
width: parent.width
height: 94
color: X.ribbon
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.ribbonLine }
// ---- Home ----
Row {
visible: rib.tab === 1
height: parent.height - 2
RGroup {
title: "Playback"
RButton {
big: true
icon: player.status === "playing" ? "pause" : "play"
text: player.status === "playing" ? "Pause" : "Play"
tip: "Play or pause (Space)"
onClicked: player.toggle()
}
Column {
RButton { icon: "previous"; text: "Previous"; tip: "Previous song (Ctrl+Left)"; onClicked: player.previous() }
RButton { icon: "next"; text: "Next"; tip: "Next song (Ctrl+Right)"; onClicked: player.next() }
RButton { icon: "stop"; text: "Stop"; onClicked: { engine.pause(); player.seek(0) } }
}
Column {
RButton { icon: "shuffle"; text: "Shuffle"; checked: player.shuffleOn; onClicked: player.toggleShuffle() }
RButton {
icon: player.repeatMode === "one" ? "repeat_one" : "repeat"
text: player.repeatMode === "one" ? "Repeat One" : "Repeat"
checked: player.repeatMode !== "off"
onClicked: player.cycleRepeat()
}
}
}
RGroup {
title: "Karaoke"
RButton {
big: true
enabled: player.sourceKind === "stems"
icon: player.presetLabel === "Karaoke" ? "vocals_off" : "vocals"
text: player.presetLabel === "Karaoke" ? "Vocals\nOff" : "Vocals\nOn"
checked: player.presetLabel === "Karaoke"
tip: "Mute the vocals (V)"
onClicked: player.toggleVocals()
}
}
RGroup {
title: "Mix Styles"
Grid {
columns: 4
rowSpacing: 3
columnSpacing: 3
y: 4
Repeater {
model: player.presets
delegate: Rectangle {
id: sty
required property int index
required property var modelData
readonly property var s: X.styles[index]
width: 82
height: 28
opacity: modelData.usable ? 1 : 0.35
color: s.fill
border.width: modelData.active ? 2 : 1
border.color: modelData.active ? X.green : (sm.containsMouse ? "#8A8A8A" : s.border)
T {
anchors.fill: parent
anchors.leftMargin: 5
font.family: cellFont
font.pixelSize: 14
font.bold: sty.s.bold
color: sty.s.text
text: sty.modelData.label
}
MouseArea {
id: sm
anchors.fill: parent
hoverEnabled: true
enabled: sty.modelData.usable
onClicked: player.applyPreset(sty.index)
}
Tip { target: sm; text: "Style: " + sty.s.name + " — " + sty.modelData.label }
}
}
}
}
RGroup {
title: "Cells"
RButton { big: true; icon: "play_next"; text: "Play\nNext"; tip: "Play the selection after this song"; onClicked: library.playNextRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "queue_add"; text: "Add to\nQueue"; tip: "Add the selection to the end of the queue"; onClicked: library.queueRows(rib.win.selTop, rib.win.selBottom) }
RButton {
big: true
icon: "delete_rows"
text: "Delete\nRows"
enabled: library.layout === "queue" || library.sheetTitle !== "" && rib.win.inPlaylist
tip: "Remove the selected rows from the queue or playlist (Delete)"
onClicked: library.removeRows(rib.win.selTop, rib.win.selBottom)
}
}
RGroup {
title: "Editing"
Column {
RButton { icon: "sigma"; text: "AutoShuffle"; tip: "Shuffle everything on this sheet"; onClicked: library.shuffleSheet() }
RButton { icon: "sort_az"; text: "Sort & Filter"; dropdown: true; onClicked: sortMenu.popup() }
RButton { icon: "search"; text: "Find & Select"; tip: "Search the library (Ctrl+F)"; onClicked: rib.focusSearch() }
}
}
}
// ---- Insert ----
Row {
visible: rib.tab === 2
height: parent.height - 2
RGroup {
title: "Playlists"
RButton { big: true; icon: "playlist_new"; text: "New\nPlaylist"; tip: "A Plex playlist from the selected rows"; onClicked: rib.win.newPlaylist() }
RButton { big: true; icon: "playlist_add"; text: "Add to\nPlaylist"; dropdown: true; onClicked: plMenu.popup() }
}
RGroup {
title: "Queue"
RButton { big: true; icon: "play_next"; text: "Play\nNext"; onClicked: library.playNextRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "queue_add"; text: "Add to\nQueue"; onClicked: library.queueRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "play"; text: "Play\nSelection"; onClicked: library.playRows(rib.win.selTop, rib.win.selBottom) }
}
RGroup {
title: "Charts"
RButton { big: true; icon: "chart"; text: "Stem\nChart"; checked: rib.win.showMixer; tip: "Show the stem mixer pane"; onClicked: rib.win.toggleMixer() }
}
}
// ---- Data ----
Row {
visible: rib.tab === 3
height: parent.height - 2
RGroup {
title: "Get External Data"
RButton { big: true; icon: "plex"; text: library.signedIn ? "Plex\nAccount" : "Connect\nto Plex"; onClicked: rib.win.openBackstage("account") }
RButton { big: true; icon: "server"; text: "Connections"; tip: "Server addresses and folders"; onClicked: rib.win.openBackstage("options") }
}
RGroup {
title: "Connections"
RButton { big: true; icon: "refresh"; text: "Refresh\nAll"; tip: "Reload the Plex library and the stems index"; onClicked: library.refresh() }
Column {
y: 4
spacing: 2
T { text: "Songs: " + library.counts.songs.toLocaleString(Qt.locale(), "f", 0); height: 20 }
T { text: "With stems: " + library.counts.stems.toLocaleString(Qt.locale(), "f", 0); height: 20 }
T {
text: "Stem server: " + (library.counts.stemOnline === true ? "Online" : library.counts.stemOnline === false ? "Offline" : "Checking…")
color: library.counts.stemOnline === true ? X.goodText : library.counts.stemOnline === false ? X.badText : X.inkSoft
height: 20
}
}
}
RGroup {
title: "Sort & Filter"
Column {
RButton { icon: "sort_az"; text: "Sort A to Z"; onClicked: rib.win.sortCurrent(false) }
RButton { icon: "sort_za"; text: "Sort Z to A"; onClicked: rib.win.sortCurrent(true) }
RButton { icon: "filter"; text: "Stems Only"; tip: "Songs that have stems"; onClicked: library.current = 4 }
}
}
}
// ---- View ----
Row {
visible: rib.tab === 4
height: parent.height - 2
RGroup {
title: "Show"
Column {
y: 2
XCheck { text: "Gridlines"; checked: rib.win.gridlines; onToggled: rib.win.setOption("gridlines", !rib.win.gridlines) }
XCheck { text: "Headings"; checked: rib.win.headings; onToggled: rib.win.setOption("headings", !rib.win.headings) }
XCheck { text: "Mixer Pane"; checked: rib.win.showMixer; onToggled: rib.win.toggleMixer() }
}
}
RGroup {
title: "Zoom"
RButton { big: true; icon: "zoom"; text: "Zoom"; tip: "Ctrl+scroll on the grid"; onClicked: rib.win.setZoom(rib.win.zoom >= 150 ? 100 : rib.win.zoom + 25) }
RButton { big: true; icon: "grid"; text: "100%"; onClicked: rib.win.setZoom(100) }
}
RGroup {
title: "Window"
RButton { big: true; icon: "freeze"; text: "Freeze\nPanes"; checked: true; tip: "The header row is always frozen" }
}
}
}
XMenu {
id: sortMenu
Action { text: "Sort A to Z"; icon.name: "sort_az"; onTriggered: rib.win.sortCurrent(false) }
Action { text: "Sort Z to A"; icon.name: "sort_za"; onTriggered: rib.win.sortCurrent(true) }
MenuSeparator {}
Action { text: "Filter: Stems Only"; icon.name: "filter"; onTriggered: library.current = 4 }
}
XMenu {
id: plMenu
Instantiator {
model: library.playlistNames
delegate: MenuItem {
required property int index
required property string modelData
text: modelData
onTriggered: rib.win.addToPlaylist(modelData)
}
onObjectAdded: (i, o) => plMenu.insertItem(i, o)
onObjectRemoved: (i, o) => plMenu.removeItem(o)
}
}
}
+501
View File
@@ -0,0 +1,501 @@
import QtQuick
import QtQuick.Controls.Basic
// The worksheet. Row 1 is the frozen table header; data starts on row 2. Past the data the
// sheet carries on as empty gridlines, as Excel's does.
FocusScope {
id: g
property var win: Window.window
readonly property real z100: win.zoom / 100
readonly property int rowH: Math.round(20 * z100)
readonly property int headW: Math.max(30, Math.round(rowNumW.advanceWidth + 12))
readonly property int colHeadH: 20
readonly property var cols: library.columns
readonly property int rows: table.rows
readonly property int padCols: 26
property real extraColW: 64 * z100
function colX(c) {
var x = 0
for (var i = 0; i < c && i < cols.length; i++) x += cols[i].width * z100
if (c > cols.length) x += (c - cols.length) * extraColW
return x
}
function colW(c) { return c < cols.length ? cols[c].width * z100 : extraColW }
function colAt(x) {
var acc = 0
for (var i = 0; i < cols.length; i++) {
acc += cols[i].width * z100
if (x < acc) return i
}
return cols.length + Math.floor((x - acc) / extraColW)
}
function ensureVisible(r, c) {
var y = r * rowH
if (y < table.contentY) table.contentY = y
else if (y + rowH > table.contentY + table.height) table.contentY = y + rowH - table.height
var x = colX(c), w = colW(c)
if (x < table.contentX) table.contentX = x
else if (x + w > table.contentX + table.width) table.contentX = Math.min(x + w - table.width, Math.max(0, table.contentWidth - table.width))
}
function moveTo(r, c, extend) {
r = Math.max(0, Math.min(r, Math.max(0, rows - 1)))
c = Math.max(0, Math.min(c, cols.length - 1))
win.curRow = r
win.curCol = c
if (!extend) { win.anchorRow = r; win.anchorCol = c }
ensureVisible(r, c)
}
TextMetrics { id: rowNumW; font.family: uiFont; font.pixelSize: X.fsSmall; text: String(g.rows + 40) }
// ---- column letters ----
Rectangle {
id: colHead
visible: win.headings
x: g.headW
width: parent.width - x
height: visible ? g.colHeadH : 0
color: X.header
clip: true
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Repeater {
model: g.cols.length + g.padCols
delegate: Rectangle {
required property int index
readonly property bool sel: index >= Math.min(win.curCol, win.anchorCol) && index <= Math.max(win.curCol, win.anchorCol)
x: g.colX(index) - table.contentX
width: g.colW(index)
height: g.colHeadH
visible: x + width > 0 && x < colHead.width
color: sel ? X.headerSel : X.header
T {
anchors.centerIn: parent
text: X.colName(index)
font.pixelSize: X.fsSmall
color: parent.sel ? X.greenText : X.ink
}
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
Rectangle { visible: parent.sel; anchors.bottom: parent.bottom; width: parent.width; height: 2; color: X.green }
MouseArea {
anchors.fill: parent
onClicked: if (index < g.cols.length) { win.anchorRow = 0; win.curRow = Math.max(0, g.rows - 1); win.curCol = win.anchorCol = index }
}
}
}
}
// the select-all corner
Rectangle {
visible: win.headings
width: g.headW
height: g.colHeadH
color: X.header
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Canvas {
anchors { right: parent.right; bottom: parent.bottom; margins: 3 }
width: 10
height: 10
onPaint: {
var c = getContext("2d")
c.fillStyle = "#B4B4B4"
c.beginPath(); c.moveTo(10, 0); c.lineTo(10, 10); c.lineTo(0, 10); c.closePath(); c.fill()
}
}
MouseArea { anchors.fill: parent; onClicked: win.selectAll() }
}
// ---- row numbers ----
Rectangle {
id: rowHead
visible: win.headings
y: colHead.height
width: visible ? g.headW : 0
height: parent.height - y
color: X.header
clip: true
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
// row 1: the frozen table header
Rectangle {
width: parent.width
height: g.rowH
color: X.header
T { anchors.centerIn: parent; text: "1"; font.pixelSize: X.fsSmall }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
}
Item {
y: g.rowH
width: parent.width
height: parent.height - y
clip: true
Repeater {
model: Math.max(0, Math.ceil(parent.height / g.rowH) + 1)
delegate: Rectangle {
required property int index
readonly property int r: Math.floor(table.contentY / g.rowH) + index
readonly property bool sel: r >= win.selTop && r <= win.selBottom
y: r * g.rowH - table.contentY
width: rowHead.width
height: g.rowH
color: sel ? X.headerSel : X.header
T {
anchors.centerIn: parent
text: parent.r + 2
font.pixelSize: X.fsSmall
color: parent.sel ? X.greenText : X.ink
}
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Rectangle { anchors.right: parent.right; width: parent.sel ? 2 : 1; height: parent.height; color: parent.sel ? X.green : X.headerLine }
MouseArea {
anchors.fill: parent
onClicked: (m) => { if (parent.r < g.rows) { g.moveTo(parent.r, 0, m.modifiers & Qt.ShiftModifier); if (!(m.modifiers & Qt.ShiftModifier)) win.anchorCol = 0; win.curCol = g.cols.length - 1 } }
onDoubleClicked: if (parent.r < g.rows) library.activate(parent.r, 0)
}
}
}
}
}
// ---- sheet ----
Item {
id: sheet
x: rowHead.width
y: colHead.height
width: parent.width - x
height: parent.height - y
clip: true
Rectangle { anchors.fill: parent; color: "#FFFFFF" }
// the empty sheet: gridlines everywhere, under the cells
Item {
anchors.fill: parent
visible: win.gridlines
Repeater {
model: Math.max(0, Math.ceil(sheet.height / g.rowH) + 2)
delegate: Rectangle {
required property int index
y: g.rowH + (index + 1) * g.rowH - (table.contentY % g.rowH) - 1
width: sheet.width
height: 1
color: X.grid
}
}
Repeater {
model: g.cols.length + g.padCols
delegate: Rectangle {
required property int index
x: g.colX(index + 1) - table.contentX - 1
width: 1
height: sheet.height
color: X.grid
}
}
}
// row 1, frozen: the table header with its filter buttons
Item {
id: headRow
width: parent.width
height: g.rowH
z: 2
Repeater {
model: g.cols
delegate: Rectangle {
id: hc
required property int index
required property var modelData
x: g.colX(index) - table.contentX
width: g.colW(index)
height: g.rowH
color: X.tableHead
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: "#FFFFFF"; opacity: 0.5 }
T {
x: 4
width: parent.width - 26
height: parent.height
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
font.bold: true
color: "#FFFFFF"
text: hc.modelData.title
horizontalAlignment: hc.modelData.kind === "text" || hc.modelData.kind === "stems" ? Text.AlignLeft : Text.AlignRight
rightPadding: hc.modelData.kind === "text" || hc.modelData.kind === "stems" ? 0 : 2
}
// the AutoFilter button: sorts this column
Rectangle {
anchors { right: parent.right; rightMargin: 3; verticalCenter: parent.verticalCenter }
width: Math.round(15 * g.z100)
height: width
color: fm.containsMouse ? "#F0F0F0" : "#FFFFFF"
border.color: "#A0A0A0"
Ico {
anchors.centerIn: parent
size: Math.round(12 * g.z100)
name: library.sortColumn === hc.index ? (library.sortDescending ? "sort_down" : "sort_up") : "dropdown"
}
MouseArea {
id: fm
anchors.fill: parent
hoverEnabled: true
onClicked: { g.win.curCol = g.win.anchorCol = hc.index; filterMenu.col = hc.index; filterMenu.popup() }
}
}
MouseArea {
anchors { fill: parent; rightMargin: 20 }
onClicked: library.sortBy(hc.index)
}
}
}
}
TableView {
id: table
y: g.rowH
width: parent.width
height: parent.height - y
model: sheetModel
clip: true
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
columnWidthProvider: (c) => g.colW(c)
rowHeightProvider: (r) => g.rowH
interactive: false // the wheel and the scrollbars move it; dragging selects
ScrollBar.vertical: XScrollBar { id: vbar }
ScrollBar.horizontal: XScrollBar { id: hbar }
Connections {
target: g.win
function onZoomChanged() { Qt.callLater(table.forceLayout) }
}
delegate: Rectangle {
id: cell
required property int row
required property int column
required property string display
required property string align
required property string cellStyle
required property bool playing
readonly property bool bar: cellStyle.indexOf("bar:") === 0
color: cellStyle === "good" ? X.good : (row % 2 === 1 ? X.tableBand : "#FFFFFF")
Rectangle { // data bar
visible: cell.bar
x: 2
y: 2
height: parent.height - 4
width: cell.bar ? (parent.width - 4) * parseFloat(cell.cellStyle.slice(4)) : 0
color: X.dataBar
opacity: 0.85
}
Rectangle { visible: g.win.gridlines; anchors.right: parent.right; width: 1; height: parent.height; color: X.grid }
Rectangle { visible: g.win.gridlines; anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.grid }
Text {
anchors.fill: parent
leftPadding: 3
rightPadding: 4
verticalAlignment: Text.AlignBottom
bottomPadding: Math.round(2 * g.z100)
horizontalAlignment: cell.align === "right" ? Text.AlignRight : Text.AlignLeft
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
font.bold: cell.playing
color: cell.cellStyle === "good" ? X.goodText : X.cellText
text: cell.cellStyle === "good" ? cell.display + " stems" : cell.display
elide: cell.align === "right" ? Text.ElideLeft : Text.ElideRight
renderType: Text.NativeRendering
}
// Excel's green error triangle, here marking the song that's playing
Canvas {
visible: cell.playing && cell.column === 0
width: 7
height: 7
onPaint: {
var c = getContext("2d")
c.fillStyle = "#217346"
c.beginPath(); c.moveTo(0, 0); c.lineTo(7, 0); c.lineTo(0, 7); c.closePath(); c.fill()
}
}
}
// selection: range fill, green border, fill handle
Rectangle {
id: selBox
parent: table.contentItem
z: 50
visible: g.rows > 0
readonly property bool range: g.win.selTop !== g.win.selBottom || g.win.curCol !== g.win.anchorCol
x: g.colX(Math.min(g.win.curCol, g.win.anchorCol)) - 1
y: g.win.selTop * g.rowH - 1
width: g.colX(Math.max(g.win.curCol, g.win.anchorCol) + 1) - x + 1
height: (g.win.selBottom - g.win.selTop + 1) * g.rowH + 1
color: range ? X.selFill : "transparent"
border.color: X.green
border.width: 2
Rectangle {
// the active cell stays clear inside a range
visible: selBox.range
x: g.colX(g.win.curCol) - parent.x
y: g.win.curRow * g.rowH - parent.y
width: g.colW(g.win.curCol)
height: g.rowH
color: "#FFFFFF"
opacity: 0.6
}
Rectangle {
x: parent.width - 4
y: parent.height - 4
width: 7
height: 7
color: X.green
border.color: "#FFFFFF"
border.width: 1
}
}
MouseArea {
parent: table.contentItem
z: 60
width: Math.max(table.contentWidth, table.width)
height: Math.max(table.contentHeight, table.height)
acceptedButtons: Qt.LeftButton | Qt.RightButton
property bool dragging: false
function cellAt(m) { return [Math.floor(m.y / g.rowH), g.colAt(m.x)] }
onPressed: (m) => {
g.forceActiveFocus()
var rc = cellAt(m)
if (rc[0] >= g.rows) return
if (m.button === Qt.RightButton) {
if (rc[0] < g.win.selTop || rc[0] > g.win.selBottom) g.moveTo(rc[0], rc[1], false)
ctxMenu.popup()
return
}
g.moveTo(rc[0], rc[1], m.modifiers & Qt.ShiftModifier)
dragging = true
}
onPositionChanged: (m) => {
if (!dragging) return
var rc = cellAt(m)
g.moveTo(rc[0], rc[1], true)
}
onReleased: dragging = false
onDoubleClicked: (m) => { var rc = cellAt(m); if (rc[0] < g.rows) library.activate(rc[0], 0) }
onWheel: (w) => {
if (w.modifiers & Qt.ControlModifier) {
g.win.setZoom(g.win.zoom + (w.angleDelta.y > 0 ? 10 : -10))
return
}
var dy = w.pixelDelta.y !== 0 ? w.pixelDelta.y : w.angleDelta.y / 120 * 3 * g.rowH
var dx = w.pixelDelta.x !== 0 ? w.pixelDelta.x : w.angleDelta.x / 120 * 3 * g.rowH
if (w.modifiers & Qt.ShiftModifier) { dx = dy; dy = 0 }
table.contentY = Math.max(0, Math.min(table.contentY - dy, table.contentHeight - table.height))
table.contentX = Math.max(0, Math.min(table.contentX - dx, Math.max(0, table.contentWidth - table.width)))
}
}
}
// #GETTING_DATA: what Excel shows while a query runs
T {
visible: library.loading
x: 4
y: g.rowH
height: g.rowH
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
color: X.cellText
text: "#GETTING_DATA"
}
// a cell comment when there's nothing to show
Rectangle {
visible: g.rows === 0 && !library.loading
x: g.colX(1) + 30
y: g.rowH * 2
width: 300
height: note.implicitHeight + 16
color: X.note
border.color: "#B2B2B2"
Rectangle { x: -30; y: 6; width: 30; height: 1; color: "#000000" }
Text {
id: note
x: 8
y: 8
width: parent.width - 16
wrapMode: Text.WordWrap
font.family: uiFont
font.pixelSize: X.fsUi
textFormat: Text.StyledText
text: !library.signedIn ? "<b>StemRoller:</b><br>This workbook isn't connected yet. Go to <b>Data → Connect to Plex</b> and sign in with the code."
: library.layout === "queue" ? "<b>StemRoller:</b><br>The queue is empty. Double-click a song on any sheet to play it."
: library.status !== "" ? "<b>StemRoller:</b><br>Loading the library from Plex…"
: "<b>StemRoller:</b><br>Nothing on this sheet."
}
}
}
Keys.onPressed: (e) => {
var ext = e.modifiers & Qt.ShiftModifier
var ctrl = e.modifiers & Qt.ControlModifier
var page = Math.max(1, Math.floor(table.height / g.rowH) - 1)
switch (e.key) {
case Qt.Key_Down: g.moveTo(ctrl ? g.rows - 1 : win.curRow + 1, win.curCol, ext); break
case Qt.Key_Up: g.moveTo(ctrl ? 0 : win.curRow - 1, win.curCol, ext); break
case Qt.Key_Right: if (ctrl) { player.next(); break } g.moveTo(win.curRow, win.curCol + 1, ext); break
case Qt.Key_Left: if (ctrl) { player.previous(); break } g.moveTo(win.curRow, win.curCol - 1, ext); break
case Qt.Key_Tab: g.moveTo(win.curRow, win.curCol + 1, false); break
case Qt.Key_PageDown: g.moveTo(win.curRow + page, win.curCol, ext); break
case Qt.Key_PageUp: g.moveTo(win.curRow - page, win.curCol, ext); break
case Qt.Key_Home: g.moveTo(ctrl ? 0 : win.curRow, 0, ext); break
case Qt.Key_End: g.moveTo(ctrl ? g.rows - 1 : win.curRow, g.cols.length - 1, ext); break
case Qt.Key_Return:
case Qt.Key_Enter: library.activate(win.curRow, 0); break
case Qt.Key_Space: player.toggle(); break
case Qt.Key_Delete: library.removeRows(win.selTop, win.selBottom); break
case Qt.Key_Backspace: library.back(); break
case Qt.Key_A: if (ctrl) win.selectAll(); else return; break
case Qt.Key_C: if (ctrl) win.copySelection(); else return; break
case Qt.Key_V: if (!ctrl) player.toggleVocals(); else return; break
default: return
}
e.accepted = true
}
// A new sheet starts at the top; fresh data for the same sheet keeps its scroll position.
property real keptY: 0
property bool switching: false
Connections {
target: library
function onSheetSwitched() { g.switching = true; table.contentY = 0; table.contentX = 0 }
}
Connections {
target: sheetModel
function onModelAboutToBeReset() { g.keptY = table.contentY }
function onModelReset() {
if (g.switching) { g.switching = false; return }
Qt.callLater(() => { table.contentY = Math.min(g.keptY, Math.max(0, table.contentHeight - table.height)) })
}
}
XMenu {
id: ctxMenu
Action { text: "Play"; icon.name: "play"; onTriggered: library.playRows(g.win.selTop, g.win.selBottom) }
Action { text: "Play Next"; icon.name: "play_next"; onTriggered: library.playNextRows(g.win.selTop, g.win.selBottom) }
Action { text: "Add to Queue"; icon.name: "queue_add"; onTriggered: library.queueRows(g.win.selTop, g.win.selBottom) }
MenuSeparator {}
Action { text: "Open"; icon.name: "right"; onTriggered: library.activate(g.win.curRow, 0) }
Action { text: "New Playlist from Selection…"; icon.name: "playlist_new"; onTriggered: g.win.newPlaylist() }
Action { text: "Copy"; onTriggered: g.win.copySelection() }
MenuSeparator {}
Action {
text: "Delete Rows"
icon.name: "delete_rows"
enabled: library.layout === "queue" || g.win.inPlaylist
onTriggered: library.removeRows(g.win.selTop, g.win.selBottom)
}
}
XMenu {
id: filterMenu
property int col: 0
Action { text: "Sort A to Z"; icon.name: "sort_az"; onTriggered: g.win.sortColumn(filterMenu.col, false) }
Action { text: "Sort Z to A"; icon.name: "sort_za"; onTriggered: g.win.sortColumn(filterMenu.col, true) }
}
}
@@ -0,0 +1,89 @@
import QtQuick
// Worksheet tabs. The first six are fixed; opened artists, albums, playlists and searches follow.
Rectangle {
id: st
property var win: Window.window
height: 26
color: X.ribbon
Rectangle { width: parent.width; height: 1; color: X.ribbonLine }
Row {
id: nav
x: 6
height: parent.height
spacing: 2
Repeater {
model: ["left", "right"]
delegate: Item {
required property int index
required property string modelData
width: 18
height: parent.height
Ico { anchors.centerIn: parent; name: parent.modelData; size: 12; opacity: na.containsMouse ? 1 : 0.6 }
MouseArea {
id: na
anchors.fill: parent
hoverEnabled: true
onClicked: library.current = Math.max(0, Math.min(library.tabs.length - 1, library.current + (parent.index ? 1 : -1)))
}
}
}
}
Flickable {
id: flick
x: nav.x + nav.width + 8
width: parent.width - x - 8
height: parent.height
contentWidth: tabRow.width + 40
clip: true
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Row {
id: tabRow
height: parent.height
Repeater {
model: library.tabs
delegate: Item {
id: tb
required property int index
required property var modelData
readonly property bool active: library.current === index
width: lbl.implicitWidth + 28
height: st.height
Rectangle {
anchors.fill: parent
anchors.topMargin: 0
color: tb.active ? "#FFFFFF" : tm.containsMouse ? "#E5E5E5" : "transparent"
}
Rectangle { visible: !tb.active; anchors.right: parent.right; y: 6; width: 1; height: parent.height - 12; color: "#C8C8C8" }
Rectangle { visible: tb.active; anchors.bottom: parent.bottom; width: parent.width; height: 3; color: X.green }
T {
id: lbl
anchors.centerIn: parent
text: tb.modelData.title
color: tb.active ? X.greenText : X.ink
font.bold: tb.active
}
MouseArea {
id: tm
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: (m) => m.button === Qt.MiddleButton ? library.closeTab(tb.index) : (library.current = tb.index)
}
Tip { target: tm; text: tb.modelData.base ? "" : "Middle-click or Backspace to close" }
}
}
// ⊕ new sheet: here, the search box
Item {
width: 30
height: st.height
Ico { anchors.centerIn: parent; name: "plus"; size: 16; opacity: pm.containsMouse ? 1 : 0.6 }
MouseArea { id: pm; anchors.fill: parent; hoverEnabled: true; onClicked: st.win.focusSearch() }
Tip { target: pm; text: "New sheet from a search" }
}
}
}
}
@@ -0,0 +1,96 @@
import QtQuick
// Ready / Calculating on the left; Average, Count, Sum; view buttons; the zoom slider is the volume.
Rectangle {
id: sb
property var win: Window.window
height: 24
color: X.green
T {
x: 10
height: parent.height
color: "#FFFFFF"
text: library.status !== "" ? library.status + (library.progress >= 0 ? ": " + Math.round(library.progress * 100) + "%" : "")
: player.status === "loading" ? "Calculating..."
: player.status === "buffering" ? "Buffering..."
: player.status === "playing" ? "Playing" + (player.presetLabel && player.presetLabel !== "Full Mix" ? " · " + player.presetLabel : "")
: "Ready"
}
Rectangle { // progress for Calculating, like Excel's
visible: library.progress >= 0
x: 190
anchors.verticalCenter: parent.verticalCenter
width: 120
height: 8
color: "#FFFFFF"
opacity: 0.9
Rectangle { width: parent.width * Math.max(0, library.progress); height: parent.height; color: "#9FD2B4" }
}
Row {
anchors.right: parent.right
anchors.rightMargin: 10
height: parent.height
spacing: 18
Row {
height: parent.height
spacing: 18
visible: !!sb.win.selStats.count
T { color: "#FFFFFF"; height: parent.height; visible: !!sb.win.selStats.average; text: "Average: " + (sb.win.selStats.average || "") }
T { color: "#FFFFFF"; height: parent.height; text: "Count: " + (sb.win.selStats.count || "") }
T { color: "#FFFFFF"; height: parent.height; visible: !!sb.win.selStats.sum; text: "Sum: " + (sb.win.selStats.sum || "") }
}
Row {
height: parent.height
spacing: 2
Repeater {
model: ["normal_view", "page_view", "break_view"]
delegate: Item {
required property int index
required property string modelData
width: 24
height: sb.height
Rectangle { anchors.fill: parent; anchors.margins: 2; color: index === 0 ? X.greenDark : "transparent" }
Ico { anchors.centerIn: parent; name: parent.modelData; ink: "#FFFFFF"; size: 16 }
}
}
}
// the zoom slider, wired to the master volume
Row {
height: parent.height
spacing: 6
T { color: "#FFFFFF"; height: parent.height; text: "−"; font.pixelSize: 16
MouseArea { anchors.fill: parent; anchors.margins: -4; onClicked: engine.volume = Math.max(0, engine.volume - 0.05) } }
Item {
id: track
width: 110
height: parent.height
Rectangle { anchors.verticalCenter: parent.verticalCenter; width: parent.width; height: 2; color: "#FFFFFF"; opacity: 0.8 }
Rectangle { x: parent.width / 2 - 1; anchors.verticalCenter: parent.verticalCenter; width: 2; height: 8; color: "#FFFFFF"; opacity: 0.8 }
Rectangle {
x: engine.volume * (track.width - width)
anchors.verticalCenter: parent.verticalCenter
width: 6
height: 13
color: vm.pressed ? "#E0E0E0" : "#FFFFFF"
}
MouseArea {
id: vm
anchors.fill: parent
anchors.margins: -3
hoverEnabled: true
function set(m) { engine.volume = Math.max(0, Math.min(1, (m.x - 3) / (track.width))) }
onPressed: (m) => set(m)
onPositionChanged: (m) => { if (pressed) set(m) }
onWheel: (w) => engine.volume = Math.max(0, Math.min(1, engine.volume + (w.angleDelta.y > 0 ? 0.03 : -0.03)))
}
Tip { target: vm; text: "Volume" }
}
T { color: "#FFFFFF"; height: parent.height; text: "+"; font.pixelSize: 15
MouseArea { anchors.fill: parent; anchors.margins: -4; onClicked: engine.volume = Math.min(1, engine.volume + 0.05) } }
T { color: "#FFFFFF"; height: parent.height; width: 40; horizontalAlignment: Text.AlignRight; text: Math.round(engine.volume * 100) + "%" }
}
}
}
@@ -0,0 +1,136 @@
import QtQuick
// The stem faders as an Excel column chart. Drag a column to set its level; double-click
// for 0 dB. Below each category: M(ute) and S(olo).
Rectangle {
id: ch
color: "#FFFFFF"
border.color: "#D9D9D9"
readonly property var stems: player.stems
readonly property int n: stems.length
readonly property real unity: 0.8
T {
id: title
anchors.horizontalCenter: parent.horizontalCenter
y: 6
text: "Stem Mix"
font.family: cellFont
font.pixelSize: 17
color: X.chartText
}
Item {
id: plot
x: 34
y: title.y + title.height + 18
width: parent.width - x - 8
height: parent.height - y - 52
// value axis: fader position, 0 dB marked
Repeater {
model: [0, 0.25, 0.5, 0.8, 1.0]
delegate: Item {
required property var modelData
y: plot.height * (1 - modelData)
width: plot.width
Rectangle { width: parent.width; height: 1; color: modelData === 0.8 ? "#A6A6A6" : X.chartLine }
T {
x: -32
width: 28
y: -7
height: 14
horizontalAlignment: Text.AlignRight
font.family: cellFont
font.pixelSize: 11
color: X.chartText
text: modelData === 0 ? "-∞" : modelData === 0.8 ? "0 dB" : modelData === 1 ? "+4" : (40 * Math.log(modelData / 0.8) / Math.LN10).toFixed(0)
}
}
}
Repeater {
model: ch.stems
delegate: Item {
id: cat
required property int index
required property var modelData
readonly property real cw: plot.width / Math.max(1, ch.n)
x: index * cw
width: cw
height: plot.height
Rectangle {
id: bar
anchors.horizontalCenter: parent.horizontalCenter
width: cat.cw * 0.62
height: Math.max(1, plot.height * cat.modelData.level)
y: plot.height - height
color: cat.modelData.color
opacity: cat.modelData.audible ? 1 : 0.22
border.color: dm.containsMouse || dm.pressed ? "#404040" : "transparent"
Behavior on height { enabled: !dm.pressed; NumberAnimation { duration: X.easeMs; easing.type: Easing.OutCubic } }
}
T { // data label
anchors.horizontalCenter: parent.horizontalCenter
y: bar.y - 16
font.family: cellFont
font.pixelSize: 11
color: X.chartText
text: cat.modelData.muted ? "Muted" : !cat.modelData.audible ? "—" : cat.modelData.level <= 0.001 ? "-∞" : (cat.modelData.db >= 0 ? "+" : "") + cat.modelData.db.toFixed(1)
}
MouseArea {
id: dm
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SizeVerCursor
function set(m) { player.setLevel(cat.index, Math.max(0, Math.min(1, 1 - m.y / plot.height))) }
onPressed: (m) => set(m)
onPositionChanged: (m) => { if (pressed) set(m) }
onDoubleClicked: player.setLevel(cat.index, ch.unity)
onWheel: (w) => player.setLevel(cat.index, Math.max(0, Math.min(1, cat.modelData.level + (w.angleDelta.y > 0 ? 0.02 : -0.02))))
}
Tip { target: dm; text: cat.modelData.label + ": drag to set, double-click for 0 dB" }
// category axis label and the M / S toggles
Column {
y: plot.height + 4
anchors.horizontalCenter: parent.horizontalCenter
spacing: 3
T {
anchors.horizontalCenter: parent.horizontalCenter
font.family: cellFont
font.pixelSize: 12
color: X.chartText
text: cat.modelData.label
width: Math.min(implicitWidth, cat.cw)
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 2
Toggle { label: "M"; on: cat.modelData.muted; onFill: X.bad; onText: X.badText; tip: "Mute " + cat.modelData.label; onClicked: player.toggleMute(cat.index) }
Toggle { label: "S"; on: cat.modelData.solo; onFill: X.neutral; onText: X.neutralText; tip: "Solo " + cat.modelData.label; onClicked: player.toggleSolo(cat.index) }
}
}
}
}
Rectangle { y: plot.height; width: plot.width; height: 1; color: "#BFBFBF" }
}
component Toggle: Rectangle {
id: tg
property string label
property bool on: false
property color onFill
property color onText
property string tip
signal clicked()
width: 18
height: 18
color: on ? onFill : tgm.containsMouse ? "#F2F2F2" : "#FFFFFF"
border.color: on ? onText : "#BFBFBF"
T { anchors.centerIn: parent; text: tg.label; font.pixelSize: 11; font.bold: tg.on; color: tg.on ? tg.onText : X.inkSoft }
MouseArea { id: tgm; anchors.fill: parent; hoverEnabled: true; onClicked: tg.clicked() }
Tip { target: tgm; text: tg.tip }
}
}
+10
View File
@@ -0,0 +1,10 @@
import QtQuick
Text {
font.family: uiFont
font.pixelSize: X.fsUi
color: X.ink
renderType: Text.NativeRendering
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
@@ -0,0 +1,302 @@
import QtQuick
import QtQuick.Controls.Basic
// The "Format Mix" task pane: what's playing, a sparkline to seek with, and the stems as a chart.
Rectangle {
id: pane
property var win: Window.window
color: "#FFFFFF"
Rectangle { width: 1; height: parent.height; color: X.ribbonLine }
Item {
id: head
x: 1
width: parent.width - 1
height: 40
T { x: 14; height: parent.height; text: "Format Mix"; font.pixelSize: 17; color: X.ink }
Ico { anchors.right: parent.right; anchors.rightMargin: 34; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 12 }
Item {
anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter }
width: 24
height: 24
Rectangle { anchors.fill: parent; color: cm.containsMouse ? X.hover : "transparent" }
Ico { anchors.centerIn: parent; name: "close"; size: 20 }
MouseArea { id: cm; anchors.fill: parent; hoverEnabled: true; onClicked: pane.win.toggleMixer() }
}
}
Flickable {
id: flick
x: 1
y: head.height
width: parent.width - 1
height: parent.height - y
contentHeight: col.height + 20
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: XScrollBar { policy: flick.contentHeight > flick.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff }
Column {
id: col
x: 14
width: flick.width - 34
spacing: 10
// ---- now playing ----
Row {
width: parent.width
spacing: 12
Rectangle {
width: 128
height: 128
color: "#F2F2F2"
border.color: "#D9D9D9"
Image {
id: art
anchors.fill: parent
anchors.margins: 1
source: player.track.art || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
sourceSize.width: 256
sourceSize.height: 256
}
Ico { visible: art.status !== Image.Ready; anchors.centerIn: parent; name: "excel"; size: 48; opacity: 0.25 }
}
Column {
width: parent.width - 140
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: player.track.title || "Nothing playing"
font.family: uiFont
font.pixelSize: 16
font.bold: true
color: "#222222"
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
}
T { width: parent.width; text: player.track.artist || "Double-click a song to play it"; font.pixelSize: 13; color: X.inkSoft }
Text {
width: parent.width
visible: !!player.track.album
text: (player.track.album || "") + (player.track.year ? " · " + player.track.year : "")
font.family: uiFont
font.pixelSize: 12
color: X.inkFaint
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
}
}
// ---- the sparkline seek bar ----
Item {
id: spark
width: parent.width
height: 46
readonly property real dur: engine.duration > 0 ? engine.duration : (player.track.durationMs || 0) / 1000
readonly property real frac: dur > 0 ? Math.min(1, pane.win.playPos / dur) : 0
readonly property var bars: player.wave.length ? player.wave : []
readonly property int n: bars.length ? bars.length : 80
property real hoverFrac: -1
Rectangle { anchors.fill: parent; color: "#FFFFFF"; border.color: "#D9D9D9" }
Canvas {
id: sparkCanvas
anchors.fill: parent
anchors.margins: 3
property real frac: spark.frac
property var bars: spark.bars
onFracChanged: requestPaint()
onBarsChanged: requestPaint()
onWidthChanged: requestPaint()
onPaint: {
var c = getContext("2d")
c.reset()
var n = spark.n, w = width / n, h = height
for (var i = 0; i < n; i++) {
var v = bars.length ? bars[i] : 0.12
var bh = Math.max(2, v * h)
c.fillStyle = (i + 0.5) / n <= frac ? "#217346" : "#A9D08E"
c.fillRect(i * w + w * 0.15, h - bh, Math.max(1, w * 0.7), bh)
}
}
}
Rectangle { // playhead
visible: spark.dur > 0
x: 3 + spark.frac * (parent.width - 6) - 1
y: 2
width: 2
height: parent.height - 4
color: "#C00000"
}
Rectangle {
visible: spark.hoverFrac >= 0
x: Math.max(0, Math.min(parent.width - width, spark.hoverFrac * parent.width - width / 2))
y: -22
width: hv.implicitWidth + 10
height: 18
color: "#FFFFFF"
border.color: "#BEBEBE"
T { id: hv; anchors.centerIn: parent; font.pixelSize: X.fsSmall; text: X.time(spark.hoverFrac * spark.dur) }
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
function f(m) { return Math.max(0, Math.min(1, (m.x - 3) / (width - 6))) }
onPositionChanged: (m) => { spark.hoverFrac = f(m); if (pressed) pane.win.seekTo(f(m) * spark.dur) }
onExited: spark.hoverFrac = -1
onPressed: (m) => pane.win.seekTo(f(m) * spark.dur)
}
}
Item {
width: parent.width
height: 14
y: -6
T { font.pixelSize: X.fsSmall; color: X.inkSoft; text: X.time(pane.win.playPos) }
T {
anchors.right: parent.right
font.pixelSize: X.fsSmall
color: X.inkSoft
text: spark.dur > 0 ? "-" + X.time(spark.dur - pane.win.playPos) : ""
}
}
// ---- transport ----
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
PaneButton { icon: "shuffle"; checked: player.shuffleOn; tip: "Shuffle"; onClicked: player.toggleShuffle() }
PaneButton { icon: "previous"; tip: "Previous"; onClicked: player.previous() }
PaneButton {
icon: player.status === "playing" ? "pause" : "play"
size: 40
tip: "Play / Pause (Space)"
onClicked: player.toggle()
}
PaneButton { icon: "next"; tip: "Next"; onClicked: player.next() }
PaneButton {
icon: player.repeatMode === "one" ? "repeat_one" : "repeat"
checked: player.repeatMode !== "off"
tip: player.repeatMode === "off" ? "Repeat" : player.repeatMode === "all" ? "Repeat all" : "Repeat one"
onClicked: player.cycleRepeat()
}
}
// output meter, as a pair of data bars
Column {
width: parent.width
spacing: 2
Repeater {
model: 2
delegate: Rectangle {
required property int index
width: parent.width
height: 5
color: "#EDEDED"
Rectangle {
readonly property real lv: index === 0 ? pane.win.levelL : pane.win.levelR
readonly property real db: 20 * Math.log(Math.max(lv, 1e-5)) / Math.LN10
width: parent.width * Math.max(0, Math.min(1, (db + 48) / 48))
height: parent.height
color: db > -1 ? "#C00000" : db > -9 ? "#FFC000" : X.dataBar
}
}
}
}
// ---- section: Stem Mix ----
Row {
spacing: 6
height: 24
Ico { anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
T { anchors.verticalCenter: parent.verticalCenter; text: "Stem Mix"; font.bold: true; font.pixelSize: 13 }
}
Rectangle { // "comment" for songs without stems
visible: player.sourceKind === "original"
width: parent.width
height: noteTxt.implicitHeight + 16
color: X.note
border.color: "#B2B2B2"
Text {
id: noteTxt
x: 8
y: 8
width: parent.width - 16
wrapMode: Text.WordWrap
font.family: uiFont
font.pixelSize: X.fsUi
textFormat: Text.StyledText
text: "<b>StemRoller:</b><br>This song hasn't been separated yet, so it plays the original file. The server's stemd will get to it."
}
}
StemChart {
visible: player.sourceKind === "stems"
width: parent.width
height: 250
}
Row {
visible: player.sourceKind === "stems"
spacing: 8
T { height: 22; text: "Mix style:" }
Rectangle {
width: 150
height: 22
border.color: pm.containsMouse ? X.green : "#ABABAB"
T { x: 6; height: parent.height; width: parent.width - 24; text: player.presetLabel }
Ico { anchors.right: parent.right; anchors.rightMargin: 5; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
MouseArea { id: pm; anchors.fill: parent; hoverEnabled: true; onClicked: presetMenu.popup() }
}
}
XCheck {
text: "Keep the mix for the next song"
checked: player.keepMix
onToggled: player.setKeepMix(!player.keepMix)
}
}
}
XMenu {
id: presetMenu
Instantiator {
model: player.presets
delegate: MenuItem {
required property int index
required property var modelData
text: modelData.label
enabled: modelData.usable
checkable: true
checked: modelData.active
onTriggered: player.applyPreset(index)
}
onObjectAdded: (i, o) => presetMenu.insertItem(i, o)
onObjectRemoved: (i, o) => presetMenu.removeItem(o)
}
}
component PaneButton: Item {
id: pb
property string icon
property bool checked: false
property int size: 32
property string tip: ""
signal clicked()
width: size
height: size
Rectangle {
anchors.fill: parent
radius: pb.size / 2
color: pb.checked ? X.checked : pbm.pressed ? X.pressed : pbm.containsMouse ? X.hover : "transparent"
}
Ico { anchors.centerIn: parent; name: pb.icon; size: Math.round(pb.size * 0.55) }
MouseArea { id: pbm; anchors.fill: parent; hoverEnabled: true; onClicked: pb.clicked() }
Tip { target: pbm; text: pb.tip }
}
}
+22
View File
@@ -0,0 +1,22 @@
import QtQuick
import QtQuick.Controls.Basic
// Office-style tooltip: white box, gray border, after a short hover.
ToolTip {
id: tt
property var target
visible: target && target.containsMouse && text !== "" && hoverTimer.done
delay: 0
timeout: 6000
padding: 6
Timer {
id: hoverTimer
property bool done: false
interval: 600
running: tt.target && tt.target.containsMouse
onRunningChanged: if (!running) done = false
onTriggered: done = true
}
contentItem: T { text: tt.text; font.pixelSize: X.fsSmall; elide: Text.ElideNone }
background: Rectangle { color: "#FFFFFF"; border.color: "#BEBEBE" }
}
@@ -0,0 +1,65 @@
import QtQuick
// Excel's green title bar: Quick Access Toolbar, workbook name, account, caption buttons.
Rectangle {
id: bar
property var win: Window.window
height: 32
color: X.green
DragHandler {
target: null
grabPermissions: PointerHandler.CanTakeOverFromAnything
onActiveChanged: if (active) bar.win.startSystemMove()
}
TapHandler { onDoubleTapped: bar.win.toggleMaximized() }
Row {
x: 6
height: parent.height
spacing: 0
Ico { name: "excel"; size: 20; anchors.verticalCenter: parent.verticalCenter }
Item { width: 10; height: 1 }
QatButton { icon: player.status === "playing" ? "pause" : "play"; tip: player.status === "playing" ? "Pause (Space)" : "Play (Space)"; onClicked: player.toggle() }
QatButton { icon: "undo"; tip: "Undo — previous song (Ctrl+Left)"; onClicked: player.previous() }
QatButton { icon: "redo"; tip: "Redo — next song (Ctrl+Right)"; onClicked: player.next() }
QatButton { icon: "dropdown"; tip: "Customize Quick Access Toolbar"; width: 16 }
}
T {
anchors.centerIn: parent
width: Math.min(implicitWidth, parent.width - 560)
color: "#FFFFFF"
font.pixelSize: X.fsUi
text: (player.track.title ? player.track.title + " - " + player.track.artist : "Book1") + ".xlsx - Excel"
}
Row {
anchors.right: parent.right
height: parent.height
T {
anchors.verticalCenter: parent.verticalCenter
color: "#FFFFFF"
text: library.signedIn ? (settings.get("plexUser") || "Plex") : "Sign in"
rightPadding: 14
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: bar.win.openBackstage("account") }
}
CaptionButton { icon: "pane"; tip: "Show or hide the mixer pane"; onClicked: bar.win.toggleMixer() }
CaptionButton { icon: "minimize"; onClicked: bar.win.showMinimized() }
CaptionButton { icon: bar.win.maximized ? "restore" : "maximize"; onClicked: bar.win.toggleMaximized() }
CaptionButton { icon: "close"; danger: true; onClicked: bar.win.close() }
}
component QatButton: Item {
id: q
property string icon
property string tip
signal clicked()
width: 26
height: parent.height
Rectangle { anchors.fill: parent; color: qm.pressed ? X.greenDark : qm.containsMouse ? X.greenHover : "transparent" }
Ico { anchors.centerIn: parent; name: q.icon; ink: "#FFFFFF"; size: q.icon === "dropdown" ? 10 : 16 }
MouseArea { id: qm; anchors.fill: parent; hoverEnabled: true; onClicked: q.clicked() }
Tip { target: qm; text: q.tip }
}
}
+71
View File
@@ -0,0 +1,71 @@
pragma Singleton
import QtQuick
// Excel 2016, "Colorful" Office theme.
QtObject {
readonly property color green: "#217346"
readonly property color greenDark: "#185C37"
readonly property color greenHover: "#2A8754"
readonly property color greenText: "#217346"
readonly property color ribbon: "#F3F3F3"
readonly property color ribbonLine: "#D5D5D5"
readonly property color hover: "#DCDCDC"
readonly property color pressed: "#C6C6C6"
readonly property color checked: "#C6C6C6"
readonly property color checkedBorder: "#A5A5A5"
readonly property color ink: "#444444"
readonly property color inkSoft: "#666666"
readonly property color inkFaint: "#9A9A9A"
readonly property color white: "#FFFFFF"
readonly property color grid: "#D4D4D4"
readonly property color header: "#F3F3F3"
readonly property color headerSel: "#D2D2D2"
readonly property color headerLine: "#BDBDBD"
readonly property color selFill: Qt.rgba(0, 0, 0, 0.09)
readonly property color cellText: "#000000"
readonly property color tableHead: "#70AD47"
readonly property color tableBand: "#E2EFDA"
readonly property color good: "#C6EFCE"
readonly property color goodText: "#006100"
readonly property color bad: "#FFC7CE"
readonly property color badText: "#9C0006"
readonly property color neutral: "#FFEB9C"
readonly property color neutralText: "#9C5700"
readonly property color dataBar: "#63BE7B"
readonly property color note: "#FFFFE1"
readonly property color menuBorder: "#C6C6C6"
readonly property color chartLine: "#D9D9D9"
readonly property color chartText: "#595959"
// Excel's cell styles, used for the mix presets.
readonly property var styles: [
{ name: "Normal", fill: "#FFFFFF", text: "#000000", border: "#D4D4D4", bold: false },
{ name: "Bad", fill: "#FFC7CE", text: "#9C0006", border: "#FFC7CE", bold: false },
{ name: "Good", fill: "#C6EFCE", text: "#006100", border: "#C6EFCE", bold: false },
{ name: "Neutral", fill: "#FFEB9C", text: "#9C5700", border: "#FFEB9C", bold: false },
{ name: "Calculation", fill: "#F2F2F2", text: "#FA7D00", border: "#7F7F7F", bold: true },
{ name: "Check Cell", fill: "#A5A5A5", text: "#FFFFFF", border: "#3F3F3F", bold: true },
{ name: "Note", fill: "#FFFFCC", text: "#000000", border: "#B2B2B2", bold: false }
]
readonly property int fsSmall: 11
readonly property int fsUi: 12
readonly property int fsCell: 15 // Calibri 11 pt
readonly property int easeMs: 120
function colName(i) {
var s = ""
i += 1
while (i > 0) {
var m = (i - 1) % 26
s = String.fromCharCode(65 + m) + s
i = Math.floor((i - 1) / 26)
}
return s
}
function time(sec) {
if (!(sec > 0)) sec = 0
var s = Math.floor(sec), m = Math.floor(s / 60)
return m + ":" + ("0" + (s % 60)).slice(-2)
}
}
@@ -0,0 +1,24 @@
import QtQuick
// Office checkbox: square box, green tick.
Item {
id: c
property bool checked: false
property string text: ""
signal toggled()
implicitWidth: 18 + lbl.implicitWidth + 4
implicitHeight: 20
opacity: enabled ? 1 : 0.4
Rectangle {
id: box
width: 13
height: 13
y: (parent.height - height) / 2
x: 2
color: "#FFFFFF"
border.color: ma.containsMouse ? X.green : "#7A7A7A"
Ico { visible: c.checked; anchors.centerIn: parent; name: "check"; size: 12 }
}
T { id: lbl; x: 20; height: parent.height; text: c.text }
MouseArea { id: ma; anchors.fill: parent; hoverEnabled: true; onClicked: c.toggled() }
}
@@ -0,0 +1,17 @@
import QtQuick
import QtQuick.Controls.Basic
TextField {
font.family: uiFont
font.pixelSize: X.fsUi
color: X.ink
selectByMouse: true
selectionColor: "#B7D7C4"
selectedTextColor: "#000000"
leftPadding: 5
rightPadding: 5
topPadding: 2
bottomPadding: 2
implicitHeight: 22
background: Rectangle { color: "#FFFFFF"; border.color: parent.activeFocus ? X.green : "#ABABAB" }
}
@@ -0,0 +1,48 @@
import QtQuick
import QtQuick.Controls.Basic
// Office context menu: white, gray border, icon gutter on the left.
Menu {
id: m
padding: 2
delegate: MenuItem {
id: mi
implicitWidth: Math.max(200, lab.implicitWidth + 56)
implicitHeight: mi.text === "" ? 7 : 26
enabled: mi.action ? mi.action.enabled : true
contentItem: Item {
T {
id: lab
x: 28
anchors.verticalCenter: parent.verticalCenter
text: mi.text
color: mi.enabled ? X.ink : X.inkFaint
}
Ico {
visible: mi.action && mi.action.icon.name !== ""
name: mi.action ? mi.action.icon.name : ""
size: 16
x: 4
anchors.verticalCenter: parent.verticalCenter
}
Ico {
visible: mi.checkable && mi.checked
name: "check"
size: 14
x: 5
anchors.verticalCenter: parent.verticalCenter
}
}
background: Rectangle {
color: mi.highlighted && mi.enabled ? "#E0E0E0" : "transparent"
}
arrow: null
indicator: null
}
background: Rectangle {
implicitWidth: 200
color: "#FFFFFF"
border.color: X.menuBorder
Rectangle { x: 25; y: 1; width: 1; height: parent.height - 2; color: "#E7E7E7" }
}
}
@@ -0,0 +1,18 @@
import QtQuick
import QtQuick.Controls.Basic
// Office scrollbar: flat gray thumb on a light track.
ScrollBar {
id: sb
policy: ScrollBar.AlwaysOn
minimumSize: 0.04
padding: 2
implicitWidth: 15
implicitHeight: 15
contentItem: Rectangle {
implicitWidth: 9
implicitHeight: 9
color: sb.pressed ? "#7A7A7A" : sb.hovered ? "#A6A6A6" : "#C2C2C2"
}
background: Rectangle { color: "#F0F0F0"; border.color: "#E0E0E0" }
}
+25
View File
@@ -0,0 +1,25 @@
module StemPlayer
singleton X 1.0 X.qml
Backstage 1.0 Backstage.qml
CaptionButton 1.0 CaptionButton.qml
FormulaBar 1.0 FormulaBar.qml
Ico 1.0 Ico.qml
Main 1.0 Main.qml
MessageBar 1.0 MessageBar.qml
OfficeButton 1.0 OfficeButton.qml
RButton 1.0 RButton.qml
ResizeEdge 1.0 ResizeEdge.qml
RGroup 1.0 RGroup.qml
Ribbon 1.0 Ribbon.qml
Sheet 1.0 Sheet.qml
SheetTabs 1.0 SheetTabs.qml
StatusBar 1.0 StatusBar.qml
StemChart 1.0 StemChart.qml
TaskPane 1.0 TaskPane.qml
Tip 1.0 Tip.qml
TitleBar 1.0 TitleBar.qml
T 1.0 T.qml
XCheck 1.0 XCheck.qml
XField 1.0 XField.qml
XMenu 1.0 XMenu.qml
XScrollBar 1.0 XScrollBar.qml