diff --git a/README.md b/README.md index a2322eb29..a6a4b00d0 100644 --- a/README.md +++ b/README.md @@ -409,7 +409,8 @@ default, you must create it manually. "enabled": true, "dragThreshold": 50, "mediaUpdateInterval": 500, - "showOnHover": true + "showOnHover": true, + "showAudioMixerOverMediaGif": false }, "launcher": { "actionPrefix": ">", diff --git a/components/controls/StyledSelect.qml b/components/controls/StyledSelect.qml new file mode 100644 index 000000000..68d310e5c --- /dev/null +++ b/components/controls/StyledSelect.qml @@ -0,0 +1,113 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Mpris +import Quickshell.Services.Pipewire + +// Used the following source as a template: +// https://stackoverflow.com/questions/9634897/qt-qml-dropdown-list-like-in-html +// Posted by Paul Drummond +// Retrieved 2025-11-08, License - CC BY-SA 3.0 +Rectangle { + id: root + required property list items + required property int defIndex + property alias selectedItem: chosenItemText.text + property alias selectedIndex: listView.currentIndex + signal optionClicked(index: int) + height: 30 + color: "transparent" + + Rectangle { + id: chosenItem + border.color: Colours.palette.m3outline + border.width: 1 + radius: Appearance.rounding.normal + width: parent.width + height: root.height + color: Colours.palette.m3surfaceContainer + + StyledText { + id: chosenItemText + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 6 + anchors.leftMargin: 12 + anchors.rightMargin: 16 + elide: Text.ElideRight + text: root.items[root.defIndex] + } + + WrapperMouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + dropDown.open() + } + } + } + + Popup { + id: dropDown + width: root.width + height: 120 + clip: true + visible: false + + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.small + color: Colours.palette.m3surfaceContainer + ListView { + id: listView + height: dropDown.height; + model: root.items + currentIndex: 0 + + delegate: Rectangle { + width: root.width + height: root.height + radius: Appearance.rounding.small + color: dropDownMa.containsMouse ? Colours.palette.m3outline : "transparent" + + StyledText { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 5 + elide: Text.ElideRight + text: modelData + } + WrapperMouseArea { + id: dropDownMa + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + root.state = ""; + var prevSelection = chosenItemText.text; + chosenItemText.text = modelData; + if(chosenItemText.text != prevSelection){ + root.optionClicked(index); + } + listView.currentIndex = index; + dropDown.close() + } + } + } + } + } + onVisibleChanged: { + if (dropDown.visible) { + dropDown.x = chosenItem.x; + dropDown.y = chosenItem.y + chosenItem.height + Appearance.spacing.small; + } + } + } +} diff --git a/components/controls/StyledSlider.qml b/components/controls/StyledSlider.qml index 92c8aa822..e65d78c0b 100644 --- a/components/controls/StyledSlider.qml +++ b/components/controls/StyledSlider.qml @@ -7,6 +7,21 @@ import QtQuick.Templates Slider { id: root + enum SliderType { + Default = 0, + Error = 1 + } + property int type: StyledSlider.SliderType.Default + + function getBarColour() { + if (type === StyledSlider.SliderType.Default) return Colours.palette.m3primary; + if (type === StyledSlider.SliderType.Error) return Colours.palette.m3error; + } + function getHandleColour() { + if (type === StyledSlider.SliderType.Default) return Colours.palette.m3surfaceContainer; + if (type === StyledSlider.SliderType.Error) return Colours.palette.m3errorContainer; + } + background: Item { StyledRect { anchors.top: parent.top @@ -17,7 +32,7 @@ Slider { implicitWidth: root.handle.x - root.implicitHeight / 6 - color: Colours.palette.m3primary + color: root.getBarColour() radius: Appearance.rounding.full topRightRadius: root.implicitHeight / 15 bottomRightRadius: root.implicitHeight / 15 @@ -32,7 +47,7 @@ Slider { implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6 - color: Colours.tPalette.m3surfaceContainer + color: root.getHandleColour() radius: Appearance.rounding.full topLeftRadius: root.implicitHeight / 15 bottomLeftRadius: root.implicitHeight / 15 @@ -45,7 +60,7 @@ Slider { implicitWidth: root.implicitHeight / 4.5 implicitHeight: root.implicitHeight - color: Colours.palette.m3primary + color: root.getBarColour() radius: Appearance.rounding.full MouseArea { diff --git a/config/DashboardConfig.qml b/config/DashboardConfig.qml index 030292b14..bf4c78361 100644 --- a/config/DashboardConfig.qml +++ b/config/DashboardConfig.qml @@ -5,6 +5,7 @@ JsonObject { property bool showOnHover: true property int mediaUpdateInterval: 500 property int dragThreshold: 50 + property bool showAudioMixerOverMediaGif: false property Sizes sizes: Sizes {} component Sizes: JsonObject { diff --git a/modules/controlcenter/Panes.qml b/modules/controlcenter/Panes.qml index 2548c3d9f..0c29644d9 100644 --- a/modules/controlcenter/Panes.qml +++ b/modules/controlcenter/Panes.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import "bluetooth" +import "audio" import qs.components import qs.services import qs.config @@ -43,15 +44,7 @@ ClippingRectangle { Pane { index: 2 - sourceComponent: Item { - StyledText { - anchors.centerIn: parent - text: qsTr("Work in progress") - color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.extraLarge - font.weight: 500 - } - } + sourceComponent: AudioPane { } } Behavior on y { diff --git a/modules/controlcenter/audio/AudioPane.qml b/modules/controlcenter/audio/AudioPane.qml new file mode 100644 index 000000000..18f19ee71 --- /dev/null +++ b/modules/controlcenter/audio/AudioPane.qml @@ -0,0 +1,602 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Quickshell.Widgets +import Quickshell.Services.Pipewire +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Effects + +RowLayout { + id: root + + anchors.fill: parent + spacing: 0 + + Item { + Layout.preferredWidth: parent.width * 0.5 + Layout.minimumWidth: 420 + Layout.fillHeight: true + + ColumnLayout { + id: outputColumn + anchors.fill: parent + anchors.margins: Appearance.padding.large + Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + Appearance.padding.normal / 2 + spacing: Appearance.spacing.large + + StyledText { + text: qsTr("Output") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + RowLayout { + anchors.left: outputColumn.left + anchors.right: outputColumn.right + spacing: Appearance.spacing.large + StyledText { + Layout.alignment: Qt.AlignVCenter + text: qsTr("Main Output") + } + StyledSelect { + Layout.fillWidth: true + items: Audio.sinks.map(e => e.description) + defIndex: Audio.sinks.indexOf(Audio.sink) + + onOptionClicked: (index) => Audio.setAudioSink(Audio.sinks[index]) + } + } + RowLayout { + anchors.left: outputColumn.left + anchors.right: outputColumn.right + spacing: Appearance.spacing.smaller + // mute button + IconButton { + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: Appearance.padding.normal * 3 + + icon: Icons.getVolumeIcon(Audio.sink.audio.volume, Audio.sink.audio.muted) + checked: Audio.sink.audio.muted + radius: Appearance.rounding.normal + activeColour: Colours.palette.m3errorContainer + inactiveColour: Colours.palette.m3primaryContainer + activeOnColour: Colours.palette.m3onErrorContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + toggle: true + radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial + radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + onClicked: { + if (Audio.sink.ready && Audio.sink.audio) { + Audio.sink.audio.muted = !Audio.sink.audio.muted; + } + } + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + // slider + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + onWheel: event => { + if (event.angleDelta.y > 0) + Audio.incrementVolume(); + else if (event.angleDelta.y < 0) + Audio.decrementVolume(); + } + + StyledSlider { + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight + + type: Audio.sink.audio.muted ? StyledSlider.SliderType.Error : StyledSlider.SliderType.Default + + value: Audio.volume + onMoved: Audio.setVolume(value) + + Behavior on value { + Anim {} + } + } + } + // volume + StyledText { + id: volumeLevel + property string displayText: `${Math.round(Audio.sink.audio.volume * 100)}%` + color: Audio.sink.audio.muted ? Colours.palette.m3error : Colours.palette.m3primary + opacity: Audio.sink.audio.muted ? 0.6 : 1 + font.pointSize: Appearance.font.size.normal + text: displayText + + // Set the width of the text to the max width it can get, + // so that when values change slider dont change it's when volume text changes size + FontMetrics { + id: fm + font: volumeLevel.font + } + Component.onCompleted: { + const maxWidth = Math.ceil(fm.advanceWidth("100%")); + volumeLevel.Layout.minimumWidth = maxWidth + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + } + + ColumnLayout { + Layout.fillHeight: true + Layout.fillWidth: true + spacing: Appearance.spacing.larger + + StyledText { + text: qsTr("Programs") + font.pointSize: Appearance.font.size.large + font.weight: 300 + } + StyledListView { + id: outputList + visible: Audio.sinkStreams.length > 0 + model: Audio.sinkStreams + anchors.topMargin: Appearance.spacing.normal + Layout.fillHeight: true + Layout.fillWidth: true + spacing: Appearance.spacing.larger + clip: true + + StyledScrollBar.vertical: StyledScrollBar { + flickable: outputList + } + + delegate: ColumnLayout { + required property PwNode modelData + anchors.left: outputList.contentItem.left + anchors.right: outputList.contentItem.right + spacing: Appearance.spacing.smaller / 2 + + // Text + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + IconImage { + width: Appearance.padding.smaller * 4 + height: Appearance.padding.smaller * 4 + + source: Icons.getAppIcon(modelData.name, "image-missing") + } + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: { + // Copied from https://git.outfoxxed.me/quickshell/quickshell-examples/src/branch/master/mixer + // application.name -> description -> name + const app = modelData.properties["application.name"] ?? (modelData.description != "" ? modelData.description : modelData.name); + let media = modelData.properties["media.name"]; + return media != undefined ? `${app}: ${media}` : app; + } + } + } + RowLayout { + spacing: Appearance.spacing.smaller + // mute button + IconButton { + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: Appearance.padding.normal * 3 + + icon: Icons.getVolumeIcon(modelData.audio.volume, modelData.audio.muted) + checked: modelData.audio.muted + radius: Appearance.rounding.normal + activeColour: Colours.palette.m3errorContainer + inactiveColour: Colours.palette.m3primaryContainer + activeOnColour: Colours.palette.m3onErrorContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + toggle: true + radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial + radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + onClicked: { + if (modelData.ready && modelData.audio) { + modelData.audio.muted = !modelData.audio.muted; + } + } + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + // Slider + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: Appearance.padding.smaller * 3 + + function setVolume(newVolume: real): void { + if (modelData.ready && modelData.audio) { + modelData.audio.muted = false; + modelData.audio.volume = Math.max(0, Math.min(1, newVolume)); + } + } + + onWheel: event => { + if (event.angleDelta.y > 0) + setVolume(modelData.audio.volume + Config.services.audioIncrement); + else if (event.angleDelta.y < 0) + setVolume(modelData.audio.volume - Config.services.audioIncrement); + } + + StyledSlider { + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight + + opacity: modelData.audio.muted ? 0.6 : 1 + value: modelData.audio.volume + type: modelData.audio.muted ? StyledSlider.SliderType.Error : StyledSlider.SliderType.Default + onMoved: parent.setVolume(value) + + Behavior on value { + Anim {} + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + // Volume Text + StyledText { + id: volumeLevel + property string displayText: `${Math.round(modelData.audio.volume * 100)}%` + color: modelData.audio.muted ? Colours.palette.m3error : Colours.palette.m3primary + opacity: modelData.audio.muted ? 0.6 : 1 + font.pointSize: Appearance.font.size.normal + text: displayText + + // Set the width of the text to the max width it can get, + // so that when values change slider dont change it's when volume text changes size + FontMetrics { + id: fm + font: volumeLevel.font + } + Component.onCompleted: { + const maxWidth = Math.ceil(fm.advanceWidth("100%")); + volumeLevel.Layout.minimumWidth = maxWidth + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + } + } + StyledText { + visible: Audio.sinkStreams.length === 0 + anchors.topMargin: Appearance.spacing.normal + Layout.fillHeight: true + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + color: Colours.palette.m3outline + text: qsTr("No output sources available") + } + } + } + + InnerBorder { + leftThickness: 0 + rightThickness: Appearance.padding.normal / 2 + } + } + Item { + Layout.preferredWidth: parent.width * 0.5 + Layout.minimumWidth: 420 + Layout.fillHeight: true + + ColumnLayout { + id: inputColumn + anchors.fill: parent + anchors.margins: Appearance.padding.large + Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + Appearance.padding.normal / 2 + spacing: Appearance.spacing.large + + StyledText { + text: qsTr("Input") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + RowLayout { + anchors.left: inputColumn.left + anchors.right: inputColumn.right + spacing: Appearance.spacing.large + StyledText { + Layout.alignment: Qt.AlignVCenter + text: qsTr("Main Input") + } + StyledSelect { + Layout.fillWidth: true + items: Audio.sources.map(e => e.description) + defIndex: Audio.sources.indexOf(Audio.source) + + onOptionClicked: (index) => Audio.setAudioSource(Audio.sources[index]) + } + } + RowLayout { + anchors.left: inputColumn.left + anchors.right: inputColumn.right + spacing: Appearance.spacing.smaller + // mute button + IconButton { + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: Appearance.padding.normal * 3 + + icon: Icons.getMicVolumeIcon(Audio.source.audio.volume, Audio.source.audio.muted) + checked: Audio.source.audio.muted + radius: Appearance.rounding.normal + activeColour: Colours.palette.m3errorContainer + inactiveColour: Colours.palette.m3primaryContainer + activeOnColour: Colours.palette.m3onErrorContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + toggle: true + radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial + radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + onClicked: { + if (Audio.source.ready && Audio.source.audio) { + Audio.source.audio.muted = !Audio.source.audio.muted; + } + } + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + // slider + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + onWheel: event => { + if (event.angleDelta.y > 0) + Audio.incrementSourceVolume(); + else if (event.angleDelta.y < 0) + Audio.decrementSourceVolume(); + } + + StyledSlider { + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight + + type: Audio.source.audio.muted ? StyledSlider.SliderType.Error : StyledSlider.SliderType.Default + + value: Audio.sourceVolume + onMoved: Audio.setSourceVolume(value) + + Behavior on value { + Anim {} + } + } + } + // volume + StyledText { + id: inputVolumeLevel + property string displayText: `${Math.round(Audio.source.audio.volume * 100)}%` + color: Audio.source.audio.muted ? Colours.palette.m3error : Colours.palette.m3primary + opacity: Audio.source.audio.muted ? 0.6 : 1 + font.pointSize: Appearance.font.size.normal + text: displayText + + // Set the width of the text to the max width it can get, + // so that when values change slider dont change it's when volume text changes size + FontMetrics { + id: inputfm + font: inputVolumeLevel.font + } + Component.onCompleted: { + const maxWidth = Math.ceil(inputfm.advanceWidth("100%")); + inputVolumeLevel.Layout.minimumWidth = maxWidth + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + } + + ColumnLayout { + Layout.fillHeight: true + Layout.fillWidth: true + spacing: Appearance.spacing.larger + + StyledText { + text: qsTr("Programs") + font.pointSize: Appearance.font.size.large + font.weight: 300 + } + StyledListView { + id: inputList + visible: Audio.sourceStreams.length > 0 + model: Audio.sourceStreams + anchors.topMargin: Appearance.spacing.normal + Layout.fillHeight: true + Layout.fillWidth: true + spacing: Appearance.spacing.larger + clip: true + + StyledScrollBar.vertical: StyledScrollBar { + flickable: inputList + } + + delegate: ColumnLayout { + required property PwNode modelData + anchors.left: inputList.contentItem.left + anchors.right: inputList.contentItem.right + spacing: Appearance.spacing.smaller / 2 + + // Text + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + IconImage { + width: Appearance.padding.smaller * 4 + height: Appearance.padding.smaller * 4 + + source: Icons.getAppIcon(modelData.name, "image-missing") + } + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: { + // Copied from https://git.outfoxxed.me/quickshell/quickshell-examples/src/branch/master/mixer + // application.name -> description -> name + const app = modelData.properties["application.name"] ?? (modelData.description != "" ? modelData.description : modelData.name); + let media = modelData.properties["media.name"]; + return media != undefined ? `${app}: ${media}` : app; + } + } + } + RowLayout { + spacing: Appearance.spacing.smaller + // mute button + IconButton { + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: Appearance.padding.normal * 3 + + icon: Icons.getMicVolumeIcon(modelData.audio.volume, modelData.audio.muted) + checked: modelData.audio.muted + radius: Appearance.rounding.normal + activeColour: Colours.palette.m3errorContainer + inactiveColour: Colours.palette.m3primaryContainer + activeOnColour: Colours.palette.m3onErrorContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + toggle: true + radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial + radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + onClicked: { + if (modelData.ready && modelData.audio) { + modelData.audio.muted = !modelData.audio.muted; + } + } + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + // Slider + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: Appearance.padding.smaller * 3 + + function setVolume(newVolume: real): void { + if (modelData.ready && modelData.audio) { + modelData.audio.muted = false; + modelData.audio.volume = Math.max(0, Math.min(1, newVolume)); + } + } + + onWheel: event => { + if (event.angleDelta.y > 0) + setVolume(modelData.audio.volume + Config.services.audioIncrement); + else if (event.angleDelta.y < 0) + setVolume(modelData.audio.volume - Config.services.audioIncrement); + } + + StyledSlider { + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight + + opacity: modelData.audio.muted ? 0.6 : 1 + value: modelData.audio.volume + type: modelData.audio.muted ? StyledSlider.SliderType.Error : StyledSlider.SliderType.Default + onMoved: parent.setVolume(value) + + Behavior on value { + Anim {} + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + // Volume Text + StyledText { + id: volumeLevel + property string displayText: `${Math.round(modelData.audio.volume * 100)}%` + color: modelData.audio.muted ? Colours.palette.m3error : Colours.palette.m3primary + opacity: modelData.audio.muted ? 0.6 : 1 + font.pointSize: Appearance.font.size.normal + text: displayText + + // Set the width of the text to the max width it can get, + // so that when values change slider dont change it's when volume text changes size + FontMetrics { + id: fm + font: volumeLevel.font + } + Component.onCompleted: { + const maxWidth = Math.ceil(fm.advanceWidth("100%")); + volumeLevel.Layout.minimumWidth = maxWidth + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + } + } + StyledText { + visible: Audio.sourceStreams.length === 0 + anchors.topMargin: Appearance.spacing.normal + Layout.fillHeight: true + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + color: Colours.palette.m3outline + text: qsTr("No input sources available") + } + } + } + + InnerBorder { + id: rightBorder + leftThickness: Appearance.padding.normal / 2 + } + } +} diff --git a/modules/dashboard/Media.qml b/modules/dashboard/Media.qml index 3d4dcdb3d..a8297d1b0 100644 --- a/modules/dashboard/Media.qml +++ b/modules/dashboard/Media.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import qs.components import qs.components.effects import qs.components.controls +import qs.components.containers import qs.services import qs.utils import qs.config @@ -10,9 +11,12 @@ import Caelestia.Services import Quickshell import Quickshell.Widgets import Quickshell.Services.Mpris +import Quickshell.Services.Pipewire import QtQuick import QtQuick.Layouts import QtQuick.Shapes +import QtQuick.Controls +import QtQuick.Effects Item { id: root @@ -37,8 +41,8 @@ Item { return `${mins}:${secs}`; } - implicitWidth: cover.implicitWidth + Config.dashboard.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2 - implicitHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2 + implicitWidth: cover.implicitWidth + Config.dashboard.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + mediaGif.implicitWidth + mediaGif.anchors.leftMargin * 2 + Appearance.padding.large * 2 + implicitHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, details.implicitHeight, mediaGif.implicitHeight) + Appearance.padding.large * 2 Behavior on playerProgress { Anim { @@ -364,7 +368,7 @@ Item { } Item { - id: bongocat + id: mediaGif anchors.verticalCenter: parent.verticalCenter anchors.left: details.right @@ -373,7 +377,31 @@ Item { implicitWidth: visualiser.width implicitHeight: visualiser.height + Loader { + anchors.fill: parent + sourceComponent: Config.dashboard.showAudioMixerOverMediaGif ? mixerCardComponent : mediaGifComponent + } + } + + component PlayerControl: IconButton { + Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0) + radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : implicitHeight / 2 + radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial + radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + + + Component { + id: mediaGifComponent AnimatedImage { + id: mediaGif anchors.centerIn: parent width: visualiser.width * 0.75 @@ -387,16 +415,171 @@ Item { } } - component PlayerControl: IconButton { - Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0) - radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : implicitHeight / 2 - radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial - radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + Component { + id: mixerCardComponent + StyledRect { + id: mixerCard + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainer + width: mediaGif.implicitWidth + height: mediaGif.implicitHeight + + StyledRect { + id: padding + anchors.fill: parent + anchors.margins: Appearance.padding.normal - Behavior on Layout.preferredWidth { - Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + StyledText { + id: audioMixerTitle + + Layout.minimumWidth: mixerCard.width - Appearance.spacing.normal * 2 + horizontalAlignment: Text.AlignLeft + text: qsTr("Audio Mixer") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.larger + elide: Text.ElideRight + } + StyledListView { + id: list + model: Audio.sinkStreams + anchors.top: audioMixerTitle.bottom + anchors.topMargin: Appearance.spacing.normal + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + spacing: Appearance.spacing.small + clip: true + + StyledScrollBar.vertical: StyledScrollBar { + flickable: list + } + + delegate: RowLayout { + required property PwNode modelData + anchors.left: list.contentItem.left + anchors.right: list.contentItem.right + spacing: Appearance.spacing.normal + + // Icon and mute button + Item { + Layout.alignment: Qt.AlignVCente + + implicitWidth: Appearance.padding.smaller * 3 + implicitHeight: Appearance.padding.smaller * 3 + + IconImage { + id: icon + anchors.fill: parent + source: Icons.getAppIcon(modelData.name, "image-missing") + } + MultiEffect { + anchors.fill: icon + source: icon + colorization: modelData.audio.muted && 1 + // set to pure red instead of m3error so that the red color is more intense + colorizationColor: modelData.audio.muted && Qt.rgba(1.0, 0.0, 0.0, 1.0) + } + WrapperMouseArea { + id: ma + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: modelData.audio.muted = !modelData.audio.muted + } + ToolTip { + delay: Appearance.anim.durations.normal + visible: ma.containsMouse + opacity: opened ? 1 : 0 + + contentItem: StyledText { + leftPadding: Appearance.spacing.small + rightPadding: Appearance.spacing.small + color: modelData.audio.muted ? Colours.palette.m3onErrorContainer : Colours.palette.m3onPrimaryContainer + text: { + // Copied from https://git.outfoxxed.me/quickshell/quickshell-examples/src/branch/master/mixer + // application.name -> description -> name + const app = modelData.properties["application.name"] ?? (modelData.description != "" ? modelData.description : modelData.name); + let media = modelData.properties["media.name"]; + return media != undefined ? `${app} - ${media}` : app; + } + } + background: StyledRect { + color: modelData.audio.muted ? Colours.palette.m3errorContainer : Colours.palette.m3primaryContainer + radius: Appearance.rounding.small + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + // Slider + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: Appearance.padding.smaller * 3 + + function setVolume(newVolume: real): void { + if (modelData.ready && modelData.audio) { + modelData.audio.muted = false; + modelData.audio.volume = Math.max(0, Math.min(1, newVolume)); + } + } + + onWheel: event => { + if (event.angleDelta.y > 0) + setVolume(modelData.audio.volume + Config.services.audioIncrement); + else if (event.angleDelta.y < 0) + setVolume(modelData.audio.volume - Config.services.audioIncrement); + } + + StyledSlider { + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight + + opacity: modelData.audio.muted ? 0.6 : 1 + value: modelData.audio.volume + type: modelData.audio.muted ? StyledSlider.SliderType.Error : StyledSlider.SliderType.Default + onMoved: parent.setVolume(value) + + Behavior on value { + Anim {} + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + // Volume Text + StyledText { + id: volumeLevel + property string displayText: `${Math.round(modelData.audio.volume * 100)}%` + color: modelData.audio.muted ? Colours.palette.m3error : Colours.palette.m3primary + opacity: modelData.audio.muted ? 0.6 : 1 + font.pointSize: Appearance.font.size.normal + text: displayText + + // Set the width of the text to the max width it can get, + // so that when values change slider dont change it's when volume text changes size + FontMetrics { + id: fm + font: volumeLevel.font + } + Component.onCompleted: { + const maxWidth = Math.ceil(fm.advanceWidth("100%")); + volumeLevel.Layout.minimumWidth = maxWidth + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + } + } } } } diff --git a/services/Audio.qml b/services/Audio.qml index 71ccb86e7..cf0f60850 100644 --- a/services/Audio.qml +++ b/services/Audio.qml @@ -14,7 +14,12 @@ Singleton { property string previousSourceName: "" readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => { - if (!node.isStream) { + if (node.isStream) { + if (node.isSink) + acc.sinkStreams.push(node); + else + acc.sourceStreams.push(node); + } else { if (node.isSink) acc.sinks.push(node); else if (node.audio) @@ -23,11 +28,15 @@ Singleton { return acc; }, { sources: [], - sinks: [] + sinks: [], + sinkStreams: [], + sourceStreams: [] }) readonly property list sinks: nodes.sinks readonly property list sources: nodes.sources + readonly property list sinkStreams: nodes.sinkStreams + readonly property list sourceStreams: nodes.sourceStreams readonly property PwNode sink: Pipewire.defaultAudioSink readonly property PwNode source: Pipewire.defaultAudioSource @@ -109,7 +118,7 @@ Singleton { } PwObjectTracker { - objects: [...root.sinks, ...root.sources] + objects: [...root.sinks, ...root.sources, ...root.sinkStreams, ...root.sourceStreams] } CavaProvider {