1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-07 14:05:38 -05:00

iBluetooth repair, input device support, some media player improvements

This commit is contained in:
bbedward
2025-07-11 13:50:10 -04:00
parent b4f73ceb7b
commit d169f5d4a3
14 changed files with 1533 additions and 829 deletions

View File

@@ -1,420 +0,0 @@
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Services.Mpris
import "Services"
PanelWindow {
id: mediaPlayer
property var theme
property bool isVisible: false
property MprisPlayer activePlayer: MprisController.activePlayer
property bool hasActiveMedia: MprisController.isPlaying && (activePlayer?.trackTitle || activePlayer?.trackArtist)
property var defaultTheme: QtObject {
property color primary: "#D0BCFF"
property color background: "#10121E"
property color surfaceContainer: "#1D1B20"
property color surfaceText: "#E6E0E9"
property color surfaceVariant: "#49454F"
property color surfaceVariantText: "#CAC4D0"
property color outline: "#938F99"
property color error: "#F2B8B5"
property real cornerRadius: 12
property real cornerRadiusLarge: 16
property real cornerRadiusXLarge: 24
property real cornerRadiusSmall: 8
property real spacingXS: 4
property real spacingS: 8
property real spacingM: 12
property real spacingL: 16
property real spacingXL: 24
property real fontSizeLarge: 16
property real fontSizeMedium: 14
property real fontSizeSmall: 12
property real iconSize: 24
property real iconSizeLarge: 32
property string iconFont: "Material Symbols Rounded"
property int iconFontWeight: Font.Normal
property int shortDuration: 150
property int mediumDuration: 300
property int standardEasing: Easing.OutCubic
property int emphasizedEasing: Easing.OutQuart
}
property var activeTheme: theme || defaultTheme
onHasActiveMediaChanged: {
if (!hasActiveMedia && isVisible) {
hide()
}
}
anchors {
top: true
left: true
right: true
bottom: true
}
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: isVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
WlrLayershell.namespace: "quickshell-media-player"
visible: isVisible
color: "transparent"
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.3)
opacity: mediaPlayer.isVisible ? 1.0 : 0.0
visible: mediaPlayer.isVisible
Behavior on opacity {
NumberAnimation {
duration: activeTheme.shortDuration
easing.type: activeTheme.standardEasing
}
}
MouseArea {
anchors.fill: parent
enabled: mediaPlayer.isVisible
onClicked: mediaPlayer.hide()
}
}
Rectangle {
id: mediaPanel
width: 480
height: 320
anchors.centerIn: parent
color: Qt.rgba(activeTheme.surfaceContainer.r, activeTheme.surfaceContainer.g, activeTheme.surfaceContainer.b, 0.98)
radius: activeTheme.cornerRadiusXLarge
Rectangle {
anchors.fill: parent
anchors.margins: -2
color: "transparent"
radius: parent.radius + 2
border.color: Qt.rgba(0, 0, 0, 0.08)
border.width: 1
z: -2
}
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.12)
border.width: 1
radius: parent.radius
z: -1
}
transform: [
Scale {
origin.x: mediaPanel.width / 2
origin.y: mediaPanel.height / 2
xScale: mediaPlayer.isVisible ? 1.0 : 0.9
yScale: mediaPlayer.isVisible ? 1.0 : 0.9
Behavior on xScale {
NumberAnimation {
duration: activeTheme.mediumDuration
easing.type: activeTheme.emphasizedEasing
}
}
Behavior on yScale {
NumberAnimation {
duration: activeTheme.mediumDuration
easing.type: activeTheme.emphasizedEasing
}
}
}
]
opacity: mediaPlayer.isVisible ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: activeTheme.mediumDuration
easing.type: activeTheme.emphasizedEasing
}
}
Column {
anchors.fill: parent
anchors.margins: activeTheme.spacingXL
spacing: activeTheme.spacingL
Row {
width: parent.width
height: 32
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Now Playing"
font.pixelSize: activeTheme.fontSizeLarge + 4
font.weight: Font.Bold
color: activeTheme.surfaceText
}
Item { width: parent.width - 200; height: 1 }
Rectangle {
width: 32
height: 32
radius: activeTheme.cornerRadius
color: closeArea.containsMouse ? Qt.rgba(activeTheme.error.r, activeTheme.error.g, activeTheme.error.b, 0.12) : "transparent"
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "close"
font.family: activeTheme.iconFont
font.pixelSize: activeTheme.iconSize
color: closeArea.containsMouse ? activeTheme.error : activeTheme.surfaceText
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: mediaPlayer.hide()
}
}
}
Row {
width: parent.width
height: parent.height - 80
spacing: activeTheme.spacingXL
Rectangle {
width: 180
height: parent.height
radius: activeTheme.cornerRadiusLarge
color: Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.3)
Item {
anchors.fill: parent
clip: true
Image {
id: albumArt
anchors.fill: parent
source: activePlayer?.trackArtUrl || ""
fillMode: Image.PreserveAspectCrop
smooth: true
}
Rectangle {
anchors.fill: parent
visible: albumArt.status !== Image.Ready
color: "transparent"
Text {
anchors.centerIn: parent
text: "album"
font.family: activeTheme.iconFont
font.pixelSize: 48
color: activeTheme.surfaceVariantText
}
}
}
}
Column {
width: parent.width - 180 - activeTheme.spacingXL
height: parent.height
spacing: activeTheme.spacingM
Column {
width: parent.width
spacing: activeTheme.spacingS
Text {
text: activePlayer?.trackTitle || "No title"
font.pixelSize: activeTheme.fontSizeLarge + 2
font.weight: Font.Bold
color: activeTheme.surfaceText
elide: Text.ElideRight
width: parent.width
}
Text {
text: activePlayer?.trackArtist || "Unknown artist"
font.pixelSize: activeTheme.fontSizeLarge
color: activeTheme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
}
Text {
text: activePlayer?.trackAlbum || ""
font.pixelSize: activeTheme.fontSizeMedium
color: activeTheme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
visible: text.length > 0
}
}
Item { height: activeTheme.spacingM }
Column {
width: parent.width
spacing: activeTheme.spacingS
Rectangle {
width: parent.width
height: 6
radius: 3
color: Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.3)
Rectangle {
width: parent.width * (activePlayer?.position / Math.max(activePlayer?.length || 1, 1))
height: parent.height
radius: parent.radius
color: activeTheme.primary
}
}
Row {
width: parent.width
Text {
text: formatTime(activePlayer?.position || 0)
font.pixelSize: activeTheme.fontSizeSmall
color: activeTheme.surfaceVariantText
}
Item { width: parent.width - 100; height: 1 }
Text {
text: formatTime(activePlayer?.length || 0)
font.pixelSize: activeTheme.fontSizeSmall
color: activeTheme.surfaceVariantText
}
}
}
Item { height: activeTheme.spacingL }
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: activeTheme.spacingL
Rectangle {
width: 48
height: 48
radius: 24
color: prevArea.containsMouse ? Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "skip_previous"
font.family: activeTheme.iconFont
font.pixelSize: activeTheme.iconSize
color: activeTheme.surfaceText
}
MouseArea {
id: prevArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: activePlayer?.previous()
}
}
Rectangle {
width: 56
height: 56
radius: 28
color: activeTheme.primary
Text {
anchors.centerIn: parent
text: activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow"
font.family: activeTheme.iconFont
font.pixelSize: activeTheme.iconSizeLarge
color: activeTheme.background
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: activePlayer?.togglePlaying()
}
}
Rectangle {
width: 48
height: 48
radius: 24
color: nextArea.containsMouse ? Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "skip_next"
font.family: activeTheme.iconFont
font.pixelSize: activeTheme.iconSize
color: activeTheme.surfaceText
}
MouseArea {
id: nextArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: activePlayer?.next()
}
}
}
}
}
}
}
Timer {
running: activePlayer?.playbackState === MprisPlaybackState.Playing
interval: 1000
repeat: true
onTriggered: activePlayer?.positionChanged()
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return mins + ":" + (secs < 10 ? "0" : "") + secs
}
function show() {
mediaPlayer.isVisible = true
}
function hide() {
mediaPlayer.isVisible = false
}
function toggle() {
if (mediaPlayer.isVisible) {
hide()
} else {
show()
}
}
}

View File

@@ -11,6 +11,11 @@ Singleton {
property var audioSinks: []
property string currentAudioSink: ""
// Microphone properties
property int micLevel: 50
property var audioSources: []
property string currentAudioSource: ""
// Real Audio Control
Process {
id: volumeChecker
@@ -27,6 +32,22 @@ Singleton {
}
}
// Microphone level checker
Process {
id: micLevelChecker
command: ["bash", "-c", "pactl get-source-volume @DEFAULT_SOURCE@ | grep -o '[0-9]*%' | head -1 | tr -d '%'"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
root.micLevel = Math.min(100, parseInt(data.trim()) || 50)
}
}
}
}
Process {
id: audioSinkLister
command: ["pactl", "list", "sinks"]
@@ -35,7 +56,6 @@ Singleton {
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
console.log("Parsing pactl sink output...")
let sinks = []
let lines = text.trim().split('\n')
@@ -88,7 +108,6 @@ Singleton {
sink.displayName = displayName
}
console.log("Final audio sinks:", JSON.stringify(sinks, null, 2))
root.audioSinks = sinks
defaultSinkChecker.running = true
}
@@ -96,6 +115,61 @@ Singleton {
}
}
// Audio source (microphone) lister
Process {
id: audioSourceLister
command: ["pactl", "list", "sources"]
running: true
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
let sources = []
let lines = text.trim().split('\n')
let currentSource = null
for (let line of lines) {
line = line.trim()
// New source starts
if (line.startsWith('Source #')) {
if (currentSource && currentSource.name && currentSource.id) {
sources.push(currentSource)
}
currentSource = {
id: line.replace('Source #', '').replace(':', ''),
name: '',
displayName: '',
active: false
}
}
// Source name
else if (line.startsWith('Name: ') && currentSource) {
currentSource.name = line.replace('Name: ', '')
}
// Description (display name)
else if (line.startsWith('Description: ') && currentSource) {
let desc = line.replace('Description: ', '')
currentSource.displayName = desc
}
}
// Add the last source
if (currentSource && currentSource.name && currentSource.id) {
sources.push(currentSource)
}
// Filter out monitor sources (we want actual input devices)
sources = sources.filter(source => !source.name.includes('.monitor'))
root.audioSources = sources
defaultSourceChecker.running = true
}
}
}
}
Process {
id: defaultSinkChecker
command: ["pactl", "get-default-sink"]
@@ -106,7 +180,6 @@ Singleton {
onRead: (data) => {
if (data.trim()) {
root.currentAudioSink = data.trim()
console.log("Default audio sink:", root.currentAudioSink)
// Update active status in audioSinks
let updatedSinks = []
@@ -124,6 +197,34 @@ Singleton {
}
}
// Default source (microphone) checker
Process {
id: defaultSourceChecker
command: ["pactl", "get-default-source"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
root.currentAudioSource = data.trim()
// Update active status in audioSources
let updatedSources = []
for (let source of root.audioSources) {
updatedSources.push({
id: source.id,
name: source.name,
displayName: source.displayName,
active: source.name === root.currentAudioSource
})
}
root.audioSources = updatedSources
}
}
}
}
function setVolume(percentage) {
let volumeSetProcess = Qt.createQmlObject('
import Quickshell.Io
@@ -135,6 +236,17 @@ Singleton {
', root)
}
function setMicLevel(percentage) {
let micSetProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["pactl", "set-source-volume", "@DEFAULT_SOURCE@", "' + percentage + '%"]
running: true
onExited: micLevelChecker.running = true
}
', root)
}
function setAudioSink(sinkName) {
console.log("Setting audio sink to:", sinkName)
@@ -160,4 +272,39 @@ Singleton {
}
}
}
function setAudioSource(sourceName) {
console.log("Setting audio source to:", sourceName)
sourceSetProcess.command = ["pactl", "set-default-source", sourceName]
sourceSetProcess.running = true
}
// Dedicated process for setting audio source
Process {
id: sourceSetProcess
running: false
onExited: (exitCode) => {
console.log("Audio source change exit code:", exitCode)
if (exitCode === 0) {
console.log("Audio source changed successfully")
// Refresh current source and list
defaultSourceChecker.running = true
audioSourceLister.running = true
} else {
console.error("Failed to change audio source")
}
}
}
// Timer to refresh audio devices regularly (catches new Bluetooth devices)
Timer {
interval: 4000 // 4s refresh to catch new BT devices
running: true; repeat: true
onTriggered: {
audioSinkLister.running = true
audioSourceLister.running = true
}
}
}

View File

@@ -17,14 +17,13 @@ Singleton {
// Real Bluetooth Management
Process {
id: bluetoothStatusChecker
command: ["bluetoothctl", "show"]
command: ["bluetoothctl", "show"] // Use default controller
running: true
stdout: StdioCollector {
onStreamFinished: {
root.bluetoothAvailable = text.trim() !== "" && !text.includes("No default controller")
root.bluetoothEnabled = text.includes("Powered: yes")
console.log("Bluetooth available:", root.bluetoothAvailable, "enabled:", root.bluetoothEnabled)
if (root.bluetoothEnabled && root.bluetoothAvailable) {
bluetoothDeviceScanner.running = true
@@ -37,7 +36,7 @@ Singleton {
Process {
id: bluetoothDeviceScanner
command: ["bash", "-c", "bluetoothctl devices | while read -r line; do if [[ $line =~ Device\\ ([0-9A-F:]+)\\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ! $name =~ ^/org/bluez ]]; then info=$(bluetoothctl info $mac); connected=$(echo \"$info\" | grep 'Connected:' | grep -q 'yes' && echo 'true' || echo 'false'); battery=$(echo \"$info\" | grep 'Battery Percentage' | grep -o '([0-9]*)' | tr -d '()'); echo \"$mac|$name|$connected|${battery:-}\"; fi; fi; done"]
command: ["bash", "-c", "bluetoothctl devices | while read -r line; do if [[ $line =~ Device\\ ([0-9A-F:]+)\\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ! $name =~ ^/org/bluez ]]; then info=$(bluetoothctl info $mac); connected=$(echo \"$info\" | grep -m1 'Connected:' | awk '{print $2}'); battery=$(echo \"$info\" | grep -m1 'Battery Percentage:' | grep -o '[0-9]\\+'); echo \"$mac|$name|$connected|${battery:-}\"; fi; fi; done"]
running: false
stdout: StdioCollector {
@@ -52,7 +51,7 @@ Singleton {
if (parts.length >= 3) {
let mac = parts[0].trim()
let name = parts[1].trim()
let connected = parts[2].trim() === 'true'
let connected = parts[2].trim() === 'yes'
let battery = parts[3] ? parseInt(parts[3]) : -1
// Skip if name is still a technical path
@@ -82,7 +81,6 @@ Singleton {
}
root.bluetoothDevices = devices
console.log("Found", devices.length, "Bluetooth devices")
}
}
}
@@ -95,23 +93,12 @@ Singleton {
}
function startDiscovery() {
console.log("Starting Bluetooth discovery...")
let discoveryProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "scan", "on"]
running: true
onExited: {
root.scanning = true
// Scan for 10 seconds then get discovered devices
discoveryScanTimer.start()
}
}
', root)
// Run comprehensive scan that gets all devices
discoveryScanner.running = true
}
function stopDiscovery() {
console.log("Stopping Bluetooth discovery...")
let stopDiscoveryProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
@@ -180,7 +167,6 @@ Singleton {
}
function toggleBluetoothDevice(mac) {
console.log("Toggling Bluetooth device:", mac)
let device = root.bluetoothDevices.find(d => d.mac === mac)
if (device) {
let action = device.connected ? "disconnect" : "connect"
@@ -207,26 +193,68 @@ Singleton {
', root)
}
// Timer for discovery scanning
// Timer to refresh adapter & device state
Timer {
id: discoveryScanTimer
interval: 8000 // 8 seconds
repeat: false
interval: 3000 // 3s refresh for more responsive updates
running: true; repeat: true
onTriggered: {
availableDeviceScanner.running = true
bluetoothStatusChecker.running = true
if (root.bluetoothEnabled) {
bluetoothDeviceScanner.running = true
// Also refresh paired devices to get current connection status
pairedDeviceChecker.discoveredToMerge = []
pairedDeviceChecker.running = true
}
}
}
// Scan for available/discoverable devices
property var discoveredDevices: []
// Handle discovered devices
function _handleDiscovered(found) {
let discoveredDevices = []
for (let device of found) {
let type = "bluetooth"
let nameLower = device.name.toLowerCase()
if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis") || nameLower.includes("audio")) type = "headset"
else if (nameLower.includes("mouse")) type = "mouse"
else if (nameLower.includes("keyboard")) type = "keyboard"
else if (nameLower.includes("phone") || nameLower.includes("iphone") || nameLower.includes("samsung") || nameLower.includes("android")) type = "phone"
else if (nameLower.includes("watch")) type = "watch"
else if (nameLower.includes("speaker")) type = "speaker"
else if (nameLower.includes("tv") || nameLower.includes("display")) type = "tv"
discoveredDevices.push({
mac: device.mac,
name: device.name,
type: type,
paired: false,
connected: false,
rssi: -70,
signalStrength: "fair",
canPair: true
})
console.log(" -", device.name, "(", device.mac, ")")
}
// Get paired devices first, then merge with discovered
pairedDeviceChecker.discoveredToMerge = discoveredDevices
pairedDeviceChecker.running = true
}
// Get only currently connected/paired devices that matter
Process {
id: availableDeviceScanner
command: ["bash", "-c", "timeout 5 bluetoothctl devices | grep -v 'Device.*/' | while read -r line; do if [[ $line =~ Device\ ([0-9A-F:]+)\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ! $name =~ ^/org/bluez ]] && [[ ! $name =~ hci0 ]]; then info=$(timeout 3 bluetoothctl info $mac 2>/dev/null); paired=$(echo \"$info\" | grep 'Paired:' | grep -q 'yes' && echo 'true' || echo 'false'); connected=$(echo \"$info\" | grep 'Connected:' | grep -q 'yes' && echo 'true' || echo 'false'); rssi=$(echo \"$info\" | grep 'RSSI:' | awk '{print $2}' | head -n1); echo \"$mac|$name|$paired|$connected|${rssi:-}\"; fi; fi; done"]
command: ["bash", "-c", "bluetoothctl devices | while read -r line; do if [[ $line =~ Device\\ ([A-F0-9:]+)\\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; info=$(bluetoothctl info \"$mac\" 2>/dev/null); paired=$(echo \"$info\" | grep -m1 'Paired:' | awk '{print $2}'); connected=$(echo \"$info\" | grep -m1 'Connected:' | awk '{print $2}'); if [[ \"$paired\" == \"yes\" ]] || [[ \"$connected\" == \"yes\" ]]; then echo \"$mac|$name|$paired|$connected\"; fi; fi; done"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
let devices = []
if (text.trim()) {
let lines = text.trim().split('\n')
for (let line of lines) {
@@ -235,16 +263,15 @@ Singleton {
if (parts.length >= 4) {
let mac = parts[0].trim()
let name = parts[1].trim()
let paired = parts[2].trim() === 'true'
let connected = parts[3].trim() === 'true'
let rssi = parts[4] ? parseInt(parts[4]) : 0
let paired = parts[2].trim() === 'yes'
let connected = parts[3].trim() === 'yes'
// Skip if name is still a technical path
if (name.startsWith('/org/bluez') || name.includes('hci0')) {
// Skip technical names
if (name.startsWith('/org/bluez') || name.includes('hci0') || name.length < 3) {
continue
}
// Determine device type from name
// Determine device type
let type = "bluetooth"
let nameLower = name.toLowerCase()
if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis") || nameLower.includes("audio")) type = "headset"
@@ -255,33 +282,129 @@ Singleton {
else if (nameLower.includes("speaker")) type = "speaker"
else if (nameLower.includes("tv") || nameLower.includes("display")) type = "tv"
// Signal strength assessment
let signalStrength = "unknown"
if (rssi !== 0) {
if (rssi >= -50) signalStrength = "excellent"
else if (rssi >= -60) signalStrength = "good"
else if (rssi >= -70) signalStrength = "fair"
else signalStrength = "weak"
}
devices.push({
mac: mac,
name: name,
type: type,
paired: paired,
connected: connected,
rssi: rssi,
signalStrength: signalStrength,
canPair: !paired
rssi: 0,
signalStrength: "unknown",
canPair: false // Already paired
})
}
}
}
}
root.availableDevices = devices
console.log("Found", devices.length, "available Bluetooth devices")
}
}
}
// Discovery scanner using bluetoothctl --timeout
Process {
id: discoveryScanner
// Discover for 8 s in non-interactive mode, then auto-exit
command: ["bluetoothctl",
"--timeout", "8",
"--monitor", // keeps stdout unbuffered
"scan", "on"]
running: false
stdout: StdioCollector {
onStreamFinished: {
/*
* bluetoothctl prints lines like:
* [NEW] Device 12:34:56:78:9A:BC My-Headphones
*/
const rx = /^\[NEW\] Device ([0-9A-F:]+)\s+(.+)$/i;
const found = text.split('\n')
.filter(l => rx.test(l))
.map(l => {
const [,mac,name] = l.match(rx);
return { mac, name };
});
root._handleDiscovered(found);
}
}
onExited: {
root.scanning = false
}
}
// Get paired devices and merge with discovered ones
Process {
id: pairedDeviceChecker
command: ["bash", "-c", "bluetoothctl devices | while read -r line; do if [[ $line =~ Device\\ ([A-F0-9:]+)\\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ${#name} -gt 3 ]] && [[ ! $name =~ ^/org/bluez ]] && [[ ! $name =~ hci0 ]]; then info=$(bluetoothctl info \"$mac\" 2>/dev/null); paired=$(echo \"$info\" | grep -m1 'Paired:' | awk '{print $2}'); connected=$(echo \"$info\" | grep -m1 'Connected:' | awk '{print $2}'); echo \"$mac|$name|$paired|$connected\"; fi; fi; done"]
running: false
property var discoveredToMerge: []
stdout: StdioCollector {
onStreamFinished: {
// Start with discovered devices (unpaired, available to pair)
let allDevices = [...pairedDeviceChecker.discoveredToMerge]
let seenMacs = new Set(allDevices.map(d => d.mac))
// Add only actually paired devices from bluetoothctl
if (text.trim()) {
let lines = text.trim().split('\n')
for (let line of lines) {
if (line.trim()) {
let parts = line.split('|')
if (parts.length >= 4) {
let mac = parts[0].trim()
let name = parts[1].trim()
let paired = parts[2].trim() === 'yes'
let connected = parts[3].trim() === 'yes'
// Only include if actually paired
if (!paired) continue
// Check if already in discovered list
if (seenMacs.has(mac)) {
// Update existing device to show it's paired
let existing = allDevices.find(d => d.mac === mac)
if (existing) {
existing.paired = true
existing.connected = connected
existing.canPair = false
}
continue
}
// Add paired device not found during scan
let type = "bluetooth"
let nameLower = name.toLowerCase()
if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis") || nameLower.includes("audio")) type = "headset"
else if (nameLower.includes("mouse")) type = "mouse"
else if (nameLower.includes("keyboard")) type = "keyboard"
else if (nameLower.includes("phone") || nameLower.includes("iphone") || nameLower.includes("samsung") || nameLower.includes("android")) type = "phone"
else if (nameLower.includes("watch")) type = "watch"
else if (nameLower.includes("speaker")) type = "speaker"
else if (nameLower.includes("tv") || nameLower.includes("display")) type = "tv"
allDevices.push({
mac: mac,
name: name,
type: type,
paired: true,
connected: connected,
rssi: -100,
signalStrength: "unknown",
canPair: false
})
}
}
}
}
root.availableDevices = allDevices
root.scanning = false
}
}
}
}

View File

@@ -27,16 +27,15 @@ Singleton {
target: modelData
Component.onCompleted: {
console.log("MPRIS Player connected:", modelData.identity)
if (root.trackedPlayer == null || modelData.isPlaying) {
root.trackedPlayer = modelData
}
}
Component.onDestruction: {
if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) {
if (root.trackedPlayer == null || root.trackedPlayer.playbackState !== MprisPlaybackState.Playing) {
for (const player of Mpris.players.values) {
if (player.playbackState.isPlaying) {
if (player.playbackState === MprisPlaybackState.Playing) {
root.trackedPlayer = player
break
}
@@ -73,7 +72,6 @@ Singleton {
onActivePlayerChanged: this.updateTrack()
function updateTrack() {
console.log(`MPRIS Track Update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtist}`)
this.activeTrack = {
uniqueId: this.activePlayer?.uniqueId ?? 0,
artUrl: this.activePlayer?.trackArtUrl ?? "",
@@ -86,7 +84,7 @@ Singleton {
this.__reverse = false
}
property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying
property bool isPlaying: this.activePlayer && this.activePlayer.playbackState === MprisPlaybackState.Playing
property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false
function togglePlaying() {
if (this.canTogglePlaying) this.activePlayer.togglePlaying()
@@ -128,7 +126,6 @@ Singleton {
function setActivePlayer(player) {
const targetPlayer = player ?? Mpris.players[0]
console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`)
if (targetPlayer && this.activePlayer) {
this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer)
@@ -139,22 +136,4 @@ Singleton {
this.trackedPlayer = targetPlayer
}
// Debug timer
Timer {
interval: 3000
running: true
repeat: true
onTriggered: {
if (activePlayer) {
console.log(` Track: ${activePlayer.trackTitle || 'Unknown'} by ${activePlayer.trackArtist || 'Unknown'}`)
console.log(` State: ${activePlayer.playbackState}`)
} else if (Mpris.players.length === 0) {
console.log(" No MPRIS players detected. Try:")
console.log(" - mpv --script-opts=mpris-title='{{media-title}}' file.mp3")
console.log(" - firefox/chromium (YouTube, Spotify Web)")
console.log(" - vlc file.mp3")
console.log(" Check available players: busctl --user list | grep mpris")
}
}
}
}

View File

@@ -13,6 +13,18 @@ PanelWindow {
visible: root.calendarVisible
// Timer to update MPRIS position like the example
Timer {
running: root.activePlayer?.playbackState === MprisPlaybackState.Playing
interval: 1000
repeat: true
onTriggered: {
if (root.activePlayer) {
root.activePlayer.positionChanged()
}
}
}
implicitWidth: 320
implicitHeight: 400
@@ -162,13 +174,6 @@ PanelWindow {
height: parent.height
radius: parent.radius
color: Theme.primary
Behavior on width {
NumberAnimation {
duration: 200
easing.type: Easing.OutQuad
}
}
}
MouseArea {
@@ -180,11 +185,7 @@ PanelWindow {
const ratio = mouse.x / width
const newPosition = ratio * root.activePlayer.length
console.log("Seeking to position:", newPosition, "ratio:", ratio, "canSeek:", root.activePlayer.canSeek)
if (root.activePlayer.canSeek) {
root.activePlayer.position = newPosition
} else {
console.log("Player does not support seeking")
}
root.activePlayer.setPosition(newPosition)
}
}
}
@@ -214,7 +215,16 @@ PanelWindow {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activePlayer?.previous()
onClicked: {
if (!root.activePlayer) return
// >8 s → jump to start, otherwise previous track
if (root.activePlayer.position > 8000000) {
root.activePlayer.setPosition(0)
} else {
root.activePlayer.previous()
}
}
}
}

View File

@@ -0,0 +1,174 @@
import QtQuick
import QtQuick.Controls
import "../../Common"
Column {
id: calendarWidget
property var theme: Theme
property date displayDate: new Date()
property date selectedDate: new Date()
spacing: theme.spacingM
// Month navigation header
Row {
width: parent.width
height: 40
Rectangle {
width: 40
height: 40
radius: theme.cornerRadius
color: prevMonthArea.containsMouse ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "chevron_left"
font.family: theme.iconFont
font.pixelSize: theme.iconSize
color: theme.primary
font.weight: theme.iconFontWeight
}
MouseArea {
id: prevMonthArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let newDate = new Date(displayDate)
newDate.setMonth(newDate.getMonth() - 1)
displayDate = newDate
}
}
}
Text {
width: parent.width - 80
height: 40
text: Qt.formatDate(displayDate, "MMMM yyyy")
font.pixelSize: theme.fontSizeLarge
color: theme.surfaceText
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Rectangle {
width: 40
height: 40
radius: theme.cornerRadius
color: nextMonthArea.containsMouse ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "chevron_right"
font.family: theme.iconFont
font.pixelSize: theme.iconSize
color: theme.primary
font.weight: theme.iconFontWeight
}
MouseArea {
id: nextMonthArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let newDate = new Date(displayDate)
newDate.setMonth(newDate.getMonth() + 1)
displayDate = newDate
}
}
}
}
// Days of week header
Row {
width: parent.width
height: 32
Repeater {
model: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
Rectangle {
width: parent.width / 7
height: 32
color: "transparent"
Text {
anchors.centerIn: parent
text: modelData
font.pixelSize: theme.fontSizeSmall
color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.6)
font.weight: Font.Medium
}
}
}
}
// Calendar grid
Grid {
width: parent.width
height: 200 // Fixed height for calendar
columns: 7
rows: 6
property date firstDay: {
let date = new Date(displayDate.getFullYear(), displayDate.getMonth(), 1)
let dayOfWeek = date.getDay()
date.setDate(date.getDate() - dayOfWeek)
return date
}
Repeater {
model: 42
Rectangle {
width: parent.width / 7
height: parent.height / 6
property date dayDate: {
let date = new Date(parent.firstDay)
date.setDate(date.getDate() + index)
return date
}
property bool isCurrentMonth: dayDate.getMonth() === displayDate.getMonth()
property bool isToday: dayDate.toDateString() === new Date().toDateString()
property bool isSelected: dayDate.toDateString() === selectedDate.toDateString()
color: isSelected ? theme.primary :
isToday ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.12) :
dayArea.containsMouse ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.08) : "transparent"
radius: theme.cornerRadiusSmall
Text {
anchors.centerIn: parent
text: dayDate.getDate()
font.pixelSize: theme.fontSizeMedium
color: isSelected ? theme.surface :
isToday ? theme.primary :
isCurrentMonth ? theme.surfaceText :
Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.4)
font.weight: isToday || isSelected ? Font.Medium : Font.Normal
}
MouseArea {
id: dayArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
selectedDate = dayDate
}
}
}
}
}
}

View File

@@ -0,0 +1,117 @@
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Services.Mpris
import "../../Common"
import "../../Services"
PanelWindow {
id: centerCommandCenter
property var theme: Theme
property bool hasActiveMedia: root.hasActiveMedia
property var weather: root.weather
property bool useFahrenheit: false
// Prevent media player from disappearing during track changes
property bool showMediaPlayer: hasActiveMedia || hideMediaTimer.running
Timer {
id: hideMediaTimer
interval: 3000 // 3 second grace period
running: false
repeat: false
}
onHasActiveMediaChanged: {
if (hasActiveMedia) {
hideMediaTimer.stop()
} else {
hideMediaTimer.start()
}
}
visible: root.calendarVisible
implicitWidth: 320
implicitHeight: 400
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
Rectangle {
width: 400
height: showMediaPlayer ? 540 : (weather?.available ? 480 : 400)
x: (parent.width - width) / 2
y: theme.barHeight + theme.spacingS
color: theme.surfaceContainer
radius: theme.cornerRadiusLarge
border.color: Qt.rgba(theme.outline.r, theme.outline.g, theme.outline.b, 0.12)
border.width: 1
opacity: root.calendarVisible ? 1.0 : 0.0
scale: root.calendarVisible ? 1.0 : 0.85
Behavior on opacity {
NumberAnimation {
duration: theme.mediumDuration
easing.type: theme.emphasizedEasing
}
}
Behavior on scale {
NumberAnimation {
duration: theme.mediumDuration
easing.type: theme.emphasizedEasing
}
}
Column {
anchors.fill: parent
anchors.margins: theme.spacingL
spacing: theme.spacingM
// Media Player (when active)
MediaPlayerWidget {
visible: showMediaPlayer
theme: centerCommandCenter.theme
}
// Weather header (when available and no media)
WeatherWidget {
visible: weather?.available && !showMediaPlayer
theme: centerCommandCenter.theme
weather: centerCommandCenter.weather
useFahrenheit: centerCommandCenter.useFahrenheit
}
// Calendar
CalendarWidget {
width: parent.width
height: showMediaPlayer ? parent.height - 200 : (weather?.available ? parent.height - 120 : parent.height - 40)
theme: centerCommandCenter.theme
}
}
}
MouseArea {
anchors.fill: parent
z: -1
onClicked: {
root.calendarVisible = false
}
}
}

View File

@@ -0,0 +1,269 @@
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Services.Mpris
import "../../Common"
import "../../Services"
Rectangle {
id: mediaPlayerWidget
property MprisPlayer activePlayer: MprisController.activePlayer
property var theme: Theme
width: parent.width
height: 160 // Reduced height to prevent overflow
radius: theme.cornerRadius
color: Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.08)
border.color: Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.2)
border.width: 1
// Timer to update MPRIS position
property bool justSeeked: false
property real seekTargetPosition: 0
Timer {
id: positionTimer
running: activePlayer?.playbackState === MprisPlaybackState.Playing && !justSeeked
interval: 1000
repeat: true
onTriggered: {
if (activePlayer) {
activePlayer.positionChanged()
}
}
}
// Timer to resume position updates after seeking
Timer {
id: seekCooldownTimer
interval: 1000 // Reduced from 2000
repeat: false
onTriggered: {
justSeeked = false
// Force position update after seek
if (activePlayer) {
activePlayer.positionChanged()
}
}
}
Column {
anchors.fill: parent
anchors.margins: theme.spacingM
spacing: theme.spacingM
// Album art and track info
Row {
width: parent.width
height: 70 // Reduced height
spacing: theme.spacingM
// Album Art
Rectangle {
width: 70
height: 70
radius: theme.cornerRadius
color: Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.3)
Item {
anchors.fill: parent
clip: true
Image {
id: albumArt
anchors.fill: parent
source: activePlayer?.trackArtUrl || ""
fillMode: Image.PreserveAspectCrop
smooth: true
}
Rectangle {
anchors.fill: parent
visible: albumArt.status !== Image.Ready
color: "transparent"
Text {
anchors.centerIn: parent
text: "album"
font.family: theme.iconFont
font.pixelSize: 28
color: theme.surfaceVariantText
}
}
}
}
// Track Info
Column {
width: parent.width - 70 - theme.spacingM
spacing: theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
Text {
text: activePlayer?.trackTitle || "Unknown Track"
font.pixelSize: theme.fontSizeMedium
font.weight: Font.Bold
color: theme.surfaceText
width: parent.width
elide: Text.ElideRight
}
Text {
text: activePlayer?.trackArtist || "Unknown Artist"
font.pixelSize: theme.fontSizeSmall
color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.8)
width: parent.width
elide: Text.ElideRight
}
Text {
text: activePlayer?.trackAlbum || ""
font.pixelSize: theme.fontSizeSmall
color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.6)
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
}
}
}
// Simple progress bar - click to seek only
Rectangle {
width: parent.width
height: 6
radius: 3
color: Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.3)
Rectangle {
width: {
if (!activePlayer || !activePlayer.length || activePlayer.length === 0) return 0
// Use seek target position if we just seeked
const currentPos = justSeeked ? seekTargetPosition : activePlayer.position
return Math.max(0, Math.min(parent.width, parent.width * (currentPos / activePlayer.length)))
}
height: parent.height
radius: parent.radius
color: theme.primary
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
const ratio = mouse.x / width
const targetPosition = Math.floor(ratio * activePlayer.length)
const currentPosition = activePlayer.position || 0
const seekOffset = targetPosition - currentPosition
console.log("Simple seek - offset:", seekOffset, "target:", targetPosition, "current:", currentPosition)
// Store target position for visual feedback
seekTargetPosition = targetPosition
justSeeked = true
seekCooldownTimer.restart()
activePlayer.seek(seekOffset)
}
}
}
}
// Control buttons - compact to fit
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: theme.spacingL
// Previous button
Rectangle {
width: 28
height: 28
radius: 14
color: prevBtnArea.containsMouse ? Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "skip_previous"
font.family: theme.iconFont
font.pixelSize: 16
color: theme.surfaceText
}
MouseArea {
id: prevBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!activePlayer) return
// >8 s → jump to start, otherwise previous track
if (activePlayer.position > 8000000) {
console.log("Jumping to start - current position:", activePlayer.position)
// Store target position for visual feedback
seekTargetPosition = 0
justSeeked = true
seekCooldownTimer.restart()
// Seek to the beginning
activePlayer.seek(-activePlayer.position)
} else {
activePlayer.previous()
}
}
}
}
// Play/Pause button
Rectangle {
width: 36
height: 36
radius: 18
color: theme.primary
Text {
anchors.centerIn: parent
text: activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow"
font.family: theme.iconFont
font.pixelSize: 20
color: theme.background
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: activePlayer?.togglePlaying()
}
}
// Next button
Rectangle {
width: 28
height: 28
radius: 14
color: nextBtnArea.containsMouse ? Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "skip_next"
font.family: theme.iconFont
font.pixelSize: 16
color: theme.surfaceText
}
MouseArea {
id: nextBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: activePlayer?.next()
}
}
}
}
}

View File

@@ -0,0 +1,135 @@
import QtQuick
import QtQuick.Controls
import "../../Common"
import "../../Services"
Rectangle {
id: weatherWidget
property var theme: Theme
property var weather
property bool useFahrenheit: false
width: parent.width
height: 80
radius: theme.cornerRadius
color: Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.08)
border.color: Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.2)
border.width: 1
Row {
anchors.centerIn: parent
spacing: theme.spacingL
// Weather icon and temp
Column {
spacing: 2
anchors.verticalCenter: parent.verticalCenter
Text {
text: WeatherService.getWeatherIcon(weather.wCode)
font.family: theme.iconFont
font.pixelSize: theme.iconSize + 4
color: theme.primary
anchors.horizontalCenter: parent.horizontalCenter
}
Text {
text: (useFahrenheit ? weather.tempF : weather.temp) + "°" + (useFahrenheit ? "F" : "C")
font.pixelSize: theme.fontSizeLarge
color: theme.surfaceText
font.weight: Font.Bold
anchors.horizontalCenter: parent.horizontalCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: useFahrenheit = !useFahrenheit
}
}
Text {
text: weather.city
font.pixelSize: theme.fontSizeSmall
color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.7)
anchors.horizontalCenter: parent.horizontalCenter
}
}
// Weather details grid
Grid {
columns: 2
spacing: theme.spacingS
anchors.verticalCenter: parent.verticalCenter
Row {
spacing: theme.spacingXS
Text {
text: "humidity_low"
font.family: theme.iconFont
font.pixelSize: theme.fontSizeSmall
color: theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: weather.humidity + "%"
font.pixelSize: theme.fontSizeSmall
color: theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
spacing: theme.spacingXS
Text {
text: "air"
font.family: theme.iconFont
font.pixelSize: theme.fontSizeSmall
color: theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: weather.wind
font.pixelSize: theme.fontSizeSmall
color: theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
spacing: theme.spacingXS
Text {
text: "wb_twilight"
font.family: theme.iconFont
font.pixelSize: theme.fontSizeSmall
color: theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: weather.sunrise
font.pixelSize: theme.fontSizeSmall
color: theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
spacing: theme.spacingXS
Text {
text: "bedtime"
font.family: theme.iconFont
font.pixelSize: theme.fontSizeSmall
color: theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: weather.sunset
font.pixelSize: theme.fontSizeSmall
color: theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}

View File

@@ -0,0 +1,4 @@
CenterCommandCenter 1.0 CenterCommandCenter.qml
MediaPlayerWidget 1.0 MediaPlayerWidget.qml
WeatherWidget 1.0 WeatherWidget.qml
CalendarWidget 1.0 CalendarWidget.qml

View File

@@ -828,10 +828,74 @@ PanelWindow {
}
// Audio Tab
ScrollView {
Item {
id: audioTabContainer
anchors.fill: parent
anchors.margins: Theme.spacingM
visible: controlCenterPopup.currentTab === 1
property int audioSubTab: 0 // 0: Output, 1: Input
Column {
anchors.fill: parent
spacing: Theme.spacingM
// Audio Sub-tabs
Row {
width: parent.width
height: 40
spacing: 2
Rectangle {
width: parent.width / 2 - 1
height: parent.height
radius: Theme.cornerRadius
color: audioTabContainer.audioSubTab === 0 ? Theme.primary : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
Text {
anchors.centerIn: parent
text: "Output"
font.pixelSize: Theme.fontSizeMedium
color: audioTabContainer.audioSubTab === 0 ? Theme.primaryText : Theme.surfaceText
font.weight: Font.Medium
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: audioTabContainer.audioSubTab = 0
}
}
Rectangle {
width: parent.width / 2 - 1
height: parent.height
radius: Theme.cornerRadius
color: audioTabContainer.audioSubTab === 1 ? Theme.primary : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
Text {
anchors.centerIn: parent
text: "Input"
font.pixelSize: Theme.fontSizeMedium
color: audioTabContainer.audioSubTab === 1 ? Theme.primaryText : Theme.surfaceText
font.weight: Font.Medium
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: audioTabContainer.audioSubTab = 1
}
}
}
// Output Tab Content
ScrollView {
width: parent.width
height: parent.height - 48
visible: audioTabContainer.audioSubTab === 0
clip: true
Column {
@@ -1062,6 +1126,242 @@ PanelWindow {
}
}
// Input Tab Content
ScrollView {
width: parent.width
height: parent.height - 48
visible: audioTabContainer.audioSubTab === 1
clip: true
Column {
width: parent.width
spacing: Theme.spacingL
// Microphone Level Control
Column {
width: parent.width
spacing: Theme.spacingM
Text {
text: "Microphone Level"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
Row {
width: parent.width
spacing: Theme.spacingM
Text {
text: "mic"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
id: micSliderTrack
width: parent.width - 80
height: 8
radius: 4
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: micSliderFill
width: parent.width * (root.micLevel / 100)
height: parent.height
radius: parent.radius
color: Theme.primary
Behavior on width {
NumberAnimation { duration: 100 }
}
}
// Draggable handle
Rectangle {
id: micHandle
width: 18
height: 18
radius: 9
color: Theme.primary
border.color: Qt.lighter(Theme.primary, 1.3)
border.width: 2
x: Math.max(0, Math.min(parent.width - width, micSliderFill.width - width/2))
anchors.verticalCenter: parent.verticalCenter
scale: micMouseArea.containsMouse || micMouseArea.pressed ? 1.2 : 1.0
Behavior on scale {
NumberAnimation { duration: 150 }
}
}
MouseArea {
id: micMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
let ratio = Math.max(0, Math.min(1, mouse.x / width))
let newMicLevel = Math.round(ratio * 100)
AudioService.setMicLevel(newMicLevel)
}
onPositionChanged: (mouse) => {
if (pressed) {
let ratio = Math.max(0, Math.min(1, mouse.x / width))
let newMicLevel = Math.round(ratio * 100)
AudioService.setMicLevel(newMicLevel)
}
}
}
}
Text {
text: "mic"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Text {
text: root.micLevel + "%"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
}
// Input Devices
Column {
width: parent.width
spacing: Theme.spacingM
Text {
text: "Input Device"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
// Current device indicator
Rectangle {
width: parent.width
height: 35
radius: Theme.cornerRadius
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
border.width: 1
visible: root.currentAudioSource !== ""
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
Text {
text: "check_circle"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 4
color: Theme.primary
}
Text {
text: "Current: " + (function() {
for (let source of root.audioSources) {
if (source.name === root.currentAudioSource) {
return source.displayName
}
}
return root.currentAudioSource
})()
font.pixelSize: Theme.fontSizeMedium
color: Theme.primary
font.weight: Font.Medium
}
}
}
// Real audio input devices
Repeater {
model: root.audioSources
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: sourceArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
(modelData.active ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08))
border.color: modelData.active ? Theme.primary : "transparent"
border.width: 1
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
Text {
text: {
if (modelData.name.includes("bluez")) return "headset_mic"
else if (modelData.name.includes("usb")) return "headset_mic"
else return "mic"
}
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: modelData.active ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: 2
anchors.verticalCenter: parent.verticalCenter
Text {
text: modelData.displayName
font.pixelSize: Theme.fontSizeMedium
color: modelData.active ? Theme.primary : Theme.surfaceText
font.weight: modelData.active ? Font.Medium : Font.Normal
}
Text {
text: modelData.active ? "Selected" : ""
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.8)
visible: modelData.active
}
}
}
MouseArea {
id: sourceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
console.log("Clicked audio source:", JSON.stringify(modelData))
console.log("Source name to set:", modelData.name)
AudioService.setAudioSource(modelData.name)
}
}
}
}
}
}
}
}
}
// Bluetooth Tab
ScrollView {
anchors.fill: parent

View File

@@ -1,139 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Services.SystemTray
PanelWindow {
id: topBar
property var theme
property var root
anchors {
top: true
left: true
right: true
}
WlrLayershell.topMargin: 8
WlrLayershell.bottomMargin: 8
WlrLayershell.leftMargin: 16
WlrLayershell.rightMargin: 16
implicitHeight: theme.barHeight - 4
color: "transparent"
Rectangle {
anchors.fill: parent
anchors.margins: 2
anchors.topMargin: 6
anchors.bottomMargin: 2
anchors.leftMargin: 8
anchors.rightMargin: 8
radius: theme.cornerRadiusXLarge
color: Qt.rgba(theme.surfaceContainer.r, theme.surfaceContainer.g, theme.surfaceContainer.b, 0.75)
// Material 3 elevation shadow
layer.enabled: true
layer.effect: DropShadow {
horizontalOffset: 0
verticalOffset: 4
radius: 16
samples: 33
color: Qt.rgba(0, 0, 0, 0.15)
transparentBorder: true
}
// Subtle border for definition
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: Qt.rgba(theme.outline.r, theme.outline.g, theme.outline.b, 0.08)
border.width: 1
radius: parent.radius
}
// Subtle surface tint overlay with animation
Rectangle {
anchors.fill: parent
color: Qt.rgba(theme.surfaceTint.r, theme.surfaceTint.g, theme.surfaceTint.b, 0.04)
radius: parent.radius
SequentialAnimation on opacity {
running: true
loops: Animation.Infinite
NumberAnimation {
to: 0.08
duration: theme.extraLongDuration
easing.type: theme.standardEasing
}
NumberAnimation {
to: 0.02
duration: theme.extraLongDuration
easing.type: theme.standardEasing
}
}
}
}
Item {
anchors.fill: parent
anchors.leftMargin: theme.spacingL
anchors.rightMargin: theme.spacingL
anchors.topMargin: theme.spacingXS
anchors.bottomMargin: theme.spacingXS
// Left section - Apps and Workspace Switcher
Row {
id: leftSection
height: parent.height
spacing: theme.spacingL
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
AppLauncherButton {
theme: topBar.theme
root: topBar.root
}
WorkspaceSwitcher {
theme: topBar.theme
root: topBar.root
}
}
// Center section - Clock/Media Player
ClockWidget {
id: clockWidget
theme: topBar.theme
root: topBar.root
anchors.centerIn: parent
}
// Right section - System controls
Row {
id: rightSection
height: parent.height
spacing: theme.spacingXS
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
ClipboardButton {
theme: topBar.theme
root: topBar.root
}
ColorPickerButton {
theme: topBar.theme
root: topBar.root
}
NotificationButton {
theme: topBar.theme
root: topBar.root
}
}
}
}

View File

@@ -1,5 +1,4 @@
TopBar 1.0 TopBar.qml
TopBarSimple 1.0 TopBarSimple.qml
AppLauncherButton 1.0 AppLauncherButton.qml
WorkspaceSwitcher 1.0 WorkspaceSwitcher.qml
ClockWidget 1.0 ClockWidget.qml

View File

@@ -12,6 +12,7 @@ import Quickshell.Services.Notifications
import Quickshell.Services.Mpris
import "Services"
import "Widgets"
import "Widgets/CenterCommandCenter"
import "Common"
import "Common/Utilities.js" as Utils
@@ -65,6 +66,11 @@ ShellRoot {
property var audioSinks: AudioService.audioSinks
property string currentAudioSink: AudioService.currentAudioSink
// Microphone properties from AudioService
property int micLevel: AudioService.micLevel
property var audioSources: AudioService.audioSources
property string currentAudioSource: AudioService.currentAudioSource
// Bluetooth properties from BluetoothService
property var bluetoothDevices: BluetoothService.bluetoothDevices
@@ -280,7 +286,7 @@ ShellRoot {
}
// Global popup windows
CalendarPopup {}
CenterCommandCenter {}
TrayMenuPopup {}
NotificationPopup {}
NotificationHistoryPopup {}