mirror of
https://github.com/Qortal/Brooklyn.git
synced 2025-02-15 11:45:54 +00:00
605 lines
25 KiB
QML
605 lines
25 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.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 && 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
|
|
|
|
// 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.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_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
|
|
clip: true
|
|
|
|
ShaderEffect {
|
|
id: backgroundImage
|
|
property real scaleFactor: 1.0
|
|
property Image source: albumArt
|
|
|
|
anchors.centerIn: parent
|
|
visible: !!root.track && source.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
|
|
}
|
|
}
|
|
// use State to avoid unnecessary reevaluation of width and height
|
|
states: State {
|
|
name: "albumArtReady"
|
|
when: plasmoid.expanded && backgroundImage.visible && albumArt.paintedWidth > 0
|
|
PropertyChanges {
|
|
target: backgroundImage
|
|
scaleFactor: Math.max(parent.width / source.paintedWidth, parent.height / source.paintedHeight)
|
|
width: Math.round(source.paintedWidth * scaleFactor)
|
|
height: Math.round(source.paintedHeight * scaleFactor)
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
Loader {
|
|
// When albumArt is shown, the icon is unloaded to reduce memory usage.
|
|
readonly property string icon: (mpris2Source.currentData && mpris2Source.currentData["Desktop Icon Name"]) || "media-album-cover"
|
|
active: !albumArt.visible
|
|
anchors.fill: parent
|
|
|
|
sourceComponent: root.track ? fallbackIconItem : placeholderMessage
|
|
|
|
Component {
|
|
id: fallbackIconItem
|
|
|
|
PlasmaCore.IconItem { // Fallback
|
|
source: icon
|
|
anchors {
|
|
fill: parent
|
|
margins: PlasmaCore.Units.largeSpacing * 2
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: placeholderMessage
|
|
Item { // Put PlaceholderMessage in Item so PlaceholderMessage will not fill its parent.
|
|
anchors.fill: parent
|
|
|
|
PlasmaExtras.PlaceholderMessage { // "No media playing" placeholder message
|
|
width: parent.width // For text wrap
|
|
anchors.centerIn: parent
|
|
iconName: icon
|
|
text: i18n("No media playing")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ColumnLayout { // Details Column
|
|
visible: root.track
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
Layout.preferredWidth: 50
|
|
|
|
/*
|
|
* 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
|
|
|
|
Layout.fillWidth: true
|
|
Layout.maximumHeight: PlasmaCore.Units.gridUnit*5
|
|
}
|
|
Kirigami.Heading { // Song Artist
|
|
id: songArtist
|
|
visible: 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: PlasmaCore.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: PlasmaCore.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: PlasmaCore.Theme.smallestFont
|
|
color: PlasmaCore.ColorScope.textColor
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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: expandedRepresentation.controlSize
|
|
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: expandedRepresentation.controlSize
|
|
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: expandedRepresentation.controlSize
|
|
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: expandedRepresentation.controlSize
|
|
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: expandedRepresentation.controlSize
|
|
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)
|
|
}
|
|
}
|
|
}
|