Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Ui
|
||||
import qs.Commons
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.media"
|
||||
|
||||
readonly property var mediaService: bar?.shell?.firstPartyServiceFor("blob.media")
|
||||
readonly property var activePlayer: mediaService ? mediaService.activePlayer : null
|
||||
readonly property var sourcePlayers: mediaService ? mediaService.sourcePlayers : []
|
||||
|
||||
readonly property bool hasMedia: activePlayer !== null && (activePlayer.trackTitle || activePlayer.trackArtist)
|
||||
readonly property string playIcon: activePlayer && activePlayer.isPlaying ? "" : ""
|
||||
readonly property string title: activePlayer ? (activePlayer.trackTitle || "") : ""
|
||||
readonly property string artist: activePlayer ? (activePlayer.trackArtist || "") : ""
|
||||
|
||||
property bool popupOpen: false
|
||||
|
||||
function close() { popupOpen = false }
|
||||
property real maxLabelWidth: 180
|
||||
|
||||
visible: hasMedia
|
||||
implicitWidth: hasMedia ? row.implicitWidth + Style.space(14) : 0
|
||||
implicitHeight: barSize
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.space(6)
|
||||
|
||||
Text {
|
||||
id: glyph
|
||||
textFormat: Text.PlainText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.playIcon
|
||||
color: activePlayer && activePlayer.isPlaying ? root.bar.barForeground : Qt.darker(root.bar.barForeground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
Behavior on color {
|
||||
enabled: !root.bar || root.bar.foregroundAnimationEnabled
|
||||
ColorAnimation { duration: 160 }
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: scrollClip
|
||||
width: Math.min(root.maxLabelWidth, labelText.implicitWidth)
|
||||
height: glyph.height
|
||||
clip: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !root.bar.vertical && root.title !== ""
|
||||
|
||||
Text {
|
||||
id: labelText
|
||||
textFormat: Text.PlainText
|
||||
text: root.title + (root.artist ? " · " + root.artist : "")
|
||||
color: root.bar.barForeground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
property bool needsScroll: implicitWidth > scrollClip.width
|
||||
|
||||
NumberAnimation on x {
|
||||
id: scrollAnim
|
||||
running: labelText.needsScroll && !root.popupOpen && !root.bar.vertical
|
||||
loops: Animation.Infinite
|
||||
duration: Math.max(6000, labelText.implicitWidth * 25)
|
||||
from: scrollClip.width
|
||||
to: -labelText.implicitWidth
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.activePlayer ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
|
||||
onClicked: function(mouse) {
|
||||
if (!root.activePlayer) return
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
if (root.mediaService) root.mediaService.runAction("next", false)
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
root.popupOpen = !root.popupOpen
|
||||
} else {
|
||||
if (root.mediaService) root.mediaService.runAction("playPause", false)
|
||||
}
|
||||
}
|
||||
onWheel: function(wheel) {
|
||||
if (!root.activePlayer) return
|
||||
if (wheel.angleDelta.y > 0 && root.mediaService) root.mediaService.runAction("previous", false)
|
||||
else if (wheel.angleDelta.y < 0 && root.mediaService) root.mediaService.runAction("next", false)
|
||||
}
|
||||
onEntered: if (root.bar) root.bar.showTooltip(root, root.hasMedia ? (root.title + (root.artist ? " — " + root.artist : "")) : "")
|
||||
onExited: if (root.bar) root.bar.hideTooltip(root)
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: popup
|
||||
anchorItem: root
|
||||
bar: root.bar
|
||||
owner: root
|
||||
open: root.popupOpen
|
||||
contentWidth: popup.fittedContentWidth(Style.space(320))
|
||||
contentHeight: popup.fittedContentHeight(column.implicitHeight)
|
||||
|
||||
Column {
|
||||
id: column
|
||||
anchors.fill: parent
|
||||
spacing: Style.space(10)
|
||||
|
||||
Row {
|
||||
spacing: Style.space(10)
|
||||
width: parent.width
|
||||
|
||||
BorderSurface {
|
||||
width: Style.space(64)
|
||||
height: Style.space(64)
|
||||
radius: Style.spacing.labelGap
|
||||
color: Style.normalFillFor(root.bar.foreground, Color.accent)
|
||||
borderSpec: Border.controlSpec("normal", root.bar.foreground, Color.accent)
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.space(2)
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
source: root.activePlayer && root.activePlayer.trackArtUrl ? root.activePlayer.trackArtUrl : ""
|
||||
visible: source !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: !root.activePlayer || !root.activePlayer.trackArtUrl
|
||||
text: ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.displayLarge
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: Style.space(4)
|
||||
width: parent.width - Style.space(74)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.title || "Nothing playing"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.artist
|
||||
color: Qt.darker(root.bar.foreground, 1.3)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : ""
|
||||
color: Qt.darker(root.bar.foreground, 1.6)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Style.space(6)
|
||||
|
||||
Button {
|
||||
iconText: ""
|
||||
foreground: root.bar.foreground
|
||||
horizontalPadding: Style.spacing.controlPaddingX
|
||||
verticalPadding: Style.spacing.controlPaddingY
|
||||
enabled: root.activePlayer && root.activePlayer.canGoPrevious
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
onClicked: if (root.mediaService) root.mediaService.runAction("previous", false, root.mediaService.playerKey(root.activePlayer))
|
||||
}
|
||||
|
||||
Button {
|
||||
iconText: root.activePlayer && root.activePlayer.isPlaying ? "" : ""
|
||||
foreground: root.bar.foreground
|
||||
horizontalPadding: Style.spacing.panelGap
|
||||
verticalPadding: Style.spacing.controlPaddingY
|
||||
iconSize: Style.font.iconLarge
|
||||
enabled: root.activePlayer && (root.activePlayer.canTogglePlaying || root.activePlayer.canPlay || root.activePlayer.canPause)
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
onClicked: if (root.mediaService) root.mediaService.runAction("playPause", false, root.mediaService.playerKey(root.activePlayer))
|
||||
}
|
||||
|
||||
Button {
|
||||
iconText: ""
|
||||
foreground: root.bar.foreground
|
||||
horizontalPadding: Style.spacing.controlPaddingX
|
||||
verticalPadding: Style.spacing.controlPaddingY
|
||||
enabled: root.activePlayer && root.activePlayer.canGoNext
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
onClicked: if (root.mediaService) root.mediaService.runAction("next", false, root.mediaService.playerKey(root.activePlayer))
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator {
|
||||
visible: root.sourcePlayers.length > 1
|
||||
foreground: root.bar.foreground
|
||||
}
|
||||
|
||||
Column {
|
||||
id: sourceList
|
||||
visible: root.sourcePlayers.length > 1
|
||||
width: parent.width
|
||||
spacing: Style.space(4)
|
||||
|
||||
Repeater {
|
||||
model: root.sourcePlayers
|
||||
|
||||
BorderSurface {
|
||||
id: sourceRow
|
||||
required property var modelData
|
||||
|
||||
readonly property var player: modelData
|
||||
readonly property bool selected: root.activePlayer && player
|
||||
&& root.mediaService.playerKey(root.activePlayer) === root.mediaService.playerKey(player)
|
||||
readonly property string sourceTitle: player ? (player.trackTitle || player.identity || player.desktopEntry || "Media source") : "Media source"
|
||||
readonly property string sourceDetail: player && player.trackArtist ? player.trackArtist : (player && player.identity ? player.identity : "")
|
||||
|
||||
width: sourceList.width
|
||||
height: sourceInner.implicitHeight + Style.space(10)
|
||||
radius: Style.spacing.labelGap
|
||||
color: selected ? Style.selectedFillFor(root.bar.foreground, Color.accent) : "transparent"
|
||||
borderSpec: selected ? Border.controlSpec("normal", root.bar.foreground, Color.accent) : Border.none()
|
||||
|
||||
Row {
|
||||
id: sourceInner
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: sourceRow.borderLeft + Style.space(8)
|
||||
anchors.rightMargin: sourceRow.borderRight + Style.space(8)
|
||||
spacing: Style.space(8)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: sourceRow.player && sourceRow.player.isPlaying ? "" : ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
width: Style.space(18)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - Style.space(26)
|
||||
spacing: Style.space(1)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: sourceRow.sourceTitle
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.bold: sourceRow.selected
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: sourceRow.sourceDetail
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: if (root.mediaService) root.mediaService.selectPlayer(root.mediaService.playerKey(sourceRow.player))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
function isProxyPlayer(player) {
|
||||
var dbusName = String(player && player.dbusName || "").toLowerCase()
|
||||
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
|
||||
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
|
||||
}
|
||||
|
||||
function hasMetadata(player) {
|
||||
return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry))
|
||||
}
|
||||
|
||||
function hasTrackMetadata(player) {
|
||||
return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl))
|
||||
}
|
||||
|
||||
function playerCanControl(player) {
|
||||
return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious))
|
||||
}
|
||||
|
||||
function canHandleAction(player, action) {
|
||||
if (!player) return false
|
||||
if (action === "next") return !!player.canGoNext
|
||||
if (action === "previous") return !!player.canGoPrevious
|
||||
if (action === "play") return !!(player.canPlay || player.canTogglePlaying)
|
||||
if (action === "pause") return !!(player.canPause || player.canTogglePlaying)
|
||||
if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause)
|
||||
return false
|
||||
}
|
||||
|
||||
function canCycleSource(player) {
|
||||
return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay))
|
||||
}
|
||||
|
||||
function nodeProps(node) {
|
||||
return node && node.ready && node.properties ? node.properties : {}
|
||||
}
|
||||
|
||||
function isPlaybackStream(node) {
|
||||
if (!node || !node.isStream) return false
|
||||
if (node.isSink === true) return true
|
||||
|
||||
var mediaClass = String(node.type || "")
|
||||
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|
||||
|| mediaClass.indexOf("AudioOutStream") !== -1
|
||||
|| mediaClass.indexOf("Output") !== -1
|
||||
}
|
||||
|
||||
function streamLabelKey(label) {
|
||||
var key = String(label || "").toLowerCase()
|
||||
key = key.replace(/^pipewire alsa \[/, "")
|
||||
key = key.replace(/\]$/, "")
|
||||
key = key.replace(/^alsa playback \[/, "")
|
||||
key = key.replace(/[^a-z0-9]+/g, "")
|
||||
return key
|
||||
}
|
||||
|
||||
function rawStreamLabel(node) {
|
||||
if (!node) return ""
|
||||
var p = nodeProps(node)
|
||||
return p["application.name"]
|
||||
|| node.description
|
||||
|| p["media.name"]
|
||||
|| p["node.name"]
|
||||
|| node.name
|
||||
}
|
||||
|
||||
function playerAppLabel(player) {
|
||||
if (!player) return ""
|
||||
var dbus = String(player.dbusName || "")
|
||||
dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "")
|
||||
dbus = dbus.replace(/\.instance[0-9]+$/, "")
|
||||
return player.desktopEntry || player.identity || dbus
|
||||
}
|
||||
|
||||
function playerHasPlaybackStream(player, playbackStreams) {
|
||||
var playerKey = streamLabelKey(playerAppLabel(player))
|
||||
if (!playerKey) return false
|
||||
|
||||
var streams = Array.isArray(playbackStreams) ? playbackStreams : []
|
||||
for (var i = 0; i < streams.length; i++) {
|
||||
var streamKey = streamLabelKey(rawStreamLabel(streams[i]))
|
||||
if (!streamKey) continue
|
||||
if (streamKey === playerKey
|
||||
|| streamKey.indexOf(playerKey) !== -1
|
||||
|| playerKey.indexOf(streamKey) !== -1)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function playerKey(player) {
|
||||
if (!player) return ""
|
||||
return String(player.dbusName || player.desktopEntry || player.identity || "")
|
||||
}
|
||||
|
||||
function trackSignature(player) {
|
||||
if (!player) return ""
|
||||
return [
|
||||
player.trackTitle || "",
|
||||
player.trackArtist || "",
|
||||
player.trackAlbum || "",
|
||||
player.trackArtUrl || ""
|
||||
].join("\u001f")
|
||||
}
|
||||
|
||||
function trackChanged(previousSignature, player) {
|
||||
return trackSignature(player) !== String(previousSignature || "")
|
||||
}
|
||||
|
||||
function labelFor(player) {
|
||||
if (!player) return ""
|
||||
return player.trackTitle || player.identity || player.desktopEntry || ""
|
||||
}
|
||||
|
||||
function osdMessage(player, fallback) {
|
||||
if (!player) return fallback
|
||||
var label = labelFor(player)
|
||||
if (label && player.trackArtist) return label + " - " + player.trackArtist
|
||||
return label || fallback
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
isProxyPlayer: isProxyPlayer,
|
||||
hasMetadata: hasMetadata,
|
||||
hasTrackMetadata: hasTrackMetadata,
|
||||
playerCanControl: playerCanControl,
|
||||
canHandleAction: canHandleAction,
|
||||
canCycleSource: canCycleSource,
|
||||
nodeProps: nodeProps,
|
||||
isPlaybackStream: isPlaybackStream,
|
||||
streamLabelKey: streamLabelKey,
|
||||
rawStreamLabel: rawStreamLabel,
|
||||
playerAppLabel: playerAppLabel,
|
||||
playerHasPlaybackStream: playerHasPlaybackStream,
|
||||
playerKey: playerKey,
|
||||
trackSignature: trackSignature,
|
||||
trackChanged: trackChanged,
|
||||
labelFor: labelFor,
|
||||
osdMessage: osdMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Mpris
|
||||
import Quickshell.Services.Pipewire
|
||||
import "MediaModel.js" as MediaModel
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var shell: null
|
||||
property string preferredPlayerKey: ""
|
||||
property var playerStartedAt: ({})
|
||||
property var pendingTrackOsd: null
|
||||
property int playSerial: 0
|
||||
|
||||
readonly property var players: Mpris.players ? Mpris.players.values : []
|
||||
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
|
||||
readonly property var playbackStreams: {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i]
|
||||
if (n && n.isStream && isPlaybackStream(n) && n.audio) list.push(n)
|
||||
}
|
||||
return list
|
||||
}
|
||||
readonly property var sourcePlayers: orderedSourcePlayers()
|
||||
readonly property var sourceCyclePlayers: orderedCycleSourcePlayers()
|
||||
readonly property var activePlayer: selectActivePlayer()
|
||||
readonly property bool hasMedia: activePlayer !== null && (activePlayer.trackTitle || activePlayer.trackArtist)
|
||||
readonly property string title: activePlayer ? (activePlayer.trackTitle || "") : ""
|
||||
readonly property string artist: activePlayer ? (activePlayer.trackArtist || "") : ""
|
||||
readonly property string album: activePlayer && activePlayer.trackAlbum ? activePlayer.trackAlbum : ""
|
||||
readonly property string artUrl: activePlayer && activePlayer.trackArtUrl ? activePlayer.trackArtUrl : ""
|
||||
readonly property string identity: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "") : ""
|
||||
|
||||
function isProxyPlayer(player) {
|
||||
return MediaModel.isProxyPlayer(player)
|
||||
}
|
||||
|
||||
function hasMetadata(player) {
|
||||
return MediaModel.hasMetadata(player)
|
||||
}
|
||||
|
||||
function hasTrackMetadata(player) {
|
||||
return MediaModel.hasTrackMetadata(player)
|
||||
}
|
||||
|
||||
function playerCanControl(player) {
|
||||
return MediaModel.playerCanControl(player)
|
||||
}
|
||||
|
||||
function canHandleAction(player, action) {
|
||||
return MediaModel.canHandleAction(player, action)
|
||||
}
|
||||
|
||||
function canCycleSource(player) {
|
||||
return MediaModel.canCycleSource(player)
|
||||
}
|
||||
|
||||
function nodeProps(node) {
|
||||
return MediaModel.nodeProps(node)
|
||||
}
|
||||
|
||||
function isPlaybackStream(node) {
|
||||
return MediaModel.isPlaybackStream(node)
|
||||
}
|
||||
|
||||
function streamLabelKey(label) {
|
||||
return MediaModel.streamLabelKey(label)
|
||||
}
|
||||
|
||||
function rawStreamLabel(node) {
|
||||
return MediaModel.rawStreamLabel(node)
|
||||
}
|
||||
|
||||
function playerAppLabel(player) {
|
||||
return MediaModel.playerAppLabel(player)
|
||||
}
|
||||
|
||||
function playerHasPlaybackStream(player) {
|
||||
return MediaModel.playerHasPlaybackStream(player, playbackStreams)
|
||||
}
|
||||
|
||||
function playerKey(player) {
|
||||
return MediaModel.playerKey(player)
|
||||
}
|
||||
|
||||
function playerForKey(key) {
|
||||
if (!key) return null
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var p = players[i]
|
||||
if (playerKey(p) === key) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function playerOrder(player, fallback) {
|
||||
var key = playerKey(player)
|
||||
var value = key ? playerStartedAt[key] : undefined
|
||||
return value === undefined ? fallback : value
|
||||
}
|
||||
|
||||
function syncPlayingOrder() {
|
||||
var next = {}
|
||||
var alive = {}
|
||||
var serial = playSerial
|
||||
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var p = players[i]
|
||||
var key = playerKey(p)
|
||||
if (!key) continue
|
||||
|
||||
alive[key] = true
|
||||
if (!p.isPlaying) continue
|
||||
|
||||
if (playerStartedAt[key] === undefined) {
|
||||
serial += 1
|
||||
next[key] = serial
|
||||
} else {
|
||||
next[key] = playerStartedAt[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredPlayerKey && !alive[preferredPlayerKey]) preferredPlayerKey = ""
|
||||
|
||||
playSerial = serial
|
||||
playerStartedAt = next
|
||||
}
|
||||
|
||||
function orderedSourcePlayers() {
|
||||
var list = []
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var p = players[i]
|
||||
if (hasMetadata(p)) list.push(p)
|
||||
}
|
||||
|
||||
list.sort(function(a, b) {
|
||||
if (!!a.isPlaying !== !!b.isPlaying) return a.isPlaying ? -1 : 1
|
||||
if (isProxyPlayer(a) !== isProxyPlayer(b)) return isProxyPlayer(a) ? 1 : -1
|
||||
if (a.isPlaying && b.isPlaying) {
|
||||
var orderDelta = playerOrder(a, 1000) - playerOrder(b, 1000)
|
||||
if (orderDelta !== 0) return orderDelta
|
||||
}
|
||||
return labelFor(a).localeCompare(labelFor(b))
|
||||
})
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
function orderedCycleSourcePlayers() {
|
||||
var list = []
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var p = players[i]
|
||||
if (canCycleSource(p)) list.push(p)
|
||||
}
|
||||
|
||||
list.sort(function(a, b) {
|
||||
if (isProxyPlayer(a) !== isProxyPlayer(b)) return isProxyPlayer(a) ? 1 : -1
|
||||
return labelFor(a).localeCompare(labelFor(b))
|
||||
})
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
function oldestPlayingPlayer(requirePlaybackStream) {
|
||||
var oldest = null
|
||||
var oldestOrder = 0
|
||||
var playingProxy = null
|
||||
var proxyOrder = 0
|
||||
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var p = players[i]
|
||||
if (!p) continue
|
||||
|
||||
var proxyPlayer = isProxyPlayer(p)
|
||||
if (p.isPlaying) {
|
||||
if (requirePlaybackStream && !playerHasPlaybackStream(p)) continue
|
||||
|
||||
var order = playerOrder(p, i + 1000)
|
||||
if (!proxyPlayer && (!oldest || order < oldestOrder)) {
|
||||
oldest = p
|
||||
oldestOrder = order
|
||||
} else if (proxyPlayer && (!playingProxy || order < proxyOrder)) {
|
||||
playingProxy = p
|
||||
proxyOrder = order
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return oldest || playingProxy || null
|
||||
}
|
||||
|
||||
function selectActivePlayer() {
|
||||
var preferred = null
|
||||
var trackPlayer = null
|
||||
var trackProxy = null
|
||||
var streamPlayer = null
|
||||
var streamProxy = null
|
||||
var controllablePlayer = null
|
||||
var controllableProxy = null
|
||||
var identityPlayer = null
|
||||
var identityProxy = null
|
||||
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var p = players[i]
|
||||
if (!p) continue
|
||||
|
||||
var proxy = isProxyPlayer(p)
|
||||
|
||||
if (preferredPlayerKey && playerKey(p) === preferredPlayerKey && hasMetadata(p)) preferred = p
|
||||
|
||||
if (playerHasPlaybackStream(p)) {
|
||||
if (!proxy && !streamPlayer) streamPlayer = p
|
||||
else if (proxy && !streamProxy) streamProxy = p
|
||||
} else if (hasTrackMetadata(p)) {
|
||||
if (!proxy && !trackPlayer) trackPlayer = p
|
||||
else if (proxy && !trackProxy) trackProxy = p
|
||||
} else if (playerCanControl(p)) {
|
||||
if (!proxy && !controllablePlayer) controllablePlayer = p
|
||||
else if (proxy && !controllableProxy) controllableProxy = p
|
||||
} else if (hasMetadata(p)) {
|
||||
if (!proxy && !identityPlayer) identityPlayer = p
|
||||
else if (proxy && !identityProxy) identityProxy = p
|
||||
}
|
||||
}
|
||||
|
||||
if (preferred && preferred.isPlaying) return preferred
|
||||
var streamCandidate = streamPlayer || streamProxy
|
||||
var streamPreferred = preferred && playerHasPlaybackStream(preferred) ? preferred : null
|
||||
return oldestPlayingPlayer(true) || oldestPlayingPlayer(false) || streamPreferred || streamCandidate || preferred || trackPlayer || trackProxy || controllablePlayer || controllableProxy || identityPlayer || identityProxy || null
|
||||
}
|
||||
|
||||
function labelFor(player) {
|
||||
return MediaModel.labelFor(player)
|
||||
}
|
||||
|
||||
function osdMessage(player, fallback) {
|
||||
return MediaModel.osdMessage(player, fallback)
|
||||
}
|
||||
|
||||
function trackSignature(player) {
|
||||
return MediaModel.trackSignature(player)
|
||||
}
|
||||
|
||||
function showOsd(actionLabel, iconName, player) {
|
||||
if (!shell) return
|
||||
shell.summon("blob.osd", JSON.stringify({
|
||||
icon: iconName || "media",
|
||||
message: osdMessage(player || activePlayer, actionLabel)
|
||||
}))
|
||||
}
|
||||
|
||||
function scheduleOsd(actionLabel, iconName, player, waitForTrackChange, beforeTrackSignature) {
|
||||
if (waitForTrackChange) {
|
||||
pendingTrackOsd = {
|
||||
actionLabel: actionLabel,
|
||||
iconName: iconName,
|
||||
player: player,
|
||||
playerKey: playerKey(player),
|
||||
before: beforeTrackSignature,
|
||||
attempts: 0
|
||||
}
|
||||
trackOsdTimer.restart()
|
||||
} else {
|
||||
Qt.callLater(function() { root.showOsd(actionLabel, iconName, player) })
|
||||
}
|
||||
}
|
||||
|
||||
function flushPendingTrackOsd(force) {
|
||||
var pending = pendingTrackOsd
|
||||
if (!pending) return
|
||||
|
||||
var player = playerForKey(pending.playerKey) || pending.player
|
||||
if (force || MediaModel.trackChanged(pending.before, player) || pending.attempts >= 10) {
|
||||
pendingTrackOsd = null
|
||||
trackOsdTimer.stop()
|
||||
root.showOsd(pending.actionLabel, pending.iconName, player)
|
||||
return
|
||||
}
|
||||
|
||||
pending.attempts = pending.attempts + 1
|
||||
pendingTrackOsd = pending
|
||||
trackOsdTimer.restart()
|
||||
}
|
||||
|
||||
function selectPlayer(key) {
|
||||
var player = playerForKey(key)
|
||||
if (!player || !hasMetadata(player)) return false
|
||||
preferredPlayerKey = playerKey(player)
|
||||
return true
|
||||
}
|
||||
|
||||
function playPlayer(player) {
|
||||
if (!player) return false
|
||||
if (player.canPlay) {
|
||||
player.play()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function pausePlayer(player) {
|
||||
if (!player) return false
|
||||
if (player.canPause) {
|
||||
player.pause()
|
||||
return true
|
||||
}
|
||||
if (player.canTogglePlaying && player.isPlaying) {
|
||||
player.togglePlaying()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function switchSource(delta, transferPlayback, showFeedback) {
|
||||
var list = sourceCyclePlayers
|
||||
if (!list || list.length === 0) return false
|
||||
|
||||
var activeKey = playerKey(activePlayer)
|
||||
var index = 0
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (playerKey(list[i]) === activeKey) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
index = (index + delta + list.length) % list.length
|
||||
var current = activePlayer
|
||||
var next = list[index]
|
||||
var currentWasPlaying = current && current.isPlaying
|
||||
var currentKey = playerKey(current)
|
||||
var nextKey = playerKey(next)
|
||||
|
||||
preferredPlayerKey = nextKey
|
||||
|
||||
if (transferPlayback && currentWasPlaying && next && nextKey !== currentKey) {
|
||||
var nextWasPlaying = next.isPlaying
|
||||
var nextStarted = nextWasPlaying || playPlayer(next)
|
||||
if (nextStarted) pausePlayer(current)
|
||||
}
|
||||
|
||||
if (showFeedback !== false) Qt.callLater(function() {
|
||||
root.showOsd("Source", "media-source", next)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function playerForAction(action, targetKey) {
|
||||
var targeted = playerForKey(targetKey)
|
||||
if (targeted) return targeted
|
||||
|
||||
if (action === "pause" || action === "playPause") {
|
||||
var oldest = oldestPlayingPlayer(true) || oldestPlayingPlayer(false)
|
||||
if (oldest) return oldest
|
||||
}
|
||||
|
||||
if (canHandleAction(activePlayer, action)) return activePlayer
|
||||
|
||||
var list = sourcePlayers
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (canHandleAction(list[i], action)) return list[i]
|
||||
}
|
||||
|
||||
return activePlayer
|
||||
}
|
||||
|
||||
function runAction(action, showFeedback, targetKey) {
|
||||
var player = playerForAction(action, targetKey)
|
||||
var key = playerKey(player)
|
||||
var actionLabel = "Play/pause"
|
||||
var iconName = "media"
|
||||
var beforeTrackSignature = trackSignature(player)
|
||||
var handled = false
|
||||
|
||||
if (action === "next") {
|
||||
actionLabel = "Next"
|
||||
iconName = "media-next"
|
||||
if (player && player.canGoNext) {
|
||||
player.next()
|
||||
handled = true
|
||||
}
|
||||
} else if (action === "previous") {
|
||||
actionLabel = "Previous"
|
||||
iconName = "media-previous"
|
||||
if (player && player.canGoPrevious) {
|
||||
player.previous()
|
||||
handled = true
|
||||
}
|
||||
} else if (action === "play") {
|
||||
actionLabel = "Play"
|
||||
iconName = "media-play"
|
||||
if (player && player.canPlay) {
|
||||
player.play()
|
||||
handled = true
|
||||
} else if (player && player.canTogglePlaying && !player.isPlaying) {
|
||||
player.togglePlaying()
|
||||
handled = true
|
||||
}
|
||||
} else if (action === "pause") {
|
||||
actionLabel = "Pause"
|
||||
iconName = "media-pause"
|
||||
if (player && player.canPause) {
|
||||
player.pause()
|
||||
handled = true
|
||||
} else if (player && player.canTogglePlaying && player.isPlaying) {
|
||||
player.togglePlaying()
|
||||
handled = true
|
||||
}
|
||||
} else if (action === "playPause") {
|
||||
actionLabel = player && player.isPlaying ? "Pause" : "Play"
|
||||
iconName = player && player.isPlaying ? "media-pause" : "media-play"
|
||||
if (player && player.isPlaying && player.canPause) {
|
||||
player.pause()
|
||||
handled = true
|
||||
} else if (player && !player.isPlaying && player.canPlay) {
|
||||
player.play()
|
||||
handled = true
|
||||
} else if (player && player.canTogglePlaying) {
|
||||
player.togglePlaying()
|
||||
handled = true
|
||||
}
|
||||
}
|
||||
|
||||
if (handled && key) preferredPlayerKey = key
|
||||
if (showFeedback !== false)
|
||||
scheduleOsd(actionLabel, iconName, player, handled && (action === "next" || action === "previous"), beforeTrackSignature)
|
||||
return handled
|
||||
}
|
||||
|
||||
// Recompute play-order reactively instead of polling every 500ms.
|
||||
// syncPlayingOrder only depends on the set of players and each player's
|
||||
// isPlaying state: onPlayersChanged covers players appearing/disappearing,
|
||||
// and the Instantiator wires isPlayingChanged for each live player.
|
||||
Component.onCompleted: root.syncPlayingOrder()
|
||||
onPlayersChanged: root.syncPlayingOrder()
|
||||
|
||||
Instantiator {
|
||||
model: root.players
|
||||
delegate: Connections {
|
||||
required property var modelData
|
||||
target: modelData
|
||||
function onIsPlayingChanged() { root.syncPlayingOrder() }
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: trackOsdTimer
|
||||
interval: 120
|
||||
repeat: false
|
||||
onTriggered: root.flushPendingTrackOsd(false)
|
||||
}
|
||||
|
||||
PwObjectTracker { objects: root.playbackStreams }
|
||||
|
||||
function statusJson() {
|
||||
var p = activePlayer
|
||||
return JSON.stringify({
|
||||
hasPlayer: p !== null,
|
||||
hasMedia: root.hasMedia,
|
||||
playing: p ? !!p.isPlaying : false,
|
||||
identity: p ? (p.identity || "") : "",
|
||||
desktopEntry: p ? (p.desktopEntry || "") : "",
|
||||
title: p ? (p.trackTitle || "") : "",
|
||||
artist: p ? (p.trackArtist || "") : "",
|
||||
album: p && p.trackAlbum ? p.trackAlbum : "",
|
||||
artUrl: p && p.trackArtUrl ? p.trackArtUrl : "",
|
||||
canGoNext: p ? !!p.canGoNext : false,
|
||||
canGoPrevious: p ? !!p.canGoPrevious : false,
|
||||
canTogglePlaying: p ? !!p.canTogglePlaying : false
|
||||
})
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "media"
|
||||
|
||||
function status(): string {
|
||||
return root.statusJson()
|
||||
}
|
||||
|
||||
function playPause(): string {
|
||||
return root.runAction("playPause", true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function next(): string {
|
||||
return root.runAction("next", true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function previous(): string {
|
||||
return root.runAction("previous", true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function play(): string {
|
||||
return root.runAction("play", true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function pause(): string {
|
||||
return root.runAction("pause", true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function sourceNext(): string {
|
||||
return root.switchSource(1, false, true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function sourcePrevious(): string {
|
||||
return root.switchSource(-1, false, true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function sourceSwitch(): string {
|
||||
return root.switchSource(1, true, true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function sourceSwitchPrevious(): string {
|
||||
return root.switchSource(-1, true, true) ? "ok" : "unhandled"
|
||||
}
|
||||
|
||||
function ping(): string {
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.media",
|
||||
"name": "Media",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "MPRIS media control service",
|
||||
"kinds": [
|
||||
"service",
|
||||
"bar-widget"
|
||||
],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {
|
||||
"service": "Service.qml",
|
||||
"barWidget": "BarWidget.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Media",
|
||||
"description": "MPRIS now-playing with playback controls",
|
||||
"category": "Media",
|
||||
"allowMultiple": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user