This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -0,0 +1,182 @@
pragma Singleton
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
property ListModel availableFonts: ListModel {}
property ListModel monospaceFonts: ListModel {}
property bool fontsLoaded: false
property bool isLoading: false
function init() {
if (fontsLoaded || isLoading)
return;
Logger.i("Font", "Service started");
loadFontsViaFcList();
}
// Load all fonts using fc-list in background - no main thread blocking
function loadFontsViaFcList() {
if (isLoading)
return;
isLoading = true;
allFontsProcess.running = true;
}
function populateModels(allFontsText, monoFontsText) {
// Parse monospace fonts into a lookup set
// fc-list returns comma-separated family names for fonts with multiple families
var monoLookup = {};
var monoLines = monoFontsText.split('\n');
for (var i = 0; i < monoLines.length; i++) {
var line = monoLines[i].trim();
if (line) {
var monoFamilies = line.split(',');
for (var mi = 0; mi < monoFamilies.length; mi++) {
var monoName = monoFamilies[mi].trim();
if (monoName)
monoLookup[monoName] = true;
}
}
}
// Parse all fonts - split comma-separated family names
var allLines = allFontsText.split('\n');
var fontSet = {}; // Deduplicate font families
for (var j = 0; j < allLines.length; j++) {
var line = allLines[j].trim();
if (line) {
var families = line.split(',');
for (var fi = 0; fi < families.length; fi++) {
var fontName = families[fi].trim();
if (fontName && !fontSet[fontName]) {
fontSet[fontName] = true;
}
}
}
}
// Sort font names
var sortedFonts = Object.keys(fontSet).sort(function (a, b) {
return a.localeCompare(b);
});
// Build arrays for batch insert
var allBatch = [];
var monoBatch = [];
for (var k = 0; k < sortedFonts.length; k++) {
var name = sortedFonts[k];
var fontObj = {
"key": name,
"name": name
};
allBatch.push(fontObj);
// Check if monospace
if (monoLookup[name] || name.toLowerCase().includes("mono")) {
monoBatch.push(fontObj);
}
}
// Clear and populate models (single batch operation)
availableFonts.clear();
monospaceFonts.clear();
availableFonts.append({
"key": Qt.application.font.family,
"name": I18n.tr("panels.indicator.system-default")
});
monospaceFonts.append({
"key": "monospace",
"name": I18n.tr("panels.indicator.system-default")
});
for (var m = 0; m < allBatch.length; m++)
availableFonts.append(allBatch[m]);
for (var n = 0; n < monoBatch.length; n++)
monospaceFonts.append(monoBatch[n]);
fontsLoaded = true;
isLoading = false;
Logger.i("Font", "Loaded", availableFonts.count, "fonts,", monospaceFonts.count, "monospace");
}
// Temporary storage for process outputs
property string _allFontsOutput: ""
property string _monoFontsOutput: ""
property bool _allFontsDone: false
property bool _monoFontsDone: false
function checkBothProcessesDone() {
if (_allFontsDone && _monoFontsDone) {
populateModels(_allFontsOutput, _monoFontsOutput);
// Clear temp storage
_allFontsOutput = "";
_monoFontsOutput = "";
_allFontsDone = false;
_monoFontsDone = false;
}
}
// Process to get all font families (runs in background)
Process {
id: allFontsProcess
command: ["fc-list", "--format", "%{family}\\n"]
running: false
stdout: StdioCollector {
onStreamFinished: {
root._allFontsOutput = this.text;
root._allFontsDone = true;
root.checkBothProcessesDone();
}
}
onRunningChanged: {
if (running) {
// Start mono fonts process in parallel
monoFontsProcess.running = true;
}
}
onExited: function (exitCode, exitStatus) {
if (exitCode !== 0) {
Logger.w("Font", "fc-list failed with exit code", exitCode);
root._allFontsOutput = "";
root._allFontsDone = true;
root.checkBothProcessesDone();
}
}
}
// Process to get monospace font families (runs in background, parallel)
Process {
id: monoFontsProcess
command: ["fc-list", ":mono", "--format", "%{family}\\n"]
running: false
stdout: StdioCollector {
onStreamFinished: {
root._monoFontsOutput = this.text;
root._monoFontsDone = true;
root.checkBothProcessesDone();
}
}
onExited: function (exitCode, exitStatus) {
if (exitCode !== 0) {
root._monoFontsOutput = "";
root._monoFontsDone = true;
root.checkBothProcessesDone();
}
}
}
}
@@ -0,0 +1,214 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
// Public properties
property string osPretty: ""
property string osLogo: ""
property bool isNixOS: false
property bool isReady: false
// User info
readonly property string username: (Quickshell.env("USER") || "")
readonly property string envRealName: (Quickshell.env("NOCTALIA_REALNAME") || "")
property string realName: ""
// Machine info
property string hostName: ""
// Internal: pending logo name for fallback after probe fails
property string pendingLogoName: ""
readonly property string displayName: {
// Explicit override
if (envRealName && envRealName.length > 0) {
return envRealName;
}
// Name from getent
if (realName && realName.length > 0) {
return realName;
}
// Fallback: $USER as-is (login names are case-sensitive on some systems)
if (username && username.length > 0) {
return username;
}
// Last resort: placeholder
return "User";
}
function init() {
Logger.i("HostService", "Service started");
}
// Internal helpers
function buildCandidates(name) {
const n = (name || "").trim();
if (!n)
return [];
const sizes = ["512x512", "256x256", "128x128", "64x64", "48x48", "32x32", "24x24", "22x22", "16x16"];
const exts = ["svg", "png"];
const candidates = [];
// pixmaps
for (const ext of exts) {
candidates.push(`/usr/share/pixmaps/${n}.${ext}`);
}
// hicolor scalable and raster sizes
candidates.push(`/usr/share/icons/hicolor/scalable/apps/${n}.svg`);
for (const s of sizes) {
for (const ext of exts) {
candidates.push(`/usr/share/icons/hicolor/${s}/apps/${n}.${ext}`);
}
}
// NixOS hicolor paths
candidates.push(`/run/current-system/sw/share/icons/hicolor/scalable/apps/${n}.svg`);
for (const s of sizes) {
for (const ext of exts) {
candidates.push(`/run/current-system/sw/share/icons/hicolor/${s}/apps/${n}.${ext}`);
}
}
// Generic icon themes under /usr/share/icons (common cases)
for (const ext of exts) {
candidates.push(`/usr/share/icons/${n}.${ext}`);
candidates.push(`/usr/share/icons/${n}/${n}.${ext}`);
candidates.push(`/usr/share/icons/${n}/apps/${n}.${ext}`);
}
return candidates;
}
function resolveLogo(name) {
const n = (name || "").trim();
if (!n)
return;
// First try Quickshell's icon lookup for direct file paths
try {
const path = Quickshell.iconPath(n, "");
if (path && path !== "" && !path.startsWith("image://")) {
// Got a direct file path - use it
const finalPath = path.startsWith("file://") ? path : "file://" + path;
root.osLogo = finalPath;
Logger.d("HostService", "Found logo via icon theme:", root.osLogo);
return;
}
} catch (e) {
// Ignore and continue to manual probe
}
// Try manual probing for hicolor/pixmaps paths
// Store name for fallback to image:// URI if probe fails
root.pendingLogoName = n;
const all = buildCandidates(n);
if (all.length === 0) {
// No candidates, try image:// URI directly
root.osLogo = `image://icon/${n}`;
Logger.d("HostService", "Using theme icon URI:", root.osLogo);
return;
}
const script = all.map(p => `if [ -f "${p}" ]; then echo "${p}"; exit 0; fi`).join("; ") + "; exit 1";
probe.command = ["sh", "-c", script];
probe.running = true;
}
// Read /etc/os-release and trigger resolution
FileView {
id: osInfo
path: "/etc/os-release"
onLoaded: {
try {
const lines = text().split("\n");
const val = k => {
const l = lines.find(x => x.startsWith(k + "="));
return l ? l.split("=")[1].replace(/"/g, "") : "";
};
root.osPretty = val("PRETTY_NAME") || val("NAME");
Logger.i("HostService", "Detected", root.osPretty);
const osId = (val("ID") || "").toLowerCase();
root.isNixOS = osId === "nixos" || (root.osPretty || "").toLowerCase().includes("nixos");
const logoName = val("LOGO");
Logger.i("HostService", "Looking for logo icon:", logoName);
if (logoName) {
resolveLogo(logoName);
}
root.isReady = true;
} catch (e) {
Logger.w("HostService", "failed to read os-release", e);
}
}
}
Process {
id: probe
onExited: code => {
const p = String(stdout.text || "").trim();
if (code === 0 && p) {
root.osLogo = `file://${p}`;
root.pendingLogoName = "";
Logger.d("HostService", "Found", root.osLogo);
} else if (root.pendingLogoName) {
// Manual probe failed, fallback to image:// URI (theme icon)
root.osLogo = `image://icon/${root.pendingLogoName}`;
root.pendingLogoName = "";
Logger.d("HostService", "Using theme icon URI:", root.osLogo);
} else {
root.osLogo = "";
Logger.w("HostService", "No distro logo found");
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Resolve GECOS real name once on startup
Process {
id: realNameProcess
command: ["sh", "-c", "getent passwd \"$USER\" | cut -d: -f5 | cut -d, -f1"]
running: true
stdout: StdioCollector {
onStreamFinished: {
const name = String(text || "").trim();
if (name.length > 0) {
root.realName = name;
Logger.i("HostService", "resolved real name", name);
}
}
}
stderr: StdioCollector {}
}
// Resolve hostname from distro-specific locations.
// Prefer /etc/hostname, fallback to Gentoo's /etc/conf.d/hostname.
Process {
id: hostNameProcess
command: ["sh", "-c",
"if [ -r /etc/hostname ]; then sed -n '1p' /etc/hostname; exit 0; fi; if [ -r /etc/conf.d/hostname ]; then v=$(sed -n -E 's/^[[:space:]]*[Hh][Oo][Ss][Tt][Nn][Aa][Mm][Ee][[:space:]]*=[[:space:]]*//p' /etc/conf.d/hostname | sed -n '1p'); v=$(printf '%s' \"$v\" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/^\"//; s/\"$//; s/^\x27//; s/\x27$//'); printf '%s\n' \"$v\"; exit 0; fi; exit 0"]
running: true
stdout: StdioCollector {
onStreamFinished: {
const name = String(text || "").trim();
if (name.length > 0) {
root.hostName = name;
Logger.i("HostService", "resolved hostname", name);
}
}
}
stderr: StdioCollector {}
}
}
@@ -0,0 +1,86 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
readonly property string rulesFilePath: Settings.configDir + "notification-rules.json"
property var rules: []
property FileView rulesFileView: FileView {
id: rulesFileView
path: root.rulesFilePath
watchChanges: true
printErrors: false
adapter: JsonAdapter {
id: rulesAdapter
property var rules: []
}
onLoaded: {
try {
const parsed = JSON.parse(rulesFileView.text());
const raw = Array.isArray(parsed.rules) ? parsed.rules : [];
root.rules = raw.filter(r => (r.pattern || "").trim() !== "");
} catch (e) {
root.rules = [];
}
}
onLoadFailed: function (error) {
root.rules = [];
}
}
function init() {
}
function save() {
Quickshell.execDetached(["mkdir", "-p", Settings.configDir]);
root.rules = root.rules.filter(r => (r.pattern || "").trim() !== "");
rulesAdapter.rules = root.rules;
rulesFileView.writeAdapter();
}
function evaluate(appName, summary, body) {
const haystack = [appName || "", summary || "", body || ""].join(" ");
for (let i = 0; i < root.rules.length; i++) {
const r = root.rules[i];
const pattern = (r.pattern || "").trim();
if (pattern === "")
continue;
let matched = false;
if (pattern.length >= 3 && pattern.startsWith("/") && pattern.endsWith("/")) {
try {
matched = new RegExp(pattern.slice(1, -1)).test(haystack);
} catch (e) {
Logger.w("NotificationRulesService", "Invalid regex:", pattern, e);
}
} else if (pattern.includes("*")) {
try {
const reStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
matched = new RegExp(reStr, "i").test(haystack);
} catch (e) {
matched = haystack.toLowerCase().includes(pattern.toLowerCase());
}
} else {
matched = haystack.toLowerCase().includes(pattern.toLowerCase());
}
if (matched) {
const a = (r.action || "block").toLowerCase();
if (a === "mute" || a === "hide")
return a;
if (a === "silence")
return "hide";
return "block";
}
}
return null;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Theming
// Service to check if various programs are available on the system
Singleton {
id: root
// Program availability properties
property bool nmcliAvailable: false
property bool bluetoothctlAvailable: false
property bool wlsunsetAvailable: false
property bool gnomeCalendarAvailable: false
property bool pythonAvailable: false
property bool wtypeAvailable: false
// Programs to check - maps property names to commands
readonly property var programsToCheck: ({
"bluetoothctlAvailable": ["sh", "-c", "command -v bluetoothctl"],
"nmcliAvailable": ["sh", "-c", "command -v nmcli"],
"wlsunsetAvailable": ["sh", "-c", "command -v wlsunset"],
"gnomeCalendarAvailable": ["sh", "-c", "command -v gnome-calendar"],
"wtypeAvailable": ["sh", "-c", "command -v wtype"],
"pythonAvailable": ["sh", "-c", "command -v python3"]
})
// Discord client auto-detection
property var availableDiscordClients: []
// Code client auto-detection
property var availableCodeClients: []
// Emacs client auto-detection
property var availableEmacsClients: []
// Signal emitted when all checks are complete
signal checksCompleted
// disable Night Light in settings if wlsunset is not available
onChecksCompleted: {
if (!wlsunsetAvailable && Settings.data.nightLight.enabled) {
Settings.data.nightLight.enabled = false;
}
}
onWlsunsetAvailableChanged: {
if (!wlsunsetAvailable && Settings.data.nightLight.enabled) {
Settings.data.nightLight.enabled = false;
}
}
// Function to detect Discord client by checking config directories
function detectDiscordClient() {
// Build shell script to check each client
var scriptParts = ["available_clients=\"\";"];
for (var i = 0; i < TemplateRegistry.discordClients.length; i++) {
var client = TemplateRegistry.discordClients[i];
var clientName = client.name;
var configPath = client.configPath;
// Use the actual config path from the client, removing ~ prefix
var checkPath = configPath.startsWith("~") ? configPath.substring(2) : configPath.substring(1);
scriptParts.push("if [ -d \"$HOME/" + checkPath + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
}
scriptParts.push("echo \"$available_clients\"");
// Use a Process to check directory existence for all clients
discordDetector.command = ["sh", "-c", scriptParts.join(" ")];
discordDetector.running = true;
}
// Process to detect Discord client directories
Process {
id: discordDetector
running: false
onExited: function (exitCode) {
availableDiscordClients = [];
if (exitCode === 0) {
var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
return client.length > 0;
});
if (detectedClients.length > 0) {
// Build list of available clients
for (var i = 0; i < detectedClients.length; i++) {
var clientName = detectedClients[i];
for (var j = 0; j < TemplateRegistry.discordClients.length; j++) {
var client = TemplateRegistry.discordClients[j];
if (client.name === clientName) {
availableDiscordClients.push(client);
break;
}
}
}
Logger.d("ProgramChecker", "Detected Discord clients:", detectedClients.join(", "));
}
}
if (availableDiscordClients.length === 0) {
Logger.d("ProgramChecker", "No Discord clients detected");
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Function to detect Code client by checking config directories
function detectCodeClient() {
// Build shell script to check each client
var scriptParts = ["available_clients=\"\";"];
for (var i = 0; i < TemplateRegistry.codeClients.length; i++) {
var client = TemplateRegistry.codeClients[i];
var clientName = client.name;
var configPath = client.configPath;
// Check if the config directory exists
scriptParts.push("if [ -d \"$HOME" + configPath.substring(1) + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
}
scriptParts.push("echo \"$available_clients\"");
// Use a Process to check directory existence for all clients
codeDetector.command = ["sh", "-c", scriptParts.join(" ")];
codeDetector.running = true;
}
// Process to detect Code client directories
Process {
id: codeDetector
running: false
onExited: function (exitCode) {
availableCodeClients = [];
if (exitCode === 0) {
var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
return client.length > 0;
});
if (detectedClients.length > 0) {
// Build list of available clients
for (var i = 0; i < detectedClients.length; i++) {
var clientName = detectedClients[i];
for (var j = 0; j < TemplateRegistry.codeClients.length; j++) {
var client = TemplateRegistry.codeClients[j];
if (client.name === clientName) {
availableCodeClients.push(client);
break;
}
}
}
Logger.d("ProgramChecker", "Detected Code clients:", detectedClients.join(", "));
}
}
if (availableCodeClients.length === 0) {
Logger.d("ProgramChecker", "No Code clients detected");
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Function to detect Emacs client by checking config directories
function detectEmacsClient() {
// Build shell script to check each client
var scriptParts = ["available_clients=\"\";"];
for (var i = 0; i < TemplateRegistry.emacsClients.length; i++) {
var client = TemplateRegistry.emacsClients[i];
var clientName = client.name;
var configPath = client.path;
// Check if the config directory exists
scriptParts.push("if [ -d \"$HOME" + configPath.substring(1) + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
}
scriptParts.push("echo \"$available_clients\"");
// Use a Process to check directory existence for all clients
emacsDetector.command = ["sh", "-c", scriptParts.join(" ")];
emacsDetector.running = true;
}
// Process to detect Emacs client directories
Process {
id: emacsDetector
running: false
onExited: function (exitCode) {
availableEmacsClients = [];
if (exitCode === 0) {
var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
return client.length > 0;
});
if (detectedClients.length > 0) {
// Build list of available clients
for (var i = 0; i < detectedClients.length; i++) {
var clientName = detectedClients[i];
for (var j = 0; j < TemplateRegistry.emacsClients.length; j++) {
var client = TemplateRegistry.emacsClients[j];
if (client.name === clientName) {
availableEmacsClients.push(client);
break;
}
}
}
Logger.d("ProgramChecker", "Detected Emacs clients:", detectedClients.join(", "));
}
}
if (availableEmacsClients.length === 0) {
Logger.d("ProgramChecker", "No Emacs clients detected");
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Internal tracking
property int completedChecks: 0
property int totalChecks: Object.keys(programsToCheck).length
// Single reusable Process object
Process {
id: checker
running: false
property string currentProperty: ""
onExited: function (exitCode) {
// Set the availability property
root[currentProperty] = (exitCode === 0);
// Stop the process to free resources
running = false;
// Track completion
root.completedChecks++;
// Check next program or emit completion signal
if (root.completedChecks >= root.totalChecks) {
// Run Discord, Code and Emacs client detection after all checks are complete
root.detectDiscordClient();
root.detectCodeClient();
root.detectEmacsClient();
root.checksCompleted();
} else {
root.checkNextProgram();
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Queue of programs to check
property var checkQueue: []
property int currentCheckIndex: 0
// Function to check the next program in the queue
function checkNextProgram() {
if (currentCheckIndex >= checkQueue.length)
return;
var propertyName = checkQueue[currentCheckIndex];
var command = programsToCheck[propertyName];
checker.currentProperty = propertyName;
checker.command = command;
checker.running = true;
currentCheckIndex++;
}
// Function to run all program checks
function checkAllPrograms() {
// Reset state
completedChecks = 0;
currentCheckIndex = 0;
checkQueue = Object.keys(programsToCheck);
// Start first check
if (checkQueue.length > 0) {
checkNextProgram();
}
}
// Function to check a specific program
function checkProgram(programProperty) {
if (!programsToCheck.hasOwnProperty(programProperty)) {
Logger.w("ProgramChecker", "Unknown program property:", programProperty);
return;
}
checker.currentProperty = programProperty;
checker.command = programsToCheck[programProperty];
checker.running = true;
}
// Manual function to test Discord detection (for debugging)
function testDiscordDetection() {
Logger.d("ProgramChecker", "Testing Discord detection...");
Logger.d("ProgramChecker", "HOME:", Quickshell.env("HOME"));
// Test each client directory
for (var i = 0; i < TemplateRegistry.discordClients.length; i++) {
var client = TemplateRegistry.discordClients[i];
var configDir = client.configPath.replace("~", Quickshell.env("HOME"));
Logger.d("ProgramChecker", "Checking:", configDir);
}
detectDiscordClient();
}
// Initialize checks when service is created
Component.onCompleted: {
checkAllPrograms();
}
}
@@ -0,0 +1,205 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
Singleton {
id: root
// Map to track active sound players: resolvedPath -> MediaPlayer instance
property var activePlayers: ({})
// Check if QtMultimedia is available
property bool multimediaAvailable: false
// Container for dynamically created players
Item {
id: playersContainer
}
Component.onCompleted: {
// Test if QtMultimedia is available by trying to create a simple component
try {
var testComponent = Qt.createQmlObject(`
import QtQuick
import QtMultimedia
Item {}
`, root, "MultimediaTest");
if (testComponent) {
multimediaAvailable = true;
testComponent.destroy();
Logger.i("SoundService", "QtMultimedia found - sound playback enabled");
}
} catch (e) {
multimediaAvailable = false;
Logger.w("SoundService", "QtMultimedia not available - no audio will be played from noctalia-shell");
}
}
function resolvePath(soundPath) {
if (!soundPath || soundPath === "") {
return "";
}
let resolvedPath = soundPath;
// If it's just a filename (no path separators), assume it's in Assets/Sounds/
if (!soundPath.includes("/") && !soundPath.startsWith("file://")) {
resolvedPath = Quickshell.shellDir + "/Assets/Sounds/" + soundPath;
} else if (!soundPath.startsWith("/") && !soundPath.startsWith("file://")) {
// Relative path - assume it's relative to shellDir
resolvedPath = Quickshell.shellDir + "/" + soundPath;
} else if (soundPath.startsWith("file://")) {
resolvedPath = soundPath.substring(7); // Remove "file://" prefix
}
// Absolute paths are used as-is
return resolvedPath;
}
function playSound(soundPath, options) {
if (!soundPath || soundPath === "") {
Logger.w("SoundService", "No sound path provided");
return;
}
if (!multimediaAvailable) {
Logger.d("SoundService", "QtMultimedia not available, cannot play sound:", soundPath);
return;
}
const opts = options || {};
const volume = opts.volume !== undefined ? opts.volume : 1.0;
const fallback = opts.fallback !== undefined ? opts.fallback : false;
const repeat = opts.repeat !== undefined ? opts.repeat : false;
// Resolve path
const resolvedPath = resolvePath(soundPath);
// Stop any existing player for this path if it's looping
if (repeat && activePlayers[resolvedPath]) {
stopSound(soundPath);
}
// Create MediaPlayer instance dynamically with QtMultimedia import
const loopsValue = repeat ? "MediaPlayer.Infinite" : "1";
const escapedPath = resolvedPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const playerQml = `
import QtQuick
import QtMultimedia
import Quickshell
import qs.Commons
import qs.Services.System
MediaPlayer {
id: mediaPlayer
property string resolvedPath: "${escapedPath}"
property bool shouldFallback: ${fallback && !repeat}
property real soundVolume: ${Math.max(0, Math.min(1, volume))}
source: "file://${escapedPath}"
loops: ${loopsValue}
audioOutput: AudioOutput {
volume: soundVolume
}
onErrorOccurred: {
Logger.w("SoundService", "Error playing sound:", source, error, errorString);
if (shouldFallback) {
const fallbackPath = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
if (fallbackPath !== resolvedPath) {
SoundService.playSound(fallbackPath, {
volume: soundVolume,
fallback: false,
repeat: false
});
}
}
if (SoundService.activePlayers[resolvedPath]) {
delete SoundService.activePlayers[resolvedPath];
}
destroy();
}
onPlaybackStateChanged: function (state) {
if (state === MediaPlayer.StoppedState && loops === 1) {
if (SoundService.activePlayers[resolvedPath]) {
delete SoundService.activePlayers[resolvedPath];
}
destroy();
}
}
Component.onCompleted: {
play();
}
}
`;
try {
const player = Qt.createQmlObject(playerQml, playersContainer, "MediaPlayer_" + resolvedPath.replace(/[^a-zA-Z0-9]/g, "_"));
if (!player) {
Logger.w("SoundService", "Failed to create MediaPlayer for:", resolvedPath);
// Try fallback if requested
if (fallback && !repeat) {
const defaultSound = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
if (defaultSound !== resolvedPath) {
playSound(defaultSound, {
volume: volume,
fallback: false,
repeat: false
});
}
}
return;
}
// Store player in activePlayers map
activePlayers[resolvedPath] = player;
Logger.d("SoundService", "Playing sound:", resolvedPath, `(volume: ${Math.round(volume * 100)}%)`, repeat ? "(repeat)" : "");
} catch (e) {
Logger.w("SoundService", "Failed to create MediaPlayer:", e);
// Try fallback if requested
if (fallback && !repeat) {
const defaultSound = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
if (defaultSound !== resolvedPath) {
playSound(defaultSound, {
volume: volume,
fallback: false,
repeat: false
});
}
}
}
}
function stopSound(soundPath) {
if (!multimediaAvailable) {
// If multimedia isn't available, there are no active players to stop
return;
}
if (soundPath) {
// Resolve path the same way as playSound
const resolvedPath = resolvePath(soundPath);
// Stop and remove the player for this specific sound
if (activePlayers[resolvedPath]) {
const player = activePlayers[resolvedPath];
player.stop();
delete activePlayers[resolvedPath];
player.destroy();
Logger.d("SoundService", "Stopped sound:", resolvedPath);
}
} else {
// Stop all active players (typically used for repeating sounds)
const paths = Object.keys(activePlayers);
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
const player = activePlayers[path];
player.stop();
player.destroy();
}
activePlayers = {};
Logger.d("SoundService", "Stopped all sounds");
}
}
}
File diff suppressed because it is too large Load Diff