Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 316 additions & 0 deletions res/skins/LateNightQML/Deck/LateNightBpmTapEditor.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
import QtQuick
Comment thread
JoergAtGithub marked this conversation as resolved.
import Mixxx 1.0 as Mixxx
import "../LateNightTheme"

Item {
id: root

required property string group

property string mode: "listen"
property real originalEditValue: 0
property bool editingBpm: true
property real tapModeEnteredAt: 0

readonly property bool listenHovered: listenArea.containsMouse
readonly property int hoverLeaveTimeout: 2000
readonly property int inactiveTimeout: 5000
readonly property int tapModeClickGuardTimeout: 350
readonly property real bpmStepSize: 1.0
readonly property real rateStepSize: 0.01
readonly property bool trackLoaded: trackLoadedProxy.value > 0

function switchMode(newMode) {
hideTimer.stop();
if (!root.trackLoaded) {
newMode = "listen";
}
root.mode = newMode;
if (newMode === "select") {
hideTimer.stop();
} else if (newMode === "edit") {
startEditMode();
} else if (newMode === "tap") {
root.tapModeEnteredAt = Date.now();
hideTimer.stop();
} else if (newMode === "listen") {
editInput.text = "";
editInput.focus = false;
}
}

function handleTapClick(mouse) {
if (Date.now() - root.tapModeEnteredAt < root.tapModeClickGuardTimeout) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tapModeEnteredAt should be an required property

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tapModeEnteredAt is internal state set when the component enters tap mode, so making it required would add an invalid caller contract. I’ll keep it local.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant "readonly property" with initialization. Without initialization the outcome of
if (Date.now() - root.tapModeEnteredAt < root.tapModeClickGuardTimeout)
is random.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. Since the guard is restarted whenever tap mode opens, the timestamp cannot be readonly. I replaced it with a Timer so the guard state is initialized and deterministic.

return;
}
root.tapControl(mouse.button === Qt.RightButton ? bpmTapProxy : tempoTapProxy);
}

function tapControl(control) {
hideTimer.interval = root.inactiveTimeout;
hideTimer.restart();
control.value = 1;
control.value = 0;
}

function startEditMode() {
root.editingBpm = fileBpmProxy.value !== 0;
if (root.editingBpm) {
root.originalEditValue = bpmProxy.value;
editInput.text = root.originalEditValue.toFixed(2);
} else {
root.originalEditValue = rateRatioProxy.value * 100;
editInput.text = root.originalEditValue.toFixed(2);
}
editInput.forceActiveFocus();
editInput.selectAll();
}

function applyStep(steps) {
if (root.editingBpm) {
const newBpm = Math.max(1, Math.min(fileBpmProxy.value * 4, bpmProxy.value + steps * root.bpmStepSize));
bpmProxy.value = newBpm;
editInput.text = newBpm.toFixed(2);
root.originalEditValue = newBpm;
} else {
const newRateRatio = Math.max(0.01, Math.min(4.0, rateRatioProxy.value + steps * root.rateStepSize));
rateRatioProxy.value = newRateRatio;
editInput.text = (newRateRatio * 100).toFixed(2);
root.originalEditValue = newRateRatio * 100;
}
editInput.selectAll();
}

function applyEditValueAndQuit() {
const parsedValue = Number(editInput.text);
if (!isFinite(parsedValue)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Number("") returns 0, not NaN, so isFinite guard does't catch empty input which result in 0.0 BPM display. Please fix and test this!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NaN, doesn't legacy display it as 0.0 too? I didn't get you

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy LateNight restores the original BPM value in case of an empty string. With 0.0 BPM the music would stop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Empty input now closes the editor without writing the control, so the original BPM or rate is preserved.

switchMode("listen");
return;
}
if (parsedValue !== root.originalEditValue) {
if (root.editingBpm) {
bpmProxy.value = Math.max(1, Math.min(fileBpmProxy.value * 4, parsedValue));
} else {
rateRatioProxy.value = Math.max(0.01, Math.min(4.0, parsedValue / 100));
}
}
switchMode("listen");
}

Mixxx.ControlProxy {
id: tempoTapProxy
group: root.group
key: "tempo_tap"
}

Mixxx.ControlProxy {
id: bpmTapProxy
group: root.group
key: "bpm_tap"
}

Mixxx.ControlProxy {
id: trackLoadedProxy
group: root.group
key: "track_loaded"
}

Mixxx.ControlProxy {
id: bpmProxy
group: root.group
key: "bpm"
}

Mixxx.ControlProxy {
id: fileBpmProxy
group: root.group
key: "file_bpm"
}

Mixxx.ControlProxy {
id: rateRatioProxy
group: root.group
key: "rate_ratio"
}

onTrackLoadedChanged: {
if (!root.trackLoaded) {
root.switchMode("listen");
}
}

Timer {
id: hideTimer
interval: root.hoverLeaveTimeout
repeat: false
onTriggered: root.switchMode("listen")
}

MouseArea {
id: listenArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
visible: root.mode === "listen"
onClicked: root.switchMode("select")
onDoubleClicked: function(mouse) {
root.switchMode(mouse.x < width / 2 ? "tap" : "edit");
}
}

Row {
anchors.fill: parent
visible: root.mode === "select"

Rectangle {
width: parent.width / 2
height: parent.height
color: LateNightTheme.bpmTapEditorSelectBackgroundColor
border.width: 1
border.color: LateNightTheme.bpmTapEditorSelectBorderColor

Image {
anchors.centerIn: parent
source: LateNightTheme.assetDeckBpmSelectTapButton
fillMode: Image.PreserveAspectFit
}

MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: hideTimer.stop()
onExited: {
hideTimer.interval = root.hoverLeaveTimeout;
hideTimer.restart();
}
onClicked: root.switchMode("tap")
onDoubleClicked: root.switchMode("tap")
}
}

Rectangle {
width: parent.width / 2
height: parent.height
color: LateNightTheme.bpmTapEditorSelectBackgroundColor
border.width: 1
border.color: LateNightTheme.bpmTapEditorSelectBorderColor

Image {
anchors.centerIn: parent
source: LateNightTheme.assetDeckBpmSelectEditButton
fillMode: Image.PreserveAspectFit
}

MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: hideTimer.stop()
onExited: {
hideTimer.interval = root.hoverLeaveTimeout;
hideTimer.restart();
}
onClicked: root.switchMode("edit")
onDoubleClicked: root.switchMode("edit")
}
}
}

Rectangle {
anchors.fill: parent
visible: root.mode === "tap"
color: LateNightTheme.bpmTapEditorBackgroundColor
border.width: 1
border.color: tapArea.pressed ? LateNightTheme.bpmTapEditorEditBorderColor : LateNightTheme.bpmTapEditorSelectBorderColor
radius: 1

MouseArea {
id: tapArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onEntered: hideTimer.stop()
onExited: {
hideTimer.interval = root.hoverLeaveTimeout;
hideTimer.restart();
}
onClicked: function(mouse) { root.handleTapClick(mouse); }
onDoubleClicked: function(mouse) { mouse.accepted = true; }
}
}

Rectangle {
anchors.fill: parent
visible: root.mode === "edit"
color: LateNightTheme.bpmTapEditorBackgroundColor
border.width: 1
border.color: LateNightTheme.bpmTapEditorEditBorderColor
radius: 2

TextInput {
id: editInput
x: 1
y: 1
width: parent.width - 2
height: parent.height - 17
color: LateNightTheme.textColor
selectedTextColor: LateNightTheme.deckActiveButtonTextColor
selectionColor: LateNightTheme.textColor
font.family: "Open Sans"
font.pixelSize: 14
font.weight: Font.Medium
horizontalAlignment: TextInput.AlignHCenter
verticalAlignment: TextInput.AlignVCenter
inputMethodHints: Qt.ImhFormattedNumbersOnly
selectByMouse: true
onActiveFocusChanged: {
if (!activeFocus && root.mode === "edit") {
root.switchMode("listen");
}
}

Keys.onReturnPressed: root.applyEditValueAndQuit()
Keys.onEnterPressed: root.applyEditValueAndQuit()
Keys.onEscapePressed: root.switchMode("listen")
}

Rectangle {
x: 0
y: parent.height - 16
width: parent.width / 2
height: 16
color: LateNightTheme.bpmTapEditorButtonColor

Image {
anchors.centerIn: parent
source: decreaseArea.pressed ? LateNightTheme.assetDeckBpmSpinboxMinusPressedButton : LateNightTheme.assetDeckBpmSpinboxMinusButton
fillMode: Image.PreserveAspectFit
}

MouseArea {
id: decreaseArea
anchors.fill: parent
onClicked: root.applyStep(-1)
}
}

Rectangle {
x: parent.width / 2
y: parent.height - 16
width: parent.width / 2
height: 16
color: LateNightTheme.bpmTapEditorButtonColor

Image {
anchors.centerIn: parent
source: increaseArea.pressed ? LateNightTheme.assetDeckBpmSpinboxPlusPressedButton : LateNightTheme.assetDeckBpmSpinboxPlusButton
fillMode: Image.PreserveAspectFit
}

MouseArea {
id: increaseArea
anchors.fill: parent
onClicked: root.applyStep(1)
}
}
}
}
6 changes: 5 additions & 1 deletion res/skins/LateNightQML/Deck/LateNightCycleButton.qml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ LateNightIconButton {
property var stateLabels: []
property real activeOpacity: 0.95
property real inactiveOpacity: 0.72
property color activeLabelColor: LateNightTheme.textColor
property color inactiveLabelColor: LateNightTheme.textColorMuted
property bool activeWhenNonzero: false

readonly property int currentState: cycleBehavior.currentState

activeState: root.activeWhenNonzero && root.currentState > 0
label: cycleBehavior.label
labelPixelSize: 9
labelColor: root.currentState > 0 ? LateNightTheme.textColor : LateNightTheme.textColorMuted
labelColor: root.currentState > 0 ? root.activeLabelColor : root.inactiveLabelColor
contentOpacity: root.currentState > 0 ? root.activeOpacity : root.inactiveOpacity

Skin.ControlCycleButtonBehavior {
Expand Down
Loading
Loading