- 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>
206 lines
9.2 KiB
QML
206 lines
9.2 KiB
QML
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 }
|
|
}
|