Files
noctalia-shell/Modules/Bar/Widgets/MediaMini.qml
T

496 lines
17 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.Bar.Extras
import qs.Services.Media
import qs.Services.UI
import qs.Widgets
import qs.Widgets.AudioSpectrum
Item {
id: root
property ShellScreen screen
property string widgetId: ""
property string section: ""
property int sectionWidgetIndex: -1
property int sectionWidgetsCount: 0
// Settings
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId]
// Explicit screenName property ensures reactive binding when screen changes
readonly property string screenName: screen ? screen.name : ""
property var widgetSettings: {
if (section && sectionWidgetIndex >= 0 && screenName) {
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
if (widgets && sectionWidgetIndex < widgets.length) {
return widgets[sectionWidgetIndex];
}
}
return {};
}
// Bar orientation (per-screen)
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
// Widget settings
readonly property string hideMode: widgetSettings.hideMode !== undefined ? widgetSettings.hideMode : widgetMetadata.hideMode
readonly property bool hideWhenIdle: widgetSettings.hideWhenIdle !== undefined ? widgetSettings.hideWhenIdle : widgetMetadata.hideWhenIdle
readonly property bool showAlbumArt: widgetSettings.showAlbumArt !== undefined ? widgetSettings.showAlbumArt : widgetMetadata.showAlbumArt
readonly property bool showArtistFirst: widgetSettings.showArtistFirst !== undefined ? widgetSettings.showArtistFirst : widgetMetadata.showArtistFirst
readonly property bool showVisualizer: widgetSettings.showVisualizer !== undefined ? widgetSettings.showVisualizer : widgetMetadata.showVisualizer
readonly property string visualizerType: widgetSettings.visualizerType !== undefined ? widgetSettings.visualizerType : widgetMetadata.visualizerType
readonly property string scrollingMode: widgetSettings.scrollingMode !== undefined ? widgetSettings.scrollingMode : widgetMetadata.scrollingMode
readonly property bool showProgressRing: widgetSettings.showProgressRing !== undefined ? widgetSettings.showProgressRing : widgetMetadata.showProgressRing
readonly property bool useFixedWidth: widgetSettings.useFixedWidth !== undefined ? widgetSettings.useFixedWidth : widgetMetadata.useFixedWidth
readonly property real maxWidth: widgetSettings.maxWidth !== undefined ? widgetSettings.maxWidth : Math.max(widgetMetadata.maxWidth, screen ? screen.width * 0.06 : 0)
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
readonly property color textColor: Color.resolveColorKey(textColorKey)
// Dimensions
readonly property int artSize: Style.toOdd(capsuleHeight * 0.75)
readonly property int iconSize: Style.toOdd(capsuleHeight * 0.75)
readonly property int verticalSize: Style.toOdd(capsuleHeight * 0.85)
readonly property int progressWidth: 2
// State
readonly property bool hasPlayer: MediaService.currentPlayer !== null
readonly property bool shouldHideIdle: (hideMode === "idle" || hideWhenIdle) && !MediaService.isPlaying
readonly property bool shouldHideEmpty: !hasPlayer && hideMode === "hidden"
readonly property bool isHidden: shouldHideIdle || shouldHideEmpty
// Title
readonly property string title: {
if (!hasPlayer)
return I18n.tr("bar.media-mini.no-active-player");
var artist = MediaService.trackArtist;
var track = MediaService.trackTitle;
return showArtistFirst ? (artist ? `${artist} - ${track}` : track) : (artist ? `${track} - ${artist}` : track);
}
// SpectrumService registration for visualizer
readonly property string spectrumComponentId: "bar:mediamini:" + root.screen?.name + ":" + root.section + ":" + root.sectionWidgetIndex
readonly property bool needsSpectrum: root.showVisualizer && root.visualizerType !== "" && root.visualizerType !== "none" && !root.isHidden
Layout.preferredHeight: isVertical ? -1 : Style.getBarHeightForScreen(screenName)
Layout.preferredWidth: isVertical ? Style.getBarHeightForScreen(screenName) : -1
Layout.fillHeight: false
Layout.fillWidth: false
onNeedsSpectrumChanged: {
if (root.needsSpectrum) {
SpectrumService.registerComponent(root.spectrumComponentId);
} else {
SpectrumService.unregisterComponent(root.spectrumComponentId);
}
}
Component.onCompleted: {
if (root.needsSpectrum) {
SpectrumService.registerComponent(root.spectrumComponentId);
}
}
Component.onDestruction: {
SpectrumService.unregisterComponent(root.spectrumComponentId);
}
// Layout
// For horizontal bars, height is always capsuleHeight (no animation needed to prevent jitter)
// For vertical bars, collapse to 0 when hidden
implicitWidth: isVertical ? (isHidden ? 0 : verticalSize) : (isHidden ? 0 : contentWidth)
implicitHeight: isVertical ? (isHidden ? 0 : verticalSize) : capsuleHeight
visible: !shouldHideIdle && (hideMode !== "hidden" || opacity > 0)
opacity: isHidden ? 0.0 : ((hideMode === "transparent" && !hasPlayer) ? 0.0 : 1.0)
property real mainContentWidth: 0
readonly property real contentWidth: {
if (useFixedWidth)
return maxWidth;
// Calculate icon/art width (must match RowLayout visibility)
var iconWidth = 0;
if (!hasPlayer) {
iconWidth = iconSize;
} else if (showAlbumArt || showProgressRing) {
iconWidth = artSize;
}
var margins = isVertical ? 0 : Style.margin2S;
// Add spacing and text width
var textWidth = 0;
if (titleContainer.measuredWidth > 0) {
if (iconWidth > 0)
margins += Style.marginS;
textWidth = titleContainer.measuredWidth + Style.margin2XXS;
}
var total = iconWidth + textWidth + margins;
// calculate the width of all elements except the scrolling text
mainContentWidth = total - textWidth;
return hasPlayer ? Math.min(total, maxWidth) : total;
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}
Behavior on implicitWidth {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}
Behavior on implicitHeight {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}
// Context menu
NPopupContextMenu {
id: contextMenu
model: {
var items = [];
if (hasPlayer && MediaService.canPlay) {
items.push({
"label": MediaService.isPlaying ? I18n.tr("common.pause") : I18n.tr("common.play"),
"action": "play-pause",
"icon": MediaService.isPlaying ? "media-pause" : "media-play"
});
}
if (hasPlayer && MediaService.canGoPrevious) {
items.push({
"label": I18n.tr("common.previous"),
"action": "previous",
"icon": "media-prev"
});
}
if (hasPlayer && MediaService.canGoNext) {
items.push({
"label": I18n.tr("common.next"),
"action": "next",
"icon": "media-next"
});
}
// Append available players (like in Control Center) so user can switch from the bar
var players = MediaService.getAvailablePlayers ? MediaService.getAvailablePlayers() : [];
if (players && players.length > 1) {
for (var i = 0; i < players.length; i++) {
var isCurrent = (i === MediaService.selectedPlayerIndex);
items.push({
"label": players[i].identity,
"action": "player-" + i,
"icon": isCurrent ? "check" : "disc",
"enabled": true,
"visible": true
});
}
}
items.push({
"label": I18n.tr("actions.widget-settings"),
"action": "widget-settings",
"icon": "settings"
});
return items;
}
onTriggered: action => {
contextMenu.close();
PanelService.closeContextMenu(screen);
if (action === "play-pause")
MediaService.playPause();
else if (action === "previous")
MediaService.previous();
else if (action === "next")
MediaService.next();
else if (action && action.indexOf("player-") === 0) {
var idx = parseInt(action.split("-")[1]);
if (!isNaN(idx)) {
MediaService.switchToPlayer(idx);
}
} else if (action === "widget-settings") {
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
}
}
}
// Main container - stays at content size, pixel-perfect centered in parent
Rectangle {
id: container
x: Style.pixelAlignCenter(parent.width, width)
y: Style.pixelAlignCenter(parent.height, height)
width: Style.toOdd(isVertical ? (isHidden ? 0 : verticalSize) : (isHidden ? 0 : contentWidth))
height: Style.toOdd(isVertical ? (isHidden ? 0 : verticalSize) : capsuleHeight)
radius: Style.radiusM
color: Style.capsuleColor
border.color: Style.capsuleBorderColor
border.width: Style.capsuleBorderWidth
Behavior on width {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}
Behavior on height {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}
Item {
anchors.fill: parent
anchors.leftMargin: isVertical ? 0 : Style.marginS
anchors.rightMargin: isVertical ? 0 : Style.marginS
// Visualizer
Loader {
x: Style.pixelAlignCenter(parent.width, width)
y: Style.pixelAlignCenter(parent.height, height)
width: Style.toOdd(parent.width)
height: Style.toOdd(parent.height)
active: showVisualizer
z: 0
sourceComponent: {
if (!showVisualizer)
return null;
if (visualizerType === "linear")
return linearSpectrum;
if (visualizerType === "mirrored")
return mirroredSpectrum;
if (visualizerType === "wave")
return waveSpectrum;
return null;
}
}
// Horizontal layout
RowLayout {
anchors.fill: parent
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS
visible: !isVertical
z: 1
// Album art / Progress ring
Item {
visible: hasPlayer && (showAlbumArt || showProgressRing)
Layout.preferredWidth: visible ? artSize : 0
Layout.preferredHeight: visible ? artSize : 0
Layout.alignment: Qt.AlignVCenter
ProgressRing {
id: progressRing
anchors.fill: parent
visible: showProgressRing
progress: MediaService.trackLength > 0 ? MediaService.currentPosition / MediaService.trackLength : 0
lineWidth: root.progressWidth
}
NImageRounded {
visible: showAlbumArt && hasPlayer
anchors.fill: parent
anchors.margins: showProgressRing ? root.progressWidth * 2 : 0
radius: width / 2
imagePath: MediaService.trackArtUrl
borderWidth: 0
imageFillMode: Image.PreserveAspectCrop
}
}
// Scrolling title
NScrollText {
id: titleContainer
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
Layout.preferredHeight: capsuleHeight
text: title
scrollMode: {
if (scrollingMode === "always")
return NScrollText.ScrollMode.Always;
if (scrollingMode === "hover")
return NScrollText.ScrollMode.Hover;
return NScrollText.ScrollMode.Never;
}
cursorShape: hasPlayer ? Qt.PointingHandCursor : Qt.ArrowCursor
maxWidth: root.maxWidth - root.mainContentWidth
forcedHover: mainMouseArea.containsMouse
gradientColor: Style.capsuleColor
gradientWidth: Math.round(8 * Style.uiScaleRatio)
cornerRadius: Style.radiusM
NText {
color: hasPlayer ? root.textColor : Color.mOnSurfaceVariant
pointSize: barFontSize
elide: Text.ElideNone
}
}
}
// Vertical layout
Item {
id: verticalLayout
visible: isVertical
width: Style.toOdd(verticalSize)
height: Style.toOdd(width)
x: Style.pixelAlignCenter(parent.width, width)
y: Style.pixelAlignCenter(parent.height, height)
z: 1
ProgressRing {
anchors.fill: parent
visible: showProgressRing
progress: MediaService.trackLength > 0 ? MediaService.currentPosition / MediaService.trackLength : 0
lineWidth: root.progressWidth
}
NImageRounded {
visible: showAlbumArt && hasPlayer
anchors.fill: parent
anchors.margins: showProgressRing ? root.progressWidth * 2 : 0
radius: width / 2
imagePath: MediaService.trackArtUrl
borderWidth: 0
imageFillMode: Image.PreserveAspectCrop
}
}
// Mouse interaction moved to root
}
}
// Mouse interaction
MouseArea {
id: mainMouseArea
anchors.fill: parent
// Extend click area to screen edge if widget is at the start/end
anchors.leftMargin: (!isVertical && section === "left" && sectionWidgetIndex === 0) ? -Style.marginS : 0
anchors.rightMargin: (!isVertical && section === "right" && sectionWidgetIndex === sectionWidgetsCount - 1) ? -Style.marginS : 0
anchors.topMargin: (isVertical && section === "left" && sectionWidgetIndex === 0) ? -Style.marginM : 0
anchors.bottomMargin: (isVertical && section === "right" && sectionWidgetIndex === sectionWidgetsCount - 1) ? -Style.marginM : 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton | Qt.ForwardButton | Qt.BackButton
onClicked: mouse => {
TooltipService.hide();
if (mouse.button === Qt.LeftButton) {
PanelService.getPanel("mediaPlayerPanel", screen)?.toggle(container);
} else if (mouse.button === Qt.RightButton) {
PanelService.showContextMenu(contextMenu, container, screen);
} else if (mouse.button === Qt.MiddleButton && hasPlayer) {
MediaService.playPause();
} else if (mouse.button === Qt.ForwardButton && hasPlayer) {
MediaService.next();
} else if (mouse.button === Qt.BackButton && hasPlayer) {
MediaService.previous();
}
}
onEntered: {
if (screen && (isVertical || scrollingMode === "never") && !PanelService.getPanel("mediaPlayerPanel", screen)?.isPanelOpen) {
TooltipService.show(root, title, BarService.getTooltipDirection(root.screen?.name));
}
}
onExited: TooltipService.hide()
}
// Components
Component {
id: linearSpectrum
NLinearSpectrum {
width: parent.width - Style.marginS
height: 20
values: SpectrumService.values
fillColor: Color.mPrimary
opacity: 0.4
barPosition: root.barPosition
}
}
Component {
id: mirroredSpectrum
NMirroredSpectrum {
width: parent.width - Style.marginS
height: parent.height - Style.marginS
values: SpectrumService.values
fillColor: Color.mPrimary
opacity: 0.4
}
}
Component {
id: waveSpectrum
NWaveSpectrum {
width: parent.width - Style.marginS
height: parent.height - Style.marginS
values: SpectrumService.values
fillColor: Color.mPrimary
opacity: 0.4
}
}
// Progress Ring Component
component ProgressRing: Canvas {
property real progress: 0
property real lineWidth: 2
onProgressChanged: requestPaint()
Component.onCompleted: requestPaint()
Connections {
target: Color
function onMPrimaryChanged() {
requestPaint();
}
}
onPaint: {
if (width <= 0 || height <= 0)
return;
var ctx = getContext("2d");
var centerX = width / 2;
var centerY = height / 2;
var radius = Math.min(width, height) / 2 - lineWidth;
ctx.reset();
// Background
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.4);
ctx.stroke();
// Progress
ctx.beginPath();
ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = Color.mPrimary;
ctx.lineCap = "round";
ctx.stroke();
}
}
}