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,84 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Glossy pill, 40px. variant: "filled" | "tonal" | "text". Cyan glow on hover.
|
||||
Button {
|
||||
id: b
|
||||
property string iconName: ""
|
||||
property string variant: "filled"
|
||||
property color tone: Theme.primary
|
||||
property int pillHeight: 40
|
||||
readonly property bool filled: variant === "filled"
|
||||
readonly property bool tonal: variant === "tonal"
|
||||
readonly property color fg: filled ? "#FFFFFF" : (variant === "text" ? tone : Theme.textHi)
|
||||
|
||||
implicitHeight: pillHeight
|
||||
implicitWidth: Math.max(pillHeight, row.implicitWidth + leftPadding + rightPadding)
|
||||
leftPadding: text.length ? (iconName.length ? 14 : 18) : 0
|
||||
rightPadding: text.length ? 18 : 0
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fs14
|
||||
font.weight: Font.DemiBold
|
||||
hoverEnabled: true
|
||||
focusPolicy: Qt.TabFocus
|
||||
opacity: enabled ? 1 : 0.4
|
||||
|
||||
contentItem: Item {
|
||||
implicitWidth: row.implicitWidth
|
||||
implicitHeight: row.implicitHeight
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
Icon {
|
||||
visible: b.iconName.length > 0
|
||||
name: b.iconName
|
||||
color: b.fg
|
||||
size: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Txt {
|
||||
visible: b.text.length > 0
|
||||
text: b.text
|
||||
font: b.font
|
||||
color: b.fg
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Item {
|
||||
implicitHeight: b.pillHeight
|
||||
Glow {
|
||||
radius: Math.round(b.pillHeight / 2)
|
||||
opacity: (b.hovered || b.visualFocus) && b.enabled ? (b.filled ? 0.55 : 0.35) : 0
|
||||
}
|
||||
Rectangle {
|
||||
id: base
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: b.filled ? b.tone : (b.tonal ? Theme.alpha(b.tone, 0.16) : "transparent")
|
||||
border.width: b.variant === "text" ? 0 : 1
|
||||
border.color: b.filled ? Qt.lighter(b.tone, 1.3) : Theme.alpha(b.tone, 0.35)
|
||||
|
||||
// Aero gloss on the top half
|
||||
Rectangle {
|
||||
visible: b.variant !== "text"
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
|
||||
height: (parent.height - 2) / 2
|
||||
topLeftRadius: height
|
||||
topRightRadius: height
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, b.filled ? 0.32 : 0.10) }
|
||||
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, b.filled ? 0.06 : 0.0) }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: b.down ? Qt.rgba(0, 0, 0, 0.18) : (b.hovered ? Qt.rgba(1, 1, 1, 0.07) : "transparent")
|
||||
Behavior on color { ColorAnimation { duration: Theme.fast } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Tonal glossy dropdown; menu on surface-2 at elevation 3.
|
||||
ComboBox {
|
||||
id: control
|
||||
property string iconName: ""
|
||||
property string subRole: "sub"
|
||||
property string caption: ""
|
||||
property int popupWidth: 280
|
||||
textRole: "label"
|
||||
valueRole: "id"
|
||||
implicitHeight: 36
|
||||
hoverEnabled: true
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fs14
|
||||
|
||||
background: Item {
|
||||
Glow { radius: 18; sigma: 4; opacity: (control.hovered || control.visualFocus) ? 0.3 : 0 }
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: control.down || control.popup.visible ? Qt.rgba(1, 1, 1, 0.11) : (control.hovered ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(1, 1, 1, 0.05))
|
||||
border.color: control.popup.visible ? Theme.alpha(Theme.accentGlow, 0.6) : Qt.rgba(1, 1, 1, 0.10)
|
||||
Behavior on color { ColorAnimation { duration: Theme.fast } }
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
|
||||
height: (parent.height - 2) / 2
|
||||
topLeftRadius: height
|
||||
topRightRadius: height
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.08) }
|
||||
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Row {
|
||||
leftPadding: control.iconName.length ? 12 : 16
|
||||
spacing: 8
|
||||
Icon {
|
||||
visible: control.iconName.length > 0
|
||||
name: control.iconName
|
||||
size: 18
|
||||
color: Theme.accentGlow
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Txt {
|
||||
visible: control.caption.length > 0
|
||||
text: control.caption
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.6
|
||||
color: Theme.textLo
|
||||
}
|
||||
Txt {
|
||||
text: control.displayText
|
||||
font: control.font
|
||||
width: control.width - 64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indicator: Icon {
|
||||
x: control.width - width - 10
|
||||
anchors.verticalCenter: control.verticalCenter
|
||||
name: "expand"
|
||||
size: 18
|
||||
color: Theme.textLo
|
||||
rotation: control.popup.visible ? 180 : 0
|
||||
Behavior on rotation { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
|
||||
}
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: d
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: ListView.view ? ListView.view.width : control.popupWidth
|
||||
height: 52
|
||||
highlighted: control.highlightedIndex === index
|
||||
hoverEnabled: true
|
||||
contentItem: Column {
|
||||
spacing: 2
|
||||
leftPadding: 4
|
||||
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
|
||||
Txt {
|
||||
text: d.modelData[control.textRole]
|
||||
font.weight: control.currentIndex === d.index ? Font.DemiBold : Font.Normal
|
||||
color: control.currentIndex === d.index ? Theme.accentGlow : Theme.textHi
|
||||
}
|
||||
Txt {
|
||||
text: d.modelData[control.subRole] || ""
|
||||
visible: text.length > 0
|
||||
font.pixelSize: Theme.fs12
|
||||
color: Theme.textLo
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: 6
|
||||
color: d.highlighted ? Theme.alpha(Theme.primary, 0.18) : (d.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent")
|
||||
}
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: control.height + 6
|
||||
width: Math.max(control.width, control.popupWidth)
|
||||
padding: 6
|
||||
transformOrigin: Item.Top
|
||||
implicitHeight: Math.min(contentItem.implicitHeight + 12, 420)
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: control.popup.visible ? control.delegateModel : null
|
||||
currentIndex: control.highlightedIndex
|
||||
spacing: 2
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
}
|
||||
background: Rectangle {
|
||||
color: Theme.surface2
|
||||
radius: 8
|
||||
border.color: Theme.outline
|
||||
Elevation { level: 3; radius: 8 }
|
||||
}
|
||||
enter: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.fast }
|
||||
NumberAnimation { property: "scale"; from: 0.96; to: 1; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
|
||||
}
|
||||
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
Slider {
|
||||
id: s
|
||||
property color tone: Theme.primary
|
||||
implicitHeight: 24
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
x: s.leftPadding
|
||||
y: s.topPadding + s.availableHeight / 2 - height / 2
|
||||
width: s.availableWidth
|
||||
height: 4
|
||||
radius: 2
|
||||
color: Qt.rgba(1, 1, 1, 0.10)
|
||||
Rectangle {
|
||||
width: s.visualPosition * parent.width
|
||||
height: parent.height
|
||||
radius: 2
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.alpha(s.tone, 0.7) }
|
||||
GradientStop { position: 1; color: s.tone }
|
||||
}
|
||||
}
|
||||
}
|
||||
handle: Item {
|
||||
x: s.leftPadding + s.visualPosition * (s.availableWidth - width)
|
||||
y: s.topPadding + s.availableHeight / 2 - height / 2
|
||||
width: 14
|
||||
height: 14
|
||||
Glow { radius: 7; sigma: 4; color: s.tone; opacity: s.hovered || s.pressed ? 0.6 : 0 }
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 7
|
||||
color: s.pressed ? Qt.lighter(s.tone, 1.2) : s.tone
|
||||
border.color: Qt.lighter(s.tone, 1.4)
|
||||
Rectangle { // gloss
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
|
||||
height: 5
|
||||
radius: 3
|
||||
color: Qt.rgba(1, 1, 1, 0.45)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
Switch {
|
||||
id: sw
|
||||
hoverEnabled: true
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fs14
|
||||
spacing: 12
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 40
|
||||
implicitHeight: 22
|
||||
x: sw.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: 11
|
||||
color: sw.checked ? Theme.primary : Qt.rgba(1, 1, 1, 0.10)
|
||||
border.color: sw.checked ? Qt.lighter(Theme.primary, 1.3) : Qt.rgba(1, 1, 1, 0.16)
|
||||
Behavior on color { ColorAnimation { duration: Theme.med } }
|
||||
Glow { radius: 11; sigma: 4; opacity: sw.hovered ? 0.35 : 0 }
|
||||
Rectangle {
|
||||
x: sw.checked ? parent.width - width - 3 : 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 16
|
||||
height: 16
|
||||
radius: 8
|
||||
color: "#FFFFFF"
|
||||
Behavior on x { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
|
||||
height: 6
|
||||
radius: 3
|
||||
color: Qt.rgba(1, 1, 1, 0.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
contentItem: Txt {
|
||||
text: sw.text
|
||||
font: sw.font
|
||||
leftPadding: sw.indicator.width + sw.spacing
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideNone
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Win7-flavoured caption button; close turns glossy red on hover.
|
||||
AbstractButton {
|
||||
id: b
|
||||
property string iconName: "minimize"
|
||||
property bool danger: false
|
||||
implicitWidth: 46
|
||||
implicitHeight: 30
|
||||
hoverEnabled: true
|
||||
focusPolicy: Qt.NoFocus
|
||||
background: Rectangle {
|
||||
radius: 6
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: b.danger && b.hovered ? "#F0645A" : (b.hovered ? Qt.rgba(1, 1, 1, 0.14) : "transparent") }
|
||||
GradientStop { position: 0.5; color: b.danger && b.hovered ? "#D8392B" : (b.hovered ? Qt.rgba(1, 1, 1, 0.07) : "transparent") }
|
||||
GradientStop { position: 1; color: b.danger && b.hovered ? "#C42B1C" : (b.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent") }
|
||||
}
|
||||
border.color: b.hovered ? (b.danger ? "#FF8A80" : Qt.rgba(1, 1, 1, 0.14)) : "transparent"
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: b.down ? Qt.rgba(0, 0, 0, 0.2) : "transparent"
|
||||
}
|
||||
Glow { radius: 6; sigma: 5; color: "#FF5C4C"; opacity: b.danger && b.hovered ? 0.5 : 0 }
|
||||
}
|
||||
contentItem: Item {
|
||||
Icon { anchors.centerIn: parent; name: b.iconName; size: 16; color: b.hovered ? "#FFFFFF" : Theme.textHi }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import QtQuick
|
||||
|
||||
// Material card (elevation 2, radius 8) with Aero top gloss: white 6% -> 0 over the top 40%.
|
||||
Rectangle {
|
||||
id: card
|
||||
property int elevation: 2
|
||||
property bool glossy: true
|
||||
radius: 8
|
||||
color: Theme.surface1
|
||||
border.color: Theme.hairline
|
||||
border.width: 1
|
||||
|
||||
Elevation { level: card.elevation; radius: card.radius }
|
||||
|
||||
Rectangle {
|
||||
visible: card.glossy
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
|
||||
height: (parent.height - 2) * 0.4
|
||||
topLeftRadius: card.radius - 1
|
||||
topRightRadius: card.radius - 1
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.06) }
|
||||
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
|
||||
// Static dashed rounded border (geometry built once; only opacity animates).
|
||||
Shape {
|
||||
id: s
|
||||
property color color: Theme.accentGlow
|
||||
property real radius: 12
|
||||
property real lineWidth: 2
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
ShapePath {
|
||||
strokeColor: s.color
|
||||
strokeWidth: s.lineWidth
|
||||
strokeStyle: ShapePath.DashLine
|
||||
dashPattern: [5, 4]
|
||||
fillColor: "transparent"
|
||||
PathRectangle {
|
||||
x: s.lineWidth / 2
|
||||
y: s.lineWidth / 2
|
||||
width: s.width - s.lineWidth
|
||||
height: s.height - s.lineWidth
|
||||
radius: s.radius
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import QtQuick
|
||||
|
||||
// Empty state: card filling the main pane; dashed border glows on drag-over.
|
||||
Item {
|
||||
id: dz
|
||||
property bool dragging: false
|
||||
signal browse()
|
||||
|
||||
Card {
|
||||
id: card
|
||||
anchors.fill: parent
|
||||
anchors.margins: 24
|
||||
radius: 16
|
||||
color: dz.dragging ? Theme.alpha(Theme.primary, 0.08) : Theme.alpha(Theme.surface1, 0.72)
|
||||
Behavior on color { ColorAnimation { duration: Theme.med } }
|
||||
|
||||
Glow { radius: 16; sigma: 8; opacity: dz.dragging ? 0.55 : 0 }
|
||||
DashedFrame {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
radius: 12
|
||||
color: dz.dragging ? Theme.accentGlow : Theme.alpha(Theme.textLo, 0.35)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onClicked: dz.browse()
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 16
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: 88
|
||||
height: 88
|
||||
radius: 44
|
||||
color: Theme.alpha(Theme.primary, dz.dragging ? 0.28 : 0.14)
|
||||
border.color: Theme.alpha(Theme.accentGlow, 0.5)
|
||||
scale: dz.dragging ? 1.08 : 1
|
||||
Behavior on scale { NumberAnimation { duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
|
||||
Behavior on color { ColorAnimation { duration: Theme.med } }
|
||||
Glow { radius: 44; sigma: 8; opacity: 0.35 }
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 3 }
|
||||
height: 40
|
||||
topLeftRadius: 41
|
||||
topRightRadius: 41
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.18) }
|
||||
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
|
||||
}
|
||||
}
|
||||
Icon { anchors.centerIn: parent; name: "upload"; size: 40; color: Theme.accentGlow }
|
||||
}
|
||||
Txt {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: dz.dragging ? "Release to separate" : "Drop audio files here"
|
||||
font.pixelSize: Theme.fs28
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Txt {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: "or click to browse · MP3, WAV, FLAC, M4A, OGG, OPUS and more"
|
||||
color: Theme.textLo
|
||||
font.pixelSize: Theme.fs14
|
||||
}
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 8
|
||||
topPadding: 8
|
||||
Repeater {
|
||||
model: ["vocals", "drums", "bass", "other"]
|
||||
Rectangle {
|
||||
required property string modelData
|
||||
height: 26
|
||||
width: chipText.implicitWidth + 28
|
||||
radius: 13
|
||||
color: Theme.alpha(Theme.stemColor(modelData), 0.14)
|
||||
border.color: Theme.alpha(Theme.stemColor(modelData), 0.4)
|
||||
Rectangle { x: 10; anchors.verticalCenter: parent.verticalCenter; width: 6; height: 6; radius: 3; color: Theme.stemColor(parent.modelData) }
|
||||
Txt { id: chipText; x: 21; anchors.verticalCenter: parent.verticalCenter; text: parent.modelData; font.pixelSize: Theme.fs12; color: Theme.textHi }
|
||||
}
|
||||
}
|
||||
}
|
||||
Txt {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
topPadding: 4
|
||||
text: "Separates with " + (app.models.find(m => m.id === appSettings.modelId) || {label: ""}).label
|
||||
+ " on " + ((app.devices.find(d => d.id === appSettings.deviceId) || {label: "Auto"}).label)
|
||||
color: Theme.alpha(Theme.textLo, 0.8)
|
||||
font.pixelSize: Theme.fs12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import QtQuick
|
||||
|
||||
// Static 9-patch shadow: no runtime blur, one textured quad.
|
||||
BorderImage {
|
||||
id: shadow
|
||||
property int level: 2
|
||||
property int radius: 8
|
||||
readonly property real sigma: Theme.shadowSigma[level]
|
||||
readonly property int m: Math.ceil(sigma * 3) + 1
|
||||
readonly property int dy: Theme.shadowDy[level]
|
||||
visible: level > 0
|
||||
z: -1
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: -m
|
||||
anchors.rightMargin: -m
|
||||
anchors.topMargin: -m + dy
|
||||
anchors.bottomMargin: -m - dy
|
||||
border { left: m + radius; right: m + radius; top: m + radius; bottom: m + radius }
|
||||
source: "image://fx/shadow/" + sigma + "/" + Theme.shadowAlpha[level] + "/" + radius
|
||||
cache: true
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import QtQuick
|
||||
|
||||
// Dev overlay: p50/p99 frame interval (and render-thread work with --render-hooks).
|
||||
// While visible, the heartbeat bar changes every frame so the window renders at
|
||||
// full refresh and the numbers describe real 240Hz cadence, not idle frames.
|
||||
Rectangle {
|
||||
id: o
|
||||
property var s: ({})
|
||||
property real acc: 0
|
||||
property var batch: []
|
||||
property real beat: 0
|
||||
width: 236
|
||||
height: col.implicitHeight + 24
|
||||
radius: 8
|
||||
color: Theme.alpha(Theme.surface1, 0.92)
|
||||
border.color: Theme.outline
|
||||
readonly property color good: Theme.success
|
||||
readonly property color bad: Theme.error
|
||||
|
||||
onVisibleChanged: frameStats.setHooks(visible)
|
||||
FrameAnimation {
|
||||
running: o.visible
|
||||
onTriggered: {
|
||||
o.batch.push(frameTime * 1000)
|
||||
if (frameTime * 1000 > 1.5 * (o.s.budget_ms || 4.17)) frameStats.miss(frameTime * 1000)
|
||||
o.beat = (o.beat + frameTime) % 1
|
||||
o.acc += frameTime
|
||||
if (o.acc > 0.25) {
|
||||
o.acc = 0
|
||||
frameStats.push(o.batch)
|
||||
o.batch = []
|
||||
o.s = frameStats.summary()
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { // heartbeat: moves every frame
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 3
|
||||
x: 8 + (parent.width - 16 - width) * o.beat
|
||||
width: 24
|
||||
height: 2
|
||||
radius: 1
|
||||
color: Theme.accentGlow
|
||||
opacity: 0.6
|
||||
}
|
||||
Column {
|
||||
id: col
|
||||
anchors { left: parent.left; top: parent.top; margins: 10 }
|
||||
spacing: 3
|
||||
Txt {
|
||||
text: (o.s.refresh_hz || 0).toFixed(0) + " Hz panel · " + (o.s.fps || 0).toFixed(1) + " fps"
|
||||
font.pixelSize: Theme.fs12
|
||||
font.weight: Font.DemiBold
|
||||
font.features: { "tnum": 1 }
|
||||
color: (o.s.fps || 0) >= (o.s.refresh_hz || 240) * 0.97 ? o.good : Theme.warning
|
||||
}
|
||||
Txt {
|
||||
text: o.s.work_p50 === null || o.s.work_p50 === undefined ? "frame time measuring…"
|
||||
: "frame time p50 " + o.s.work_p50.toFixed(2) + " p99 " + o.s.work_p99.toFixed(2) + " ms"
|
||||
font.pixelSize: 11; font.features: { "tnum": 1 }
|
||||
color: (o.s.work_p99 || 0) < (o.s.budget_ms || 4.17) ? Theme.textHi : o.bad
|
||||
}
|
||||
Txt {
|
||||
text: "interval p50 " + (o.s.interval_p50 || 0).toFixed(2) + " p99 " + (o.s.interval_p99 || 0).toFixed(2) + " ms"
|
||||
font.pixelSize: 11; font.features: { "tnum": 1 }
|
||||
color: Theme.textLo
|
||||
}
|
||||
Txt {
|
||||
text: "budget " + (o.s.budget_ms || 0).toFixed(2) + " ms · missed " + (o.s.missed || 0) + "/" + (o.s.frames || 0)
|
||||
font.pixelSize: 11; font.features: { "tnum": 1 }
|
||||
color: (o.s.missed || 0) === 0 ? Theme.textLo : o.bad
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import QtQuick
|
||||
|
||||
// Aero glass for chrome. Shows the window's cached, pre-blurred backdrop
|
||||
// (refreshed only on resize), so nothing is blurred per frame.
|
||||
Item {
|
||||
id: root
|
||||
property real offsetX: 0
|
||||
property real offsetY: 0
|
||||
property int blur: 16
|
||||
property real topLeftRadius: 0
|
||||
property real topRightRadius: 0
|
||||
property real bottomLeftRadius: 0
|
||||
property real bottomRightRadius: 0
|
||||
property bool gloss: true
|
||||
property bool innerBorder: true
|
||||
clip: true
|
||||
|
||||
readonly property var win: Window.window
|
||||
|
||||
Image {
|
||||
x: -root.offsetX
|
||||
y: -root.offsetY
|
||||
width: root.win ? root.win.width : 0
|
||||
height: root.win ? root.win.height : 0
|
||||
source: root.win ? "image://fx/glass/" + root.win.fxW + "x" + root.win.fxH + "/" + root.win.radius + "/" + root.blur : ""
|
||||
asynchronous: true
|
||||
retainWhileLoading: true
|
||||
cache: true
|
||||
smooth: true
|
||||
}
|
||||
|
||||
// Aero gloss: top highlight band
|
||||
Rectangle {
|
||||
visible: root.gloss
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
height: Math.min(parent.height * 0.5, 30)
|
||||
topLeftRadius: root.topLeftRadius
|
||||
topRightRadius: root.topRightRadius
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.07) }
|
||||
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
|
||||
}
|
||||
}
|
||||
|
||||
// 1px inner white border at 8%
|
||||
Rectangle {
|
||||
visible: root.innerBorder
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
border.color: Theme.outline
|
||||
border.width: 1
|
||||
topLeftRadius: root.topLeftRadius
|
||||
topRightRadius: root.topRightRadius
|
||||
bottomLeftRadius: root.bottomLeftRadius
|
||||
bottomRightRadius: root.bottomRightRadius
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import QtQuick
|
||||
|
||||
// Static 9-patch Aero hover glow; animate `opacity` only.
|
||||
BorderImage {
|
||||
property color color: Theme.accentGlow
|
||||
property int radius: 20
|
||||
property int sigma: 6
|
||||
readonly property int m: Math.ceil(sigma * 3) + 1
|
||||
anchors.fill: parent
|
||||
anchors.margins: -m
|
||||
border { left: m + radius; right: m + radius; top: m + radius; bottom: m + radius }
|
||||
source: "image://fx/glow/" + Theme.hex(color) + "/" + sigma + "/" + radius
|
||||
opacity: 0
|
||||
visible: opacity > 0.001
|
||||
z: -1
|
||||
cache: true
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.fast; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import QtQuick
|
||||
|
||||
// Material icon rendered once per (name, color, size) by the icon provider.
|
||||
Image {
|
||||
property string name: "music"
|
||||
property color color: Theme.textHi
|
||||
property int size: 20
|
||||
width: size
|
||||
height: size
|
||||
sourceSize: Qt.size(size * Screen.devicePixelRatio, size * Screen.devicePixelRatio)
|
||||
source: Theme.icon(name, color)
|
||||
smooth: true
|
||||
asynchronous: false
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Round icon button; checkable variant used for solo / mute.
|
||||
AbstractButton {
|
||||
id: b
|
||||
property string iconName: "music"
|
||||
property color iconColor: Theme.textLo
|
||||
property color activeColor: Theme.primary
|
||||
property color hoverColor: Theme.alpha(Theme.textHi, 0.08)
|
||||
property int size: 32
|
||||
property int iconSize: 18
|
||||
property string tip: ""
|
||||
property bool glow: true
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
hoverEnabled: true
|
||||
focusPolicy: Qt.TabFocus
|
||||
opacity: enabled ? 1 : 0.35
|
||||
|
||||
background: Item {
|
||||
Glow {
|
||||
radius: Math.round(b.size / 2)
|
||||
sigma: 4
|
||||
color: b.checked ? b.activeColor : Theme.accentGlow
|
||||
opacity: b.glow && (b.hovered || b.visualFocus) && b.enabled ? 0.35 : 0
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: b.checked ? Theme.alpha(b.activeColor, b.down ? 0.34 : 0.22)
|
||||
: (b.down ? Theme.alpha(Theme.textHi, 0.14) : (b.hovered ? b.hoverColor : "transparent"))
|
||||
border.width: b.checked ? 1 : 0
|
||||
border.color: Theme.alpha(b.activeColor, 0.6)
|
||||
Behavior on color { ColorAnimation { duration: Theme.fast } }
|
||||
}
|
||||
}
|
||||
contentItem: Item {
|
||||
Icon {
|
||||
anchors.centerIn: parent
|
||||
name: b.iconName
|
||||
size: b.iconSize
|
||||
color: b.checked ? b.activeColor : (b.hovered ? Theme.textHi : b.iconColor)
|
||||
}
|
||||
}
|
||||
Tip { visible: b.hovered && b.tip.length > 0; text: b.tip }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import QtQuick
|
||||
|
||||
// Stereo peak meter fed per frame from the audio ring buffer (via Main).
|
||||
Item {
|
||||
id: m
|
||||
property real levelLeft: 0
|
||||
property real levelRight: 0
|
||||
property real holdL: 0
|
||||
property real holdR: 0
|
||||
implicitWidth: 150
|
||||
implicitHeight: 22
|
||||
|
||||
function frac(v) {
|
||||
if (v <= 0.000001) return 0
|
||||
var db = 20 * Math.log(v) / Math.LN10
|
||||
return Math.max(0, Math.min(1, (db + 54) / 54))
|
||||
}
|
||||
onLevelLeftChanged: holdL = Math.max(holdL, frac(levelLeft))
|
||||
onLevelRightChanged: holdR = Math.max(holdR, frac(levelRight))
|
||||
FrameAnimation {
|
||||
running: m.holdL > 0.001 || m.holdR > 0.001
|
||||
onTriggered: {
|
||||
var d = frameTime * 0.35
|
||||
m.holdL = Math.max(m.frac(m.levelLeft), m.holdL - d)
|
||||
m.holdR = Math.max(m.frac(m.levelRight), m.holdR - d)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 4
|
||||
Repeater {
|
||||
model: 2
|
||||
Item {
|
||||
required property int index
|
||||
readonly property real level: m.frac(index === 0 ? m.levelLeft : m.levelRight)
|
||||
readonly property real hold: index === 0 ? m.holdL : m.holdR
|
||||
width: m.width
|
||||
height: (m.height - 4) / 2
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 2
|
||||
color: Qt.rgba(1, 1, 1, 0.07)
|
||||
}
|
||||
Item {
|
||||
width: parent.width * parent.level
|
||||
height: parent.height
|
||||
clip: true
|
||||
Rectangle {
|
||||
width: m.width
|
||||
height: parent.height
|
||||
radius: 2
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.success }
|
||||
GradientStop { position: 0.72; color: Theme.accentGlow }
|
||||
GradientStop { position: 0.88; color: Theme.warning }
|
||||
GradientStop { position: 1.0; color: Theme.error }
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
visible: parent.hold > 0.01
|
||||
x: Math.min(parent.width - 2, parent.width * parent.hold)
|
||||
width: 2
|
||||
height: parent.height
|
||||
color: parent.hold > 0.95 ? Theme.error : Theme.textHi
|
||||
opacity: 0.85
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
import QtQuick.Dialogs
|
||||
import StemRoller
|
||||
|
||||
Window {
|
||||
id: win
|
||||
width: 1440
|
||||
height: 900
|
||||
minimumWidth: 960
|
||||
minimumHeight: 600
|
||||
visible: true
|
||||
title: "StemRoller"
|
||||
color: "transparent"
|
||||
flags: Qt.Window | Qt.FramelessWindowHint
|
||||
|
||||
readonly property bool maximized: visibility === Window.Maximized || visibility === Window.FullScreen
|
||||
readonly property int radius: maximized ? 0 : 10
|
||||
function toggleMaximized() { maximized ? showNormal() : showMaximized() }
|
||||
|
||||
// Glass snapshot size: refreshed only after a resize settles.
|
||||
property int fxW: width
|
||||
property int fxH: height
|
||||
Timer { id: fxTimer; interval: 150; onTriggered: { win.fxW = win.width; win.fxH = win.height } }
|
||||
onWidthChanged: fxTimer.restart()
|
||||
onHeightChanged: fxTimer.restart()
|
||||
|
||||
// Per-frame playback state, read from the audio ring buffer.
|
||||
property real playPos: 0
|
||||
property real levelL: 0
|
||||
property real levelR: 0
|
||||
FrameAnimation {
|
||||
running: audio.playing || win.levelL > 0.0005 || win.levelR > 0.0005
|
||||
onTriggered: {
|
||||
var s = audio.snapshot()
|
||||
if (s[3]) win.playPos = s[0]
|
||||
var k = Math.exp(-frameTime * 10)
|
||||
win.levelL = Math.max(s[1], win.levelL * k)
|
||||
win.levelR = Math.max(s[2], win.levelR * k)
|
||||
if (win.levelL < 0.0005) win.levelL = 0
|
||||
if (win.levelR < 0.0005) win.levelR = 0
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: audio
|
||||
function onLoadedChanged() { win.playPos = 0 }
|
||||
}
|
||||
Connections {
|
||||
target: app
|
||||
function onToast(msg, kind, folder) { toast.show(msg, kind, folder) }
|
||||
}
|
||||
Component.onCompleted: Theme.fontFamily = uiFont
|
||||
|
||||
FileDialog {
|
||||
id: fileDialog
|
||||
title: "Add audio files"
|
||||
fileMode: FileDialog.OpenFiles
|
||||
nameFilters: ["Audio (*.mp3 *.wav *.flac *.ogg *.oga *.opus *.m4a *.aac *.aif *.aiff *.wma *.mp4 *.mkv *.webm *.mka *.wv *.ape)", "All files (*)"]
|
||||
onAccepted: app.addUrls(selectedFiles)
|
||||
}
|
||||
|
||||
Shortcut { sequences: ["Space"]; enabled: audio.loaded && !settings.opened; onActivated: audio.toggle() }
|
||||
Shortcut { sequences: ["Home"]; enabled: audio.loaded; onActivated: { audio.seek(0); win.playPos = 0 } }
|
||||
Shortcut { sequences: [StandardKey.Open]; onActivated: fileDialog.open() }
|
||||
Shortcut { sequences: ["Ctrl+,"]; onActivated: settings.open() }
|
||||
Shortcut { sequences: ["F12"]; onActivated: appSettings.frameOverlay = !appSettings.frameOverlay }
|
||||
Shortcut { sequences: ["F11"]; onActivated: win.toggleMaximized() }
|
||||
Shortcut { sequences: [StandardKey.Quit]; onActivated: win.close() }
|
||||
|
||||
Item {
|
||||
id: frame
|
||||
anchors.fill: parent
|
||||
|
||||
// window background: placeholder until the aurora texture arrives
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: win.radius
|
||||
color: Theme.bg
|
||||
}
|
||||
Image {
|
||||
id: backdrop
|
||||
anchors.fill: parent
|
||||
source: "image://fx/backdrop/" + win.fxW + "x" + win.fxH + "/" + win.radius
|
||||
asynchronous: true
|
||||
retainWhileLoading: true
|
||||
cache: true
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.slow } }
|
||||
}
|
||||
|
||||
TitleBar {
|
||||
id: titleBar
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
offsetX: 0
|
||||
offsetY: 0
|
||||
onOpenSettings: settings.open()
|
||||
}
|
||||
|
||||
Sidebar {
|
||||
id: sidebar
|
||||
anchors { left: parent.left; top: titleBar.bottom; bottom: transport.top }
|
||||
width: 220
|
||||
offsetX: 0
|
||||
offsetY: titleBar.height
|
||||
onBrowse: fileDialog.open()
|
||||
Rectangle { anchors { right: parent.right; top: parent.top; bottom: parent.bottom } width: 1; color: Theme.hairline }
|
||||
}
|
||||
|
||||
Item {
|
||||
id: mainPane
|
||||
anchors { left: sidebar.right; right: parent.right; top: titleBar.bottom; bottom: transport.top }
|
||||
clip: true
|
||||
|
||||
Image { // dither the dark gradients behind content
|
||||
anchors.fill: parent
|
||||
source: "image://fx/noise"
|
||||
fillMode: Image.Tile
|
||||
cache: true
|
||||
}
|
||||
|
||||
DropZone {
|
||||
anchors.fill: parent
|
||||
visible: !app.selected.valid
|
||||
dragging: drop.containsDrag
|
||||
onBrowse: fileDialog.open()
|
||||
}
|
||||
TrackView {
|
||||
anchors.fill: parent
|
||||
visible: app.selected.valid
|
||||
}
|
||||
|
||||
// drag-over affordance when a track is already showing
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: opacity > 0
|
||||
opacity: drop.containsDrag && app.selected.valid ? 1 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.med } }
|
||||
Rectangle { anchors.fill: parent; color: Theme.alpha(Theme.bg, 0.82) }
|
||||
DashedFrame { anchors.fill: parent; anchors.margins: 24; radius: 16 }
|
||||
Glow { anchors.margins: 24 - m; radius: 16; sigma: 8; opacity: 0.4 }
|
||||
Txt {
|
||||
anchors.centerIn: parent
|
||||
text: "Drop to add to the queue"
|
||||
font.pixelSize: Theme.fs28
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
FrameOverlay {
|
||||
visible: appSettings.frameOverlay || devOverlay
|
||||
anchors { right: parent.right; bottom: parent.bottom; margins: 12 }
|
||||
z: 100
|
||||
}
|
||||
}
|
||||
|
||||
Transport {
|
||||
id: transport
|
||||
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
|
||||
height: 64
|
||||
offsetX: 0
|
||||
offsetY: win.height - 64
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors { horizontalCenter: mainPane.horizontalCenter; bottom: transport.top; bottomMargin: 16 }
|
||||
width: toast.width
|
||||
height: 48
|
||||
Toast { id: toast; maxWidth: mainPane.width - 48 }
|
||||
}
|
||||
|
||||
// window edge highlight
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: win.radius
|
||||
color: "transparent"
|
||||
border.width: win.maximized ? 0 : 1
|
||||
border.color: Qt.rgba(1, 1, 1, 0.12)
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: drop
|
||||
anchors.fill: parent
|
||||
keys: ["text/uri-list"]
|
||||
onDropped: (e) => {
|
||||
if (e.hasUrls) {
|
||||
app.addUrls(e.urls)
|
||||
e.acceptProposedAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resize grips (compositor-driven)
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: !win.maximized
|
||||
readonly property int g: 6
|
||||
ResizeEdge { edges: Qt.LeftEdge; x: 0; y: 12; width: parent.g; height: parent.height - 24 }
|
||||
ResizeEdge { edges: Qt.RightEdge; x: parent.width - parent.g; y: 12; width: parent.g; height: parent.height - 24 }
|
||||
ResizeEdge { edges: Qt.TopEdge; x: 12; y: 0; width: parent.width - 24; height: parent.g - 2 }
|
||||
ResizeEdge { edges: Qt.BottomEdge; x: 12; y: parent.height - parent.g; width: parent.width - 24; height: parent.g }
|
||||
ResizeEdge { edges: Qt.LeftEdge | Qt.TopEdge; x: 0; y: 0; width: 12; height: 12 }
|
||||
ResizeEdge { edges: Qt.RightEdge | Qt.TopEdge; x: parent.width - 12; y: 0; width: 12; height: 12 }
|
||||
ResizeEdge { edges: Qt.LeftEdge | Qt.BottomEdge; x: 0; y: parent.height - 12; width: 12; height: 12 }
|
||||
ResizeEdge { edges: Qt.RightEdge | Qt.BottomEdge; x: parent.width - 12; y: parent.height - 12; width: 12; height: 12 }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDialog { id: settings; parent: Overlay.overlay; win: win }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
ItemDelegate {
|
||||
id: item
|
||||
required property string jobId
|
||||
required property string fileName
|
||||
required property string modelLabel
|
||||
required property string status
|
||||
required property real progress
|
||||
required property string stage
|
||||
required property int index
|
||||
readonly property bool selected: app.selected.valid && app.selected.id === jobId
|
||||
readonly property bool active: status === "running" || status === "queued"
|
||||
|
||||
width: ListView.view ? ListView.view.width : 200
|
||||
height: active ? 84 : 66
|
||||
hoverEnabled: true
|
||||
padding: 0
|
||||
onClicked: app.select(jobId)
|
||||
Behavior on height { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
|
||||
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: item.selected ? Theme.alpha(Theme.primary, 0.16) : (item.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent")
|
||||
border.color: item.selected ? Theme.alpha(Theme.primary, 0.45) : "transparent"
|
||||
Behavior on color { ColorAnimation { duration: Theme.fast } }
|
||||
Rectangle {
|
||||
visible: item.selected
|
||||
x: 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 3
|
||||
height: parent.height - 20
|
||||
radius: 2
|
||||
color: Theme.accentGlow
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
Column {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 12; rightMargin: 6; topMargin: 10 }
|
||||
spacing: 6
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
Icon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 16
|
||||
name: item.status === "done" ? "check" : item.status === "error" ? "error" : "music"
|
||||
color: item.status === "done" ? Theme.success : item.status === "error" ? Theme.error : Theme.textLo
|
||||
}
|
||||
Txt {
|
||||
width: parent.width - 16 - 28 - 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: item.fileName
|
||||
font.weight: item.selected ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
IconButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
iconSize: 14
|
||||
iconName: "close"
|
||||
glow: false
|
||||
tip: item.active ? "Cancel" : "Remove"
|
||||
onClicked: item.active ? app.cancelJob(item.jobId) : app.removeJob(item.jobId)
|
||||
}
|
||||
}
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
Txt {
|
||||
width: parent.width - chip.width - 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: item.status === "running" ? item.stage : item.modelLabel
|
||||
font.pixelSize: Theme.fs12
|
||||
color: Theme.textLo
|
||||
}
|
||||
StatusChip { id: chip; status: item.status; progress: item.progress }
|
||||
}
|
||||
StripedProgress {
|
||||
visible: item.active
|
||||
width: parent.width - 6
|
||||
value: item.progress
|
||||
active: item.status === "running"
|
||||
opacity: item.status === "running" ? 1 : 0.35
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,274 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
import QtQuick.Dialogs
|
||||
|
||||
// Glass dialog (elevation 3, stronger 24px blur, 50% scrim).
|
||||
Popup {
|
||||
id: dlg
|
||||
property var win: null
|
||||
modal: true
|
||||
focus: true
|
||||
width: Math.min(parent.width - 64, 640)
|
||||
height: Math.min(parent.height - 64, content.implicitHeight + 120)
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
padding: 0
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
onOpened: app.refreshCacheSize()
|
||||
|
||||
Overlay.modal: Rectangle {
|
||||
color: Qt.rgba(0, 0, 0, 0.5)
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.med } }
|
||||
}
|
||||
enter: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
|
||||
NumberAnimation { property: "scale"; from: 0.95; to: 1; duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
|
||||
}
|
||||
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
|
||||
|
||||
background: Item {
|
||||
Elevation { level: 3; radius: 12 }
|
||||
Image { // stronger-blur glass sheet, pre-rounded, rendered once per open/resize
|
||||
anchors.fill: parent
|
||||
asynchronous: true
|
||||
retainWhileLoading: true
|
||||
source: dlg.win && dlg.width > 0
|
||||
? "image://fx/glasscrop/" + dlg.win.fxW + "x" + dlg.win.fxH + "/0/24/"
|
||||
+ Math.round(dlg.x) + "," + Math.round(dlg.y) + "," + Math.round(dlg.width) + "," + Math.round(dlg.height) + "/12"
|
||||
: ""
|
||||
}
|
||||
Rectangle { // Aero gloss
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
|
||||
height: 48
|
||||
topLeftRadius: 11
|
||||
topRightRadius: 11
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.08) }
|
||||
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 12
|
||||
color: "transparent"
|
||||
border.color: Qt.rgba(1, 1, 1, 0.12)
|
||||
}
|
||||
}
|
||||
|
||||
FolderDialog {
|
||||
id: folderDialog
|
||||
title: "Choose the stems folder"
|
||||
currentFolder: "file://" + appSettings.stemsDir
|
||||
onAccepted: appSettings.stemsDir = decodeURIComponent(selectedFolder.toString().replace("file://", ""))
|
||||
}
|
||||
|
||||
component SectionTitle: Txt {
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.8
|
||||
color: Theme.accentGlow
|
||||
topPadding: 8
|
||||
}
|
||||
component Hint: Txt {
|
||||
font.pixelSize: Theme.fs12
|
||||
color: Theme.textLo
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideNone
|
||||
}
|
||||
component Segmented: Row {
|
||||
id: seg
|
||||
property var options: []
|
||||
property string value: ""
|
||||
signal picked(string v)
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: seg.options
|
||||
AbstractButton {
|
||||
id: sb
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool on: seg.value === modelData.v
|
||||
implicitWidth: 84
|
||||
implicitHeight: 34
|
||||
hoverEnabled: true
|
||||
onClicked: seg.picked(modelData.v)
|
||||
background: Rectangle {
|
||||
topLeftRadius: sb.index === 0 ? 17 : 0
|
||||
bottomLeftRadius: sb.index === 0 ? 17 : 0
|
||||
topRightRadius: sb.index === seg.options.length - 1 ? 17 : 0
|
||||
bottomRightRadius: sb.index === seg.options.length - 1 ? 17 : 0
|
||||
color: sb.on ? Theme.alpha(Theme.primary, 0.28) : (sb.hovered ? Qt.rgba(1, 1, 1, 0.07) : Qt.rgba(1, 1, 1, 0.03))
|
||||
border.color: sb.on ? Theme.alpha(Theme.primary, 0.7) : Qt.rgba(1, 1, 1, 0.12)
|
||||
Behavior on color { ColorAnimation { duration: Theme.fast } }
|
||||
}
|
||||
contentItem: Txt {
|
||||
text: sb.modelData.t
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.weight: sb.on ? Font.DemiBold : Font.Normal
|
||||
color: sb.on ? Theme.textHi : Theme.textLo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
Item {
|
||||
id: head
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
height: 60
|
||||
Txt {
|
||||
anchors { left: parent.left; leftMargin: 24; verticalCenter: parent.verticalCenter }
|
||||
text: "Settings"
|
||||
font.pixelSize: Theme.fs20
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
IconButton {
|
||||
anchors { right: parent.right; rightMargin: 14; verticalCenter: parent.verticalCenter }
|
||||
iconName: "close"
|
||||
onClicked: dlg.close()
|
||||
}
|
||||
Rectangle { anchors { left: parent.left; right: parent.right; bottom: parent.bottom } height: 1; color: Theme.hairline }
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors { left: parent.left; right: parent.right; top: head.bottom; bottom: parent.bottom }
|
||||
contentHeight: content.implicitHeight + 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar { contentItem: Rectangle { implicitWidth: 4; radius: 2; color: Qt.rgba(1, 1, 1, 0.18); visible: parent.size < 1 }
|
||||
background: Item {} }
|
||||
|
||||
Column {
|
||||
id: content
|
||||
x: 24
|
||||
y: 12
|
||||
width: parent.width - 48
|
||||
spacing: 12
|
||||
|
||||
SectionTitle { text: "OUTPUT" }
|
||||
Segmented {
|
||||
options: [{ v: "beside", t: "Beside" }, { v: "folder", t: "Folder" }]
|
||||
value: appSettings.outputMode
|
||||
onPicked: (v) => appSettings.outputMode = v
|
||||
}
|
||||
Hint {
|
||||
width: parent.width
|
||||
text: appSettings.outputMode === "beside"
|
||||
? "Stems go next to the original, in “<name> - stems”."
|
||||
: "Stems go to a folder per track inside the stems folder."
|
||||
}
|
||||
Row {
|
||||
visible: appSettings.outputMode === "folder"
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
Rectangle {
|
||||
width: parent.width - browseBtn.width - 8
|
||||
height: 36
|
||||
radius: 18
|
||||
color: Qt.rgba(1, 1, 1, 0.05)
|
||||
border.color: Qt.rgba(1, 1, 1, 0.12)
|
||||
Txt {
|
||||
anchors { fill: parent; leftMargin: 16; rightMargin: 16 }
|
||||
text: appSettings.stemsDir
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
AeroButton { id: browseBtn; variant: "tonal"; pillHeight: 36; iconName: "folder"; text: "Browse"; onClicked: folderDialog.open() }
|
||||
}
|
||||
Row {
|
||||
spacing: 16
|
||||
Txt { text: "Format"; width: 80; height: 34 }
|
||||
Segmented {
|
||||
options: [{ v: "wav", t: "WAV" }, { v: "flac", t: "FLAC" }, { v: "mp3", t: "MP3" }]
|
||||
value: appSettings.exportFormat
|
||||
onPicked: (v) => appSettings.exportFormat = v
|
||||
}
|
||||
}
|
||||
Hint { width: parent.width; text: "WAV and FLAC are 24-bit (WAV switches to 32-bit float if a stem would clip). MP3 is 320 kbps." }
|
||||
AeroSwitch {
|
||||
width: parent.width
|
||||
text: "Export all stems automatically when separation finishes"
|
||||
checked: appSettings.autoExport
|
||||
onToggled: appSettings.autoExport = checked
|
||||
}
|
||||
|
||||
SectionTitle { text: "SEPARATION" }
|
||||
AeroSwitch {
|
||||
width: parent.width
|
||||
text: "Half precision on GPU (about 2x faster on the RX 9070 XT, same quality)"
|
||||
checked: appSettings.fp16
|
||||
onToggled: appSettings.fp16 = checked
|
||||
}
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Segment"; width: 80; height: 24 }
|
||||
AeroSlider {
|
||||
id: segSlider
|
||||
width: parent.width - 200
|
||||
from: 0; to: 7.8; stepSize: 0.1
|
||||
value: appSettings.segment
|
||||
onMoved: appSettings.segment = value
|
||||
}
|
||||
Txt { text: appSettings.segment <= 0.05 ? "Model default" : appSettings.segment.toFixed(1) + " s"; width: 96; height: 24; color: Theme.textLo }
|
||||
}
|
||||
Hint { width: parent.width; text: "Demucs chunk length. Lower it only if you run out of VRAM; 16 GB fits every model at the default." }
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Shifts"; width: 80; height: 24 }
|
||||
AeroSlider {
|
||||
width: parent.width - 200
|
||||
from: 0; to: 5; stepSize: 1; snapMode: Slider.SnapAlways
|
||||
value: appSettings.shifts
|
||||
onMoved: appSettings.shifts = Math.round(value)
|
||||
}
|
||||
Txt { text: appSettings.shifts === 0 ? "Off" : appSettings.shifts + " passes"; width: 96; height: 24; color: Theme.textLo }
|
||||
}
|
||||
Hint { width: parent.width; text: "Demucs random-shift averaging: small quality gain, each pass adds a full run." }
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Overlap"; width: 80; height: 24 }
|
||||
AeroSlider {
|
||||
width: parent.width - 200
|
||||
from: 0.1; to: 0.9; stepSize: 0.05
|
||||
value: appSettings.overlap
|
||||
onMoved: appSettings.overlap = value
|
||||
}
|
||||
Txt { text: Math.round(appSettings.overlap * 100) + "%"; width: 96; height: 24; color: Theme.textLo }
|
||||
}
|
||||
Hint { width: parent.width; text: "Segment overlap. RoFormer models always use at least 75%." }
|
||||
|
||||
SectionTitle { text: "STORAGE" }
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Stem cache uses " + app.cacheText; height: 36 }
|
||||
AeroButton { variant: "tonal"; pillHeight: 36; iconName: "delete"; text: "Clear cache"; onClicked: app.clearCache() }
|
||||
}
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Keep up to"; width: 80; height: 24 }
|
||||
AeroSlider {
|
||||
width: parent.width - 200
|
||||
from: 1; to: 40; stepSize: 1; snapMode: Slider.SnapAlways
|
||||
value: appSettings.cacheLimitGb
|
||||
onMoved: appSettings.cacheLimitGb = Math.round(value)
|
||||
}
|
||||
Txt { text: appSettings.cacheLimitGb.toFixed(0) + " GB"; width: 96; height: 24; color: Theme.textLo }
|
||||
}
|
||||
Hint { width: parent.width; text: "Cached stems make re-opening a track instant. Oldest entries are pruned past the limit." }
|
||||
|
||||
SectionTitle { text: "DEVELOPER" }
|
||||
AeroSwitch {
|
||||
width: parent.width
|
||||
text: "Frame-time overlay (p50 / p99) · F12"
|
||||
checked: appSettings.frameOverlay
|
||||
onToggled: appSettings.frameOverlay = checked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
GlassPanel {
|
||||
id: side
|
||||
signal browse()
|
||||
|
||||
Item {
|
||||
id: header
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
height: 52
|
||||
Txt {
|
||||
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
|
||||
text: "Queue"
|
||||
font.pixelSize: Theme.fs16
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Rectangle {
|
||||
visible: list.count > 0
|
||||
anchors { left: parent.left; leftMargin: 76; verticalCenter: parent.verticalCenter }
|
||||
width: cnt.implicitWidth + 12
|
||||
height: 18
|
||||
radius: 9
|
||||
color: Qt.rgba(1, 1, 1, 0.08)
|
||||
Txt { id: cnt; anchors.centerIn: parent; text: list.count; font.pixelSize: 11; color: Theme.textLo }
|
||||
}
|
||||
IconButton {
|
||||
anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
|
||||
iconName: "add"
|
||||
tip: "Add files (Ctrl+O)"
|
||||
onClicked: side.browse()
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
anchors { left: parent.left; right: parent.right; top: header.bottom; bottom: parent.bottom; margins: 8; topMargin: 0 }
|
||||
model: jobsModel
|
||||
spacing: 4
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
delegate: QueueItem {}
|
||||
add: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.med }
|
||||
NumberAnimation { property: "x"; from: -16; duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
|
||||
}
|
||||
displaced: Transition {
|
||||
NumberAnimation { properties: "y"; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
|
||||
}
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
contentItem: Rectangle { implicitWidth: 4; radius: 2; color: Qt.rgba(1, 1, 1, 0.18); visible: parent.size < 1 }
|
||||
background: Item {}
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: list.count === 0
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
width: parent.width - 32
|
||||
Icon { anchors.horizontalCenter: parent.horizontalCenter; name: "upload"; size: 28; color: Theme.alpha(Theme.textLo, 0.7) }
|
||||
Txt {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
text: "Drop audio anywhere to queue it"
|
||||
color: Theme.textLo
|
||||
font.pixelSize: Theme.fs12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import QtQuick
|
||||
|
||||
Rectangle {
|
||||
id: chip
|
||||
property string status: "queued"
|
||||
property real progress: 0
|
||||
readonly property color tone: status === "done" ? Theme.success
|
||||
: status === "error" ? Theme.error
|
||||
: status === "running" ? Theme.primary : Theme.textLo
|
||||
readonly property string label: status === "running" ? Math.floor(progress * 100) + "%"
|
||||
: status === "done" ? "Done"
|
||||
: status === "error" ? "Failed"
|
||||
: status === "cancelled" ? "Cancelled" : "Queued"
|
||||
implicitWidth: t.implicitWidth + 16
|
||||
implicitHeight: 20
|
||||
radius: 10
|
||||
color: Theme.alpha(tone, 0.16)
|
||||
border.color: Theme.alpha(tone, 0.35)
|
||||
Txt {
|
||||
id: t
|
||||
anchors.centerIn: parent
|
||||
text: chip.label
|
||||
color: chip.tone
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.DemiBold
|
||||
font.features: { "tnum": 1 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// One lane per stem: color strip, solo / mute / gain / export, waveform texture.
|
||||
Item {
|
||||
id: lane
|
||||
required property int index
|
||||
required property var modelData
|
||||
property string jobId: ""
|
||||
property real viewStart: 0
|
||||
property real viewEnd: 1
|
||||
property int waveW: 100
|
||||
property int waveH: 60
|
||||
property real waveX: 0
|
||||
property bool anySolo: false
|
||||
property bool solo: false
|
||||
property bool mute: false
|
||||
property real gainDb: 0
|
||||
signal soloToggled(bool on)
|
||||
readonly property string stem: modelData.name
|
||||
readonly property color tone: modelData.color
|
||||
readonly property bool audible: anySolo ? solo : !mute
|
||||
height: 92
|
||||
|
||||
Card {
|
||||
anchors.fill: parent
|
||||
clip: false
|
||||
|
||||
// color strip with soft glow
|
||||
Rectangle {
|
||||
x: 0
|
||||
width: 4
|
||||
height: parent.height
|
||||
topLeftRadius: 8
|
||||
bottomLeftRadius: 8
|
||||
color: lane.tone
|
||||
}
|
||||
Rectangle {
|
||||
x: 4
|
||||
width: 24
|
||||
height: parent.height
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.alpha(lane.tone, 0.14) }
|
||||
GradientStop { position: 1; color: Theme.alpha(lane.tone, 0.0) }
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: controls
|
||||
x: 18
|
||||
width: lane.waveX - 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
Row {
|
||||
spacing: 6
|
||||
width: parent.width
|
||||
Txt {
|
||||
width: parent.width - 3 * 30 - 12
|
||||
text: lane.stem.charAt(0).toUpperCase() + lane.stem.slice(1)
|
||||
font.pixelSize: Theme.fs16
|
||||
font.weight: Font.DemiBold
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
IconButton {
|
||||
size: 30; iconSize: 16
|
||||
iconName: "headphones"
|
||||
checkable: true
|
||||
checked: lane.solo
|
||||
activeColor: Theme.warning
|
||||
tip: "Solo"
|
||||
onClicked: lane.soloToggled(checked)
|
||||
}
|
||||
IconButton {
|
||||
size: 30; iconSize: 16
|
||||
iconName: lane.mute ? "volume_off" : "volume"
|
||||
checkable: true
|
||||
checked: lane.mute
|
||||
activeColor: Theme.error
|
||||
tip: "Mute"
|
||||
onClicked: { lane.mute = checked; audio.setMute(lane.index, checked) }
|
||||
}
|
||||
IconButton {
|
||||
size: 30; iconSize: 16
|
||||
iconName: "download"
|
||||
tip: "Export " + lane.stem
|
||||
onClicked: app.exportStem(lane.stem)
|
||||
}
|
||||
}
|
||||
Row {
|
||||
spacing: 8
|
||||
width: parent.width
|
||||
AeroSlider {
|
||||
id: gain
|
||||
width: parent.width - 52
|
||||
from: -24
|
||||
to: 12
|
||||
stepSize: 0.5
|
||||
value: lane.gainDb
|
||||
tone: lane.tone
|
||||
onMoved: { lane.gainDb = value; audio.setGain(lane.index, value) }
|
||||
// double-click resets to unity
|
||||
TapHandler { onDoubleTapped: { lane.gainDb = 0; audio.setGain(lane.index, 0) } }
|
||||
}
|
||||
Txt {
|
||||
width: 44
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: (lane.gainDb > 0 ? "+" : "") + lane.gainDb.toFixed(1) + " dB"
|
||||
font.pixelSize: 11
|
||||
font.features: { "tnum": 1 }
|
||||
color: lane.gainDb === 0 ? Theme.textLo : Theme.textHi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { // waveform well
|
||||
x: lane.waveX
|
||||
y: 8
|
||||
width: lane.waveW
|
||||
height: parent.height - 16
|
||||
radius: 6
|
||||
color: Qt.rgba(0, 0, 0, 0.22)
|
||||
border.color: Qt.rgba(1, 1, 1, 0.04)
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.alpha(lane.tone, 0.18)
|
||||
}
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
asynchronous: true
|
||||
retainWhileLoading: true
|
||||
cache: false
|
||||
smooth: true
|
||||
opacity: lane.audible ? 1 : 0.28
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.med } }
|
||||
source: lane.jobId && lane.waveW > 4
|
||||
? "image://wave/" + lane.jobId + "/" + lane.stem + "/"
|
||||
+ Math.round(lane.waveW * Screen.devicePixelRatio) + "x" + Math.round((lane.height - 16) * Screen.devicePixelRatio)
|
||||
+ "/" + lane.viewStart.toFixed(3) + "/" + lane.viewEnd.toFixed(3) + "/" + Theme.hex(lane.tone)
|
||||
: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import QtQuick
|
||||
|
||||
// Win7-style green-to-cyan striped bar. Backend sends <= 30 updates/s;
|
||||
// `shown` eases toward `value` and the stripes advance per frame by delta-time.
|
||||
Item {
|
||||
id: root
|
||||
property real value: 0
|
||||
property bool active: true
|
||||
property real shown: 0
|
||||
property real phase: 0
|
||||
property int barHeight: 6
|
||||
implicitHeight: barHeight
|
||||
readonly property int tile: barHeight * 2
|
||||
|
||||
onValueChanged: if (value < shown) shown = value
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: Qt.rgba(1, 1, 1, 0.07)
|
||||
border.color: Qt.rgba(1, 1, 1, 0.05)
|
||||
}
|
||||
Item {
|
||||
id: fill
|
||||
height: parent.height
|
||||
width: Math.max(root.active ? root.barHeight * 2 : 0, parent.width * Math.min(1, root.shown))
|
||||
clip: true
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.success }
|
||||
GradientStop { position: 1; color: Theme.accentGlow }
|
||||
}
|
||||
}
|
||||
Image {
|
||||
visible: root.active
|
||||
x: -root.phase
|
||||
width: parent.width + root.tile
|
||||
height: parent.height
|
||||
fillMode: Image.Tile
|
||||
source: "image://fx/stripes/" + root.barHeight
|
||||
cache: true
|
||||
}
|
||||
Rectangle { // gloss
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
height: parent.height / 2
|
||||
topLeftRadius: height
|
||||
topRightRadius: height
|
||||
color: Qt.rgba(1, 1, 1, 0.28)
|
||||
}
|
||||
}
|
||||
|
||||
FrameAnimation {
|
||||
running: root.visible && (root.active || Math.abs(root.value - root.shown) > 0.0005)
|
||||
onTriggered: {
|
||||
root.shown += (root.value - root.shown) * (1 - Math.exp(-frameTime * 10))
|
||||
if (root.active)
|
||||
root.phase = (root.phase + frameTime * root.tile * 2.5) % root.tile
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
|
||||
// Design tokens: Material structure (8dp grid, type ramp, motion), Aero surface.
|
||||
QtObject {
|
||||
// dark gray tokens
|
||||
readonly property color bg: "#1D1E21"
|
||||
readonly property color surface1: "#26272B"
|
||||
readonly property color surface2: "#2F3034"
|
||||
readonly property color glassTint: Qt.rgba(36 / 255, 37 / 255, 41 / 255, 0.70)
|
||||
readonly property color primary: "#3BA8FF"
|
||||
readonly property color accentGlow: "#5FE3FF"
|
||||
readonly property color success: "#4CD07D"
|
||||
readonly property color error: "#FF5C6C"
|
||||
readonly property color warning: "#FFB547"
|
||||
readonly property color textHi: "#ECEDEF"
|
||||
readonly property color textLo: "#9B9DA3"
|
||||
readonly property color outline: Qt.rgba(1, 1, 1, 0.08)
|
||||
readonly property color hairline: Qt.rgba(1, 1, 1, 0.06)
|
||||
|
||||
// 8dp grid
|
||||
readonly property int u: 8
|
||||
|
||||
// type ramp 12 / 14 / 16 / 20 / 28
|
||||
property string fontFamily: "Inter"
|
||||
readonly property int fs12: 12
|
||||
readonly property int fs14: 14
|
||||
readonly property int fs16: 16
|
||||
readonly property int fs20: 20
|
||||
readonly property int fs28: 28
|
||||
|
||||
// motion: Material standard easing, nothing over 300 ms
|
||||
readonly property int fast: 100
|
||||
readonly property int med: 200
|
||||
readonly property int slow: 300
|
||||
readonly property var ease: [0.2, 0, 0, 1, 1, 1]
|
||||
|
||||
// elevation: blur sigma, alpha, y offset per level
|
||||
readonly property var shadowSigma: [0, 2, 4, 8]
|
||||
readonly property var shadowAlpha: [0, 0.35, 0.45, 0.55]
|
||||
readonly property var shadowDy: [0, 1, 2, 4]
|
||||
|
||||
readonly property var stemColors: ({
|
||||
"vocals": "#FF7AB6", "drums": "#FFB547", "bass": "#7C8CFF", "other": "#4CD07D",
|
||||
"guitar": "#FF8A5C", "piano": "#B48CFF", "instrumental": "#3DD6C6"
|
||||
})
|
||||
readonly property var stemIcons: ({
|
||||
"vocals": "music", "drums": "equalizer", "bass": "speed", "other": "layers",
|
||||
"guitar": "music", "piano": "music", "instrumental": "layers"
|
||||
})
|
||||
|
||||
function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
|
||||
function hex(c) { return c.toString().substring(1, 7).toUpperCase() }
|
||||
function icon(name, color) { return "image://icon/" + name + "/" + hex(color) }
|
||||
function stemColor(name) { return stemColors[name] || "#9B9DA3" }
|
||||
|
||||
function time(sec, tenths) {
|
||||
if (!isFinite(sec) || sec < 0) sec = 0
|
||||
var m = Math.floor(sec / 60)
|
||||
var s = sec - m * 60
|
||||
var ss = tenths ? s.toFixed(1) : Math.floor(s).toString()
|
||||
if ((tenths && s < 10) || (!tenths && s < 10)) ss = "0" + ss
|
||||
return m + ":" + ss
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
ToolTip {
|
||||
id: tip
|
||||
delay: 600
|
||||
timeout: 4000
|
||||
padding: 8
|
||||
contentItem: Txt { text: tip.text; font.pixelSize: Theme.fs12 }
|
||||
background: Rectangle {
|
||||
color: Theme.surface2
|
||||
radius: 6
|
||||
border.color: Theme.outline
|
||||
Elevation { level: 2; radius: 6 }
|
||||
}
|
||||
enter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.fast } }
|
||||
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
GlassPanel {
|
||||
id: bar
|
||||
property var win: Window.window
|
||||
signal openSettings()
|
||||
height: 52
|
||||
topLeftRadius: win.radius
|
||||
topRightRadius: win.radius
|
||||
|
||||
// Drag to move / double-click to maximize via the compositor (Wayland-native).
|
||||
DragHandler {
|
||||
target: null
|
||||
grabPermissions: PointerHandler.CanTakeOverFromAnything
|
||||
onActiveChanged: if (active) bar.win.startSystemMove()
|
||||
}
|
||||
TapHandler {
|
||||
acceptedButtons: Qt.LeftButton
|
||||
onDoubleTapped: bar.win.toggleMaximized()
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
|
||||
spacing: 10
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: "#5FC8FF" }
|
||||
GradientStop { position: 1; color: "#2A7BD8" }
|
||||
}
|
||||
border.color: Qt.rgba(1, 1, 1, 0.3)
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
|
||||
height: 12
|
||||
topLeftRadius: 7
|
||||
topRightRadius: 7
|
||||
color: Qt.rgba(1, 1, 1, 0.25)
|
||||
}
|
||||
Icon { anchors.centerIn: parent; name: "equalizer"; size: 18; color: "#FFFFFF" }
|
||||
}
|
||||
Txt {
|
||||
text: "StemRoller"
|
||||
font.pixelSize: Theme.fs16
|
||||
font.weight: Font.DemiBold
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Rectangle { // engine status
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 22
|
||||
width: st.implicitWidth + 26
|
||||
radius: 11
|
||||
readonly property color tone: app.workerState === "ready" ? Theme.success
|
||||
: app.workerState === "busy" ? Theme.primary
|
||||
: app.workerState === "crashed" ? Theme.error : Theme.warning
|
||||
color: Theme.alpha(tone, 0.12)
|
||||
border.color: Theme.alpha(tone, 0.3)
|
||||
Rectangle {
|
||||
x: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 7
|
||||
height: 7
|
||||
radius: 4
|
||||
color: parent.tone
|
||||
}
|
||||
Txt {
|
||||
id: st
|
||||
x: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.Medium
|
||||
color: parent.tone
|
||||
text: app.workerState === "ready" ? "Engine ready"
|
||||
: app.workerState === "busy" ? "Separating"
|
||||
: app.workerState === "crashed" ? "Engine restarting" : "Starting engine"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
|
||||
spacing: 8
|
||||
|
||||
AeroComboBox {
|
||||
id: modelPicker
|
||||
width: 240
|
||||
caption: "MODEL"
|
||||
iconName: "layers"
|
||||
implicitHeight: 40
|
||||
subRole: "note"
|
||||
popupWidth: 300
|
||||
model: app.models
|
||||
currentIndex: app.models.findIndex(m => m.id === appSettings.modelId)
|
||||
onActivated: appSettings.modelId = currentValue
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
AbstractButton { // 2-stem vocals toggle chip
|
||||
id: twoStem
|
||||
readonly property var model: app.models.find(m => m.id === appSettings.modelId)
|
||||
readonly property bool applicable: !!model && model.stems > 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checkable: true
|
||||
checked: appSettings.twoStems && applicable
|
||||
enabled: applicable
|
||||
onToggled: appSettings.twoStems = checked
|
||||
hoverEnabled: true
|
||||
implicitHeight: 32
|
||||
implicitWidth: twoLbl.implicitWidth + 40
|
||||
opacity: enabled ? 1 : 0.4
|
||||
background: Rectangle {
|
||||
radius: 16
|
||||
color: twoStem.checked ? Theme.alpha(Theme.stemColor("vocals"), 0.2) : (twoStem.hovered ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(1, 1, 1, 0.04))
|
||||
border.color: twoStem.checked ? Theme.alpha(Theme.stemColor("vocals"), 0.6) : Qt.rgba(1, 1, 1, 0.10)
|
||||
Behavior on color { ColorAnimation { duration: Theme.fast } }
|
||||
}
|
||||
contentItem: Row {
|
||||
leftPadding: 12
|
||||
spacing: 6
|
||||
Icon {
|
||||
name: twoStem.checked ? "check" : "music"
|
||||
size: 16
|
||||
color: twoStem.checked ? Theme.stemColor("vocals") : Theme.textLo
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Txt {
|
||||
id: twoLbl
|
||||
text: "Vocals only"
|
||||
font.pixelSize: Theme.fs12
|
||||
font.weight: Font.Medium
|
||||
color: twoStem.checked ? Theme.textHi : Theme.textLo
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
Tip { visible: twoStem.hovered; text: twoStem.applicable ? "2-stem mode: vocals + instrumental" : "This model is already 2-stem" }
|
||||
}
|
||||
|
||||
AeroComboBox {
|
||||
id: devicePicker
|
||||
width: 250
|
||||
caption: app.vram.valid === true && appSettings.deviceId !== "cpu"
|
||||
? "DEVICE · " + (app.vram.free / 1024).toFixed(1) + " GB FREE" : "DEVICE"
|
||||
iconName: "chip"
|
||||
implicitHeight: 40
|
||||
popupWidth: 300
|
||||
model: app.devices
|
||||
currentIndex: Math.max(0, app.devices.findIndex(d => d.id === appSettings.deviceId))
|
||||
onActivated: appSettings.deviceId = currentValue
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
HoverHandler { id: devHover }
|
||||
Tip { visible: devHover.hovered && app.vram.valid === true; text: app.vram.text || "" }
|
||||
}
|
||||
|
||||
IconButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "settings"
|
||||
size: 36
|
||||
tip: "Settings (Ctrl+,)"
|
||||
onClicked: bar.openSettings()
|
||||
}
|
||||
|
||||
Rectangle { width: 1; height: 24; color: Theme.hairline; anchors.verticalCenter: parent.verticalCenter }
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
CaptionButton { iconName: "minimize"; onClicked: bar.win.showMinimized() }
|
||||
CaptionButton { iconName: bar.win.maximized ? "restore" : "maximize"; onClicked: bar.win.toggleMaximized() }
|
||||
CaptionButton { iconName: "close"; danger: true; onClicked: bar.win.close() }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { // separator to content (elevation 1 edge)
|
||||
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
|
||||
height: 1
|
||||
color: Theme.hairline
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import QtQuick
|
||||
|
||||
// Material snackbar on surface-2.
|
||||
Item {
|
||||
id: t
|
||||
property string message: ""
|
||||
property string kind: "info"
|
||||
property string folder: ""
|
||||
property real maxWidth: 800
|
||||
width: Math.min(maxWidth, row.implicitWidth + 32)
|
||||
height: 48
|
||||
opacity: 0
|
||||
visible: opacity > 0
|
||||
y: shown ? 0 : 12
|
||||
property bool shown: false
|
||||
|
||||
function show(m, k, f) {
|
||||
message = m; kind = k; folder = f
|
||||
shown = true
|
||||
hideTimer.restart()
|
||||
}
|
||||
Timer { id: hideTimer; interval: 5000; onTriggered: t.shown = false }
|
||||
states: State { when: t.shown; PropertyChanges { t.opacity: 1 } }
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
|
||||
Behavior on y { NumberAnimation { duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 8
|
||||
color: Theme.surface2
|
||||
border.color: t.kind === "error" ? Theme.alpha(Theme.error, 0.5) : t.kind === "success" ? Theme.alpha(Theme.success, 0.45) : Theme.outline
|
||||
Elevation { level: 3; radius: 8 }
|
||||
}
|
||||
Row {
|
||||
id: row
|
||||
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
|
||||
spacing: 12
|
||||
Icon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: t.kind === "error" ? "error" : t.kind === "success" ? "check" : "music"
|
||||
size: 18
|
||||
color: t.kind === "error" ? Theme.error : t.kind === "success" ? Theme.success : Theme.accentGlow
|
||||
}
|
||||
Txt {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: t.message
|
||||
width: Math.min(implicitWidth, 640)
|
||||
}
|
||||
AeroButton {
|
||||
visible: t.folder.length > 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
variant: "text"
|
||||
pillHeight: 32
|
||||
text: "Open folder"
|
||||
onClicked: { app.openFolder(t.folder); t.shown = false }
|
||||
}
|
||||
IconButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 28; iconSize: 14
|
||||
iconName: "close"
|
||||
glow: false
|
||||
onClicked: t.shown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Main pane for the selected track: progress, error, or stem lanes.
|
||||
Item {
|
||||
id: tv
|
||||
property var win: Window.window
|
||||
readonly property var sel: app.selected
|
||||
readonly property string status: sel.valid ? sel.status : ""
|
||||
|
||||
// Lane model only changes when the track or its stems change, so per-lane
|
||||
// UI state survives the frequent selectedChanged notifications.
|
||||
property string stemsKey: ""
|
||||
property var stems: []
|
||||
property var soloState: []
|
||||
property var muteState: []
|
||||
property var gainState: []
|
||||
property real viewStart: 0
|
||||
property real viewEnd: 1
|
||||
property int waveW: 100
|
||||
|
||||
function syncStems() {
|
||||
var key = sel.valid && sel.status === "done" ? sel.id + ":" + sel.stems.map(s => s.name).join(",") : ""
|
||||
if (key === stemsKey) return
|
||||
stemsKey = key
|
||||
stems = key ? sel.stems : []
|
||||
soloState = stems.map(() => false)
|
||||
muteState = stems.map(() => false)
|
||||
gainState = stems.map(() => 0)
|
||||
viewStart = 0
|
||||
viewEnd = Math.max(0.01, sel.duration || 1)
|
||||
}
|
||||
onSelChanged: syncStems()
|
||||
Component.onCompleted: syncStems()
|
||||
|
||||
readonly property bool anySolo: soloState.indexOf(true) >= 0
|
||||
readonly property real span: Math.max(0.01, viewEnd - viewStart)
|
||||
readonly property real duration: Math.max(0.01, sel.duration || 1)
|
||||
|
||||
function zoomAt(fx, factor) {
|
||||
var t = viewStart + fx * span
|
||||
var newSpan = Math.max(0.25, Math.min(duration, span * factor))
|
||||
var s = Math.max(0, Math.min(duration - newSpan, t - fx * newSpan))
|
||||
viewStart = s
|
||||
viewEnd = s + newSpan
|
||||
}
|
||||
function pan(dt) {
|
||||
var s = Math.max(0, Math.min(duration - span, viewStart + dt))
|
||||
viewEnd = s + span
|
||||
viewStart = s
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- done
|
||||
Item {
|
||||
id: doneView
|
||||
anchors.fill: parent
|
||||
visible: tv.status === "done"
|
||||
|
||||
Card {
|
||||
id: header
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 24; bottomMargin: 0 }
|
||||
height: 104
|
||||
Column {
|
||||
anchors { left: parent.left; leftMargin: 20; right: actions.left; rightMargin: 16; verticalCenter: parent.verticalCenter }
|
||||
spacing: 6
|
||||
Txt {
|
||||
width: parent.width
|
||||
text: tv.sel.fileName || ""
|
||||
font.pixelSize: Theme.fs20
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Txt {
|
||||
width: parent.width
|
||||
color: Theme.textLo
|
||||
font.pixelSize: Theme.fs12
|
||||
text: [tv.sel.modelLabel, tv.sel.deviceLabel, Theme.time(tv.sel.duration || 0, false),
|
||||
tv.sel.elapsed > 0 ? "separated in " + tv.sel.elapsed.toFixed(1) + " s" : "from cache"]
|
||||
.filter(s => s && s.length).join(" · ")
|
||||
}
|
||||
Row {
|
||||
spacing: 6
|
||||
Icon { name: "folder"; size: 14; color: Theme.textLo; anchors.verticalCenter: parent.verticalCenter }
|
||||
Txt {
|
||||
text: (tv.sel.exportedTo ? "Exported to " : "Exports to ") + (tv.sel.target || "")
|
||||
+ " · " + appSettings.exportFormat.toUpperCase()
|
||||
font.pixelSize: Theme.fs12
|
||||
color: tv.sel.exportedTo ? Theme.success : Theme.textLo
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
id: actions
|
||||
anchors { right: parent.right; rightMargin: 20; verticalCenter: parent.verticalCenter }
|
||||
spacing: 8
|
||||
AeroButton {
|
||||
visible: (tv.sel.rerunLabel || "").length > 0
|
||||
variant: "text"
|
||||
iconName: "refresh"
|
||||
text: "Re-run with " + (tv.sel.rerunLabel || "")
|
||||
onClicked: app.rerunSelected()
|
||||
}
|
||||
IconButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 40
|
||||
iconName: "folder"
|
||||
tip: "Open output folder"
|
||||
onClicked: app.openTargetFolder()
|
||||
}
|
||||
AeroButton {
|
||||
variant: "tonal"
|
||||
iconName: "layers"
|
||||
text: "Export mix"
|
||||
onClicked: app.exportMix()
|
||||
}
|
||||
AeroButton {
|
||||
variant: "filled"
|
||||
iconName: "download"
|
||||
text: "Export all"
|
||||
onClicked: app.exportAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// timeline ruler + zoom hint
|
||||
Item {
|
||||
id: ruler
|
||||
anchors { left: parent.left; right: parent.right; top: header.bottom; leftMargin: 24; rightMargin: 24; topMargin: 12 }
|
||||
height: 22
|
||||
readonly property real waveX: 248
|
||||
Txt {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: tv.span < tv.duration - 0.01 ? "Zoomed · Ctrl+wheel to zoom, Shift+wheel to pan, double-click to reset"
|
||||
: "Ctrl+wheel to zoom"
|
||||
font.pixelSize: 11
|
||||
color: Theme.alpha(Theme.textLo, 0.8)
|
||||
}
|
||||
Repeater {
|
||||
model: 6
|
||||
Txt {
|
||||
required property int index
|
||||
x: ruler.waveX + (ruler.width - ruler.waveX - 8) * index / 5 - (index === 5 ? width : index === 0 ? 0 : width / 2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Theme.time(tv.viewStart + tv.span * index / 5, tv.span < 20)
|
||||
font.pixelSize: 11
|
||||
font.features: { "tnum": 1 }
|
||||
color: Theme.textLo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: lanes
|
||||
anchors { left: parent.left; right: parent.right; top: ruler.bottom; bottom: parent.bottom; leftMargin: 24; rightMargin: 24; topMargin: 6 }
|
||||
contentHeight: col.height + 24
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
contentItem: Rectangle { implicitWidth: 4; radius: 2; color: Qt.rgba(1, 1, 1, 0.18); visible: parent.size < 1 }
|
||||
background: Item {}
|
||||
}
|
||||
|
||||
readonly property real waveX: ruler.waveX
|
||||
onWidthChanged: waveTimer.restart()
|
||||
Timer { // debounce texture re-renders during window resize
|
||||
id: waveTimer
|
||||
interval: 90
|
||||
onTriggered: tv.waveW = Math.max(10, lanes.width - lanes.waveX - 8)
|
||||
}
|
||||
Component.onCompleted: tv.waveW = Math.max(10, lanes.width - lanes.waveX - 8)
|
||||
|
||||
Column {
|
||||
id: col
|
||||
width: lanes.width
|
||||
spacing: 8
|
||||
topPadding: 4
|
||||
Repeater {
|
||||
model: tv.stems
|
||||
StemLane {
|
||||
width: col.width
|
||||
jobId: tv.sel.id || ""
|
||||
waveX: lanes.waveX
|
||||
waveW: tv.waveW
|
||||
viewStart: tv.viewStart
|
||||
viewEnd: tv.viewEnd
|
||||
anySolo: tv.anySolo
|
||||
solo: tv.soloState[index] || false
|
||||
mute: tv.muteState[index] || false
|
||||
gainDb: tv.gainState[index] || 0
|
||||
onSoloToggled: (on) => {
|
||||
var s = tv.soloState.slice(); s[index] = on; tv.soloState = s
|
||||
audio.setSolo(index, on)
|
||||
}
|
||||
onMuteChanged: { var m = tv.muteState.slice(); m[index] = mute; tv.muteState = m }
|
||||
onGainDbChanged: { var g = tv.gainState.slice(); g[index] = gainDb; tv.gainState = g }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// click / drag on any waveform to seek
|
||||
MouseArea {
|
||||
x: lanes.waveX
|
||||
y: 0
|
||||
width: tv.waveW
|
||||
height: col.height
|
||||
cursorShape: Qt.IBeamCursor
|
||||
enabled: audio.loaded
|
||||
function seekTo(mx) {
|
||||
var t = tv.viewStart + Math.max(0, Math.min(1, mx / width)) * tv.span
|
||||
audio.seek(t)
|
||||
tv.win.playPos = t
|
||||
}
|
||||
onPressed: (e) => seekTo(e.x)
|
||||
onPositionChanged: (e) => { if (pressed) seekTo(e.x) }
|
||||
onDoubleClicked: { tv.viewStart = 0; tv.viewEnd = tv.duration }
|
||||
}
|
||||
|
||||
// playhead (position interpolated per frame from the audio ring buffer)
|
||||
Rectangle {
|
||||
readonly property real f: (tv.win.playPos - tv.viewStart) / tv.span
|
||||
visible: audio.loaded && f >= 0 && f <= 1
|
||||
x: lanes.waveX + f * tv.waveW - 1
|
||||
y: 2
|
||||
width: 2
|
||||
height: col.height
|
||||
color: "#FFFFFF"
|
||||
Glow { radius: 1; sigma: 3; opacity: 0.8 }
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
acceptedModifiers: Qt.ControlModifier
|
||||
onWheel: (e) => {
|
||||
var fx = Math.max(0, Math.min(1, (point.position.x - lanes.waveX) / tv.waveW))
|
||||
tv.zoomAt(fx, e.angleDelta.y > 0 ? 0.8 : 1.25)
|
||||
}
|
||||
}
|
||||
WheelHandler {
|
||||
acceptedModifiers: Qt.ShiftModifier
|
||||
onWheel: (e) => tv.pan(-(e.angleDelta.y || e.angleDelta.x) / 120 * tv.span * 0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- running / queued
|
||||
Card {
|
||||
visible: tv.status === "running" || tv.status === "queued"
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 48, 620)
|
||||
height: 236
|
||||
elevation: 2
|
||||
Column {
|
||||
anchors { fill: parent; margins: 28 }
|
||||
spacing: 12
|
||||
Txt {
|
||||
width: parent.width
|
||||
text: tv.sel.fileName || ""
|
||||
font.pixelSize: Theme.fs20
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Txt {
|
||||
text: tv.status === "queued" ? "Waiting for the GPU · " + (tv.sel.modelLabel || "")
|
||||
: (tv.sel.stage || "") + " · " + (tv.sel.modelLabel || "")
|
||||
color: Theme.textLo
|
||||
}
|
||||
Row {
|
||||
spacing: 12
|
||||
Txt {
|
||||
text: Math.floor((progressBar.shown) * 100) + "%"
|
||||
font.pixelSize: Theme.fs28
|
||||
font.weight: Font.DemiBold
|
||||
font.features: { "tnum": 1 }
|
||||
color: tv.status === "running" ? Theme.textHi : Theme.textLo
|
||||
}
|
||||
}
|
||||
StripedProgress {
|
||||
id: progressBar
|
||||
width: parent.width
|
||||
barHeight: 10
|
||||
value: tv.sel.progress || 0
|
||||
active: tv.status === "running"
|
||||
opacity: tv.status === "running" ? 1 : 0.4
|
||||
}
|
||||
Item { width: 1; height: 4 }
|
||||
AeroButton {
|
||||
variant: "tonal"
|
||||
tone: Theme.error
|
||||
iconName: "close"
|
||||
text: tv.status === "queued" ? "Remove from queue" : "Cancel"
|
||||
onClicked: app.cancelJob(tv.sel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------ error / cancelled
|
||||
Card {
|
||||
visible: tv.status === "error" || tv.status === "cancelled"
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 48, 620)
|
||||
height: errCol.implicitHeight + 56
|
||||
Column {
|
||||
id: errCol
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 28 }
|
||||
spacing: 12
|
||||
Row {
|
||||
spacing: 10
|
||||
Icon {
|
||||
name: tv.status === "error" ? "error" : "close"
|
||||
size: 24
|
||||
color: tv.status === "error" ? Theme.error : Theme.textLo
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Txt {
|
||||
text: tv.status === "error" ? "Separation failed" : "Cancelled"
|
||||
font.pixelSize: Theme.fs20
|
||||
font.weight: Font.DemiBold
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
Txt { width: parent.width; text: tv.sel.fileName || ""; color: Theme.textLo }
|
||||
Rectangle {
|
||||
visible: tv.status === "error"
|
||||
width: parent.width
|
||||
height: msg.implicitHeight + 20
|
||||
radius: 6
|
||||
color: Theme.alpha(Theme.error, 0.08)
|
||||
border.color: Theme.alpha(Theme.error, 0.25)
|
||||
Txt {
|
||||
id: msg
|
||||
anchors { fill: parent; margins: 10 }
|
||||
text: tv.sel.message || ""
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideNone
|
||||
font.pixelSize: Theme.fs12
|
||||
}
|
||||
}
|
||||
Row {
|
||||
spacing: 8
|
||||
AeroButton { variant: "filled"; iconName: "refresh"; text: "Retry"; onClicked: app.retryJob(tv.sel.id) }
|
||||
AeroButton { variant: "text"; tone: Theme.textLo; iconName: "delete"; text: "Remove"; onClicked: app.removeJob(tv.sel.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
GlassPanel {
|
||||
id: tr
|
||||
property var win: Window.window
|
||||
readonly property bool ready: audio.loaded
|
||||
readonly property real dur: audio.duration
|
||||
bottomLeftRadius: win.radius
|
||||
bottomRightRadius: win.radius
|
||||
gloss: true
|
||||
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
height: 1
|
||||
color: Theme.hairline
|
||||
}
|
||||
|
||||
Row {
|
||||
id: left
|
||||
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
|
||||
spacing: 10
|
||||
IconButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "skip_start"
|
||||
enabled: tr.ready
|
||||
tip: "Back to start (Home)"
|
||||
onClicked: { audio.seek(0); tr.win.playPos = 0 }
|
||||
}
|
||||
AbstractButton { // glossy round play/pause
|
||||
id: play
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 44
|
||||
height: 44
|
||||
enabled: tr.ready
|
||||
hoverEnabled: true
|
||||
opacity: enabled ? 1 : 0.4
|
||||
onClicked: audio.toggle()
|
||||
background: Item {
|
||||
Glow { radius: 22; opacity: play.hovered && play.enabled ? 0.6 : 0 }
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 22
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: "#6CC4FF" }
|
||||
GradientStop { position: 1; color: "#1F7FE0" }
|
||||
}
|
||||
border.color: Qt.rgba(1, 1, 1, 0.35)
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
|
||||
height: 19
|
||||
topLeftRadius: 20
|
||||
topRightRadius: 20
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.45) }
|
||||
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.08) }
|
||||
}
|
||||
}
|
||||
Rectangle { anchors.fill: parent; radius: 22; color: play.down ? Qt.rgba(0, 0, 0, 0.2) : "transparent" }
|
||||
}
|
||||
}
|
||||
contentItem: Item {
|
||||
Icon { anchors.centerIn: parent; name: audio.playing ? "pause" : "play"; size: 24; color: "#FFFFFF" }
|
||||
}
|
||||
}
|
||||
Txt {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 118
|
||||
text: Theme.time(tr.win.playPos, true) + " / " + Theme.time(tr.dur, false)
|
||||
font.features: { "tnum": 1 }
|
||||
font.pixelSize: Theme.fs14
|
||||
color: tr.ready ? Theme.textHi : Theme.textLo
|
||||
}
|
||||
}
|
||||
|
||||
// scrub bar
|
||||
Item {
|
||||
id: scrub
|
||||
anchors { left: left.right; leftMargin: 16; right: volume.left; rightMargin: 24; verticalCenter: parent.verticalCenter }
|
||||
height: 24
|
||||
enabled: tr.ready
|
||||
readonly property real frac: tr.dur > 0 ? Math.min(1, tr.win.playPos / tr.dur) : 0
|
||||
Rectangle {
|
||||
id: track
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width
|
||||
height: scrubArea.containsMouse || scrubArea.pressed ? 6 : 4
|
||||
radius: 3
|
||||
color: Qt.rgba(1, 1, 1, 0.10)
|
||||
Behavior on height { NumberAnimation { duration: Theme.fast } }
|
||||
Rectangle {
|
||||
width: parent.width * scrub.frac
|
||||
height: parent.height
|
||||
radius: 3
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.primary }
|
||||
GradientStop { position: 1; color: Theme.accentGlow }
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
visible: tr.ready
|
||||
x: scrub.width * scrub.frac - width / 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: scrubArea.containsMouse || scrubArea.pressed ? 14 : 10
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: "#FFFFFF"
|
||||
Behavior on width { NumberAnimation { duration: Theme.fast } }
|
||||
Glow { radius: 7; sigma: 4; opacity: scrubArea.containsMouse ? 0.7 : 0.3 }
|
||||
}
|
||||
MouseArea {
|
||||
id: scrubArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: tr.ready
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
function seekTo(x) {
|
||||
var t = Math.max(0, Math.min(1, x / width)) * tr.dur
|
||||
audio.seek(t)
|
||||
tr.win.playPos = t
|
||||
}
|
||||
onPressed: (e) => seekTo(e.x)
|
||||
onPositionChanged: (e) => { if (pressed) seekTo(e.x) }
|
||||
}
|
||||
}
|
||||
|
||||
// master volume: monitoring only, never baked into exports
|
||||
Row {
|
||||
id: volume
|
||||
anchors { right: meter.left; rightMargin: 24; verticalCenter: parent.verticalCenter }
|
||||
spacing: 6
|
||||
IconButton {
|
||||
id: muteBtn
|
||||
property real restore: 1.0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: audio.volume <= 0 ? "volume_off" : "volume"
|
||||
tip: audio.volume <= 0 ? "Unmute" : "Mute"
|
||||
onClicked: {
|
||||
if (audio.volume > 0) {
|
||||
restore = audio.volume
|
||||
audio.volume = 0
|
||||
} else {
|
||||
audio.volume = restore > 0 ? restore : 1.0
|
||||
}
|
||||
appSettings.masterVolume = audio.volume
|
||||
}
|
||||
}
|
||||
AeroSlider {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 112
|
||||
from: 0
|
||||
to: 1
|
||||
value: audio.volume
|
||||
onMoved: audio.volume = value
|
||||
onPressedChanged: if (!pressed) appSettings.masterVolume = audio.volume
|
||||
WheelHandler {
|
||||
onWheel: (e) => {
|
||||
audio.volume = Math.max(0, Math.min(1, audio.volume + e.angleDelta.y / 120 * 0.05))
|
||||
appSettings.masterVolume = audio.volume
|
||||
}
|
||||
}
|
||||
}
|
||||
Txt {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 36
|
||||
text: Math.round(audio.volume * 100) + "%"
|
||||
font.features: { "tnum": 1 }
|
||||
font.pixelSize: Theme.fs12
|
||||
color: Theme.textLo
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: meter
|
||||
anchors { right: parent.right; rightMargin: 20; verticalCenter: parent.verticalCenter }
|
||||
spacing: 10
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
Txt { text: "L"; font.pixelSize: 9; color: Theme.textLo; height: 9 }
|
||||
Txt { text: "R"; font.pixelSize: 9; color: Theme.textLo; height: 9 }
|
||||
}
|
||||
LevelMeter {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 160
|
||||
levelLeft: tr.win.levelL
|
||||
levelRight: tr.win.levelR
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import QtQuick
|
||||
|
||||
Text {
|
||||
color: Theme.textHi
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fs14
|
||||
renderType: Text.NativeRendering
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
module StemRoller
|
||||
singleton Theme 1.0 Theme.qml
|
||||
AeroButton 1.0 AeroButton.qml
|
||||
AeroComboBox 1.0 AeroComboBox.qml
|
||||
AeroSlider 1.0 AeroSlider.qml
|
||||
AeroSwitch 1.0 AeroSwitch.qml
|
||||
CaptionButton 1.0 CaptionButton.qml
|
||||
Card 1.0 Card.qml
|
||||
DashedFrame 1.0 DashedFrame.qml
|
||||
DropZone 1.0 DropZone.qml
|
||||
Elevation 1.0 Elevation.qml
|
||||
FrameOverlay 1.0 FrameOverlay.qml
|
||||
GlassPanel 1.0 GlassPanel.qml
|
||||
Glow 1.0 Glow.qml
|
||||
IconButton 1.0 IconButton.qml
|
||||
Icon 1.0 Icon.qml
|
||||
LevelMeter 1.0 LevelMeter.qml
|
||||
QueueItem 1.0 QueueItem.qml
|
||||
ResizeEdge 1.0 ResizeEdge.qml
|
||||
SettingsDialog 1.0 SettingsDialog.qml
|
||||
Sidebar 1.0 Sidebar.qml
|
||||
StatusChip 1.0 StatusChip.qml
|
||||
StemLane 1.0 StemLane.qml
|
||||
StripedProgress 1.0 StripedProgress.qml
|
||||
Tip 1.0 Tip.qml
|
||||
TitleBar 1.0 TitleBar.qml
|
||||
Toast 1.0 Toast.qml
|
||||
TrackView 1.0 TrackView.qml
|
||||
Transport 1.0 Transport.qml
|
||||
Txt 1.0 Txt.qml
|
||||
Reference in new issue
Block a user