501 lines
20 KiB
QML
501 lines
20 KiB
QML
/*
|
|
SPDX-FileCopyrightText: 2013 Sebastian Kügler <sebas@kde.org>
|
|
SPDX-FileCopyrightText: 2014, 2016 Kai Uwe Broulik <kde@privat.broulik.de>
|
|
SPDX-FileCopyrightText: 2020 Carson Black <uhhadd@gmail.com>
|
|
SPDX-FileCopyrightText: 2020 Ismael Asensio <isma.af@gmail.com>
|
|
|
|
SPDX-License-Identifier: LGPL-2.0-or-later
|
|
*/
|
|
|
|
import QtQuick 2.15
|
|
import QtQuick.Controls 2.15 as QQC2
|
|
import QtQuick.Layouts 1.1
|
|
|
|
import org.kde.plasma.plasmoid 2.0
|
|
import org.kde.plasma.core 2.0 as PlasmaCore
|
|
import org.kde.plasma.components 3.0 as PlasmaComponents3
|
|
import org.kde.plasma.extras 2.0 as PlasmaExtras
|
|
import org.kde.kcoreaddons 1.0 as KCoreAddons
|
|
import org.kde.kirigami 2.4 as Kirigami
|
|
import QtGraphicalEffects 1.0
|
|
|
|
PlasmaExtras.Representation {
|
|
id: expandedRepresentation
|
|
FontLoader {
|
|
id: sofiaLight
|
|
source: "../fonts/SofiaProLight.ttf"
|
|
}
|
|
FontLoader {
|
|
id: fontB
|
|
source: "../fonts/SourceSansPro-Semibold.otf"
|
|
}
|
|
Layout.minimumWidth: Plasmoid.switchWidth
|
|
Layout.minimumHeight: Plasmoid.switchHeight
|
|
Layout.preferredWidth: PlasmaCore.Units.gridUnit * 20
|
|
Layout.preferredHeight: 212
|
|
Layout.maximumWidth: PlasmaCore.Units.gridUnit * 40
|
|
Layout.maximumHeight: PlasmaCore.Units.gridUnit * 40
|
|
|
|
collapseMarginsHint: true
|
|
|
|
readonly property int controlSize: PlasmaCore.Units.iconSizes.medium
|
|
|
|
property double position: (mpris2Source.currentData && mpris2Source.currentData.Position) || 0
|
|
readonly property real rate: (mpris2Source.currentData && mpris2Source.currentData.Rate) || 1
|
|
readonly property double length: currentMetadata ? currentMetadata["mpris:length"] || 0 : 0
|
|
readonly property bool canSeek: (mpris2Source.currentData && mpris2Source.currentData.CanSeek) || false
|
|
readonly property bool softwareRendering: GraphicsInfo.api === GraphicsInfo.Software
|
|
readonly property var appletInterface: Plasmoid.self
|
|
|
|
// only show hours (the default for KFormat) when track is actually longer than an hour
|
|
readonly property int durationFormattingOptions: length >= 60*60*1000*1000 ? 0 : KCoreAddons.FormatTypes.FoldHours
|
|
|
|
property bool disablePositionUpdate: false
|
|
property bool keyPressed: false
|
|
|
|
KeyNavigation.down: playerSelector.count ? playerSelector.currentItem : (seekSlider.visible ? seekSlider : seekSlider.KeyNavigation.down)
|
|
KeyNavigation.up: seekSlider.KeyNavigation.down
|
|
|
|
function retrievePosition() {
|
|
var service = mpris2Source.serviceForSource(mpris2Source.current);
|
|
var operation = service.operationDescription("GetPosition");
|
|
service.startOperationCall(operation);
|
|
}
|
|
|
|
Connections {
|
|
target: Plasmoid.self
|
|
function onExpandedChanged() {
|
|
if (Plasmoid.expanded) {
|
|
retrievePosition();
|
|
}
|
|
}
|
|
}
|
|
|
|
onPositionChanged: {
|
|
// we don't want to interrupt the user dragging the slider
|
|
if (!seekSlider.pressed && !keyPressed) {
|
|
// we also don't want passive position updates
|
|
disablePositionUpdate = true
|
|
// Slider refuses to set value beyond its end, make sure "to" is up-to-date first
|
|
seekSlider.to = length;
|
|
seekSlider.value = position
|
|
disablePositionUpdate = false
|
|
}
|
|
}
|
|
|
|
onLengthChanged: {
|
|
disablePositionUpdate = true
|
|
// When reducing maximumValue, value is clamped to it, however
|
|
// when increasing it again it gets its old value back.
|
|
// To keep us from seeking to the end of the track when moving
|
|
// to a new track, we'll reset the value to zero and ask for the position again
|
|
seekSlider.value = 0
|
|
seekSlider.to = length
|
|
retrievePosition()
|
|
disablePositionUpdate = false
|
|
}
|
|
|
|
Keys.onPressed: keyPressed = true
|
|
|
|
Keys.onReleased: {
|
|
keyPressed = false
|
|
|
|
if ((event.key == Qt.Key_Tab || event.key == Qt.Key_Backtab) && event.modifiers & Qt.ControlModifier) {
|
|
event.accepted = true;
|
|
if (root.mprisSourcesModel.length > 2) {
|
|
var nextIndex = playerSelector.currentIndex + 1;
|
|
if (event.key == Qt.Key_Backtab || event.modifiers & Qt.ShiftModifier) {
|
|
nextIndex -= 2;
|
|
}
|
|
if (nextIndex == root.mprisSourcesModel.length) {
|
|
nextIndex = 0;
|
|
}
|
|
if (nextIndex < 0) {
|
|
nextIndex = root.mprisSourcesModel.length - 1;
|
|
}
|
|
playerSelector.currentIndex = nextIndex;
|
|
disablePositionUpdate = true;
|
|
mpris2Source.current = root.mprisSourcesModel[nextIndex]["source"];
|
|
disablePositionUpdate = false;
|
|
}
|
|
}
|
|
|
|
if (!event.modifiers) {
|
|
event.accepted = true
|
|
|
|
if (event.key === Qt.Key_Space || event.key === Qt.Key_K) {
|
|
// K is YouTube's key for "play/pause" :)
|
|
root.togglePlaying()
|
|
} else if (event.key === Qt.Key_P) {
|
|
root.action_previous()
|
|
} else if (event.key === Qt.Key_N) {
|
|
root.action_next()
|
|
} else if (event.key === Qt.Key_S) {
|
|
root.action_stop()
|
|
} else if (event.key === Qt.Key_J) { // TODO ltr languages
|
|
// seek back 5s
|
|
seekSlider.value = Math.max(0, seekSlider.value - 5000000) // microseconds
|
|
seekSlider.moved();
|
|
} else if (event.key === Qt.Key_L) {
|
|
// seek forward 5s
|
|
seekSlider.value = Math.min(seekSlider.to, seekSlider.value + 5000000)
|
|
seekSlider.moved();
|
|
} else if (event.key === Qt.Key_Home) {
|
|
seekSlider.value = 0
|
|
seekSlider.moved();
|
|
} else if (event.key === Qt.Key_End) {
|
|
seekSlider.value = seekSlider.to
|
|
seekSlider.moved();
|
|
} else if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
|
|
// jump to percentage, ie. 0 = beginnign, 1 = 10% of total length etc
|
|
seekSlider.value = seekSlider.to * (event.key - Qt.Key_0) / 10
|
|
seekSlider.moved();
|
|
} else {
|
|
event.accepted = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Album Art Background + Details + Touch area to adjust position or volume
|
|
MultiPointTouchArea {
|
|
id: touchArea
|
|
anchors.fill: parent
|
|
clip: true
|
|
|
|
maximumTouchPoints: 1
|
|
minimumTouchPoints: 1
|
|
mouseEnabled: false
|
|
touchPoints: [
|
|
TouchPoint {
|
|
id: point1
|
|
|
|
property bool seeking: false
|
|
property bool adjustingVolume: false
|
|
|
|
onPressedChanged: if (!pressed) {
|
|
seeking = false;
|
|
adjustingVolume = false;
|
|
}
|
|
onSeekingChanged: if (seeking) {
|
|
queuedPositionUpdate.stop();
|
|
} else {
|
|
seekSlider.moved();
|
|
}
|
|
}
|
|
]
|
|
|
|
Connections {
|
|
enabled: seekSlider.visible && point1.pressed && !point1.adjustingVolume
|
|
target: point1
|
|
// Control seek slider
|
|
function onXChanged() {
|
|
if (!point1.seeking && Math.abs(point1.x - point1.startX) < touchArea.width / 20) {
|
|
return;
|
|
}
|
|
point1.seeking = true;
|
|
seekSlider.value = seekSlider.valueAt(Math.max(0, Math.min(1, seekSlider.position + (point1.x - point1.previousX) / touchArea.width))); // microseconds
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
enabled: point1.pressed && !point1.seeking
|
|
target: point1
|
|
function onYChanged() {
|
|
if (!point1.adjustingVolume && Math.abs(point1.y - point1.startY) < touchArea.height / 20) {
|
|
return;
|
|
}
|
|
point1.adjustingVolume = true;
|
|
const service = mpris2Source.serviceForSource(mpris2Source.current);
|
|
const operation = service.operationDescription("ChangeVolume");
|
|
operation.delta = (point1.previousY - point1.y) / touchArea.height;
|
|
service.startOperationCall(operation);
|
|
}
|
|
}
|
|
|
|
|
|
ColumnLayout { // Album Art + Details
|
|
id: albumRow
|
|
width: parent.width
|
|
height: 165
|
|
visible: root.track
|
|
anchors.centerIn: parent
|
|
AlbumArtStackView {
|
|
id: albumArt
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: 80
|
|
height: 80
|
|
|
|
Connections {
|
|
enabled: Plasmoid.expanded
|
|
target: root
|
|
|
|
function onAlbumArtChanged() {
|
|
albumArt.loadAlbumArt();
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: Plasmoid.self
|
|
|
|
function onExpandedChanged() {
|
|
// NOTE: Don't use strict equality
|
|
if (!Plasmoid.expanded
|
|
|| (albumArt.albumArt.currentItem instanceof Image && albumArt.albumArt.currentItem.source == root.albumArt)) {
|
|
return;
|
|
}
|
|
|
|
albumArt.loadAlbumArt();
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: seekTimer
|
|
interval: 1000 / expandedRepresentation.rate
|
|
repeat: true
|
|
running: root.isPlaying && Plasmoid.expanded && !keyPressed && interval > 0 && seekSlider.to >= 1000000
|
|
onTriggered: {
|
|
// some players don't continuously update the seek slider position via mpris
|
|
// add one second; value in microseconds
|
|
if (!seekSlider.pressed) {
|
|
disablePositionUpdate = true
|
|
if (seekSlider.value == seekSlider.to) {
|
|
retrievePosition();
|
|
} else {
|
|
seekSlider.value += 1000000
|
|
}
|
|
disablePositionUpdate = false
|
|
}
|
|
}
|
|
}
|
|
ColumnLayout { // Details Column
|
|
id: infotrack
|
|
visible: root.track
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
Layout.preferredWidth: 50
|
|
spacing: 2
|
|
/*
|
|
* We use Kirigami.Heading instead of PlasmaExtras.Heading
|
|
* to prevent a binding loop caused by the PC2 Label component
|
|
* used by PlasmaExtras.Heading
|
|
*/
|
|
Kirigami.Heading { // Song Title
|
|
id: songTitle
|
|
level: 1
|
|
|
|
color: (softwareRendering || !albumArt.hasImage) ? PlasmaCore.ColorScope.textColor : "white"
|
|
|
|
textFormat: Text.PlainText
|
|
wrapMode: Text.Wrap
|
|
fontSizeMode: Text.VerticalFit
|
|
elide: Text.ElideRight
|
|
horizontalAlignment: Text.AlignHCenter
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.track
|
|
font.family: fontB.name
|
|
font.pixelSize: 20
|
|
Layout.fillWidth: true
|
|
Layout.maximumHeight: PlasmaCore.Units.gridUnit*5
|
|
}
|
|
Rectangle {
|
|
id: progressBar
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: 280
|
|
height: 2
|
|
color: "white"
|
|
radius: 12
|
|
opacity: 0.5
|
|
|
|
Rectangle {
|
|
id: progressIndicator
|
|
width: progressBar.width * (seekSlider.value/seekSlider.to)
|
|
height: progressBar.height
|
|
color: "white"
|
|
radius: progressBar.radius
|
|
opacity: 1
|
|
}
|
|
}
|
|
Kirigami.Heading { // Song Artist
|
|
id: songArtist
|
|
visible: root.artist
|
|
level: 2
|
|
|
|
color: (softwareRendering || !albumArt.hasImage) ? PlasmaCore.ColorScope.textColor : "white"
|
|
|
|
textFormat: Text.PlainText
|
|
font.family: sofiaLight.name
|
|
wrapMode: Text.Wrap
|
|
fontSizeMode: Text.VerticalFit
|
|
font.pixelSize: 15
|
|
elide: Text.ElideRight
|
|
horizontalAlignment: Text.AlignHCenter
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.artist
|
|
Layout.fillWidth: true
|
|
Layout.maximumHeight: PlasmaCore.Units.gridUnit*2
|
|
}
|
|
|
|
}
|
|
RowLayout { // Player Controls
|
|
id: playerControls
|
|
property bool enabled: root.canControl
|
|
property int controlsSize: PlasmaCore.Theme.mSize(PlasmaCore.Theme.defaultFont).height * 3
|
|
|
|
Layout.alignment: Qt.AlignHCenter
|
|
Layout.bottomMargin: PlasmaCore.Units.smallSpacing
|
|
spacing: PlasmaCore.Units.smallSpacing
|
|
|
|
PlasmaComponents3.ToolButton { // Previous
|
|
id: previousButton
|
|
icon.width: expandedRepresentation.controlSize
|
|
icon.height: expandedRepresentation.controlSize
|
|
Layout.alignment: Qt.AlignVCenter
|
|
enabled: playerControls.enabled && root.canGoPrevious
|
|
icon.name: LayoutMirroring.enabled ? "media-skip-forward" : "media-skip-backward"
|
|
|
|
display: PlasmaComponents3.AbstractButton.IconOnly
|
|
text: i18nc("Play previous track", "Previous Track")
|
|
|
|
KeyNavigation.left: shuffleButton
|
|
KeyNavigation.right: playPauseButton.enabled ? playPauseButton : playPauseButton.KeyNavigation.right
|
|
KeyNavigation.up: playPauseButton.KeyNavigation.up
|
|
|
|
onClicked: {
|
|
seekSlider.value = 0 // Let the media start from beginning. Bug 362473
|
|
root.action_previous()
|
|
}
|
|
}
|
|
|
|
PlasmaComponents3.ToolButton { // Pause/Play
|
|
id: playPauseButton
|
|
icon.width: expandedRepresentation.controlSize
|
|
icon.height: expandedRepresentation.controlSize
|
|
|
|
Layout.alignment: Qt.AlignVCenter
|
|
enabled: root.isPlaying ? root.canPause : root.canPlay
|
|
icon.name: root.isPlaying ? "media-playback-pause" : "media-playback-start"
|
|
|
|
display: PlasmaComponents3.AbstractButton.IconOnly
|
|
text: root.isPlaying ? i18nc("Pause playback", "Pause") : i18nc("Start playback", "Play")
|
|
|
|
KeyNavigation.left: previousButton.enabled ? previousButton : previousButton.KeyNavigation.left
|
|
KeyNavigation.right: nextButton.enabled ? nextButton : nextButton.KeyNavigation.right
|
|
KeyNavigation.up: seekSlider.visible ? seekSlider : seekSlider.KeyNavigation.up
|
|
|
|
onClicked: root.togglePlaying()
|
|
}
|
|
|
|
PlasmaComponents3.ToolButton { // Next
|
|
id: nextButton
|
|
icon.width: expandedRepresentation.controlSize
|
|
icon.height: expandedRepresentation.controlSize
|
|
Layout.alignment: Qt.AlignVCenter
|
|
enabled: playerControls.enabled && root.canGoNext
|
|
icon.name: LayoutMirroring.enabled ? "media-skip-backward" : "media-skip-forward"
|
|
|
|
display: PlasmaComponents3.AbstractButton.IconOnly
|
|
text: i18nc("Play next track", "Next Track")
|
|
|
|
KeyNavigation.left: playPauseButton.enabled ? playPauseButton : playPauseButton.KeyNavigation.left
|
|
KeyNavigation.right: repeatButton
|
|
KeyNavigation.up: playPauseButton.KeyNavigation.up
|
|
|
|
onClicked: {
|
|
seekSlider.value = 0 // Let the media start from beginning. Bug 362473
|
|
root.action_next()
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
ColumnLayout { // Main Column Layout
|
|
anchors.fill: parent
|
|
anchors.top: parent.top
|
|
anchors.topMargin: albumRow.height - infotrack.height + 18
|
|
visible: root.track
|
|
|
|
RowLayout {
|
|
// Seek Bar
|
|
spacing: PlasmaCore.Units.smallSpacing
|
|
visible: false
|
|
// if there's no "mpris:length" in the metadata, we cannot seek, so hide it in that case
|
|
enabled: !root.noPlayer && root.track && expandedRepresentation.length > 0 ? true : false
|
|
opacity: enabled ? 1 : 0
|
|
Behavior on opacity {
|
|
NumberAnimation { duration: PlasmaCore.Units.longDuration }
|
|
}
|
|
|
|
Layout.alignment: Qt.AlignHCenter
|
|
Layout.fillWidth: true
|
|
Layout.maximumWidth: Math.min(PlasmaCore.Units.gridUnit*45, Math.round(expandedRepresentation.width*(7/10)))
|
|
|
|
// ensure the layout doesn't shift as the numbers change and measure roughly the longest text that could occur with the current song
|
|
|
|
|
|
|
|
PlasmaComponents3.Slider { // Slider
|
|
id: seekSlider
|
|
Layout.fillWidth: true
|
|
z: 999
|
|
value: 0
|
|
visible: false
|
|
|
|
KeyNavigation.up: playerSelector.currentItem
|
|
KeyNavigation.down: playPauseButton.enabled ? playPauseButton : (playPauseButton.KeyNavigation.left.enabled ? playPauseButton.KeyNavigation.left : playPauseButton.KeyNavigation.right)
|
|
Keys.onLeftPressed: {
|
|
seekSlider.value = Math.max(0, seekSlider.value - 5000000) // microseconds
|
|
seekSlider.moved();
|
|
}
|
|
Keys.onRightPressed: {
|
|
seekSlider.value = Math.max(0, seekSlider.value + 5000000) // microseconds
|
|
seekSlider.moved();
|
|
}
|
|
|
|
onMoved: {
|
|
if (!disablePositionUpdate) {
|
|
// delay setting the position to avoid race conditions
|
|
queuedPositionUpdate.restart()
|
|
}
|
|
}
|
|
onPressedChanged: {
|
|
// Property binding evaluation is non-deterministic
|
|
// so binding visible to pressed and delay to 0 when pressed
|
|
// will not make the tooltip show up immediately.
|
|
if (pressed) {
|
|
seekToolTip.delay = 0;
|
|
seekToolTip.visible = true;
|
|
} else {
|
|
seekToolTip.delay = Qt.binding(() => Kirigami.Units.toolTipDelay);
|
|
seekToolTip.visible = Qt.binding(() => seekToolTipHandler.hovered);
|
|
}
|
|
}
|
|
|
|
HoverHandler {
|
|
id: seekToolTipHandler
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Timer {
|
|
id: queuedPositionUpdate
|
|
interval: 100
|
|
onTriggered: {
|
|
if (position == seekSlider.value) {
|
|
return;
|
|
}
|
|
var service = mpris2Source.serviceForSource(mpris2Source.current)
|
|
var operation = service.operationDescription("SetPosition")
|
|
operation.microseconds = seekSlider.value
|
|
service.startOperationCall(operation)
|
|
}
|
|
}
|
|
}
|