mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
81c6a17ba5
MediaMini: cava respect bar location NLinearSpectrum: use barPosition if needed
576 lines
18 KiB
QML
576 lines
18 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
|
|
property real scaling: 1.0
|
|
|
|
// Settings
|
|
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId]
|
|
property var widgetSettings: {
|
|
if (section && sectionWidgetIndex >= 0) {
|
|
var widgets = Settings.data.bar.widgets[section];
|
|
if (widgets && sectionWidgetIndex < widgets.length) {
|
|
return widgets[sectionWidgetIndex];
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// Bar orientation
|
|
readonly property bool isVertical: Settings.data.bar.position === "left" || Settings.data.bar.position === "right"
|
|
|
|
// Widget settings
|
|
readonly property string hideMode: (widgetSettings.hideMode !== undefined) ? widgetSettings.hideMode : "hidden"
|
|
readonly property bool hideWhenIdle: (widgetSettings.hideWhenIdle !== undefined) ? widgetSettings.hideWhenIdle : (widgetMetadata.hideWhenIdle !== undefined ? widgetMetadata.hideWhenIdle : false)
|
|
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 !== "") ? 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)
|
|
|
|
// Dimensions
|
|
readonly property int iconSize: Math.round(18 * scaling)
|
|
readonly property int artSize: Math.round(21 * scaling)
|
|
readonly property int verticalSize: Math.round((Style.baseWidgetSize - 5) * scaling)
|
|
|
|
// 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.widget-settings.media-mini.no-active-player");
|
|
var artist = MediaService.trackArtist;
|
|
var track = MediaService.trackTitle;
|
|
return showArtistFirst ? (artist ? `${artist} - ${track}` : track) : (artist ? `${track} - ${artist}` : track);
|
|
}
|
|
|
|
readonly property string tooltipText: {
|
|
var text = title;
|
|
var controls = [];
|
|
if (MediaService.canGoNext)
|
|
controls.push("Right click for next.");
|
|
if (MediaService.canGoPrevious)
|
|
controls.push("Middle click for previous.");
|
|
return controls.length ? `${text}\n\n${controls.join("\n")}` : text;
|
|
}
|
|
|
|
// Layout
|
|
implicitWidth: visible ? (isVertical ? (isHidden ? 0 : verticalSize) : (isHidden ? 0 : contentWidth)) : 0
|
|
implicitHeight: visible ? (isVertical ? (isHidden ? 0 : verticalSize) : Style.capsuleHeight) : 0
|
|
visible: !shouldHideIdle && (hideMode !== "hidden" || opacity > 0)
|
|
opacity: isHidden ? 0.0 : ((hideMode === "transparent" && !hasPlayer) ? 0.0 : 1.0)
|
|
|
|
readonly property real contentWidth: {
|
|
if (useFixedWidth)
|
|
return maxWidth;
|
|
|
|
// Calculate icon/art width
|
|
var iconWidth = 0;
|
|
if (!hasPlayer || (!showAlbumArt && !showProgressRing)) {
|
|
iconWidth = iconSize;
|
|
} else if (showAlbumArt || showProgressRing) {
|
|
iconWidth = artSize;
|
|
}
|
|
|
|
// Add spacing and text width
|
|
var textWidth = 0;
|
|
if (titleMetrics.contentWidth > 0) {
|
|
textWidth = Style.marginS * scaling + titleMetrics.contentWidth + Style.marginXXS * 2;
|
|
}
|
|
|
|
var margins = isVertical ? 0 : (Style.marginS * scaling * 2);
|
|
var total = iconWidth + textWidth + margins;
|
|
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
|
|
}
|
|
}
|
|
|
|
// Hidden text for measurements
|
|
NText {
|
|
id: titleMetrics
|
|
visible: false
|
|
text: title
|
|
applyUiScale: false
|
|
pointSize: Style.fontSizeS * scaling
|
|
font.weight: Style.fontWeightMedium
|
|
}
|
|
|
|
// Context menu
|
|
NPopupContextMenu {
|
|
id: contextMenu
|
|
model: {
|
|
var items = [];
|
|
if (hasPlayer && MediaService.canPlay) {
|
|
items.push({
|
|
"label": MediaService.isPlaying ? I18n.tr("context-menu.pause") : I18n.tr("context-menu.play"),
|
|
"action": "play-pause",
|
|
"icon": MediaService.isPlaying ? "media-pause" : "media-play"
|
|
});
|
|
}
|
|
if (hasPlayer && MediaService.canGoPrevious) {
|
|
items.push({
|
|
"label": I18n.tr("context-menu.previous"),
|
|
"action": "previous",
|
|
"icon": "media-prev"
|
|
});
|
|
}
|
|
if (hasPlayer && MediaService.canGoNext) {
|
|
items.push({
|
|
"label": I18n.tr("context-menu.next"),
|
|
"action": "next",
|
|
"icon": "media-next"
|
|
});
|
|
}
|
|
items.push({
|
|
"label": I18n.tr("context-menu.widget-settings"),
|
|
"action": "widget-settings",
|
|
"icon": "settings"
|
|
});
|
|
return items;
|
|
}
|
|
|
|
onTriggered: action => {
|
|
var popupWindow = PanelService.getPopupMenuWindow(screen);
|
|
if (popupWindow)
|
|
popupWindow.close();
|
|
|
|
if (action === "play-pause")
|
|
MediaService.playPause();
|
|
else if (action === "previous")
|
|
MediaService.previous();
|
|
else if (action === "next")
|
|
MediaService.next();
|
|
else if (action === "widget-settings") {
|
|
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Main container
|
|
Rectangle {
|
|
id: container
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: isVertical ? (isHidden ? 0 : verticalSize) : (isHidden ? 0 : contentWidth)
|
|
height: isVertical ? (isHidden ? 0 : verticalSize) : Style.capsuleHeight
|
|
radius: Style.radiusM
|
|
color: Style.capsuleColor
|
|
|
|
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 * scaling
|
|
anchors.rightMargin: isVertical ? 0 : Style.marginS * scaling
|
|
clip: true
|
|
|
|
// Visualizer
|
|
Loader {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: parent.width
|
|
height: 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 * scaling
|
|
visible: !isVertical
|
|
z: 1
|
|
|
|
// Icon (when no player or features disabled)
|
|
NIcon {
|
|
visible: !hasPlayer || (!showAlbumArt && !showProgressRing)
|
|
icon: hasPlayer ? (MediaService.isPlaying ? "media-pause" : "media-play") : "disc"
|
|
color: hasPlayer ? Color.mOnSurface : Color.mOnSurfaceVariant
|
|
pointSize: Style.fontSizeL * scaling
|
|
Layout.preferredWidth: iconSize
|
|
Layout.preferredHeight: iconSize
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
|
|
// 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: 2.5 * scaling
|
|
}
|
|
|
|
Item {
|
|
anchors.fill: parent
|
|
anchors.margins: showProgressRing ? (3 * scaling) : 0.5
|
|
|
|
NImageRounded {
|
|
visible: showAlbumArt && hasPlayer
|
|
anchors.fill: parent
|
|
anchors.margins: showProgressRing ? 0 : -1 * scaling
|
|
radius: width / 2
|
|
imagePath: MediaService.trackArtUrl
|
|
fallbackIcon: MediaService.isPlaying ? "media-pause" : "media-play"
|
|
fallbackIconSize: showProgressRing ? 10 : 12
|
|
borderWidth: 0
|
|
}
|
|
|
|
NIcon {
|
|
visible: !showAlbumArt && showProgressRing && hasPlayer
|
|
anchors.centerIn: parent
|
|
icon: MediaService.isPlaying ? "media-pause" : "media-play"
|
|
color: Color.mOnSurface
|
|
pointSize: 8 * scaling
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scrolling title
|
|
Item {
|
|
id: titleContainer
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignVCenter
|
|
Layout.preferredHeight: titleMetrics.height
|
|
|
|
ScrollingText {
|
|
anchors.fill: parent
|
|
text: title
|
|
textColor: hasPlayer ? Color.mOnSurface : Color.mOnSurfaceVariant
|
|
fontSize: Style.fontSizeS * scaling
|
|
scrollMode: scrollingMode
|
|
needsScroll: titleMetrics.contentWidth > parent.width
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vertical layout
|
|
Item {
|
|
visible: isVertical
|
|
anchors.centerIn: parent
|
|
width: showProgressRing ? (Style.baseWidgetSize * 0.5 * scaling) : (verticalSize - 4 * scaling)
|
|
height: width
|
|
z: 1
|
|
|
|
ProgressRing {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
visible: showProgressRing
|
|
progress: MediaService.trackLength > 0 ? MediaService.currentPosition / MediaService.trackLength : 0
|
|
lineWidth: 2.5 * scaling
|
|
}
|
|
|
|
NImageRounded {
|
|
visible: showAlbumArt && hasPlayer
|
|
anchors.fill: parent
|
|
radius: width / 2
|
|
imagePath: MediaService.trackArtUrl
|
|
fallbackIcon: MediaService.isPlaying ? "media-pause" : "media-play"
|
|
fallbackIconSize: 12
|
|
borderWidth: 0
|
|
}
|
|
|
|
NIcon {
|
|
visible: !showAlbumArt || !hasPlayer
|
|
anchors.centerIn: parent
|
|
width: parent.width
|
|
height: parent.height
|
|
icon: hasPlayer ? (MediaService.isPlaying ? "media-pause" : "media-play") : "disc"
|
|
color: hasPlayer ? Color.mOnSurface : Color.mOnSurfaceVariant
|
|
pointSize: Style.fontSizeM * scaling
|
|
}
|
|
}
|
|
|
|
// Mouse interaction
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: hasPlayer ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
|
|
|
onClicked: mouse => {
|
|
if (mouse.button === Qt.LeftButton && hasPlayer && MediaService.canPlay) {
|
|
MediaService.playPause();
|
|
} else if (mouse.button === Qt.RightButton) {
|
|
TooltipService.hide();
|
|
var popupWindow = PanelService.getPopupMenuWindow(screen);
|
|
if (popupWindow) {
|
|
popupWindow.showContextMenu(contextMenu);
|
|
const pos = BarService.getContextMenuPosition(container, contextMenu.implicitWidth, contextMenu.implicitHeight);
|
|
contextMenu.openAtItem(container, pos.x, pos.y);
|
|
}
|
|
} else if (mouse.button === Qt.MiddleButton && hasPlayer && MediaService.canGoPrevious) {
|
|
MediaService.previous();
|
|
TooltipService.hide();
|
|
}
|
|
}
|
|
|
|
onEntered: {
|
|
if (isVertical || scrollingMode === "never") {
|
|
TooltipService.show(root, title, BarService.getTooltipDirection());
|
|
}
|
|
}
|
|
onExited: TooltipService.hide()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Components
|
|
Component {
|
|
id: linearSpectrum
|
|
NLinearSpectrum {
|
|
width: parent.width - Style.marginS
|
|
height: 20
|
|
values: CavaService.values
|
|
fillColor: Color.mPrimary
|
|
opacity: 0.4
|
|
barPosition: Settings.data.bar.position
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: mirroredSpectrum
|
|
NMirroredSpectrum {
|
|
width: parent.width - Style.marginS
|
|
height: parent.height - Style.marginS
|
|
values: CavaService.values
|
|
fillColor: Color.mPrimary
|
|
opacity: 0.4
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: waveSpectrum
|
|
NWaveSpectrum {
|
|
width: parent.width - Style.marginS
|
|
height: parent.height - Style.marginS
|
|
values: CavaService.values
|
|
fillColor: Color.mPrimary
|
|
opacity: 0.4
|
|
}
|
|
}
|
|
|
|
// Progress Ring Component
|
|
component ProgressRing: Canvas {
|
|
property real progress: 0
|
|
property real lineWidth: 2.5
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Scrolling Text Component
|
|
component ScrollingText: Item {
|
|
id: scrollText
|
|
property string text
|
|
property color textColor
|
|
property real fontSize
|
|
property string scrollMode
|
|
property bool needsScroll
|
|
|
|
clip: true
|
|
implicitHeight: titleText.height
|
|
|
|
property bool isScrolling: false
|
|
property bool isResetting: false
|
|
|
|
Timer {
|
|
id: scrollTimer
|
|
interval: 1000
|
|
onTriggered: {
|
|
if (scrollMode === "always" && needsScroll) {
|
|
scrollText.isScrolling = true;
|
|
scrollText.isResetting = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: hoverArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
}
|
|
|
|
function updateState() {
|
|
if (scrollMode === "never") {
|
|
isScrolling = false;
|
|
isResetting = false;
|
|
} else if (scrollMode === "always") {
|
|
if (needsScroll) {
|
|
if (hoverArea.containsMouse) {
|
|
isScrolling = false;
|
|
isResetting = true;
|
|
} else {
|
|
scrollTimer.restart();
|
|
}
|
|
}
|
|
} else if (scrollMode === "hover") {
|
|
isScrolling = hoverArea.containsMouse && needsScroll;
|
|
isResetting = !hoverArea.containsMouse && needsScroll;
|
|
}
|
|
}
|
|
|
|
onWidthChanged: updateState()
|
|
Component.onCompleted: updateState()
|
|
Connections {
|
|
target: hoverArea
|
|
function onContainsMouseChanged() {
|
|
scrollText.updateState();
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: scrollContainer
|
|
height: parent.height
|
|
property real scrollX: 0
|
|
x: scrollX
|
|
|
|
RowLayout {
|
|
spacing: 50
|
|
NText {
|
|
id: titleText
|
|
text: scrollText.text
|
|
color: textColor
|
|
pointSize: fontSize
|
|
applyUiScale: false
|
|
font.weight: Style.fontWeightMedium
|
|
onTextChanged: {
|
|
scrollText.isScrolling = false;
|
|
scrollText.isResetting = false;
|
|
scrollContainer.scrollX = 0;
|
|
if (scrollText.needsScroll)
|
|
scrollTimer.restart();
|
|
}
|
|
}
|
|
NText {
|
|
text: scrollText.text
|
|
color: textColor
|
|
pointSize: fontSize
|
|
applyUiScale: false
|
|
font.weight: Style.fontWeightMedium
|
|
visible: scrollText.needsScroll && scrollText.isScrolling
|
|
}
|
|
}
|
|
|
|
NumberAnimation on scrollX {
|
|
running: scrollText.isResetting
|
|
to: 0
|
|
duration: 300
|
|
easing.type: Easing.OutQuad
|
|
onFinished: scrollText.isResetting = false
|
|
}
|
|
|
|
NumberAnimation on scrollX {
|
|
running: scrollText.isScrolling && !scrollText.isResetting
|
|
from: 0
|
|
to: -(titleMetrics.contentWidth + 50)
|
|
duration: Math.max(4000, scrollText.text.length * 120)
|
|
loops: Animation.Infinite
|
|
easing.type: Easing.Linear
|
|
}
|
|
}
|
|
}
|
|
}
|