network widget , fixed audio widget
This commit is contained in:
@@ -55,6 +55,8 @@ Item {
|
||||
property string activeTab: "outputs" // outputs, inputs, apps
|
||||
onActiveTabChanged: updateHeroData()
|
||||
|
||||
property var peakLevels: ({})
|
||||
|
||||
readonly property color tabColor: {
|
||||
if (activeTab === "outputs") return window.blue;
|
||||
if (activeTab === "inputs") return window.mauve;
|
||||
@@ -184,6 +186,17 @@ Item {
|
||||
onTriggered: audioPoller.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: peakProc
|
||||
command: ["python3", window.scriptsDir + "/get_peak_levels.py"]
|
||||
running: true
|
||||
stdout: SplitParser {
|
||||
onRead: (line) => {
|
||||
try { window.peakLevels = JSON.parse(line); } catch(e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ANIMATIONS
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -239,7 +252,32 @@ Item {
|
||||
Behavior on color { ColorAnimation { duration: 800 } }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
// Cog button — top right corner
|
||||
Item {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 14
|
||||
width: 28; height: 28
|
||||
z: 10
|
||||
|
||||
Image {
|
||||
anchors.centerIn: parent
|
||||
source: Qt.resolvedUrl("cog.svg")
|
||||
width: 16; height: 16
|
||||
opacity: cogMa.containsMouse ? 1.0 : 0.35
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: cogMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Quickshell.execDetached(["pavucontrol"])
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 25
|
||||
spacing: 20
|
||||
@@ -600,13 +638,13 @@ Item {
|
||||
spacing: 8
|
||||
Text {
|
||||
font.family: "Iosevka Nerd Font"; font.pixelSize: 18
|
||||
color: window.activeTab === tabId ? window.crust : (tabMa.containsMouse ? window.text : window.subtext0)
|
||||
color: window.activeTab === tabId ? window.crust : (tabMa.containsMouse ? window.subtext0 : window.overlay0)
|
||||
text: icon
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
}
|
||||
Text {
|
||||
font.family: "JetBrains Mono"; font.weight: Font.Black; font.pixelSize: 13
|
||||
color: window.activeTab === tabId ? window.crust : (tabMa.containsMouse ? window.text : window.subtext0)
|
||||
color: window.activeTab === tabId ? window.crust : (tabMa.containsMouse ? window.subtext0 : window.overlay0)
|
||||
text: label
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
}
|
||||
@@ -686,7 +724,7 @@ Item {
|
||||
|
||||
// Dynamic Height: The active hero element collapses its bottom slider row
|
||||
property bool isActiveNode: model.is_default && window.activeTab !== "apps"
|
||||
height: isActiveNode ? 60 : 100
|
||||
height: isActiveNode ? 76 : 116
|
||||
Behavior on height { NumberAnimation { duration: 400; easing.type: Easing.OutQuint } }
|
||||
|
||||
radius: 14
|
||||
@@ -757,7 +795,50 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom row: Custom Slider & Mute (Hides if it's the active node)
|
||||
// VU level meter
|
||||
Item {
|
||||
id: vuMeter
|
||||
Layout.fillWidth: true
|
||||
height: 6
|
||||
|
||||
property real rawPeak: {
|
||||
let key = (window.activeTab === "outputs" ? "sink_"
|
||||
: window.activeTab === "inputs" ? "source_"
|
||||
: "app_") + model.id;
|
||||
return window.peakLevels[key] || 0.0;
|
||||
}
|
||||
property real displayPeak: 0.0
|
||||
|
||||
onRawPeakChanged: {
|
||||
if (rawPeak > displayPeak) {
|
||||
peakDecay.stop();
|
||||
displayPeak = rawPeak;
|
||||
peakDecay.restart();
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: peakDecay
|
||||
target: vuMeter; property: "displayPeak"
|
||||
to: 0.0; duration: 900; easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent; radius: 3
|
||||
color: isActiveNode ? "#20000000" : "#0dffffff"
|
||||
}
|
||||
Rectangle {
|
||||
height: parent.height; radius: 3
|
||||
width: vuMeter.width * vuMeter.displayPeak
|
||||
color: model.mute ? window.surface2
|
||||
: isActiveNode ? Qt.darker(window.tabColor, 1.3)
|
||||
: window.tabColor
|
||||
opacity: model.mute ? 0.4 : 0.9
|
||||
Behavior on color { ColorAnimation { duration: 300 } }
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom row: Custom Slider & Mute (Hides if it's the active node)
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 15
|
||||
@@ -832,8 +913,8 @@ Item {
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: model.mute ? window.surface2 : window.tabColor; Behavior on color { ColorAnimation { duration: 300 } } }
|
||||
GradientStop { position: 1.0; color: model.mute ? Qt.lighter(window.surface2, 1.15) : Qt.lighter(window.tabColor, 1.25); Behavior on color { ColorAnimation { duration: 300 } } }
|
||||
GradientStop { position: 0.0; color: model.mute ? window.surface2 : (window.activeTab === "apps" ? window.tabColor : window.surface2); Behavior on color { ColorAnimation { duration: 300 } } }
|
||||
GradientStop { position: 1.0; color: model.mute ? Qt.lighter(window.surface2, 1.15) : (window.activeTab === "apps" ? Qt.lighter(window.tabColor, 1.25) : Qt.lighter(window.surface2, 1.2)); Behavior on color { ColorAnimation { duration: 300 } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Toggle the audio widget
|
||||
|
||||
WIDGET="$(dirname "$(realpath "$0")")/audioWidget.qml"
|
||||
|
||||
if pgrep -f ".*audioWidget.qml" > /dev/null 2>&1; then
|
||||
pkill -f ".*audioWidget.qml" > /dev/null 2>&1
|
||||
else
|
||||
quickshell -p "$WIDGET" &
|
||||
fi
|
||||
@@ -21,4 +21,6 @@ FloatingWindow {
|
||||
sequence: "Escape"
|
||||
onActivated: Qt.quit()
|
||||
}
|
||||
|
||||
onVisibleChanged: if (!visible) Qt.quit()
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="31.371094" height="31.371094" viewBox="0 0 31.371094 31.371093"><path d="M13.64 0v2.602c-.945.146-1.858.395-2.726.732L9.611 1.08 6.072 3.123l1.305 2.262a13 13 0 0 0-1.992 1.992L3.123 6.072 1.078 9.613l2.256 1.301a13 13 0 0 0-.732 2.727H0v4.088h2.602c.146.946.395 1.858.732 2.726l-2.256 1.303 2.045 3.54 2.262-1.306c.59.735 1.257 1.402 1.992 1.994l-1.305 2.26 3.54 2.045 1.302-2.256c.868.337 1.781.59 2.727.736v2.6h4.088v-2.6c.945-.149 1.857-.399 2.726-.736l1.3 2.256 3.542-2.045-1.303-2.26c.733-.592 1.4-1.26 1.992-1.994l2.26 1.307 2.045-3.541-2.256-1.303c.336-.868.588-1.78.735-2.726h2.601V13.64H28.77a13 13 0 0 0-.735-2.727l2.256-1.3-2.045-3.542-2.26 1.305c-.592-.735-1.26-1.4-1.994-1.992l1.305-2.262-3.541-2.043-1.3 2.254a13 13 0 0 0-2.727-.732V0Zm2.237 5.525c5.612 0 10.16 4.548 10.16 10.159s-4.548 10.162-10.16 10.162-10.16-4.55-10.16-10.162c0-5.611 4.548-10.159 10.16-10.159m0 .557c-.065 0-.128.008-.193.01v.877l-.016.002c-2.327.01-4.183 1.08-5.256 2.394-.579.707-1.1 1.808-1.18 2.854-.142 1.854.795 2.658.875 4.338.012.253.031.59-.037.8-.093.282-.565.557-.646.989-.062.318.036.546.152.914.655.704.413 1.846 1.217 2.36.496.315 1.354.105 1.902-.192-.242.886-.594 1.865-.53 2.931.27.084.692.015 1.024.037.012-.305-.035-.67.116-.835.268.117-.006.54.076.835h1.103c.032-.309-.087-.772.116-.914.246.118-.012.592.076.914h.875c.066-.253.029-.61.133-.83.006-.013.01-.031.017-.045v-1.773l-.015-.012h-.002l-.502-.324.502-1.353.017-.045V6.97c2.325.01 4.183 1.08 5.256 2.394.579.707 1.1 1.81 1.182 2.854.141 1.854-.797 2.658-.877 4.338-.012.253-.031.59.037.8.092.282.567.557.648.989.06.318-.038.547-.154.914-.655.704-.413 1.846-1.217 2.361-.496.315-1.354.104-1.902-.193.242.886.596 1.865.533 2.931-.27.084-.697.015-1.027.037-.012-.305.035-.67-.116-.835-.266.117.007.54-.074.835h-1.103c-.034-.309.086-.772-.116-.914-.248.118.012.592-.076.914h-.875c-.068-.253-.034-.61-.136-.83v1.715c.065.002.128.008.193.008 5.295 0 9.605-4.308 9.605-9.605 0-5.295-4.31-9.602-9.605-9.602m-.191 15.654.502-.324-.502-1.353Zm-2.834-4.963a1.857 1.857 0 1 1-.004 3.715 1.857 1.857 0 0 1 .004-3.715m6.468.66c-.662.377-1.363.717-2.283.837-.203.202-.553.257-.607.609.869.53 1.894 1.091 3.234 1.027.472-.39.75-.977.723-1.865-.228-.33-.652-.466-1.067-.607" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.33333"/></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Monitors each sink-input (app) and source individually via pacat.
|
||||
Derives sink peak = max of all apps routed to that sink.
|
||||
Emits JSON lines at ~20fps. Dies with parent via PR_SET_PDEATHSIG.
|
||||
"""
|
||||
import subprocess, struct, json, threading, sys, math, time, signal, ctypes
|
||||
|
||||
try:
|
||||
ctypes.CDLL("libc.so.6").prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
RATE = 44100
|
||||
CHANNELS = 2
|
||||
CHUNK_MS = 50
|
||||
BYTES_PS = 2
|
||||
chunk_bytes = int(RATE * CHUNK_MS / 1000) * CHANNELS * BYTES_PS
|
||||
|
||||
levels = {} # key -> float 0..1
|
||||
running = {} # key -> True
|
||||
procs = {} # key -> Popen
|
||||
app_sinks = {} # app_N -> sink index
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def cleanup(*_):
|
||||
with lock:
|
||||
for p in procs.values():
|
||||
try: p.terminate()
|
||||
except: pass
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, cleanup)
|
||||
signal.signal(signal.SIGINT, cleanup)
|
||||
|
||||
|
||||
def db_scale(peak):
|
||||
if peak < 0.0001:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, (20.0 * math.log10(peak) + 60.0) / 60.0))
|
||||
|
||||
|
||||
def monitor(key, cmd):
|
||||
with lock:
|
||||
running[key] = True
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
with lock:
|
||||
procs[key] = proc
|
||||
while True:
|
||||
data = proc.stdout.read(chunk_bytes)
|
||||
if not data:
|
||||
break
|
||||
with lock:
|
||||
if key not in running:
|
||||
break
|
||||
n = len(data) // BYTES_PS
|
||||
s = struct.unpack_from(f'<{n}h', data)
|
||||
peak = max(abs(x) for x in s) / 32768.0
|
||||
with lock:
|
||||
levels[key] = db_scale(peak)
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
with lock:
|
||||
running.pop(key, None)
|
||||
levels.pop(key, None)
|
||||
procs.pop(key, None)
|
||||
|
||||
|
||||
def spawn(key, cmd):
|
||||
threading.Thread(target=monitor, args=(key, cmd), daemon=True).start()
|
||||
|
||||
|
||||
def pactl_json(obj):
|
||||
try:
|
||||
return json.loads(subprocess.check_output(
|
||||
['pactl', '-f', 'json', 'list', obj], stderr=subprocess.DEVNULL).decode())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def scan_devices():
|
||||
sources = pactl_json('sources')
|
||||
apps = pactl_json('sink-inputs')
|
||||
sinks = pactl_json('sinks')
|
||||
wanted = set()
|
||||
|
||||
# Sources (microphones etc, not monitors)
|
||||
for s in sources:
|
||||
if '.monitor' in s['name']:
|
||||
continue
|
||||
key = f"source_{s['index']}"
|
||||
wanted.add(key)
|
||||
with lock:
|
||||
if key not in running:
|
||||
spawn(key, ['pacat', '-r', '-d', s['name'],
|
||||
'--channels=2', '--format=s16le', f'--rate={RATE}'])
|
||||
|
||||
# Apps — one pacat per sink-input
|
||||
for a in apps:
|
||||
key = f"app_{a['index']}"
|
||||
wanted.add(key)
|
||||
with lock:
|
||||
app_sinks[key] = a['sink']
|
||||
if key not in running:
|
||||
spawn(key, ['pacat', '-r', f'--monitor-stream={a["index"]}',
|
||||
'--channels=2', '--format=s16le', f'--rate={RATE}'])
|
||||
|
||||
# Remove stale entries
|
||||
with lock:
|
||||
for key in list(running.keys()):
|
||||
if key not in wanted:
|
||||
p = procs.get(key)
|
||||
if p:
|
||||
try: p.terminate()
|
||||
except: pass
|
||||
del running[key]
|
||||
for key in list(app_sinks.keys()):
|
||||
if key not in wanted:
|
||||
del app_sinks[key]
|
||||
|
||||
# Ensure every sink has an entry (even if no apps)
|
||||
with lock:
|
||||
for s in sinks:
|
||||
sink_key = f"sink_{s['index']}"
|
||||
if sink_key not in levels:
|
||||
levels[sink_key] = 0.0
|
||||
|
||||
|
||||
def emit_loop():
|
||||
while True:
|
||||
with lock:
|
||||
snapshot = dict(levels)
|
||||
# Derive sink peaks from connected app peaks
|
||||
sink_peaks = {}
|
||||
for app_key, sink_idx in app_sinks.items():
|
||||
sk = f"sink_{sink_idx}"
|
||||
v = snapshot.get(app_key, 0.0)
|
||||
if v > sink_peaks.get(sk, 0.0):
|
||||
sink_peaks[sk] = v
|
||||
snapshot.update(sink_peaks)
|
||||
sys.stdout.write(json.dumps(snapshot) + '\n')
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def rescan_loop():
|
||||
while True:
|
||||
scan_devices()
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
threading.Thread(target=rescan_loop, daemon=True).start()
|
||||
threading.Thread(target=emit_loop, daemon=True).start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
cleanup()
|
||||
File diff suppressed because it is too large
Load Diff
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCAN_LOG="$HOME/.cache/bt_scan.log"
|
||||
PID_FILE="$HOME/.cache/bt_scan_pid"
|
||||
CACHE_DIR="/tmp/quickshell_network_cache"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
get_icon() {
|
||||
local type=$(echo "$1" | tr '[:upper:]' '[:lower:]')
|
||||
local name=$(echo "$2" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$type" == *"headset"* ]] || [[ "$type" == *"headphone"* ]] || [[ "$name" == *"headphone"* ]] || [[ "$name" == *"buds"* ]] || [[ "$name" == *"pods"* ]]; then echo "🎧"
|
||||
elif [[ "$type" == *"audio"* ]] || [[ "$type" == *"speaker"* ]] || [[ "$type" == *"card"* ]] || [[ "$name" == *"speaker"* ]]; then echo "蓼"
|
||||
elif [[ "$type" == *"phone"* ]] || [[ "$name" == *"phone"* ]] || [[ "$name" == *"iphone"* ]] || [[ "$name" == *"android"* ]]; then echo ""
|
||||
elif [[ "$type" == *"mouse"* ]] || [[ "$name" == *"mouse"* ]]; then echo ""
|
||||
elif [[ "$type" == *"keyboard"* ]] || [[ "$name" == *"keyboard"* ]]; then echo ""
|
||||
elif [[ "$type" == *"controller"* ]] || [[ "$name" == *"controller"* ]]; then echo ""
|
||||
else echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
get_audio_profile() {
|
||||
local mac="$1"
|
||||
local mac_us=$(echo "$mac" | tr ':' '_')
|
||||
|
||||
# Grab the block of text containing the specific device's card and extract its Active Profile
|
||||
local active=$(pactl list cards 2>/dev/null | grep -i -A 20 "Name:.*$mac_us" | grep -i "Active Profile:" | head -n 1 | cut -d: -f2 | xargs)
|
||||
|
||||
if [[ -z "$active" || "$active" == "off" ]]; then echo "None"; return; fi
|
||||
|
||||
local desc="Connected"
|
||||
if [[ "$active" == *"a2dp"* ]]; then desc="Hi-Fi (A2DP)"; fi
|
||||
if [[ "$active" == *"headset"* || "$active" == *"hfp"* ]]; then desc="Headset (HFP)"; fi
|
||||
|
||||
echo "$desc"
|
||||
}
|
||||
|
||||
get_status() {
|
||||
power="off"
|
||||
if bluetoothctl show | grep -q "Powered: yes"; then power="on"; fi
|
||||
|
||||
connected_json="[]"
|
||||
devices_json="[]"
|
||||
|
||||
if [ "$power" == "on" ]; then
|
||||
paired_macs=$(bluetoothctl devices Paired | cut -d ' ' -f 2)
|
||||
mapfile -t devices < <(bluetoothctl devices)
|
||||
|
||||
connected_list_objs=()
|
||||
paired_list_objs=()
|
||||
discovered_list_objs=()
|
||||
|
||||
# Get all connected devices
|
||||
mapfile -t connected_info_lines < <(bluetoothctl devices Connected)
|
||||
|
||||
# Extract just the MACs of connected devices for filtering the main list later
|
||||
connected_macs=$(echo "${connected_info_lines[@]}" | awk '{for(i=1;i<=NF;i++) if($i~/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/) print $i}')
|
||||
|
||||
for c_line in "${connected_info_lines[@]}"; do
|
||||
if [ -z "$c_line" ]; then continue; fi
|
||||
connected_mac=$(echo "$c_line" | cut -d ' ' -f 2)
|
||||
CACHE_FILE="$CACHE_DIR/bt_stat_${connected_mac//:/_}"
|
||||
|
||||
# Profile, Name, and Icon do not change dynamically. Calculate ONCE.
|
||||
if [ -f "$CACHE_FILE" ]; then
|
||||
source "$CACHE_FILE"
|
||||
else
|
||||
name=$(echo "$c_line" | cut -d ' ' -f 3-)
|
||||
info=$(bluetoothctl info "$connected_mac")
|
||||
icon_type=$(echo "$info" | grep "Icon:" | cut -d: -f2 | xargs)
|
||||
icon=$(get_icon "$icon_type" "$name")
|
||||
profile=$(get_audio_profile "$connected_mac")
|
||||
|
||||
echo "CACHE_NAME=\"$name\"" > "$CACHE_FILE"
|
||||
echo "CACHE_ICON=\"$icon\"" >> "$CACHE_FILE"
|
||||
echo "CACHE_PROFILE=\"$profile\"" >> "$CACHE_FILE"
|
||||
|
||||
CACHE_NAME="$name"
|
||||
CACHE_ICON="$icon"
|
||||
CACHE_PROFILE="$profile"
|
||||
fi
|
||||
|
||||
# Dynamically fetch ONLY the battery since it changes
|
||||
# Strictly extract whatever is inside the parenthesis (e.g. 100 from "0x64 (100)")
|
||||
bat=$(bluetoothctl info "$connected_mac" | awk '/Battery Percentage:/ {gsub(/.*\(/,""); gsub(/\).*/,""); print}')
|
||||
[ -z "$bat" ] && bat=$(bluetoothctl info "$connected_mac" | grep -i "Battery Percentage" | awk '{print $NF}' | tr -d '()')
|
||||
[ -z "$bat" ] || [ "$bat" == "?" ] && bat="0"
|
||||
|
||||
obj=$(jq -n -c \
|
||||
--arg id "$connected_mac" \
|
||||
--arg name "$CACHE_NAME" \
|
||||
--arg mac "$connected_mac" \
|
||||
--arg icon "$CACHE_ICON" \
|
||||
--arg bat "$bat" \
|
||||
--arg profile "$CACHE_PROFILE" \
|
||||
'{id: $id, name: $name, mac: $mac, icon: $icon, battery: $bat, profile: $profile}')
|
||||
connected_list_objs+=("$obj")
|
||||
done
|
||||
|
||||
if [ ${#connected_list_objs[@]} -gt 0 ]; then
|
||||
connected_json=$(printf '%s\n' "${connected_list_objs[@]}" | jq -s -c '.')
|
||||
fi
|
||||
|
||||
for line in "${devices[@]}"; do
|
||||
if [ -z "$line" ]; then continue; fi
|
||||
mac=$(echo "$line" | cut -d ' ' -f 2)
|
||||
|
||||
# Skip if this MAC is already in the connected list
|
||||
if echo "$connected_macs" | grep -q "$mac"; then continue; fi
|
||||
|
||||
name=$(echo "$line" | cut -d ' ' -f 3-)
|
||||
icon=$(get_icon "unknown" "$name")
|
||||
|
||||
if echo "$paired_macs" | grep -q "$mac"; then
|
||||
action="Connect"
|
||||
obj=$(jq -n -c --arg id "$mac" --arg name "$name" --arg mac "$mac" --arg icon "$icon" --arg action "$action" '{id: $id, name: $name, mac: $mac, icon: $icon, action: $action}')
|
||||
paired_list_objs+=("$obj")
|
||||
else
|
||||
action="Pair"
|
||||
obj=$(jq -n -c --arg id "$mac" --arg name "$name" --arg mac "$mac" --arg icon "$icon" --arg action "$action" '{id: $id, name: $name, mac: $mac, icon: $icon, action: $action}')
|
||||
discovered_list_objs+=("$obj")
|
||||
fi
|
||||
done
|
||||
|
||||
all_objs=("${paired_list_objs[@]}" "${discovered_list_objs[@]}")
|
||||
if [ ${#all_objs[@]} -gt 0 ]; then
|
||||
devices_json=$(printf '%s\n' "${all_objs[@]}" | jq -s -c '.')
|
||||
fi
|
||||
if [ -z "$devices_json" ]; then devices_json="[]"; fi
|
||||
fi
|
||||
|
||||
jq -n -c \
|
||||
--arg power "$power" \
|
||||
--argjson connected "${connected_json}" \
|
||||
--argjson devices "${devices_json:-[]}" \
|
||||
'{power: $power, connected: $connected, devices: $devices}'
|
||||
}
|
||||
|
||||
toggle_power() {
|
||||
if bluetoothctl show | grep -q "Powered: yes"; then
|
||||
bluetoothctl power off
|
||||
else
|
||||
bluetoothctl power on
|
||||
fi
|
||||
sleep 0.5
|
||||
}
|
||||
|
||||
connect_dev() {
|
||||
local mac="$1"
|
||||
if [ -f "$PID_FILE" ]; then kill -STOP $(cat "$PID_FILE") 2>/dev/null; fi
|
||||
bluetoothctl trust "$mac" > /dev/null 2>&1
|
||||
bluetoothctl connect "$mac"
|
||||
if [ -f "$PID_FILE" ]; then kill -CONT $(cat "$PID_FILE") 2>/dev/null; fi
|
||||
}
|
||||
|
||||
disconnect_dev() {
|
||||
local mac="$1"
|
||||
# Remove cache so a fresh connect regenerates the profile
|
||||
rm -f "/tmp/quickshell_network_cache/bt_stat_${mac//:/_}" 2>/dev/null
|
||||
bluetoothctl disconnect "$mac"
|
||||
}
|
||||
|
||||
cmd="$1"
|
||||
case $cmd in
|
||||
--status) get_status ;;
|
||||
--toggle) toggle_power ;;
|
||||
--connect) connect_dev "$2" ;;
|
||||
--disconnect) disconnect_dev "$2" ;;
|
||||
esac
|
||||
@@ -0,0 +1,23 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
FloatingWindow {
|
||||
id: root
|
||||
title: "qs-network"
|
||||
objectName: "quickshellWidget"
|
||||
color: "transparent"
|
||||
visible: true
|
||||
|
||||
implicitWidth: 900
|
||||
implicitHeight: 700
|
||||
|
||||
NetworkPopup {
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Escape"
|
||||
onActivated: Qt.quit()
|
||||
}
|
||||
}
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Toggle the network widget: launch if not running, kill if running.
|
||||
|
||||
WIDGET="$(dirname "$(realpath "$0")")/networkWidget.qml"
|
||||
|
||||
if pgrep -f "quickshell -p.*networkWidget.qml" > /dev/null 2>&1; then
|
||||
pkill -f "quickshell -p.*networkWidget.qml"
|
||||
else
|
||||
quickshell -p "$WIDGET" &
|
||||
fi
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Check if WiFi is enabled
|
||||
POWER=$(nmcli radio wifi)
|
||||
|
||||
if [[ "$POWER" == "disabled" ]]; then
|
||||
echo '{ "power": "off", "connected": null, "networks": [] }'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Function to get icon based on signal strength
|
||||
get_icon() {
|
||||
local signal=$1
|
||||
if [[ $signal -ge 80 ]]; then echo "";
|
||||
elif [[ $signal -ge 60 ]]; then echo "";
|
||||
elif [[ $signal -ge 40 ]]; then echo "";
|
||||
elif [[ $signal -ge 20 ]]; then echo "";
|
||||
else echo ""; fi
|
||||
}
|
||||
|
||||
CACHE_DIR="/tmp/quickshell_network_cache"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
# Get current connection details
|
||||
CURRENT_RAW=$(nmcli -t -f active,ssid,signal,security device wifi | grep "^yes")
|
||||
|
||||
if [[ -n "$CURRENT_RAW" ]]; then
|
||||
IFS=':' read -r active ssid signal security <<< "$CURRENT_RAW"
|
||||
icon=$(get_icon "$signal")
|
||||
|
||||
# Safe filename for cache
|
||||
SAFE_SSID="${ssid//[^a-zA-Z0-9]/_}"
|
||||
CACHE_FILE="$CACHE_DIR/wifi_$SAFE_SSID"
|
||||
|
||||
# Load cached IP and FREQ if they exist to prevent blocking
|
||||
if [ -f "$CACHE_FILE" ]; then
|
||||
source "$CACHE_FILE"
|
||||
fi
|
||||
|
||||
# If cache is missing, fetch the expensive stats once and save them
|
||||
if [ -z "$IP" ] || [ "$IP" == "No IP" ] || [ -z "$FREQ" ]; then
|
||||
IFACE=$(nmcli -t -f DEVICE,TYPE d | awk -F: '$2=="wifi"{print $1;exit}')
|
||||
IP=$(ip -4 addr show dev "$IFACE" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -n1)
|
||||
[ -z "$IP" ] && IP="No IP"
|
||||
|
||||
FREQ=$(iw dev "$IFACE" link 2>/dev/null | grep freq | awk '{print $2}')
|
||||
[ -n "$FREQ" ] && FREQ="${FREQ} MHz" || FREQ="Unknown"
|
||||
|
||||
echo "IP=\"$IP\"" > "$CACHE_FILE"
|
||||
echo "FREQ=\"$FREQ\"" >> "$CACHE_FILE"
|
||||
fi
|
||||
|
||||
CONNECTED_JSON=$(jq -n \
|
||||
--arg id "$ssid" \
|
||||
--arg ssid "$ssid" \
|
||||
--arg icon "$icon" \
|
||||
--arg signal "$signal" \
|
||||
--arg security "$security" \
|
||||
--arg ip "$IP" \
|
||||
--arg freq "$FREQ" \
|
||||
'{id: $id, ssid: $ssid, icon: $icon, signal: $signal, security: $security, ip: $ip, freq: $freq}')
|
||||
else
|
||||
CONNECTED_JSON="null"
|
||||
fi
|
||||
|
||||
# Get available networks INSTANTLY using --rescan no
|
||||
NETWORKS_JSON=$(nmcli -t -f active,ssid,signal,security device wifi list --rescan no | \
|
||||
awk -F: '!seen[$2]++ && $2 != "" && $1 != "yes" {print $2":"$3":"$4}' | \
|
||||
head -n 24 | \
|
||||
while IFS=':' read -r ssid signal security; do
|
||||
icon=$(get_icon "$signal")
|
||||
jq -n \
|
||||
--arg id "$ssid" \
|
||||
--arg ssid "$ssid" \
|
||||
--arg icon "$icon" \
|
||||
--arg signal "$signal" \
|
||||
--arg security "$security" \
|
||||
'{id: $id, ssid: $ssid, icon: $icon, signal: $signal, security: $security}'
|
||||
done | jq -s '.')
|
||||
|
||||
echo $(jq -n \
|
||||
--arg power "on" \
|
||||
--argjson connected "${CONNECTED_JSON:-null}" \
|
||||
--argjson networks "${NETWORKS_JSON:-[]}" \
|
||||
'{power: $power, connected: $connected, networks: $networks}')
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# Outputs { "networking": "enabled"|"disabled", "interfaces": [...] }
|
||||
|
||||
prefix_to_mask() {
|
||||
local p=$1
|
||||
local parts=()
|
||||
for (( i=0; i<4; i++ )); do
|
||||
local bits=$(( p > 8 ? 8 : p < 0 ? 0 : p ))
|
||||
parts+=("$(( bits == 0 ? 0 : 256 - (1 << (8 - bits)) ))")
|
||||
(( p -= 8 ))
|
||||
done
|
||||
echo "${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}"
|
||||
}
|
||||
|
||||
broadcast_addr() {
|
||||
local ip=$1 prefix=$2
|
||||
IFS='.' read -r a b c d <<< "$ip"
|
||||
local mask=$(( 0xFFFFFFFF << (32 - prefix) & 0xFFFFFFFF ))
|
||||
local ipi=$(( (a<<24)|(b<<16)|(c<<8)|d ))
|
||||
local bc=$(( ipi | (~mask & 0xFFFFFFFF) ))
|
||||
echo "$(( (bc>>24)&0xFF )).$(( (bc>>16)&0xFF )).$(( (bc>>8)&0xFF )).$(( bc&0xFF ))"
|
||||
}
|
||||
|
||||
json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
|
||||
|
||||
interfaces=()
|
||||
|
||||
while IFS= read -r device; do
|
||||
[[ -z "$device" ]] && continue
|
||||
|
||||
show=$(nmcli -t device show "$device" 2>/dev/null)
|
||||
|
||||
profile=$(echo "$show" | grep "^GENERAL.CONNECTION:" | cut -d: -f2-)
|
||||
mac=$(echo "$show" | grep "^GENERAL.HWADDR:" | cut -d: -f2-)
|
||||
driver=$(echo "$show" | grep "^GENERAL.DRIVER:" | cut -d: -f2-)
|
||||
ip_cidr=$(echo "$show" | grep "^IP4.ADDRESS\[1\]:" | cut -d: -f2-)
|
||||
gateway=$(echo "$show" | grep "^IP4.GATEWAY:" | cut -d: -f2-)
|
||||
dns1=$(echo "$show" | grep "^IP4.DNS\[1\]:" | cut -d: -f2-)
|
||||
ipv6_cidr=$(echo "$show" | grep "^IP6.ADDRESS\[1\]:" | cut -d: -f2-)
|
||||
ipv6_gw=$(echo "$show" | grep "^IP6.GATEWAY:" | cut -d: -f2-)
|
||||
ipv6_dns=$(echo "$show" | grep "^IP6.DNS\[1\]:" | cut -d: -f2-)
|
||||
speed=$(cat /sys/class/net/"$device"/speed 2>/dev/null)
|
||||
|
||||
[[ -z "$profile" || "$profile" == "--" ]] && profile="$device"
|
||||
[[ -z "$speed" || "$speed" == "-1" ]] && speed=""
|
||||
|
||||
speed_label=""
|
||||
if [[ -n "$speed" ]]; then
|
||||
if (( speed >= 1000 )); then speed_label="$(( speed / 1000 )) Gbps"
|
||||
else speed_label="${speed} Mbps"; fi
|
||||
fi
|
||||
|
||||
ip_clean="" subnet="" broadcast=""
|
||||
if [[ -n "$ip_cidr" ]]; then
|
||||
ip_clean="${ip_cidr%%/*}"
|
||||
prefix="${ip_cidr##*/}"
|
||||
subnet=$(prefix_to_mask "$prefix")
|
||||
broadcast=$(broadcast_addr "$ip_clean" "$prefix")
|
||||
fi
|
||||
|
||||
is_connected=false
|
||||
[[ -n "$ip_clean" ]] && is_connected=true
|
||||
|
||||
if $is_connected; then
|
||||
subtitle="Connected${speed_label:+ • $speed_label}"
|
||||
cmd_str="nmcli device disconnect $device"
|
||||
is_actionable=true
|
||||
else
|
||||
subtitle="Disconnected"
|
||||
cmd_str="nmcli device connect $device"
|
||||
is_actionable=true
|
||||
fi
|
||||
|
||||
obj="{\"id\":\"$(json_str "$device")\",\"name\":\"$(json_str "$profile")\",\"icon\":\"\""
|
||||
obj+=",\"ip\":\"$(json_str "$ip_clean")\",\"subnet\":\"$(json_str "$subnet")\""
|
||||
obj+=",\"broadcast\":\"$(json_str "$broadcast")\",\"gateway\":\"$(json_str "$gateway")\",\"dns\":\"$(json_str "$dns1")\""
|
||||
obj+=",\"ipv6\":\"$(json_str "$ipv6_cidr")\",\"ipv6gw\":\"$(json_str "$ipv6_gw")\",\"ipv6dns\":\"$(json_str "$ipv6_dns")\""
|
||||
obj+=",\"mac\":\"$(json_str "$mac")\",\"driver\":\"$(json_str "$driver")\",\"speed\":\"$(json_str "$speed_label")\""
|
||||
obj+=",\"action\":\"$(json_str "$subtitle")\",\"isConnected\":$is_connected"
|
||||
obj+=",\"isInfoNode\":false,\"isActionable\":$is_actionable,\"cmdStr\":\"$(json_str "$cmd_str")\"}"
|
||||
interfaces+=("$obj")
|
||||
done < <(nmcli -t -f DEVICE,TYPE device status 2>/dev/null | awk -F: '$2=="ethernet"{print $1}')
|
||||
|
||||
net_state=$(nmcli networking 2>/dev/null | tr -d '[:space:]')
|
||||
[[ "$net_state" != "enabled" ]] && net_state="disabled"
|
||||
|
||||
joined=$(IFS=,; echo "${interfaces[*]}")
|
||||
echo "{\"networking\":\"$net_state\",\"interfaces\":[$joined]}"
|
||||
@@ -55,7 +55,7 @@
|
||||
"custom/updates": {
|
||||
"return-type": "json",
|
||||
"text": "{text} ",
|
||||
"interval": 300,
|
||||
"interval": 3600,
|
||||
"exec": "$HOME/.config/waybar/scripts/dnf-updates.sh",
|
||||
"tooltip": "{tooltip} "
|
||||
},
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
"clock#1": {
|
||||
"format": "{:%a %I:%M %m-%d}",
|
||||
"on-click": "bash $HOME/.config/waybar/scripts/calendar.sh",
|
||||
"on-click": "bash $HOME/.config/waybar/scripts/calendar.sh > /dev/null 2>&1",
|
||||
"tooltip": false
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user