import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import "../components"
import "../components" as Components
// Now Playing — the signature screen. Progress is not a percent bar: it is
// an ember arc traced around the live pattern disc, with the ball as the
// moving endpoint — the interface shows progress the way the machine makes
// it. Plain Canvas drawing only (no effects layers; linuxfb-safe).
Page {
id: page
property var backend: null
property var stackView: null
property string patternName: ""
property string patternPreview: "" // Backend provides this via executionStarted signal
readonly property bool hasPattern: (backend && backend.currentFile !== "") || patternName !== ""
readonly property real progressRatio: backend ? backend.progress / 100 : 0
readonly property bool inPause: backend && backend.pauseRemaining >= 0
property string displayName: {
var name = ""
if (backend && backend.currentFile) name = backend.currentFile
else if (patternName) name = patternName
if (!name) return ""
var parts = name.split('/')
return parts[parts.length - 1].replace('.thr', '')
}
function formatDuration(s) {
if (s < 0) return ""
var h = Math.floor(s / 3600)
var m = Math.floor((s % 3600) / 60)
var sec = s % 60
function pad(n) { return (n < 10 ? "0" : "") + n }
return h > 0 ? h + ":" + pad(m) + ":" + pad(sec) : m + ":" + pad(sec)
}
// Direct connection to backend signals
Connections {
target: backend
function onExecutionStarted(fileName, preview) {
patternName = fileName
patternPreview = preview
}
}
Rectangle {
anchors.fill: parent
color: Components.ThemeManager.backgroundColor
}
ColumnLayout {
anchors.fill: parent
spacing: 0
// Header
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Components.ThemeManager.headerHeight
color: Components.ThemeManager.surfaceColor
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Components.ThemeManager.borderColor
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Components.ThemeManager.spaceLg
anchors.rightMargin: Components.ThemeManager.spaceLg
ConnectionStatus {
backend: page.backend
Layout.rightMargin: Components.ThemeManager.spaceSm
}
Label {
text: "Now Playing"
font.family: Components.ThemeManager.fontDisplay
font.pixelSize: Components.ThemeManager.fontSizeTitle
color: Components.ThemeManager.textPrimary
}
Item {
Layout.fillWidth: true
}
}
}
// Content
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
// ---- Left: the disc and its progress ring ----
Item {
id: stage
Layout.fillHeight: true
Layout.preferredWidth: page.width * 0.55
Item {
id: ringWrap
anchors.centerIn: parent
width: Math.min(stage.width, stage.height) - 2 * Components.ThemeManager.spaceXl
height: width
// Track + progress arc, redrawn only when progress moves
Canvas {
id: arcCanvas
anchors.fill: parent
// While weaving: fraction of the pattern drawn.
// Between patterns: the countdown to the next one.
property real ratio: {
if (inPause)
return backend && backend.pauseTotal > 0
? 1 - backend.pauseRemaining / backend.pauseTotal : 0
return hasPattern ? progressRatio : 0
}
onRatioChanged: requestPaint()
Connections {
target: Components.ThemeManager
function onDarkModeChanged() { arcCanvas.requestPaint() }
}
onPaint: {
var ctx = getContext("2d")
var w = width, h = height
ctx.clearRect(0, 0, w, h)
var cx = w / 2, cy = h / 2
var lineW = 5
var r = Math.min(w, h) / 2 - lineW / 2
if (r <= 0)
return // layout not settled yet
ctx.lineWidth = lineW
ctx.lineCap = "round"
// Track
ctx.beginPath()
ctx.strokeStyle = String(Components.ThemeManager.cardColor)
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
ctx.stroke()
// Progress, from 12 o'clock
if (ratio > 0) {
ctx.beginPath()
ctx.strokeStyle = String(Components.ThemeManager.accent)
ctx.arc(cx, cy, r, -Math.PI / 2,
-Math.PI / 2 + ratio * 2 * Math.PI)
ctx.stroke()
}
}
}
// The ball: endpoint of the arc (two stacked dots stand in
// for a glow — no shadow effects on linuxfb)
Item {
anchors.fill: parent
rotation: arcCanvas.ratio * 360
visible: (hasPattern || inPause) && arcCanvas.ratio > 0
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: -7
width: 19
height: 19
radius: 9.5
color: Components.ThemeManager.accent
opacity: 0.3
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: -3
width: 11
height: 11
radius: 5.5
color: Components.ThemeManager.accent
}
}
// Live pattern disc (the preview PNG is itself a round dish)
Image {
anchors.fill: parent
anchors.margins: 14
source: patternPreview ? "file://" + patternPreview : ""
fillMode: Image.PreserveAspectFit
asynchronous: true
visible: hasPattern && status === Image.Ready
}
// Resting dish when idle (or while the preview renders)
Rectangle {
anchors.fill: parent
anchors.margins: 14
radius: width / 2
color: Components.ThemeManager.surfaceColor
border.width: 1
border.color: Components.ThemeManager.borderColor
visible: !hasPattern || patternPreview === ""
Column {
anchors.centerIn: parent
spacing: Components.ThemeManager.spaceSm
Components.Icon {
name: "radio_unchecked"
size: 34
color: Components.ThemeManager.textTertiary
anchors.horizontalCenter: parent.horizontalCenter
}
Label {
text: {
if (inPause) return "Resting between patterns"
return hasPattern ? "Rendering preview" : "The table is resting"
}
font.family: Components.ThemeManager.fontMedium
font.pixelSize: Components.ThemeManager.fontSizeBody
color: Components.ThemeManager.textSecondary
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
}
// ---- Right: name, state, transport, speed ----
Rectangle {
Layout.fillHeight: true
Layout.fillWidth: true
color: Components.ThemeManager.surfaceColor
Rectangle {
anchors.left: parent.left
width: 1
height: parent.height
color: Components.ThemeManager.borderColor
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Components.ThemeManager.spaceXl
spacing: 0
Label {
text: inPause ? "Up next" : "Now weaving"
font.family: Components.ThemeManager.fontDisplay
font.pixelSize: 11
font.letterSpacing: 1.6
font.capitalization: Font.AllUppercase
color: Components.ThemeManager.accent
}
Label {
Layout.fillWidth: true
Layout.topMargin: Components.ThemeManager.spaceSm
text: {
if (inPause && backend && backend.nextPattern)
return backend.nextPattern
return displayName || "Nothing playing"
}
font.family: Components.ThemeManager.fontDisplay
font.pixelSize: Components.ThemeManager.fontSizeDisplay
color: hasPattern || inPause ? Components.ThemeManager.textPrimary
: Components.ThemeManager.textTertiary
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.Wrap
}
// Playlist position
Label {
Layout.fillWidth: true
Layout.topMargin: Components.ThemeManager.spaceXs
visible: backend && backend.playlistActive && backend.playlistTotal > 0
text: {
if (!backend) return ""
var s = (backend.playlistName ? backend.playlistName + " · " : "")
+ (backend.playlistIndex + 1) + " of " + backend.playlistTotal
if (backend.playlistClearing) s += " · clearing"
return s
}
font.family: Components.ThemeManager.fontBody
font.pixelSize: Components.ThemeManager.fontSizeCaption
color: Components.ThemeManager.textSecondary
elide: Text.ElideMiddle
}
// Progress / pause countdown line
Label {
Layout.fillWidth: true
Layout.topMargin: Components.ThemeManager.spaceLg
visible: hasPattern || inPause
textFormat: Text.StyledText
text: {
if (!backend) return ""
if (inPause)
return "" + formatDuration(backend.pauseRemaining)
+ " until the next pattern"
var pct = Math.round(backend.progress)
var s = "" + pct + "% woven"
if (backend.isPaused) s += " · paused"
return s
}
font.family: Components.ThemeManager.fontBody
font.pixelSize: Components.ThemeManager.fontSizeBody
color: Components.ThemeManager.textSecondary
}
Item { Layout.fillHeight: true }
// Transport
RowLayout {
Layout.fillWidth: true
spacing: Components.ThemeManager.spaceSm
ModernControlButton {
Layout.fillWidth: true
Layout.preferredWidth: 3
Layout.preferredHeight: Components.ThemeManager.controlHeight
icon: (backend && backend.isRunning && !backend.isPaused) ? "pause" : "play_arrow"
text: (backend && backend.isRunning && !backend.isPaused) ? "Pause" : "Resume"
buttonColor: Components.ThemeManager.accent
enabled: backend && backend.currentFile !== ""
onClicked: {
if (!backend) return
if (backend.isPaused) backend.resumeExecution()
else backend.pauseExecution()
}
}
ModernControlButton {
Layout.fillWidth: true
Layout.preferredWidth: 2
Layout.preferredHeight: Components.ThemeManager.controlHeight
icon: "stop"
text: "Stop"
outlined: true
buttonColor: Components.ThemeManager.danger
enabled: backend !== null
onClicked: if (backend) backend.stopExecution()
}
ModernControlButton {
Layout.fillWidth: true
Layout.preferredWidth: 2
Layout.preferredHeight: Components.ThemeManager.controlHeight
icon: "skip_next"
text: "Skip"
buttonColor: Components.ThemeManager.cardColor
enabled: backend !== null
onClicked: if (backend) backend.skipPattern()
}
}
// Speed
Label {
Layout.topMargin: Components.ThemeManager.spaceLg
text: "Speed · mm/s"
font.family: Components.ThemeManager.fontDisplay
font.pixelSize: 11
font.letterSpacing: 1.4
font.capitalization: Font.AllUppercase
color: Components.ThemeManager.textTertiary
}
// Segmented control
Rectangle {
id: speedSeg
Layout.fillWidth: true
Layout.topMargin: Components.ThemeManager.spaceSm
Layout.preferredHeight: 48
radius: height / 2
color: Components.ThemeManager.cardColor
property string currentSelection: backend ? backend.getCurrentSpeedOption() : "200"
Connections {
target: backend
function onSpeedChanged(speed) {
if (backend)
speedSeg.currentSelection = backend.getCurrentSpeedOption()
}
}
Row {
anchors.fill: parent
anchors.margins: 4
spacing: 2
Repeater {
model: ["50", "100", "150", "200", "300", "500"]
Rectangle {
property bool selected: speedSeg.currentSelection === modelData
width: (parent.width - 10) / 6
height: parent.height
radius: height / 2
color: selected ? Components.ThemeManager.backgroundColor : "transparent"
border.width: selected ? 1 : 0
border.color: Components.ThemeManager.borderColor
Label {
anchors.centerIn: parent
text: modelData
font.family: Components.ThemeManager.fontMedium
font.pixelSize: 13
color: parent.selected ? Components.ThemeManager.accent
: Components.ThemeManager.textSecondary
}
MouseArea {
anchors.fill: parent
onClicked: {
if (backend) {
backend.setSpeedByOption(modelData)
speedSeg.currentSelection = modelData
}
}
}
}
}
}
}
}
}
}
}
}