- 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>
291 lines
14 KiB
QML
291 lines
14 KiB
QML
import QtQuick
|
|
import QtQuick.Controls.Basic
|
|
|
|
// Tabs on the green strip, commands on the gray ribbon below.
|
|
Column {
|
|
id: rib
|
|
property var win: Window.window
|
|
property int tab: 1 // 0 = File (backstage), 1 Home, 2 Insert, 3 Data, 4 View
|
|
readonly property var tabs: ["File", "Home", "Insert", "Data", "View"]
|
|
function focusSearch() { tell.startEditing() }
|
|
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 28
|
|
color: X.green
|
|
Row {
|
|
x: 0
|
|
height: parent.height
|
|
Repeater {
|
|
model: rib.tabs
|
|
delegate: Item {
|
|
id: tabItem
|
|
required property int index
|
|
required property string modelData
|
|
readonly property bool file: index === 0
|
|
readonly property bool active: rib.tab === index && !file
|
|
width: tl.implicitWidth + (file ? 30 : 22)
|
|
height: 28
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.topMargin: tabItem.active ? 2 : 0
|
|
color: tabItem.active ? X.ribbon : tabItem.file ? (tm.containsMouse ? "#0E4B2A" : X.greenDark)
|
|
: tm.containsMouse ? X.greenHover : "transparent"
|
|
}
|
|
T {
|
|
id: tl
|
|
anchors.centerIn: parent
|
|
text: tabItem.modelData
|
|
color: tabItem.active ? X.greenText : "#FFFFFF"
|
|
}
|
|
MouseArea {
|
|
id: tm
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: tabItem.file ? rib.win.openBackstage("info") : rib.tab = tabItem.index
|
|
}
|
|
}
|
|
}
|
|
// "Tell me what you want to do": here it searches the library.
|
|
Item {
|
|
id: tell
|
|
width: 260
|
|
height: 28
|
|
function startEditing() { field.visible = true; field.forceActiveFocus(); field.selectAll() }
|
|
Ico { x: 12; anchors.verticalCenter: parent.verticalCenter; name: "lightbulb"; ink: "#FFFFFF"; size: 16 }
|
|
T {
|
|
x: 34
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: !field.visible
|
|
color: "#FFFFFF"
|
|
text: "Tell me what you want to listen to..."
|
|
}
|
|
MouseArea { anchors.fill: parent; cursorShape: Qt.IBeamCursor; onClicked: tell.startEditing() }
|
|
XField {
|
|
id: field
|
|
visible: false
|
|
x: 32
|
|
width: 220
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
placeholderText: "Song, artist or album"
|
|
onAccepted: { library.search(text); visible = false; rib.win.focusGrid() }
|
|
onActiveFocusChanged: if (!activeFocus && text === "") visible = false
|
|
Keys.onEscapePressed: { text = ""; visible = false; rib.win.focusGrid() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 94
|
|
color: X.ribbon
|
|
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.ribbonLine }
|
|
|
|
// ---- Home ----
|
|
Row {
|
|
visible: rib.tab === 1
|
|
height: parent.height - 2
|
|
RGroup {
|
|
title: "Playback"
|
|
RButton {
|
|
big: true
|
|
icon: player.status === "playing" ? "pause" : "play"
|
|
text: player.status === "playing" ? "Pause" : "Play"
|
|
tip: "Play or pause (Space)"
|
|
onClicked: player.toggle()
|
|
}
|
|
Column {
|
|
RButton { icon: "previous"; text: "Previous"; tip: "Previous song (Ctrl+Left)"; onClicked: player.previous() }
|
|
RButton { icon: "next"; text: "Next"; tip: "Next song (Ctrl+Right)"; onClicked: player.next() }
|
|
RButton { icon: "stop"; text: "Stop"; onClicked: { engine.pause(); player.seek(0) } }
|
|
}
|
|
Column {
|
|
RButton { icon: "shuffle"; text: "Shuffle"; checked: player.shuffleOn; onClicked: player.toggleShuffle() }
|
|
RButton {
|
|
icon: player.repeatMode === "one" ? "repeat_one" : "repeat"
|
|
text: player.repeatMode === "one" ? "Repeat One" : "Repeat"
|
|
checked: player.repeatMode !== "off"
|
|
onClicked: player.cycleRepeat()
|
|
}
|
|
}
|
|
}
|
|
RGroup {
|
|
title: "Karaoke"
|
|
RButton {
|
|
big: true
|
|
enabled: player.sourceKind === "stems"
|
|
icon: player.presetLabel === "Karaoke" ? "vocals_off" : "vocals"
|
|
text: player.presetLabel === "Karaoke" ? "Vocals\nOff" : "Vocals\nOn"
|
|
checked: player.presetLabel === "Karaoke"
|
|
tip: "Mute the vocals (V)"
|
|
onClicked: player.toggleVocals()
|
|
}
|
|
}
|
|
RGroup {
|
|
title: "Mix Styles"
|
|
Grid {
|
|
columns: 4
|
|
rowSpacing: 3
|
|
columnSpacing: 3
|
|
y: 4
|
|
Repeater {
|
|
model: player.presets
|
|
delegate: Rectangle {
|
|
id: sty
|
|
required property int index
|
|
required property var modelData
|
|
readonly property var s: X.styles[index]
|
|
width: 82
|
|
height: 28
|
|
opacity: modelData.usable ? 1 : 0.35
|
|
color: s.fill
|
|
border.width: modelData.active ? 2 : 1
|
|
border.color: modelData.active ? X.green : (sm.containsMouse ? "#8A8A8A" : s.border)
|
|
T {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 5
|
|
font.family: cellFont
|
|
font.pixelSize: 14
|
|
font.bold: sty.s.bold
|
|
color: sty.s.text
|
|
text: sty.modelData.label
|
|
}
|
|
MouseArea {
|
|
id: sm
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
enabled: sty.modelData.usable
|
|
onClicked: player.applyPreset(sty.index)
|
|
}
|
|
Tip { target: sm; text: "Style: " + sty.s.name + " — " + sty.modelData.label }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
RGroup {
|
|
title: "Cells"
|
|
RButton { big: true; icon: "play_next"; text: "Play\nNext"; tip: "Play the selection after this song"; onClicked: library.playNextRows(rib.win.selTop, rib.win.selBottom) }
|
|
RButton { big: true; icon: "queue_add"; text: "Add to\nQueue"; tip: "Add the selection to the end of the queue"; onClicked: library.queueRows(rib.win.selTop, rib.win.selBottom) }
|
|
RButton {
|
|
big: true
|
|
icon: "delete_rows"
|
|
text: "Delete\nRows"
|
|
enabled: library.layout === "queue" || library.sheetTitle !== "" && rib.win.inPlaylist
|
|
tip: "Remove the selected rows from the queue or playlist (Delete)"
|
|
onClicked: library.removeRows(rib.win.selTop, rib.win.selBottom)
|
|
}
|
|
}
|
|
RGroup {
|
|
title: "Editing"
|
|
Column {
|
|
RButton { icon: "sigma"; text: "AutoShuffle"; tip: "Shuffle everything on this sheet"; onClicked: library.shuffleSheet() }
|
|
RButton { icon: "sort_az"; text: "Sort & Filter"; dropdown: true; onClicked: sortMenu.popup() }
|
|
RButton { icon: "search"; text: "Find & Select"; tip: "Search the library (Ctrl+F)"; onClicked: rib.focusSearch() }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Insert ----
|
|
Row {
|
|
visible: rib.tab === 2
|
|
height: parent.height - 2
|
|
RGroup {
|
|
title: "Playlists"
|
|
RButton { big: true; icon: "playlist_new"; text: "New\nPlaylist"; tip: "A Plex playlist from the selected rows"; onClicked: rib.win.newPlaylist() }
|
|
RButton { big: true; icon: "playlist_add"; text: "Add to\nPlaylist"; dropdown: true; onClicked: plMenu.popup() }
|
|
}
|
|
RGroup {
|
|
title: "Queue"
|
|
RButton { big: true; icon: "play_next"; text: "Play\nNext"; onClicked: library.playNextRows(rib.win.selTop, rib.win.selBottom) }
|
|
RButton { big: true; icon: "queue_add"; text: "Add to\nQueue"; onClicked: library.queueRows(rib.win.selTop, rib.win.selBottom) }
|
|
RButton { big: true; icon: "play"; text: "Play\nSelection"; onClicked: library.playRows(rib.win.selTop, rib.win.selBottom) }
|
|
}
|
|
RGroup {
|
|
title: "Charts"
|
|
RButton { big: true; icon: "chart"; text: "Stem\nChart"; checked: rib.win.showMixer; tip: "Show the stem mixer pane"; onClicked: rib.win.toggleMixer() }
|
|
}
|
|
}
|
|
|
|
// ---- Data ----
|
|
Row {
|
|
visible: rib.tab === 3
|
|
height: parent.height - 2
|
|
RGroup {
|
|
title: "Get External Data"
|
|
RButton { big: true; icon: "plex"; text: library.signedIn ? "Plex\nAccount" : "Connect\nto Plex"; onClicked: rib.win.openBackstage("account") }
|
|
RButton { big: true; icon: "server"; text: "Connections"; tip: "Server addresses and folders"; onClicked: rib.win.openBackstage("options") }
|
|
}
|
|
RGroup {
|
|
title: "Connections"
|
|
RButton { big: true; icon: "refresh"; text: "Refresh\nAll"; tip: "Reload the Plex library and the stems index"; onClicked: library.refresh() }
|
|
Column {
|
|
y: 4
|
|
spacing: 2
|
|
T { text: "Songs: " + library.counts.songs.toLocaleString(Qt.locale(), "f", 0); height: 20 }
|
|
T { text: "With stems: " + library.counts.stems.toLocaleString(Qt.locale(), "f", 0); height: 20 }
|
|
T {
|
|
text: "Stem server: " + (library.counts.stemOnline === true ? "Online" : library.counts.stemOnline === false ? "Offline" : "Checking…")
|
|
color: library.counts.stemOnline === true ? X.goodText : library.counts.stemOnline === false ? X.badText : X.inkSoft
|
|
height: 20
|
|
}
|
|
}
|
|
}
|
|
RGroup {
|
|
title: "Sort & Filter"
|
|
Column {
|
|
RButton { icon: "sort_az"; text: "Sort A to Z"; onClicked: rib.win.sortCurrent(false) }
|
|
RButton { icon: "sort_za"; text: "Sort Z to A"; onClicked: rib.win.sortCurrent(true) }
|
|
RButton { icon: "filter"; text: "Stems Only"; tip: "Songs that have stems"; onClicked: library.current = 4 }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- View ----
|
|
Row {
|
|
visible: rib.tab === 4
|
|
height: parent.height - 2
|
|
RGroup {
|
|
title: "Show"
|
|
Column {
|
|
y: 2
|
|
XCheck { text: "Gridlines"; checked: rib.win.gridlines; onToggled: rib.win.setOption("gridlines", !rib.win.gridlines) }
|
|
XCheck { text: "Headings"; checked: rib.win.headings; onToggled: rib.win.setOption("headings", !rib.win.headings) }
|
|
XCheck { text: "Mixer Pane"; checked: rib.win.showMixer; onToggled: rib.win.toggleMixer() }
|
|
}
|
|
}
|
|
RGroup {
|
|
title: "Zoom"
|
|
RButton { big: true; icon: "zoom"; text: "Zoom"; tip: "Ctrl+scroll on the grid"; onClicked: rib.win.setZoom(rib.win.zoom >= 150 ? 100 : rib.win.zoom + 25) }
|
|
RButton { big: true; icon: "grid"; text: "100%"; onClicked: rib.win.setZoom(100) }
|
|
}
|
|
RGroup {
|
|
title: "Window"
|
|
RButton { big: true; icon: "freeze"; text: "Freeze\nPanes"; checked: true; tip: "The header row is always frozen" }
|
|
}
|
|
}
|
|
}
|
|
|
|
XMenu {
|
|
id: sortMenu
|
|
Action { text: "Sort A to Z"; icon.name: "sort_az"; onTriggered: rib.win.sortCurrent(false) }
|
|
Action { text: "Sort Z to A"; icon.name: "sort_za"; onTriggered: rib.win.sortCurrent(true) }
|
|
MenuSeparator {}
|
|
Action { text: "Filter: Stems Only"; icon.name: "filter"; onTriggered: library.current = 4 }
|
|
}
|
|
XMenu {
|
|
id: plMenu
|
|
Instantiator {
|
|
model: library.playlistNames
|
|
delegate: MenuItem {
|
|
required property int index
|
|
required property string modelData
|
|
text: modelData
|
|
onTriggered: rib.win.addToPlaylist(modelData)
|
|
}
|
|
onObjectAdded: (i, o) => plMenu.insertItem(i, o)
|
|
onObjectRemoved: (i, o) => plMenu.removeItem(o)
|
|
}
|
|
}
|
|
}
|