- 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>
303 lines
12 KiB
QML
303 lines
12 KiB
QML
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 }
|
|
}
|
|
}
|