mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 09:00:11 +02:00
Consent 2026-10-03, after a non-lawyer review: who runs this and how to reach them, the dataset is public (Hugging Face, possibly abroad), the Hugging Face username shows next to the contributor id, purposes (no identification), safety, the maintainer grant passes to whoever maintains Frametop next, withdrawal before and after merge, rights such as the GDPR's, and what a new version means. Residents of Illinois, Texas and Washington can't take part for now (biometric privacy laws): a third checkbox, profile consent.region_ok, checked by validate.py from this consent version on. The DRAFT banners are gone, so uploads no longer need FT_HANDREC_ALLOW_UPLOAD. hands/rec/install.sh installs the recorder on a Frame with Frametop: container packages, hand tracking and panel builds, ft-camd's capabilities, menu entry. test_qml_backend also checks each call's argument count against the slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1847 lines
92 KiB
QML
1847 lines
92 KiB
QML
// Frametop Hand Recorder (Kirigami). Backend: ft_handrec.py ("backend"); frame sets come from
|
|
// its image provider (image://frames/...).
|
|
import QtQuick
|
|
import QtQuick.Controls as Controls
|
|
import QtQuick.Layouts
|
|
import org.kde.kirigami as Kirigami
|
|
|
|
Kirigami.ApplicationWindow {
|
|
id: root
|
|
title: "Frametop Hand Recorder"
|
|
width: Kirigami.Units.gridUnit * 50
|
|
height: Kirigami.Units.gridUnit * 36
|
|
|
|
// The session chosen on the Export page, for the Upload page.
|
|
property string chosenSession: ""
|
|
|
|
globalDrawer: Kirigami.GlobalDrawer {
|
|
isMenu: false
|
|
modal: false
|
|
collapsible: true
|
|
collapsed: root.width < Kirigami.Units.gridUnit * 34
|
|
actions: [
|
|
Kirigami.Action { text: "Welcome"; icon.name: "help-about"; onTriggered: root.show(welcomePage) },
|
|
Kirigami.Action {
|
|
text: "Before you start"; icon.name: "view-task"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(checklistPage)
|
|
},
|
|
Kirigami.Action {
|
|
text: "Session"; icon.name: "media-record"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(sessionPage)
|
|
},
|
|
Kirigami.Action { text: "Review"; icon.name: "view-preview"; onTriggered: root.show(reviewPage) },
|
|
Kirigami.Action {
|
|
text: "Export"; icon.name: "document-export"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(exportPage)
|
|
},
|
|
Kirigami.Action {
|
|
text: "Upload"; icon.name: "cloud-upload"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(uploadPage)
|
|
}
|
|
]
|
|
}
|
|
|
|
// The texts wait for a legal review; until then nobody should contribute.
|
|
component DraftBanner: Kirigami.InlineMessage {
|
|
visible: backend.textsDraft
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Contributions aren't open yet. These texts are drafts waiting for a legal review: you can "
|
|
+ "record and review, but please don't upload anything until this message is gone."
|
|
}
|
|
|
|
// Read-only Markdown (CONSENT.md, UPLOAD.md), selectable for copying.
|
|
component MarkdownText: Controls.TextArea {
|
|
id: markdownText
|
|
property string markdown
|
|
// Links get the application palette's colour when the text is parsed, and Kirigami's theme
|
|
// doesn't set that palette: set it, then parse again (the theme resolves after creation).
|
|
readonly property color linkColor: Kirigami.Theme.linkColor
|
|
function parse() {
|
|
backend.setLinkColor(linkColor)
|
|
text = markdown
|
|
}
|
|
onLinkColorChanged: parse()
|
|
onMarkdownChanged: parse()
|
|
Component.onCompleted: parse()
|
|
textFormat: TextEdit.MarkdownText
|
|
readOnly: true
|
|
selectByMouse: true
|
|
wrapMode: Text.Wrap
|
|
background: null
|
|
padding: 0
|
|
onLinkActivated: link => Qt.openUrlExternally(link)
|
|
}
|
|
|
|
function plural(n, word) {
|
|
return n + " " + word + (n === 1 ? "" : "s")
|
|
}
|
|
|
|
function show(page) {
|
|
pageStack.clear()
|
|
pageStack.push(page)
|
|
}
|
|
|
|
// --page welcome|checklist|session|review|export|upload opens the app on that page; the
|
|
// Welcome page comes first until the consent is agreed to.
|
|
pageStack.initialPage: backend.needsConsent && startPage !== "review" ? welcomePage
|
|
: (({ welcome: welcomePage, checklist: checklistPage, session: sessionPage, review: reviewPage,
|
|
export: exportPage, upload: uploadPage })[startPage] || checklistPage)
|
|
|
|
Connections {
|
|
target: backend
|
|
function onMessage(text, isError) {
|
|
root.showPassiveNotification(text, isError ? "long" : "short")
|
|
}
|
|
}
|
|
|
|
// Asks before something is deleted for good: ask(title, text, function).
|
|
Kirigami.PromptDialog {
|
|
id: confirm
|
|
property var action: null
|
|
standardButtons: Controls.Dialog.Ok | Controls.Dialog.Cancel
|
|
onAccepted: if (action) action()
|
|
function ask(title, text, fn) {
|
|
confirm.title = title
|
|
confirm.subtitle = text
|
|
confirm.action = fn
|
|
confirm.open()
|
|
}
|
|
}
|
|
|
|
// Restart SteamVR (the camera check's fix). What it does to Frametop, read from the code
|
|
// (hands/README.md, "Camera check"): ft-screens quits when SteamVR does, which ends the
|
|
// desktop; its unit doesn't restart, and stopping it ends what was started in it.
|
|
function askRestartSteamVR() {
|
|
confirm.ask("Restart SteamVR?",
|
|
"SteamVR starts its cameras again, and with them the colour camera module. This closes every "
|
|
+ "VR app. It also closes the Frametop desktop with all its windows, this one too: Frametop's "
|
|
+ "screens don't come back by themselves. Save your work first; programs started from the "
|
|
+ "desktop, such as terminals and what runs in them, stop too.\n\nOnce SteamVR is back, start "
|
|
+ "the desktop again (Desktop in the library) and open the Hand recorder: it checks the cameras "
|
|
+ "again. If they're still off, restart the headset.",
|
|
() => backend.restartSteamVR())
|
|
}
|
|
|
|
// The camera check's problem, with its fix (a header message on the checklist and session pages).
|
|
component CameraMessage: Kirigami.InlineMessage {
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: backend.restartingSteamVR ? Kirigami.MessageType.Information : Kirigami.MessageType.Error
|
|
text: backend.restartingSteamVR ? "Restarting SteamVR, then checking the cameras again…" : backend.cameraText
|
|
actions: [
|
|
Kirigami.Action {
|
|
text: "Restart SteamVR…"
|
|
icon.name: "system-reboot"
|
|
enabled: !backend.restartingSteamVR && !backend.sessionActive
|
|
onTriggered: root.askRestartSteamVR()
|
|
},
|
|
Kirigami.Action {
|
|
text: "Check again"
|
|
icon.name: "view-refresh"
|
|
enabled: !backend.cameraBusy
|
|
onTriggered: backend.checkCameras()
|
|
}
|
|
]
|
|
}
|
|
|
|
// Closing during a session asks first. Quitting closes the window again (Qt 6), so the
|
|
// answer is remembered; the backend stops the session as the app quits.
|
|
property bool quitting: false
|
|
onClosing: close => {
|
|
if (backend.sessionActive && !quitting) {
|
|
close.accepted = false
|
|
confirm.ask("Stop the session?", "A session is recording. Closing stops it; what's recorded so far is kept.",
|
|
() => { root.quitting = true; Qt.quit() })
|
|
} else if (backend.uploading && !quitting) {
|
|
close.accepted = false
|
|
confirm.ask("Cancel the upload?", "An upload is running. Closing cancels it; you can start it again later.",
|
|
() => { root.quitting = true; Qt.quit() })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Welcome
|
|
Component {
|
|
id: welcomePage
|
|
Kirigami.ScrollablePage {
|
|
title: "Welcome"
|
|
header: DraftBanner { }
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
MarkdownText {
|
|
Layout.fillWidth: true
|
|
markdown: backend.consentText
|
|
}
|
|
|
|
Kirigami.Separator { Layout.fillWidth: true }
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.needsConsent
|
|
type: Kirigami.MessageType.Positive
|
|
text: "You agreed to this text (" + backend.consentAccepted + "). Your contributor id is "
|
|
+ backend.contributor + ": keep it if you might want to withdraw later."
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.needsConsent
|
|
text: "Copy contributor id"
|
|
icon.name: "edit-copy"
|
|
onClicked: backend.copy(backend.contributor)
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.needsConsent && backend.contributor !== ""
|
|
type: Kirigami.MessageType.Information
|
|
text: "This text changed since you last agreed to it. Please read it again; your contributor id stays the same."
|
|
}
|
|
|
|
Controls.CheckBox {
|
|
id: adult
|
|
visible: backend.needsConsent
|
|
text: "I'm 18 or older"
|
|
}
|
|
Controls.CheckBox {
|
|
id: region
|
|
visible: backend.needsConsent
|
|
text: "I don't live in Illinois, Texas or Washington (USA)"
|
|
}
|
|
Controls.CheckBox {
|
|
id: agree
|
|
visible: backend.needsConsent
|
|
text: "I agree to the text above"
|
|
}
|
|
RowLayout {
|
|
visible: backend.needsConsent
|
|
Controls.Label { text: "Handedness (optional):" }
|
|
Controls.ComboBox {
|
|
id: handed
|
|
model: backend.handednessChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(backend.handedness))
|
|
}
|
|
}
|
|
Controls.Button {
|
|
visible: backend.needsConsent
|
|
enabled: adult.checked && region.checked && agree.checked
|
|
text: "Agree and continue"
|
|
icon.name: "go-next"
|
|
onClicked: {
|
|
backend.acceptConsent(adult.checked, region.checked, agree.checked, handed.currentValue)
|
|
if (!backend.needsConsent)
|
|
root.show(checklistPage)
|
|
}
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.needsConsent
|
|
text: "Continue"
|
|
icon.name: "go-next"
|
|
onClicked: root.show(checklistPage)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Before you start
|
|
Component {
|
|
id: checklistPage
|
|
Kirigami.ScrollablePage {
|
|
id: checklist
|
|
title: "Before you start"
|
|
readonly property bool privacyOk: privacy1.checked && privacy2.checked && privacy3.checked
|
|
readonly property bool ready: privacyOk && backend.diskOk
|
|
&& backend.runnerError === "" && !backend.sessionActive
|
|
|
|
function answers() {
|
|
const objects = []
|
|
for (let i = 0; i < objectBoxes.count; ++i) {
|
|
const box = objectBoxes.itemAt(i)
|
|
if (box.checked)
|
|
objects.push(box.value)
|
|
}
|
|
return {
|
|
objects: objects,
|
|
own_objects: ownObjects.text.split(",").map(s => s.trim()).filter(s => s !== ""),
|
|
controllers: straps.checked ? "straps" : "none",
|
|
sleeves: sleeves.currentValue,
|
|
rings: rings.checked,
|
|
watch: watch.checked,
|
|
notes: notes.text,
|
|
privacy: privacyOk
|
|
}
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.runnerError !== ""
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: backend.runnerError
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.sessionActive
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Information
|
|
text: "A session is running."
|
|
actions: [ Kirigami.Action { text: "Go to it"; onTriggered: root.show(sessionPage) } ]
|
|
}
|
|
CameraMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.sessionActive && (backend.cameraText !== "" || backend.restartingSteamVR)
|
|
}
|
|
}
|
|
Component.onCompleted: backend.checkCameras()
|
|
|
|
Kirigami.FormLayout {
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Within reach" }
|
|
Controls.Label {
|
|
Kirigami.FormData.label: ""
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
text: "Put these on the desk or table in front of you. Untick what you don't have; "
|
|
+ "the session skips those."
|
|
}
|
|
Repeater {
|
|
id: objectBoxes
|
|
model: backend.objects
|
|
Controls.CheckBox {
|
|
required property var modelData
|
|
required property int index
|
|
readonly property string value: modelData.value
|
|
Kirigami.FormData.label: index === 0 ? "Objects:" : ""
|
|
text: modelData.text
|
|
checked: true
|
|
}
|
|
}
|
|
Controls.TextField {
|
|
id: ownObjects
|
|
Kirigami.FormData.label: "Your own:"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
placeholderText: "other things you use, separated by commas"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Controllers" }
|
|
ColumnLayout {
|
|
Kirigami.FormData.label: "Ergonomic Kit straps:"
|
|
Controls.RadioButton { id: straps; text: "Yes, I have the controllers with the straps" }
|
|
Controls.RadioButton { text: "No"; checked: true }
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "With the straps, the controllers stay on your hands while your fingers move freely. "
|
|
+ "The controllers' tracking then says exactly where your hands are, which teaches the "
|
|
+ "model how far away a hand is. Without them, the two controller sections are skipped."
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Light" }
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "The cameras see:"
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
text: backend.lightingMeasured || "Not measured yet"
|
|
}
|
|
Controls.ComboBox {
|
|
id: lighting
|
|
Kirigami.FormData.label: "Lighting this round:"
|
|
model: backend.lightingChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
currentIndex: 0
|
|
onActivated: backend.checkLighting(currentValue)
|
|
Component.onCompleted: backend.checkLighting(currentValue)
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Each round in a different light helps the most: dim, a normal room, daylight. The cameras "
|
|
+ "tell daylight from indoor light themselves; to say dim or a normal room, pick it here."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
Layout.fillWidth: true
|
|
visible: backend.lightingNote !== ""
|
|
Layout.preferredHeight: visible ? implicitHeight : 0
|
|
type: Kirigami.MessageType.Warning
|
|
text: backend.lightingNote
|
|
}
|
|
Controls.Button {
|
|
visible: backend.lightingNote !== "" || backend.lightingMeasured === ""
|
|
text: "Measure the light again"
|
|
icon.name: "view-refresh"
|
|
onClicked: backend.checkLighting(lighting.currentValue)
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "You" }
|
|
Controls.ComboBox {
|
|
id: sleeves
|
|
Kirigami.FormData.label: "Sleeves:"
|
|
model: backend.sleeveChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
}
|
|
Controls.CheckBox { id: rings; Kirigami.FormData.label: "Wearing:"; text: "Rings" }
|
|
Controls.CheckBox { id: watch; text: "A watch or bracelet" }
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Wear what you normally do: the dataset needs hands with and without them. This only notes it."
|
|
}
|
|
Controls.TextField {
|
|
id: notes
|
|
Kirigami.FormData.label: "Notes (optional):"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
placeholderText: "anything that helps, e.g. a bandage on a finger"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Privacy" }
|
|
Controls.CheckBox {
|
|
id: privacy1
|
|
Kirigami.FormData.label: "I've checked:"
|
|
text: "I'm facing away from other people, mirrors and windows"
|
|
}
|
|
Controls.CheckBox {
|
|
id: privacy2
|
|
text: "No papers, photos, screens or other private things are in view"
|
|
}
|
|
Controls.CheckBox {
|
|
id: privacy3
|
|
text: "Nobody else's face or hands will be in view"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Space" }
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Free space:"
|
|
text: backend.freeText + " (a round takes about 10 GB)"
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
Layout.fillWidth: true
|
|
visible: !backend.diskOk
|
|
type: Kirigami.MessageType.Error
|
|
text: "Not enough free space for a round. Export and delete earlier sessions (Review), "
|
|
+ "or free up space, first."
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Cameras" }
|
|
RowLayout {
|
|
Kirigami.FormData.label: "Tracking cameras:"
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
|
|
wrapMode: Text.Wrap
|
|
text: backend.camerasIgnored ? "Not checked (--ignore-cameras)"
|
|
: backend.cameraState === "ok" ? "All four are running."
|
|
: backend.cameraState === "degraded" ? "Not all running: see the message at the top."
|
|
: backend.cameraState === "unknown" ? "Couldn't tell (" + backend.cameraSummary.replace(/^unknown: /, "")
|
|
+ "). The session checks again when it starts."
|
|
: "Checking…"
|
|
}
|
|
Controls.ToolButton {
|
|
visible: backend.cameraEvidence !== ""
|
|
icon.name: "documentinfo"
|
|
text: "Details"
|
|
checkable: true
|
|
id: cameraDetails
|
|
display: Controls.AbstractButton.IconOnly
|
|
Controls.ToolTip.text: "What the check looked at"
|
|
Controls.ToolTip.visible: hovered
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "view-refresh"
|
|
text: "Check again"
|
|
display: Controls.AbstractButton.IconOnly
|
|
enabled: !backend.cameraBusy
|
|
onClicked: backend.checkCameras()
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: cameraDetails.checked && backend.cameraEvidence !== ""
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 34
|
|
wrapMode: Text.WrapAnywhere
|
|
font: Kirigami.Theme.smallFont
|
|
opacity: 0.7
|
|
textFormat: Text.PlainText
|
|
text: backend.cameraSummary + "\n" + backend.cameraEvidence
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "What will happen" }
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
text: "Press Start, then put the headset on. A panel in the headset shows each step: a picture "
|
|
+ "of the hand pose, where to hold your hands, and what to do. The sections: hand poses, "
|
|
+ "gestures, typing and the mouse, your objects, touching a dot, and moves with the "
|
|
+ "controllers on and off. Each section is recorded as one take. In the pose steps your "
|
|
+ "hands keep moving while a row of pictures lights up one shape after another: follow it.\n\n"
|
|
+ "Each step waits until you're ready: press the button on the right side of the headset, "
|
|
+ "or Space or Next in this window. A 3-2-1 "
|
|
+ "countdown follows, then hold the pose until the bar runs out. Nothing is recorded while "
|
|
+ "a step waits. The headset button also pauses and resumes a recording. In this window P "
|
|
+ "pauses, R records the last step again, S skips a section and Esc stops.\n\n"
|
|
+ "Nothing leaves the headset. Afterwards you watch the takes in Review, delete anything "
|
|
+ "you don't want to share, and only then export."
|
|
}
|
|
ColumnLayout {
|
|
Kirigami.FormData.label: "Round:"
|
|
Controls.RadioButton {
|
|
id: fullRound
|
|
text: "Full session"
|
|
checked: !backend.hasFullSession
|
|
}
|
|
Controls.RadioButton {
|
|
id: quickRound
|
|
text: "Quick round (about 3 min): hand size, poses, the dot, no hands"
|
|
checked: backend.hasFullSession
|
|
}
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
Layout.fillWidth: true
|
|
visible: backend.hasFullSession
|
|
Layout.preferredHeight: visible ? implicitHeight : 0
|
|
type: Kirigami.MessageType.Information
|
|
text: "You've recorded a full session already. A quick round in a different light (dim, "
|
|
+ "daylight) adds the most now."
|
|
}
|
|
Controls.CheckBox {
|
|
id: autoAdvance
|
|
Kirigami.FormData.label: "Pace:"
|
|
text: "Advance by itself (no Next between steps)"
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: (autoAdvance.checked
|
|
? "Each step shows for a few seconds and the next follows by itself. Quicker if you "
|
|
+ "know the steps already. "
|
|
: "") + "Length: " + backend.planText(checklist.answers(), autoAdvance.checked, quickRound.checked) + "."
|
|
}
|
|
Controls.Button {
|
|
text: "Start"
|
|
icon.name: "media-record"
|
|
enabled: checklist.ready && !backend.camerasBlockStart && !backend.restartingSteamVR
|
|
onClicked: {
|
|
if (backend.startSession(checklist.answers(), lighting.currentValue, autoAdvance.checked,
|
|
quickRound.checked))
|
|
root.show(sessionPage)
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: (!checklist.ready || backend.camerasBlockStart) && !backend.sessionActive
|
|
opacity: 0.7
|
|
text: !checklist.privacyOk ? "Tick the three privacy checks to start."
|
|
: backend.camerasBlockStart ? "The headset's cameras aren't all running: see the message at the top." : ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Session
|
|
Component {
|
|
id: sessionPage
|
|
Kirigami.Page {
|
|
id: sessionView
|
|
title: "Session"
|
|
readonly property var st: backend.status
|
|
readonly property string state: st.state || ""
|
|
readonly property bool waiting: !!st.waiting
|
|
readonly property bool stepMode: st.mode !== "auto"
|
|
readonly property var stateText: ({
|
|
starting: "Starting…", intro: "Get ready", ready: "Get ready", countdown: "Starting",
|
|
running: "Recording", paused: "Paused", between: "Between sections", done: "Done",
|
|
stopped: "Stopped", error: "Error", nohands: "No hands seen"
|
|
})
|
|
|
|
// The keys while the window has focus (the panel in the headset lists them too).
|
|
Shortcut {
|
|
sequence: "Space"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.nextStep()
|
|
}
|
|
Shortcut {
|
|
sequence: "P"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.togglePause()
|
|
}
|
|
Shortcut {
|
|
sequence: "R"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.redo()
|
|
}
|
|
Shortcut {
|
|
sequence: "S"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.skipSection()
|
|
}
|
|
Shortcut {
|
|
sequence: "Esc"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.stopSession()
|
|
}
|
|
|
|
// A hand chip like the panel's: seen (green), lost (orange) or hidden.
|
|
component HandChip: Controls.Label {
|
|
property var seen
|
|
visible: seen === true || seen === false || seen === "seen" || seen === "lost"
|
|
readonly property bool ok: seen === true || seen === "seen"
|
|
padding: Kirigami.Units.smallSpacing
|
|
leftPadding: Kirigami.Units.largeSpacing
|
|
rightPadding: Kirigami.Units.largeSpacing
|
|
color: "white"
|
|
background: Rectangle {
|
|
radius: height / 2
|
|
color: parent.ok ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.neutralTextColor
|
|
}
|
|
}
|
|
|
|
// The pose picture as the panel shows it: a right hand as drawn, a left hand flipped,
|
|
// both hands as a flipped copy beside it (DESIGN.md "The pose pictures").
|
|
component PosePicture: Row {
|
|
property string path
|
|
property string mode
|
|
property real side: Kirigami.Units.gridUnit * 9
|
|
spacing: Kirigami.Units.smallSpacing
|
|
visible: path !== ""
|
|
Image {
|
|
visible: parent.mode === "both"
|
|
width: visible ? parent.side * 0.75 : 0
|
|
height: width
|
|
source: parent.path ? "file://" + parent.path : ""
|
|
fillMode: Image.PreserveAspectFit
|
|
mirror: true
|
|
smooth: true
|
|
mipmap: true
|
|
}
|
|
Image {
|
|
width: parent.mode === "both" ? parent.side * 0.75 : parent.side
|
|
height: width
|
|
source: parent.path ? "file://" + parent.path : ""
|
|
fillMode: Image.PreserveAspectFit
|
|
mirror: parent.mode === "mirror"
|
|
smooth: true
|
|
mipmap: true
|
|
}
|
|
}
|
|
|
|
// Where to hold the hands: a front view (left, centre, right, up, down; the push
|
|
// sections' chest, desk and eye) and how far out (near, mid, far), as on the panel.
|
|
component WhereDiagram: RowLayout {
|
|
id: where
|
|
property string position
|
|
property string distance
|
|
readonly property var cell: ({ centre: [1, 1], center: [1, 1], chest: [1, 1], left: [0, 1],
|
|
right: [2, 1], up: [1, 0], eye: [1, 0], down: [1, 2],
|
|
desk: [1, 2] })[position] || null
|
|
readonly property int step: ["near", "mid", "far"].indexOf(distance)
|
|
readonly property real unit: Kirigami.Units.gridUnit * 1.1
|
|
readonly property color accent: "#4cd9ff"
|
|
spacing: Kirigami.Units.gridUnit
|
|
visible: cell !== null || step >= 0
|
|
ColumnLayout {
|
|
visible: where.cell !== null
|
|
Grid {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
columns: 3
|
|
spacing: 2
|
|
Repeater {
|
|
model: 9
|
|
Rectangle {
|
|
required property int index
|
|
width: where.unit * 1.33
|
|
height: where.unit
|
|
radius: 3
|
|
readonly property bool on: where.cell !== null && index === where.cell[1] * 3 + where.cell[0]
|
|
color: on ? where.accent
|
|
: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
|
|
Kirigami.Theme.textColor.b, 0.12)
|
|
}
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
font: Kirigami.Theme.smallFont
|
|
text: ({ left: "To your left", right: "To your right", up: "Up high", down: "Down low",
|
|
chest: "Chest height", desk: "Desk height", eye: "Eye level" })[where.position]
|
|
|| "In front"
|
|
}
|
|
}
|
|
ColumnLayout {
|
|
visible: where.step >= 0
|
|
Row {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
spacing: where.unit * 0.6
|
|
height: where.unit * 1.2
|
|
// the head, seen from the side, then the arm's reach
|
|
Rectangle {
|
|
width: where.unit
|
|
height: width
|
|
radius: width / 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
|
|
Kirigami.Theme.textColor.b, 0.6)
|
|
}
|
|
Repeater {
|
|
model: 3
|
|
Rectangle {
|
|
required property int index
|
|
readonly property bool on: index === where.step
|
|
width: on ? where.unit * 0.8 : where.unit * 0.35
|
|
height: width
|
|
radius: width / 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: on ? where.accent : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
|
|
Kirigami.Theme.textColor.b, 0.35)
|
|
}
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
font: Kirigami.Theme.smallFont
|
|
text: ["Close: a hand's length", "Halfway out", "Arm stretched out"][Math.max(0, where.step)]
|
|
}
|
|
}
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.runnerError !== "" || (sessionView.state === "error" && sessionView.st.error !== backend.cameraText)
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: backend.runnerError || ("The session stopped with an error: " + (sessionView.st.error || "unknown"))
|
|
}
|
|
// The camera check stopped the session (at its start, or after a step with no hands).
|
|
CameraMessage {
|
|
Layout.fillWidth: true
|
|
visible: (!backend.sessionActive && sessionView.state !== "" && backend.cameraText !== "")
|
|
|| backend.restartingSteamVR
|
|
}
|
|
}
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: sessionView.state === ""
|
|
icon.name: "media-record"
|
|
text: "No session yet"
|
|
explanation: "Go through Before you start, then press Start there."
|
|
helpfulAction: Kirigami.Action {
|
|
text: "Before you start"; icon.name: "view-task"
|
|
onTriggered: root.show(checklistPage)
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
anchors.margins: Kirigami.Units.gridUnit
|
|
visible: sessionView.state !== ""
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Kirigami.Heading {
|
|
level: 1
|
|
text: sessionView.stateText[sessionView.state] || sessionView.state
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Controls.Label {
|
|
visible: !!sessionView.st.take && sessionView.state !== "paused"
|
|
&& (sessionView.state === "countdown" || sessionView.state === "running"
|
|
|| !sessionView.stepMode)
|
|
text: "● Recording"
|
|
color: Kirigami.Theme.negativeTextColor
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: (sessionView.st.section_index || 0) > 0
|
|
text: "Section " + sessionView.st.section_index + " of " + sessionView.st.section_count
|
|
+ (sessionView.st.title ? ": " + sessionView.st.title : "")
|
|
+ ((sessionView.st.step_index || 0) > 0 && sessionView.stepMode
|
|
? " · step " + sessionView.st.step_index + " of " + sessionView.st.step_count : "")
|
|
font.bold: true
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.gridUnit * 1.5
|
|
|
|
ColumnLayout {
|
|
visible: !!sessionView.st.image || !!sessionView.st.position || !!sessionView.st.distance
|
|
spacing: Kirigami.Units.largeSpacing
|
|
PosePicture {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
path: sessionView.st.image || ""
|
|
mode: sessionView.st.image_mode || ""
|
|
}
|
|
Controls.Label {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 14
|
|
visible: !!sessionView.st.image && !!sessionView.st.caption
|
|
wrapMode: Text.Wrap
|
|
horizontalAlignment: Text.AlignHCenter
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: sessionView.st.caption || ""
|
|
}
|
|
WhereDiagram {
|
|
id: where
|
|
Layout.alignment: Qt.AlignHCenter
|
|
position: sessionView.st.position || ""
|
|
distance: sessionView.st.distance || ""
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
visible: !!sessionView.st.prompt
|
|
wrapMode: Text.Wrap
|
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.4
|
|
text: sessionView.st.prompt || ""
|
|
}
|
|
// A sweep's row of pictures, the lit one framed, as on the panel
|
|
Row {
|
|
visible: (sessionView.st.strip || []).length > 0
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Repeater {
|
|
model: sessionView.st.strip || []
|
|
Column {
|
|
required property var modelData
|
|
required property int index
|
|
readonly property bool lit: index === sessionView.st.cue
|
|
spacing: Kirigami.Units.smallSpacing
|
|
Rectangle {
|
|
width: Kirigami.Units.gridUnit * 5.5
|
|
height: width
|
|
radius: Kirigami.Units.smallSpacing * 2
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
|
|
Kirigami.Theme.textColor.b, 0.06)
|
|
border.width: parent.lit ? 3 : 0
|
|
border.color: "#4cd9ff"
|
|
Image {
|
|
anchors.fill: parent
|
|
anchors.margins: 4
|
|
source: modelData.image ? "file://" + modelData.image : ""
|
|
fillMode: Image.PreserveAspectFit
|
|
mirror: modelData.mode === "mirror"
|
|
opacity: parent.parent.lit ? 1 : 0.4
|
|
smooth: true
|
|
mipmap: true
|
|
}
|
|
}
|
|
Controls.Label {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: modelData.label || ""
|
|
color: parent.lit ? "#4cd9ff" : Kirigami.Theme.textColor
|
|
opacity: parent.lit ? 1 : 0.6
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The countdown's 3, 2, 1, then the hold's word, big
|
|
Controls.Label {
|
|
visible: !!sessionView.st.big && sessionView.state !== "paused"
|
|
text: sessionView.st.big || ""
|
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 4
|
|
font.bold: true
|
|
color: Kirigami.Theme.highlightColor
|
|
}
|
|
Controls.Label {
|
|
visible: !sessionView.waiting && sessionView.state !== "countdown"
|
|
&& sessionView.state !== "ready" && sessionView.st.seconds_left !== undefined
|
|
&& sessionView.st.seconds_left !== null && sessionView.st.seconds_left > 0
|
|
text: Math.ceil(sessionView.st.seconds_left || 0) + " s left"
|
|
opacity: 0.7
|
|
}
|
|
Controls.Button {
|
|
visible: sessionView.waiting && sessionView.state !== "paused"
|
|
focusPolicy: Qt.NoFocus
|
|
text: sessionView.state === "nohands" ? "Try again" : "Next"
|
|
icon.name: sessionView.state === "nohands" ? "edit-undo" : "go-next"
|
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.5
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
|
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
|
|
onClicked: backend.nextStep()
|
|
}
|
|
Controls.Label {
|
|
visible: sessionView.waiting && sessionView.state !== "paused"
|
|
opacity: 0.7
|
|
text: sessionView.state === "nohands"
|
|
? "Try again starts the same step (Space or the headset button do too). Stop ends the session, "
|
|
+ "keeping what's recorded."
|
|
: (sessionView.st.ready_text || "Ready? Press Space or click Next")
|
|
+ ". A 3-2-1 countdown starts the recording."
|
|
wrapMode: Text.Wrap
|
|
Layout.fillWidth: true
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !!sessionView.st.note
|
|
// (A hidden InlineMessage keeps its height in a layout.)
|
|
Layout.preferredHeight: visible ? implicitHeight : 0
|
|
type: Kirigami.MessageType.Warning
|
|
text: sessionView.st.note || ""
|
|
}
|
|
RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
HandChip { text: "Left hand"; seen: sessionView.st.hands ? sessionView.st.hands.left : undefined }
|
|
HandChip { text: "Right hand"; seen: sessionView.st.hands ? sessionView.st.hands.right : undefined }
|
|
}
|
|
Controls.Label {
|
|
visible: !!sessionView.st.take
|
|
text: "Take " + (sessionView.st.take || "")
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
|
|
Item { Layout.fillHeight: true }
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "The instructions appear in the headset. "
|
|
+ (sessionView.st.button ? "The button on the right side of the headset: "
|
|
+ (sessionView.stepMode ? "next, " : "") + "pause or resume. " : "")
|
|
+ "While this window has focus: "
|
|
+ (sessionView.stepMode ? "Space: next · " : "")
|
|
+ "P: pause or resume · R: record the last step again · S: skip section · Esc: stop. "
|
|
+ "Stopping keeps what's recorded so far."
|
|
}
|
|
RowLayout {
|
|
// No keyboard focus on the buttons, so Space always reaches the shortcut.
|
|
Controls.Button {
|
|
visible: !backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "New session…"
|
|
icon.name: "media-record"
|
|
onClicked: root.show(checklistPage)
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: sessionView.state === "paused" ? "Resume" : "Pause"
|
|
icon.name: sessionView.state === "paused" ? "media-playback-start" : "media-playback-pause"
|
|
onClicked: backend.togglePause()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
enabled: !!sessionView.st.can_redo
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Redo step"
|
|
icon.name: "edit-undo"
|
|
onClicked: backend.redo()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Skip section"
|
|
icon.name: "media-skip-forward"
|
|
onClicked: backend.skipSection()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Stop"
|
|
icon.name: "media-playback-stop"
|
|
onClicked: backend.stopSession()
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.sessionActive && backend.sessionId !== ""
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Review this session"
|
|
icon.name: "view-preview"
|
|
onClicked: {
|
|
root.show(reviewPage)
|
|
pageStack.push(takesPage, { session: backend.sessionId })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Review
|
|
Component {
|
|
id: reviewPage
|
|
Kirigami.ScrollablePage {
|
|
title: "Review"
|
|
actions: [
|
|
Kirigami.Action { text: "Refresh"; icon.name: "view-refresh"; onTriggered: backend.refreshSessions() }
|
|
]
|
|
|
|
ListView {
|
|
model: backend.sessions
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: parent.count === 0
|
|
text: "No sessions yet"
|
|
explanation: "Recorded sessions appear here, to watch and trim before you export them."
|
|
}
|
|
|
|
delegate: Controls.ItemDelegate {
|
|
id: sessionRow
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
onClicked: pageStack.push(takesPage, { session: modelData.id })
|
|
|
|
contentItem: RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Kirigami.Icon {
|
|
source: sessionRow.modelData.active ? "media-record" : "folder-videos"
|
|
implicitWidth: Kirigami.Units.iconSizes.medium
|
|
implicitHeight: implicitWidth
|
|
}
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
Controls.Label {
|
|
text: sessionRow.modelData.label + (sessionRow.modelData.active ? " (recording)" : "")
|
|
+ (sessionRow.modelData.dry_run ? " (dry run)" : "")
|
|
font.bold: true
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: root.plural(sessionRow.modelData.takes, "take") + " · " + sessionRow.modelData.sizeText
|
|
+ (sessionRow.modelData.statusText ? " · " + sessionRow.modelData.statusText : "")
|
|
+ (sessionRow.modelData.lightingText ? " · " + sessionRow.modelData.lightingText : "")
|
|
+ (sessionRow.modelData.export_stale ? " · changed since its export"
|
|
: sessionRow.modelData.exported ? " · exported (" + sessionRow.modelData.exportText + ")" : "")
|
|
}
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-delete"
|
|
text: "Delete session"
|
|
display: Controls.AbstractButton.IconOnly
|
|
enabled: !sessionRow.modelData.active
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: {
|
|
const sid = sessionRow.modelData.id
|
|
confirm.ask("Delete this session?",
|
|
"The session of " + sessionRow.modelData.label + " (" + root.plural(sessionRow.modelData.takes, "take")
|
|
+ ", " + sessionRow.modelData.sizeText + ") and its export are deleted from the "
|
|
+ "headset for good.",
|
|
() => { pageStack.pop(pageStack.items[0]); backend.deleteSession(sid) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: takesPage
|
|
Kirigami.ScrollablePage {
|
|
id: takesView
|
|
property string session: ""
|
|
property var rows: backend.takeList(session)
|
|
title: "Takes"
|
|
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { takesView.rows = backend.takeList(takesView.session) }
|
|
}
|
|
|
|
ListView {
|
|
model: takesView.rows
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: parent.count === 0
|
|
text: "No takes in this session"
|
|
}
|
|
|
|
delegate: Controls.ItemDelegate {
|
|
id: takeRow
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
onClicked: pageStack.push(viewerPage, { session: takesView.session, take: modelData.id })
|
|
|
|
contentItem: RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Image {
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
|
|
Layout.preferredHeight: Kirigami.Units.gridUnit * 4.5
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
source: takeRow.modelData.sets > 0
|
|
? "image://frames/thumb/" + takesView.session + "/" + takeRow.modelData.id : ""
|
|
}
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
Controls.Label { text: takeRow.modelData.title; font.bold: true }
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: takeRow.modelData.status + " · " + takeRow.modelData.durationText + " · "
|
|
+ takeRow.modelData.sets + " sets"
|
|
+ (takeRow.modelData.deleted_sets ? " (" + takeRow.modelData.deleted_sets + " deleted)" : "")
|
|
+ " · " + takeRow.modelData.sizeText
|
|
}
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-delete"
|
|
text: "Delete take"
|
|
display: Controls.AbstractButton.IconOnly
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: {
|
|
const take = takeRow.modelData.id
|
|
confirm.ask("Delete this take?",
|
|
"\"" + takeRow.modelData.title + "\" (" + takeRow.modelData.durationText
|
|
+ ") is deleted from the headset for good.",
|
|
() => { pageStack.pop(takesView); backend.deleteTake(takesView.session, take) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: viewerPage
|
|
Kirigami.Page {
|
|
id: viewer
|
|
property string session: ""
|
|
property string take: ""
|
|
property var info: backend.takeInfo(session, take)
|
|
property int index: 0
|
|
property int markStart: -1
|
|
property int markEnd: -1
|
|
title: info.title
|
|
readonly property bool deletedHere: isDeleted(index)
|
|
|
|
function step(n) {
|
|
index = Math.max(0, Math.min(info.count - 1, index + n))
|
|
}
|
|
function isDeleted(i) {
|
|
for (const r of info.ranges)
|
|
if (i >= r[0] && i <= r[1] && r[0] >= 0)
|
|
return true
|
|
return false
|
|
}
|
|
function deleteMarked() {
|
|
backend.deleteRange(session, take, markStart, markEnd)
|
|
markStart = markEnd = -1
|
|
info = backend.takeInfo(session, take)
|
|
}
|
|
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { viewer.info = backend.takeInfo(viewer.session, viewer.take) }
|
|
}
|
|
|
|
// Keyboard stepping: arrows one set, Page Up/Down ten, Home/End, [ and ] mark a range,
|
|
// Delete deletes it.
|
|
Item {
|
|
id: keys
|
|
focus: true
|
|
Component.onCompleted: forceActiveFocus()
|
|
Keys.onPressed: event => {
|
|
const k = event.key
|
|
if (k === Qt.Key_Left) viewer.step(-1)
|
|
else if (k === Qt.Key_Right) viewer.step(1)
|
|
else if (k === Qt.Key_PageUp) viewer.step(-10)
|
|
else if (k === Qt.Key_PageDown) viewer.step(10)
|
|
else if (k === Qt.Key_Home) viewer.index = 0
|
|
else if (k === Qt.Key_End) viewer.index = Math.max(0, viewer.info.count - 1)
|
|
else if (k === Qt.Key_BracketLeft) viewer.markStart = viewer.index
|
|
else if (k === Qt.Key_BracketRight) viewer.markEnd = viewer.index
|
|
else if (k === Qt.Key_Delete && viewer.markStart >= 0 && viewer.markEnd >= 0) viewer.deleteMarked()
|
|
else return
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
visible: viewer.info.count === 0
|
|
text: "This take has no frame sets"
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
visible: viewer.info.count > 0
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Image {
|
|
id: frame
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
cache: false
|
|
retainWhileLoading: true
|
|
source: viewer.info.count > 0
|
|
? "image://frames/set/" + viewer.session + "/" + viewer.take + "/" + viewer.index : ""
|
|
MouseArea { anchors.fill: parent; onClicked: keys.forceActiveFocus() }
|
|
|
|
Rectangle {
|
|
visible: viewer.deletedHere
|
|
x: (frame.width - frame.paintedWidth) / 2
|
|
y: (frame.height - frame.paintedHeight) / 2
|
|
width: frame.paintedWidth
|
|
height: frame.paintedHeight
|
|
color: Qt.rgba(0.8, 0, 0, 0.35)
|
|
Controls.Label {
|
|
anchors.centerIn: parent
|
|
text: "Deleted: not exported"
|
|
color: "white"
|
|
font.bold: true
|
|
}
|
|
}
|
|
}
|
|
|
|
// The slider, with the deleted ranges (red) and the marked range (blue) under it.
|
|
Item {
|
|
Layout.fillWidth: true
|
|
implicitHeight: slider.implicitHeight
|
|
Repeater {
|
|
model: viewer.info.ranges
|
|
Rectangle {
|
|
required property var modelData
|
|
visible: modelData[0] >= 0
|
|
readonly property real unit: (slider.availableWidth) / Math.max(1, viewer.info.count - 1)
|
|
x: slider.leftPadding + modelData[0] * unit - 2
|
|
width: Math.max(4, (modelData[1] - modelData[0]) * unit + 4)
|
|
y: slider.topPadding + slider.availableHeight / 2 + 4
|
|
height: 4
|
|
color: Kirigami.Theme.negativeTextColor
|
|
}
|
|
}
|
|
Rectangle {
|
|
visible: viewer.markStart >= 0
|
|
readonly property int last: viewer.markEnd >= 0 ? viewer.markEnd : viewer.index
|
|
readonly property real unit: (slider.availableWidth) / Math.max(1, viewer.info.count - 1)
|
|
x: slider.leftPadding + Math.min(viewer.markStart, last) * unit - 2
|
|
width: Math.abs(last - viewer.markStart) * unit + 4
|
|
y: slider.topPadding + slider.availableHeight / 2 - 8
|
|
height: 4
|
|
color: Kirigami.Theme.highlightColor
|
|
}
|
|
Controls.Slider {
|
|
id: slider
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
focusPolicy: Qt.NoFocus
|
|
from: 0
|
|
to: Math.max(0, viewer.info.count - 1)
|
|
stepSize: 1
|
|
snapMode: Controls.Slider.SnapAlways
|
|
value: viewer.index
|
|
onMoved: viewer.index = Math.round(value)
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Controls.Label {
|
|
text: "Set " + (viewer.index + 1) + " of " + viewer.info.count + " · "
|
|
+ backend.setTime(viewer.session, viewer.take, viewer.index)
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
text: viewer.markStart >= 0 ? "Start: " + (viewer.markStart + 1) : "Mark start"
|
|
icon.name: "go-first"
|
|
onClicked: viewer.markStart = viewer.index
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
text: viewer.markEnd >= 0 ? "End: " + (viewer.markEnd + 1) : "Mark end"
|
|
icon.name: "go-last"
|
|
onClicked: viewer.markEnd = viewer.index
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
enabled: viewer.markStart >= 0 && viewer.markEnd >= 0
|
|
text: "Delete range"
|
|
icon.name: "edit-cut"
|
|
onClicked: viewer.deleteMarked()
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
visible: viewer.markStart >= 0 || viewer.markEnd >= 0
|
|
text: "Clear marks"
|
|
icon.name: "edit-clear"
|
|
onClicked: viewer.markStart = viewer.markEnd = -1
|
|
}
|
|
}
|
|
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.smallSpacing
|
|
visible: viewer.info.ranges.length > 0
|
|
Controls.Label { text: "Deleted:"; topPadding: Kirigami.Units.smallSpacing }
|
|
Repeater {
|
|
model: viewer.info.ranges
|
|
Controls.Button {
|
|
required property var modelData
|
|
required property int index
|
|
focusPolicy: Qt.NoFocus
|
|
flat: true
|
|
text: (modelData[0] < 0 ? "a range with no sets"
|
|
: "sets " + (modelData[0] + 1) + " to " + (modelData[1] + 1)) + ": restore"
|
|
icon.name: "edit-undo"
|
|
onClicked: backend.restoreRange(viewer.session, viewer.take, index)
|
|
}
|
|
}
|
|
}
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: "← → step one set, Page Up/Down ten, Home/End. [ and ] mark a range's start and end, "
|
|
+ "Delete deletes it. Deleted sets stay on the headset until export, which leaves them out."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Export
|
|
Component {
|
|
id: exportPage
|
|
Kirigami.ScrollablePage {
|
|
id: exportView
|
|
title: "Export"
|
|
property bool worn: backend.headsetWorn()
|
|
readonly property var chosen: {
|
|
const list = backend.sessions
|
|
for (const s of list)
|
|
if (s.id === sessionBox.currentValue)
|
|
return s
|
|
return null
|
|
}
|
|
|
|
Timer {
|
|
interval: 5000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: exportView.worn = backend.headsetWorn()
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: exportView.worn && backend.exporting
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Information
|
|
text: "Exporting keeps the processor busy for a few minutes, so VR may stutter a little until "
|
|
+ "it's done. You can keep using the headset meanwhile."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.zstdFound
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: "zstd isn't installed in the dev container, so nothing can be exported. Run setup/dev-container.sh."
|
|
}
|
|
}
|
|
|
|
Kirigami.FormLayout {
|
|
Controls.ComboBox {
|
|
id: sessionBox
|
|
Kirigami.FormData.label: "Session:"
|
|
model: backend.sessions
|
|
textRole: "label"
|
|
valueRole: "id"
|
|
enabled: !backend.exporting
|
|
Component.onCompleted: {
|
|
const i = indexOfValue(root.chosenSession)
|
|
currentIndex = i >= 0 ? i : (count > 0 ? 0 : -1)
|
|
}
|
|
onActivated: root.chosenSession = currentValue
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Recorded:"
|
|
visible: exportView.chosen !== null
|
|
text: exportView.chosen ? root.plural(exportView.chosen.takes, "take") + ", " + exportView.chosen.sizeText : ""
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Export:"
|
|
visible: exportView.chosen !== null
|
|
text: !exportView.chosen ? ""
|
|
: exportView.chosen.export_stale ? "changed since it was exported: export it again"
|
|
: exportView.chosen.exported ? "ready, " + exportView.chosen.exportText : "not exported yet"
|
|
}
|
|
RowLayout {
|
|
Controls.Button {
|
|
text: backend.exporting ? "Exporting…"
|
|
: exportView.chosen && exportView.chosen.exported ? "Export again" : "Export"
|
|
icon.name: "document-export"
|
|
enabled: exportView.chosen !== null && !backend.exporting && backend.zstdFound
|
|
&& !exportView.chosen.active
|
|
onClicked: backend.exportSession(exportView.chosen.id, false)
|
|
}
|
|
// Shows the moment Export is pressed: the first progress can take a few seconds.
|
|
Controls.BusyIndicator {
|
|
visible: backend.exporting
|
|
running: visible
|
|
implicitWidth: Kirigami.Units.gridUnit * 1.5
|
|
implicitHeight: implicitWidth
|
|
}
|
|
Controls.Button {
|
|
visible: backend.exporting
|
|
text: "Cancel"
|
|
icon.name: "dialog-cancel"
|
|
onClicked: backend.cancelExport()
|
|
}
|
|
}
|
|
// Drawn here: the desktop style's ProgressBar paints nothing without a QApplication.
|
|
Rectangle {
|
|
Kirigami.FormData.label: "Progress:"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
implicitHeight: Kirigami.Units.smallSpacing * 2
|
|
visible: backend.exporting
|
|
radius: height / 2
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.15)
|
|
Rectangle {
|
|
width: parent.width * Math.max(0, Math.min(1, backend.exportFraction))
|
|
height: parent.height
|
|
radius: parent.radius
|
|
color: Kirigami.Theme.highlightColor
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
visible: backend.exportText !== "" && (backend.exporting || backend.exportSessionId === sessionBox.currentValue)
|
|
wrapMode: Text.WrapAnywhere
|
|
text: backend.exportText
|
|
}
|
|
RowLayout {
|
|
visible: exportView.chosen !== null && exportView.chosen.exported && !backend.exporting
|
|
Controls.Button {
|
|
text: "Upload"
|
|
icon.name: "go-next"
|
|
onClicked: root.show(uploadPage)
|
|
}
|
|
Controls.Button {
|
|
text: "Delete export"
|
|
icon.name: "edit-delete"
|
|
onClicked: {
|
|
const sid = exportView.chosen.id
|
|
confirm.ask("Delete this export?", "The export is deleted; the session's recordings stay.",
|
|
() => backend.deleteExport(sid))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Export leaves out the ranges you deleted, compresses the rest and adds a manifest and "
|
|
+ "checksums, in " + backend.exportsDir + ". It runs at the lowest priority "
|
|
+ "and takes a few minutes per round; the headset stays awake until it's done. Nothing is "
|
|
+ "uploaded until you press Upload on the Upload page."
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Upload
|
|
Component {
|
|
id: uploadPage
|
|
Kirigami.ScrollablePage {
|
|
id: uploadView
|
|
title: "Upload"
|
|
readonly property var exported: backend.sessions.filter(s => s.exported)
|
|
readonly property string session: exportBox.currentIndex >= 0 ? exportBox.currentValue || "" : ""
|
|
readonly property var chosen: exportBox.currentIndex >= 0 && exportBox.currentIndex < exported.length
|
|
? exported[exportBox.currentIndex] : null
|
|
readonly property var up: backend.upload
|
|
readonly property var upError: up.error || ({})
|
|
readonly property var upResult: up.result || ({})
|
|
// The upload shown is this export's (the window keeps the last one's result).
|
|
readonly property bool mine: up.session === session
|
|
property var info: ({ previous: {}, uploads: 0 })
|
|
readonly property bool loggedIn: backend.login.state === "ok"
|
|
// Why Upload is off, or "" when it can go.
|
|
readonly property string blocked: {
|
|
if (session === "") return "Choose an export"
|
|
if (chosen && chosen.export_stale) return "Export the session again first"
|
|
if (backend.exporting) return "Wait for the export to finish"
|
|
if (backend.uploading) return ""
|
|
if (backend.hubDryRun) return ""
|
|
if (!backend.uploadAllowed) return "Contributions aren't open yet"
|
|
if (!loggedIn) return "Log in first (step 2)"
|
|
return ""
|
|
}
|
|
|
|
function refreshInfo() { info = backend.uploadInfo(session) }
|
|
onSessionChanged: refreshInfo()
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { uploadView.refreshInfo() }
|
|
}
|
|
Component.onCompleted: {
|
|
refreshInfo()
|
|
if (backend.login.state === "unknown") backend.checkLogin()
|
|
}
|
|
|
|
function startUpload() {
|
|
if (info.previous && info.previous.export_sha)
|
|
confirm.ask("Upload this export again?",
|
|
"It was uploaded on " + info.previous.uploaded + " (" + (info.previous.pr_url || "no link")
|
|
+ "). Uploading it again opens a second pull request.",
|
|
() => backend.startUpload(uploadView.session, true))
|
|
else
|
|
backend.startUpload(session, false)
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.hubDryRun
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Information
|
|
text: "Dry run (--hub-dry-run): Upload checks the export and lists what it would send. "
|
|
+ "Nothing goes over the network."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.textsDraft && backend.allowUploadSet && !backend.hubDryRun
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Rehearsal: FT_HANDREC_ALLOW_UPLOAD=1 allows uploads to " + backend.dataset
|
|
+ " while the texts are drafts."
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.exported.length === 0
|
|
icon.name: "document-export"
|
|
text: "Nothing exported yet"
|
|
explanation: "Review a session, then export it. It can be uploaded from here."
|
|
helpfulAction: Kirigami.Action {
|
|
text: "Export"; icon.name: "document-export"
|
|
onTriggered: root.show(exportPage)
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.exported.length > 0
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
textFormat: Text.StyledText
|
|
text: "Your export goes to <a href=\"" + backend.datasetUrl + "\">" + backend.dataset + "</a> as a "
|
|
+ "pull request from your own Hugging Face account. Nothing is published until the "
|
|
+ "maintainer has checked it."
|
|
onLinkActivated: link => Qt.openUrlExternally(link)
|
|
}
|
|
|
|
// 1. The export
|
|
Kirigami.Heading { level: 3; text: "1. Choose the export" }
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Controls.ComboBox {
|
|
id: exportBox
|
|
Layout.fillWidth: true
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 22
|
|
model: uploadView.exported
|
|
textRole: "label"
|
|
valueRole: "id"
|
|
enabled: !backend.uploading
|
|
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.chosenSession))
|
|
onActivated: root.chosenSession = currentValue
|
|
}
|
|
Controls.Label {
|
|
opacity: 0.7
|
|
text: uploadView.chosen ? uploadView.chosen.exportText : ""
|
|
}
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.chosen !== null && uploadView.chosen.export_stale
|
|
type: Kirigami.MessageType.Warning
|
|
text: "This session changed since it was exported. Export it again so the upload has your latest deletions."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== "" && uploadView.info.previous.export_sha !== undefined
|
|
type: Kirigami.MessageType.Information
|
|
text: "This export was uploaded on " + (uploadView.info.previous.uploaded || "") + ": "
|
|
+ (uploadView.info.previous.pr_url || "") + ". There's no need to upload it again."
|
|
}
|
|
|
|
// 2. The login: Log in opens Hugging Face in the browser, where the person approves
|
|
// it. The token goes from there to hub.py, never into this window.
|
|
Kirigami.Heading { level: 3; text: "2. Log in to Hugging Face" }
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
visible: loginState !== "waiting"
|
|
readonly property string loginState: backend.login.state
|
|
Kirigami.Icon {
|
|
visible: parent.loginState === "ok"
|
|
source: "checkmark"
|
|
implicitWidth: Kirigami.Units.iconSizes.small
|
|
implicitHeight: implicitWidth
|
|
}
|
|
Controls.BusyIndicator {
|
|
visible: ["checking", "starting", "unknown"].indexOf(parent.loginState) >= 0
|
|
running: visible
|
|
implicitWidth: Kirigami.Units.gridUnit * 1.5
|
|
implicitHeight: implicitWidth
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
visible: parent.loginState !== "waiting"
|
|
wrapMode: Text.Wrap
|
|
text: parent.loginState === "dry" ? "Not needed for a dry run."
|
|
: parent.loginState === "unknown" ? "Checking your login"
|
|
: parent.loginState === "none"
|
|
? "Press Log in. Hugging Face opens in your browser: sign in, or make a free "
|
|
+ "account, then enter the code this page shows."
|
|
: backend.login.text || ""
|
|
}
|
|
Controls.Button {
|
|
visible: ["none", "read", "error", "ok"].indexOf(parent.loginState) >= 0
|
|
text: parent.loginState === "ok" ? "Use another account" : "Log in"
|
|
icon.name: parent.loginState === "ok" ? "system-switch-user" : "im-user-online"
|
|
flat: parent.loginState === "ok"
|
|
enabled: !backend.uploading
|
|
onClicked: backend.logIn()
|
|
}
|
|
Controls.Button {
|
|
visible: parent.loginState === "error"
|
|
text: "Check again"
|
|
icon.name: "view-refresh"
|
|
onClicked: backend.checkLogin()
|
|
}
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.login.state === "waiting"
|
|
type: Kirigami.MessageType.Information
|
|
text: "Hugging Face is open in your browser (" + (backend.login.url || "") + "). Sign in, or "
|
|
+ "make a free account, enter the code below and approve the login. If it asks about "
|
|
+ "organizations, leave them unticked. The code works for "
|
|
+ Math.round((backend.login.expires_in || 300) / 60) + " minutes; this page carries on "
|
|
+ "by itself once you've approved."
|
|
actions: [openLogin, copyLogin, cancelLogin]
|
|
Kirigami.Action {
|
|
id: openLogin
|
|
text: "Open again"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(backend.login.url)
|
|
}
|
|
Kirigami.Action {
|
|
id: copyLogin
|
|
text: "Copy code"
|
|
icon.name: "edit-copy"
|
|
onTriggered: backend.copy(backend.login.code)
|
|
}
|
|
Kirigami.Action {
|
|
id: cancelLogin
|
|
text: "Cancel"
|
|
icon.name: "dialog-cancel"
|
|
onTriggered: backend.cancelLogin()
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: backend.login.state === "waiting"
|
|
Layout.leftMargin: Kirigami.Units.gridUnit
|
|
text: backend.login.code || ""
|
|
font.family: "monospace"
|
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 2.5
|
|
font.letterSpacing: 4
|
|
}
|
|
|
|
// 3. The upload
|
|
Kirigami.Heading { level: 3; text: "3. Upload" }
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
textFormat: Text.StyledText
|
|
text: "The first time, accept the dataset's terms on <a href=\"" + backend.datasetUrl
|
|
+ "\">its page</a>. Upload checks the export, opens your pull request, then sends the files."
|
|
onLinkActivated: link => Qt.openUrlExternally(link)
|
|
}
|
|
RowLayout {
|
|
Controls.Button {
|
|
text: backend.uploading ? "Uploading…" : backend.hubDryRun ? "Upload (dry run)" : "Upload"
|
|
icon.name: "cloud-upload"
|
|
enabled: uploadView.blocked === "" && !backend.uploading
|
|
onClicked: uploadView.startUpload()
|
|
}
|
|
Controls.BusyIndicator {
|
|
visible: backend.uploading
|
|
running: visible
|
|
implicitWidth: Kirigami.Units.gridUnit * 1.5
|
|
implicitHeight: implicitWidth
|
|
}
|
|
Controls.Button {
|
|
visible: backend.uploading
|
|
text: "Cancel"
|
|
icon.name: "dialog-cancel"
|
|
onClicked: backend.cancelUpload()
|
|
}
|
|
Controls.Label {
|
|
visible: !backend.uploading && uploadView.blocked !== ""
|
|
opacity: 0.7
|
|
text: uploadView.blocked
|
|
}
|
|
}
|
|
|
|
// Progress: a share while the export is checked, a sweeping bar while it's sent
|
|
// (huggingface_hub doesn't report progress). Drawn here, as on the Export page.
|
|
Rectangle {
|
|
id: track
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
implicitHeight: Kirigami.Units.smallSpacing * 2
|
|
visible: backend.uploading
|
|
radius: height / 2
|
|
clip: true
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.15)
|
|
readonly property bool unknown: uploadView.up.fraction === undefined || uploadView.up.fraction < 0
|
|
Rectangle {
|
|
id: fill
|
|
height: parent.height
|
|
radius: parent.radius
|
|
color: Kirigami.Theme.highlightColor
|
|
width: track.unknown ? parent.width / 4 : parent.width * Math.max(0, Math.min(1, uploadView.up.fraction))
|
|
x: 0
|
|
SequentialAnimation on x {
|
|
running: track.visible && track.unknown
|
|
loops: Animation.Infinite
|
|
onRunningChanged: if (!running) fill.x = 0
|
|
NumberAnimation { from: -fill.width; to: track.width; duration: 1600 }
|
|
}
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.mine && (uploadView.up.text || "") !== ""
|
|
&& (backend.uploading || uploadView.up.phase === "failed" && uploadView.upError.kind === "cancelled")
|
|
wrapMode: Text.Wrap
|
|
text: uploadView.up.text || ""
|
|
}
|
|
|
|
// The pull request is open and the files are on their way: time to plug in.
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.uploading && uploadView.mine && (uploadView.up.pr_url || "") !== ""
|
|
type: Kirigami.MessageType.Positive
|
|
text: "Your pull request is open: " + (uploadView.up.pr_url || "") + ". The files are uploading "
|
|
+ "to it now, which can take a while. Plug in the headset and leave it plugged in until "
|
|
+ "this page says Uploaded. You can take the headset off: it stays awake until the upload "
|
|
+ "is done. Keep the Hand Recorder open."
|
|
actions: [openOpenedPr, copyOpenedPr]
|
|
Kirigami.Action {
|
|
id: openOpenedPr
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(uploadView.up.pr_url)
|
|
}
|
|
Kirigami.Action {
|
|
id: copyOpenedPr
|
|
text: "Copy link"
|
|
icon.name: "edit-copy"
|
|
onTriggered: backend.copy(uploadView.up.pr_url)
|
|
}
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.uploading && uploadView.mine && uploadView.up.phase === "done"
|
|
type: Kirigami.MessageType.Positive
|
|
text: uploadView.upResult.dry_run
|
|
? "Dry run: the export passed its checks. It would go to " + uploadView.upResult.repo + "/"
|
|
+ uploadView.upResult.path_in_repo + " (" + uploadView.upResult.files + " files). Nothing was sent."
|
|
: "Uploaded. Your pull request: " + (uploadView.upResult.pr_url || "")
|
|
+ ". The maintainer reviews it before it joins the dataset."
|
|
actions: uploadView.upResult.pr_url ? [openPr, copyPr] : []
|
|
Kirigami.Action {
|
|
id: openPr
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(uploadView.upResult.pr_url)
|
|
}
|
|
Kirigami.Action {
|
|
id: copyPr
|
|
text: "Copy link"
|
|
icon.name: "edit-copy"
|
|
onTriggered: backend.copy(uploadView.upResult.pr_url)
|
|
}
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.uploading && uploadView.mine && uploadView.up.phase === "failed"
|
|
&& uploadView.upError.kind !== "cancelled"
|
|
type: Kirigami.MessageType.Error
|
|
text: (uploadView.upError.text || "")
|
|
+ ((uploadView.upError.errors || []).length ? "\n\n• " + uploadView.upError.errors.join("\n• ") : "")
|
|
actions: uploadView.upError.link ? [openErrorLink] : []
|
|
Kirigami.Action {
|
|
id: openErrorLink
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(uploadView.upError.link)
|
|
}
|
|
}
|
|
Controls.TextArea {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.mine && (uploadView.up.log || "") !== ""
|
|
readOnly: true
|
|
selectByMouse: true
|
|
wrapMode: Text.WrapAnywhere
|
|
font.family: "monospace"
|
|
text: uploadView.up.log || ""
|
|
}
|
|
|
|
Kirigami.Separator { Layout.fillWidth: true; visible: uploadView.session !== "" }
|
|
MarkdownText {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== ""
|
|
markdown: backend.uploadText(uploadView.session)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|