You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
572 lines
24 KiB
572 lines
24 KiB
/*************************************************************************** |
|
* Copyright 2013 Sebastian Kügler <sebas@kde.org> * |
|
* Copyright 2014, 2016 Kai Uwe Broulik <kde@privat.broulik.de> * |
|
* Copyright 2020 Carson Black <uhhadd@gmail.com> * |
|
* Copyright 2020 Ismael Asensio <isma.af@gmail.com> * |
|
* * |
|
* This program is free software; you can redistribute it and/or modify * |
|
* it under the terms of the GNU Library General Public License as * |
|
* published by the Free Software Foundation; either version 2 of the * |
|
* License, or (at your option) any later version. * |
|
* * |
|
* This program is distributed in the hope that it will be useful, * |
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of * |
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * |
|
* GNU Library General Public License for more details. * |
|
* * |
|
* You should have received a copy of the GNU Library General Public * |
|
* License along with this program; if not, write to the * |
|
* Free Software Foundation, Inc., * |
|
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA . * |
|
***************************************************************************/ |
|
|
|
import QtQuick 2.8 |
|
import QtQuick.Layouts 1.1 |
|
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 |
|
|
|
Layout.minimumWidth: PlasmaCore.Units.gridUnit * 14 |
|
Layout.minimumHeight: PlasmaCore.Units.gridUnit * 14 |
|
Layout.preferredWidth: Layout.minimumWidth * 1.5 |
|
Layout.preferredHeight: Layout.minimumHeight * 1.5 |
|
|
|
collapseMarginsHint: true |
|
|
|
readonly property int controlSize: PlasmaCore.Units.iconSizes.medium |
|
|
|
property double position: mpris2Source.currentData.Position || 0 |
|
readonly property real rate: mpris2Source.currentData.Rate || 1 |
|
readonly property double length: currentMetadata ? currentMetadata["mpris:length"] || 0 : 0 |
|
readonly property bool canSeek: mpris2Source.currentData.CanSeek || false |
|
readonly property bool softwareRendering: GraphicsInfo.api === GraphicsInfo.Software |
|
|
|
// 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 |
|
|
|
function retrievePosition() { |
|
var service = mpris2Source.serviceForSource(mpris2Source.current); |
|
var operation = service.operationDescription("GetPosition"); |
|
service.startOperationCall(operation); |
|
} |
|
|
|
Connections { |
|
target: plasmoid |
|
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 |
|
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.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_Left || 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_Right || 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 |
|
} |
|
} |
|
} |
|
|
|
Item { // Album Art Background + Details |
|
anchors.fill: parent |
|
|
|
Image { |
|
id: backgroundImage |
|
|
|
source: root.albumArt |
|
sourceSize.width: 512 /* |
|
* Setting a sourceSize.width here |
|
* prevents flickering when resizing the |
|
* plasmoid on a desktop. |
|
*/ |
|
|
|
anchors.fill: parent |
|
fillMode: Image.PreserveAspectCrop |
|
|
|
asynchronous: true |
|
visible: !!root.track && status === Image.Ready && !softwareRendering |
|
|
|
layer.enabled: !softwareRendering |
|
layer.effect: HueSaturation { |
|
cached: true |
|
|
|
lightness: -0.5 |
|
saturation: 0.9 |
|
|
|
layer.enabled: true |
|
layer.effect: GaussianBlur { |
|
cached: true |
|
|
|
radius: 128 |
|
deviation: 12 |
|
samples: 63 |
|
|
|
transparentBorder: false |
|
} |
|
} |
|
} |
|
RowLayout { // Album Art + Details |
|
id: albumRow |
|
|
|
anchors { |
|
fill: parent |
|
leftMargin: PlasmaCore.Units.largeSpacing |
|
rightMargin: PlasmaCore.Units.largeSpacing |
|
} |
|
|
|
spacing: PlasmaCore.Units.largeSpacing |
|
|
|
Item { |
|
Layout.fillWidth: true |
|
Layout.fillHeight: true |
|
Layout.preferredWidth: 50 |
|
|
|
Image { // Album Art |
|
id: albumArt |
|
|
|
anchors.fill: parent |
|
|
|
visible: !!root.track && status === Image.Ready |
|
|
|
asynchronous: true |
|
|
|
horizontalAlignment: Image.AlignRight |
|
verticalAlignment: Image.AlignVCenter |
|
fillMode: Image.PreserveAspectFit |
|
|
|
source: root.albumArt |
|
} |
|
|
|
PlasmaCore.IconItem { // Fallback |
|
visible: !albumArt.visible |
|
source: { |
|
if (mpris2Source.currentData["Desktop Icon Name"]) |
|
return mpris2Source.currentData["Desktop Icon Name"] |
|
return "media-album-cover" |
|
} |
|
|
|
anchors { |
|
fill: parent |
|
margins: PlasmaCore.Units.largeSpacing*2 |
|
} |
|
} |
|
} |
|
|
|
ColumnLayout { // Details Column |
|
Layout.fillWidth: true |
|
Layout.fillHeight: true |
|
Layout.preferredWidth: 50 |
|
Layout.alignment: !(albumArt.visible || !!mpris2Source.currentData["Desktop Icon Name"]) ? Qt.AlignHCenter : 0 |
|
|
|
/* |
|
* 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.visible) ? PlasmaCore.ColorScope.textColor : "white" |
|
|
|
textFormat: Text.PlainText |
|
wrapMode: Text.Wrap |
|
fontSizeMode: Text.VerticalFit |
|
elide: Text.ElideRight |
|
|
|
text: root.track || i18n("No media playing") |
|
|
|
Layout.fillWidth: true |
|
Layout.maximumHeight: PlasmaCore.Units.gridUnit*5 |
|
} |
|
Kirigami.Heading { // Song Artist |
|
id: songArtist |
|
visible: root.track && root.artist |
|
level: 2 |
|
|
|
color: (softwareRendering || !albumArt.visible) ? PlasmaCore.ColorScope.textColor : "white" |
|
|
|
textFormat: Text.PlainText |
|
wrapMode: Text.Wrap |
|
fontSizeMode: Text.VerticalFit |
|
elide: Text.ElideRight |
|
|
|
text: root.artist |
|
Layout.fillWidth: true |
|
Layout.maximumHeight: PlasmaCore.Units.gridUnit*2 |
|
} |
|
Kirigami.Heading { // Song Album |
|
color: (softwareRendering || !albumArt.visible) ? PlasmaCore.ColorScope.textColor : "white" |
|
|
|
level: 3 |
|
opacity: 0.6 |
|
|
|
textFormat: Text.PlainText |
|
wrapMode: Text.Wrap |
|
fontSizeMode: Text.VerticalFit |
|
elide: Text.ElideRight |
|
|
|
visible: text.length !== 0 |
|
text: { |
|
var metadata = root.currentMetadata |
|
if (!metadata) { |
|
return "" |
|
} |
|
var xesamAlbum = metadata["xesam:album"] |
|
if (xesamAlbum) { |
|
return xesamAlbum |
|
} |
|
|
|
// if we play a local file without title and artist, show its containing folder instead |
|
if (metadata["xesam:title"] || root.artist) { |
|
return "" |
|
} |
|
|
|
var xesamUrl = (metadata["xesam:url"] || "").toString() |
|
if (xesamUrl.indexOf("file:///") !== 0) { // "!startsWith()" |
|
return "" |
|
} |
|
|
|
var urlParts = xesamUrl.split("/") |
|
if (urlParts.length < 3) { |
|
return "" |
|
} |
|
|
|
var lastFolderPath = urlParts[urlParts.length - 2] // last would be filename |
|
if (lastFolderPath) { |
|
return lastFolderPath |
|
} |
|
|
|
return "" |
|
} |
|
Layout.fillWidth: true |
|
Layout.maximumHeight: PlasmaCore.Units.gridUnit*2 |
|
} |
|
} |
|
} |
|
} |
|
|
|
footer: PlasmaExtras.PlasmoidHeading { |
|
id: footerItem |
|
location: PlasmaExtras.PlasmoidHeading.Location.Footer |
|
ColumnLayout { // Main Column Layout |
|
anchors.fill: parent |
|
RowLayout { // Seek Bar |
|
spacing: PlasmaCore.Units.smallSpacing |
|
|
|
// 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 |
|
TextMetrics { |
|
id: timeMetrics |
|
text: i18nc("Remaining time for song e.g -5:42", "-%1", |
|
KCoreAddons.Format.formatDuration(seekSlider.to / 1000, expandedRepresentation.durationFormattingOptions)) |
|
font: theme.smallestFont |
|
} |
|
|
|
PlasmaComponents3.Label { // Time Elapsed |
|
Layout.preferredWidth: timeMetrics.width |
|
verticalAlignment: Text.AlignVCenter |
|
horizontalAlignment: Text.AlignRight |
|
text: KCoreAddons.Format.formatDuration(seekSlider.value / 1000, expandedRepresentation.durationFormattingOptions) |
|
opacity: 0.9 |
|
font: theme.smallestFont |
|
color: PlasmaCore.ColorScope.textColor |
|
} |
|
|
|
PlasmaComponents3.Slider { // Slider |
|
id: seekSlider |
|
Layout.fillWidth: true |
|
z: 999 |
|
value: 0 |
|
visible: canSeek |
|
|
|
onMoved: { |
|
if (!disablePositionUpdate) { |
|
// delay setting the position to avoid race conditions |
|
queuedPositionUpdate.restart() |
|
} |
|
} |
|
|
|
Timer { |
|
id: seekTimer |
|
interval: 1000 / expandedRepresentation.rate |
|
repeat: true |
|
running: root.state === "playing" && 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 |
|
} |
|
} |
|
} |
|
} |
|
|
|
RowLayout { |
|
visible: !canSeek |
|
|
|
Layout.fillWidth: true |
|
Layout.preferredHeight: seekSlider.height |
|
|
|
PlasmaComponents3.ProgressBar { // Time Remaining |
|
value: seekSlider.value |
|
from: seekSlider.from |
|
to: seekSlider.to |
|
|
|
Layout.fillWidth: true |
|
Layout.fillHeight: false |
|
Layout.alignment: Qt.AlignVCenter |
|
} |
|
} |
|
|
|
PlasmaComponents3.Label { |
|
Layout.preferredWidth: timeMetrics.width |
|
verticalAlignment: Text.AlignVCenter |
|
horizontalAlignment: Text.AlignLeft |
|
text: i18nc("Remaining time for song e.g -5:42", "-%1", |
|
KCoreAddons.Format.formatDuration((seekSlider.to - seekSlider.value) / 1000, expandedRepresentation.durationFormattingOptions)) |
|
opacity: 0.9 |
|
font: theme.smallestFont |
|
color: PlasmaCore.ColorScope.textColor |
|
} |
|
} |
|
|
|
RowLayout { // Player Controls |
|
id: playerControls |
|
|
|
property bool enabled: root.canControl |
|
property int controlsSize: theme.mSize(theme.defaultFont).height * 3 |
|
|
|
Layout.alignment: Qt.AlignHCenter |
|
Layout.bottomMargin: PlasmaCore.Units.smallSpacing |
|
spacing: PlasmaCore.Units.smallSpacing |
|
|
|
PlasmaComponents3.ToolButton { |
|
Layout.rightMargin: LayoutMirroring.enabled ? 0 : PlasmaCore.Units.largeSpacing - playerControls.spacing |
|
Layout.leftMargin: LayoutMirroring.enabled ? PlasmaCore.Units.largeSpacing - playerControls.spacing : 0 |
|
icon.name: "media-playlist-shuffle" |
|
icon.width: expandedRepresentation.controlSize |
|
icon.height: icon.width |
|
checked: root.shuffle === true |
|
enabled: root.canControl && root.shuffle !== undefined |
|
Accessible.name: i18n("Shuffle") |
|
onClicked: { |
|
const service = mpris2Source.serviceForSource(mpris2Source.current); |
|
let operation = service.operationDescription("SetShuffle"); |
|
operation.on = !root.shuffle; |
|
service.startOperationCall(operation); |
|
} |
|
|
|
PlasmaComponents3.ToolTip { |
|
text: parent.Accessible.name |
|
} |
|
} |
|
|
|
PlasmaComponents3.ToolButton { // Previous |
|
icon.width: expandedRepresentation.controlSize |
|
icon.height: icon.width |
|
Layout.alignment: Qt.AlignVCenter |
|
enabled: playerControls.enabled && root.canGoPrevious |
|
icon.name: LayoutMirroring.enabled ? "media-skip-forward" : "media-skip-backward" |
|
onClicked: { |
|
seekSlider.value = 0 // Let the media start from beginning. Bug 362473 |
|
root.action_previous() |
|
} |
|
} |
|
|
|
PlasmaComponents3.ToolButton { // Pause/Play |
|
icon.width: expandedRepresentation.controlSize |
|
icon.height: icon.width |
|
Layout.alignment: Qt.AlignVCenter |
|
enabled: root.state == "playing" ? root.canPause : root.canPlay |
|
icon.name: root.state == "playing" ? "media-playback-pause" : "media-playback-start" |
|
onClicked: root.togglePlaying() |
|
} |
|
|
|
PlasmaComponents3.ToolButton { // Next |
|
icon.width: expandedRepresentation.controlSize |
|
icon.height: icon.width |
|
Layout.alignment: Qt.AlignVCenter |
|
enabled: playerControls.enabled && root.canGoNext |
|
icon.name: LayoutMirroring.enabled ? "media-skip-backward" : "media-skip-forward" |
|
onClicked: { |
|
seekSlider.value = 0 // Let the media start from beginning. Bug 362473 |
|
root.action_next() |
|
} |
|
} |
|
|
|
PlasmaComponents3.ToolButton { |
|
Layout.leftMargin: LayoutMirroring.enabled ? 0 : PlasmaCore.Units.largeSpacing - playerControls.spacing |
|
Layout.rightMargin: LayoutMirroring.enabled ? PlasmaCore.Units.largeSpacing - playerControls.spacing : 0 |
|
icon.name: root.loopStatus === "Track" ? "media-playlist-repeat-song" : "media-playlist-repeat" |
|
icon.width: expandedRepresentation.controlSize |
|
icon.height: icon.width |
|
checked: root.loopStatus !== undefined && root.loopStatus !== "None" |
|
enabled: root.canControl && root.loopStatus !== undefined |
|
Accessible.name: root.loopStatus === "Track" ? i18n("Repeat Track") : i18n("Repeat") |
|
onClicked: { |
|
const service = mpris2Source.serviceForSource(mpris2Source.current); |
|
let operation = service.operationDescription("SetLoopStatus"); |
|
switch (root.loopStatus) { |
|
case "Playlist": |
|
operation.status = "Track"; |
|
break; |
|
case "Track": |
|
operation.status = "None"; |
|
break; |
|
default: |
|
operation.status = "Playlist"; |
|
} |
|
service.startOperationCall(operation); |
|
} |
|
|
|
PlasmaComponents3.ToolTip { |
|
text: parent.Accessible.name |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
header: PlasmaExtras.PlasmoidHeading { |
|
id: headerItem |
|
location: PlasmaExtras.PlasmoidHeading.Location.Header |
|
visible: playerList.model.length > 2 // more than one player, @multiplex is always there |
|
//this removes top padding to allow tabbar to touch the edge |
|
topPadding: topInset |
|
bottomPadding: -bottomInset |
|
implicitHeight: PlasmaCore.Units.gridUnit * 2 |
|
PlasmaComponents3.TabBar { |
|
id: playerSelector |
|
position: PlasmaComponents3.TabBar.Header |
|
|
|
anchors.fill: parent |
|
|
|
implicitHeight: contentHeight |
|
|
|
Repeater { |
|
id: playerList |
|
model: root.mprisSourcesModel |
|
|
|
delegate: PlasmaComponents3.TabButton { |
|
anchors.top: parent.top |
|
anchors.bottom: parent.bottom |
|
icon.name: modelData["icon"] |
|
icon.height: PlasmaCore.Units.iconSizes.smallMedium |
|
Accessible.name: modelData["text"] |
|
PlasmaComponents3.ToolTip { |
|
text: modelData["text"] |
|
} |
|
// Keep the delegate centered by offsetting the padding removed in the parent |
|
bottomPadding: verticalPadding + headerItem.bottomPadding |
|
topPadding: verticalPadding - headerItem.bottomPadding |
|
onClicked: { |
|
disablePositionUpdate = true |
|
mpris2Source.current = modelData["source"]; |
|
disablePositionUpdate = false |
|
} |
|
} |
|
|
|
onModelChanged: { |
|
playerSelector.currentIndex = model.findIndex( |
|
(data) => { return data.source === mpris2Source.current } |
|
) |
|
} |
|
} |
|
} |
|
} |
|
|
|
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) |
|
} |
|
} |
|
}
|
|
|