261 lines
9.7 KiB
QML
261 lines
9.7 KiB
QML
pragma Singleton
|
|
|
|
// GNOME and GTK applications already honor these desktop sound preferences --
|
|
// whether event feedback plays, whether typing clicks, and which sound theme
|
|
// those samples come from. Panama controls the same durable keys so moving
|
|
// between sessions does not create two competing notions of any of it, and
|
|
// plays its own notification chime out of the same theme (see Notifs.qml).
|
|
//
|
|
// There is deliberately no alert *volume* here: the desktop sound theme has no
|
|
// volume channel of its own, and a slider that changed nothing would be worse
|
|
// than no slider at all.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property bool eventSounds: true
|
|
property bool inputFeedback: false
|
|
|
|
// The XDG sound theme, by directory name. "freedesktop" is the one every
|
|
// distribution ships and the fallback everything else is layered over.
|
|
property string soundTheme: "freedesktop"
|
|
|
|
property string lastError: ""
|
|
readonly property bool busy: eventRead.running || inputRead.running
|
|
|| themeRead.running || eventWrite.running || inputWrite.running
|
|
|| themeWrite.running
|
|
|
|
// [{ directory, name }] -- the directory is what gsettings stores, the name
|
|
// is what the theme calls itself in its index.theme.
|
|
property var themes: [{ directory: "freedesktop", name: "Default" }]
|
|
|
|
// ── The alert sound ─────────────────────────────────────────────────────
|
|
//
|
|
// A theme is a directory of samples with a fallback chain, not a single
|
|
// file, and a theme that overrides only a handful of sounds is normal. So
|
|
// the bell is resolved as a list of candidates in preference order --
|
|
// user-installed theme, system theme, freedesktop -- and the first one that
|
|
// exists is played. Resolving it in the shell rather than here keeps this
|
|
// free of file probing on a hot path.
|
|
readonly property string homeDir: Quickshell.env("HOME") || ""
|
|
|
|
readonly property var bellCandidates: [
|
|
root.homeDir !== ""
|
|
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "",
|
|
`/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`,
|
|
"/usr/share/sounds/freedesktop/stereo/bell.oga"
|
|
].filter((path, index, all) => path !== "" && all.indexOf(path) === index)
|
|
|
|
// The argv that plays the current theme's bell once, or nothing at all if
|
|
// no candidate exists. Shared with Notifs.qml, which plays the same bell
|
|
// for Panama's own notification popups.
|
|
readonly property var bellCommand: ["sh", "-c",
|
|
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
|
|
"qs-sound-feedback"].concat(root.bellCandidates)
|
|
|
|
function previewAlert(): void {
|
|
if (preview.running)
|
|
return;
|
|
preview.command = root.bellCommand;
|
|
preview.running = true;
|
|
}
|
|
|
|
function setSoundTheme(name: string): void {
|
|
const theme = String(name ?? "").trim();
|
|
if (theme === "")
|
|
return;
|
|
root.soundTheme = theme;
|
|
root._writeSoundTheme();
|
|
}
|
|
|
|
function parsedBoolean(text: string, fallback: bool): bool {
|
|
const value = text.trim();
|
|
if (value === "true")
|
|
return true;
|
|
if (value === "false")
|
|
return false;
|
|
return fallback;
|
|
}
|
|
|
|
function refresh(): void {
|
|
if (!eventRead.running)
|
|
eventRead.running = true;
|
|
if (!inputRead.running)
|
|
inputRead.running = true;
|
|
if (!themeRead.running)
|
|
themeRead.running = true;
|
|
if (!themeScan.running)
|
|
themeScan.running = true;
|
|
}
|
|
|
|
function setEventSounds(enabled: bool): void {
|
|
root.eventSounds = enabled;
|
|
root._writeEventSounds();
|
|
}
|
|
|
|
function setInputFeedback(enabled: bool): void {
|
|
root.inputFeedback = enabled;
|
|
root._writeInputFeedback();
|
|
}
|
|
|
|
// Assigning `running = true` to a Process that is already running is a
|
|
// no-op, not a queue -- so a toggle that lands mid-write would otherwise
|
|
// be dropped silently. eventWrite.onExited re-checks root.eventSounds
|
|
// against what was actually written and calls this again if they still
|
|
// disagree.
|
|
function _writeEventSounds(): void {
|
|
if (eventWrite.running)
|
|
return;
|
|
eventWrite.writtenValue = root.eventSounds;
|
|
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(root.eventSounds)];
|
|
eventWrite.running = true;
|
|
}
|
|
|
|
function _writeInputFeedback(): void {
|
|
if (inputWrite.running)
|
|
return;
|
|
inputWrite.writtenValue = root.inputFeedback;
|
|
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(root.inputFeedback)];
|
|
inputWrite.running = true;
|
|
}
|
|
|
|
function _writeSoundTheme(): void {
|
|
if (themeWrite.running)
|
|
return;
|
|
themeWrite.writtenValue = root.soundTheme;
|
|
themeWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "theme-name", root.soundTheme];
|
|
themeWrite.running = true;
|
|
}
|
|
|
|
// gsettings prints strings quoted: 'freedesktop'.
|
|
function parsedString(text: string, fallback: string): string {
|
|
const value = text.trim().replace(/^'(.*)'$/, "$1");
|
|
return value === "" ? fallback : value;
|
|
}
|
|
|
|
Process {
|
|
id: eventRead
|
|
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.eventSounds = root.parsedBoolean(this.text, root.eventSounds)
|
|
}
|
|
onExited: (code, status) => {
|
|
if (code !== 0)
|
|
root.lastError = "Event sound preferences could not be read.";
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: inputRead
|
|
command: ["gsettings", "get", "org.gnome.desktop.sound", "input-feedback-sounds"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.inputFeedback = root.parsedBoolean(this.text, root.inputFeedback)
|
|
}
|
|
onExited: (code, status) => {
|
|
if (code !== 0)
|
|
root.lastError = "Input feedback preferences could not be read.";
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: eventWrite
|
|
property bool writtenValue: true
|
|
onExited: (code, status) => {
|
|
if (code !== 0) {
|
|
root.lastError = "Event sound preferences could not be changed.";
|
|
root.refresh();
|
|
} else {
|
|
root.lastError = "";
|
|
}
|
|
if (root.eventSounds !== eventWrite.writtenValue)
|
|
root._writeEventSounds();
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: inputWrite
|
|
property bool writtenValue: false
|
|
onExited: (code, status) => {
|
|
if (code !== 0) {
|
|
root.lastError = "Input feedback preferences could not be changed.";
|
|
root.refresh();
|
|
} else {
|
|
root.lastError = "";
|
|
}
|
|
if (root.inputFeedback !== inputWrite.writtenValue)
|
|
root._writeInputFeedback();
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: themeRead
|
|
command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.soundTheme = root.parsedString(this.text, root.soundTheme)
|
|
}
|
|
onExited: (code, status) => {
|
|
if (code !== 0)
|
|
root.lastError = "The alert sound theme could not be read.";
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: themeWrite
|
|
property string writtenValue: "freedesktop"
|
|
onExited: (code, status) => {
|
|
if (code !== 0) {
|
|
root.lastError = "The alert sound theme could not be changed.";
|
|
root.refresh();
|
|
} else {
|
|
root.lastError = "";
|
|
}
|
|
if (root.soundTheme !== themeWrite.writtenValue)
|
|
root._writeSoundTheme();
|
|
}
|
|
}
|
|
|
|
// Installed themes, from the index.theme every XDG sound theme must carry.
|
|
// Emitted as directory<TAB>name so the pair cannot drift apart; a theme
|
|
// whose index.theme has no Name= falls back to its directory rather than
|
|
// being dropped, because it is still selectable and still works.
|
|
Process {
|
|
id: themeScan
|
|
command: ["sh", "-c", `
|
|
for theme in "$HOME"/.local/share/sounds/*/index.theme /usr/share/sounds/*/index.theme; do
|
|
[ -f "$theme" ] || continue
|
|
directory=$(basename "$(dirname "$theme")")
|
|
name=$(sed -n 's/^Name=//p' "$theme" | head -n1)
|
|
[ -n "$name" ] || name=$directory
|
|
printf '%s\\t%s\\n' "$directory" "$name"
|
|
done
|
|
`]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const found = [];
|
|
const seen = {};
|
|
for (const line of String(this.text).split("\n")) {
|
|
const parts = line.split("\t");
|
|
const directory = String(parts[0] ?? "").trim();
|
|
if (directory === "" || seen[directory])
|
|
continue;
|
|
seen[directory] = true;
|
|
found.push({ directory, name: String(parts[1] ?? "").trim() || directory });
|
|
}
|
|
// Never hand the UI an empty picker: freedesktop is the theme
|
|
// every fallback chain ends at, present or not.
|
|
if (!seen["freedesktop"])
|
|
found.push({ directory: "freedesktop", name: "Default" });
|
|
root.themes = found;
|
|
}
|
|
}
|
|
}
|
|
|
|
Process { id: preview }
|
|
|
|
Component.onCompleted: root.refresh()
|
|
}
|