Calendar: add timer

LocationTab: rework calendar settings
SoundService: add simple service to play & loop sounds
This commit is contained in:
Ly-sec
2025-11-26 19:18:30 +01:00
parent f611e3a2c0
commit 309648d6d6
19 changed files with 1127 additions and 164 deletions

Binary file not shown.

View File

@@ -2405,5 +2405,21 @@
"searching": "Suche nach nahegelegenen Netzwerken...",
"title": "WLAN"
}
},
"location": {
"calendar": {
"banner": {
"label": "Kopfzeile"
},
"calendar": {
"label": "Kalender"
},
"cards": {
"section": {
"description": "Karten im Kalender-Panel organisieren und aktivieren/deaktivieren.",
"label": "Kalender-Karten"
}
}
}
}
}
}

View File

@@ -426,6 +426,19 @@
"panel": {
"week": "Week"
},
"timer": {
"duration": "Duration",
"hours": "h",
"minutes": "m",
"seconds": "s",
"pause": "Pause",
"start": "Start",
"reset": "Reset",
"stopwatch": "Stopwatch",
"countdown": "Countdown",
"timer": "Timer",
"title": "Timer"
},
"weather": {
"loading": "Loading weather…"
}
@@ -1534,6 +1547,20 @@
"description": "Show the daily weather forecast directly in your calendar view.",
"label": "Display weather in calendar"
}
},
"calendar": {
"banner": {
"label": "Header"
},
"calendar": {
"label": "Calendar"
},
"cards": {
"section": {
"description": "Organize and enable/disable cards in the calendar panel.",
"label": "Calendar cards"
}
}
}
},
"lock-screen": {

View File

@@ -2405,5 +2405,21 @@
"searching": "Buscando redes cercanas...",
"title": "Wi-Fi"
}
},
"location": {
"calendar": {
"cards": {
"section": {
"description": "Organizar y activar/desactivar tarjetas en el panel del calendario.",
"label": "Tarjetas del calendario"
}
},
"banner": {
"label": "Encabezado"
},
"calendar": {
"label": "Calendario"
}
}
}
}
}

View File

@@ -2405,5 +2405,21 @@
"searching": "Recherche de réseaux à proximité en cours...",
"title": "Wi-Fi"
}
},
"location": {
"calendar": {
"cards": {
"section": {
"description": "Organiser et activer/désactiver les cartes dans le panneau du calendrier.",
"label": "Cartes du calendrier"
}
},
"banner": {
"label": "En-tête"
},
"calendar": {
"label": "Calendrier"
}
}
}
}
}

View File

@@ -2405,5 +2405,21 @@
"searching": "Zoeken naar netwerken in de buurt...",
"title": "Wi-Fi"
}
},
"location": {
"calendar": {
"cards": {
"section": {
"description": "Organiseer en schakel kaarten in het kalenderpaneel in/uit.",
"label": "Kalenderkaarten"
}
},
"banner": {
"label": "Koptekst"
},
"calendar": {
"label": "Kalender"
}
}
}
}
}

View File

@@ -2405,5 +2405,21 @@
"searching": "Procurando redes próximas...",
"title": "Wi-Fi"
}
},
"location": {
"calendar": {
"cards": {
"section": {
"description": "Organize e ative/desative cartões no painel do calendário.",
"label": "Cartões do calendário"
}
},
"banner": {
"label": "Cabeçalho"
},
"calendar": {
"label": "Calendário"
}
}
}
}
}

View File

@@ -2405,5 +2405,21 @@
"searching": "Поиск ближайших сетей...",
"title": "Wi-Fi"
}
},
"location": {
"calendar": {
"cards": {
"section": {
"description": "Организуйте и включайте/отключайте карточки на панели календаря.",
"label": "Карточки календаря"
}
},
"banner": {
"label": "Заголовок"
},
"calendar": {
"label": "Календарь"
}
}
}
}
}

View File

@@ -2405,5 +2405,21 @@
"searching": "Yakındaki ağlar aranıyor...",
"title": "Wi-Fi"
}
},
"location": {
"calendar": {
"cards": {
"section": {
"description": "Takvim panelindeki kartları düzenleyin ve etkinleştirin/devre dışı bırakın.",
"label": "Takvim kartları"
}
},
"banner": {
"label": "Başlık"
},
"calendar": {
"label": "Takvim"
}
}
}
}
}

View File

@@ -2405,5 +2405,21 @@
"searching": "Пошук близьких мереж...",
"title": "Wi-Fi"
}
},
"location": {
"calendar": {
"cards": {
"section": {
"description": "Організуйте та увімкніть/вимкніть картки на панелі календаря.",
"label": "Картки календаря"
}
},
"banner": {
"label": "Заголовок"
},
"calendar": {
"label": "Календар"
}
}
}
}
}

View File

@@ -2405,5 +2405,21 @@
"searching": "正在搜索附近网络...",
"title": "Wi-Fi"
}
},
"location": {
"calendar": {
"cards": {
"section": {
"description": "组织并启用/禁用日历面板中的卡片。",
"label": "日历卡片"
}
},
"banner": {
"label": "标题"
},
"calendar": {
"label": "日历"
}
}
}
}
}

View File

@@ -100,6 +100,26 @@
"analogClockInCalendar": false,
"firstDayOfWeek": -1
},
"calendar": {
"cards": [
{
"id": "banner-card",
"enabled": true
},
{
"id": "calendar-card",
"enabled": true
},
{
"id": "timer-card",
"enabled": true
},
{
"id": "weather-card",
"enabled": true
}
]
},
"screenRecorder": {
"directory": "",
"frameRate": 60,

View File

@@ -252,6 +252,28 @@ Singleton {
property int firstDayOfWeek: -1 // -1 = auto (use locale), 0 = Sunday, 1 = Monday, 6 = Saturday
}
// calendar
property JsonObject calendar: JsonObject {
property list<var> cards: [
{
"id": "banner-card",
"enabled": true
},
{
"id": "calendar-card",
"enabled": true
},
{
"id": "timer-card",
"enabled": true
},
{
"id": "weather-card",
"enabled": true
}
]
}
// screen recorder
property JsonObject screenRecorder: JsonObject {
property string directory: ""

View File

@@ -3,6 +3,7 @@ import QtQuick
import Quickshell
import qs.Commons
import qs.Services.System
Singleton {
id: root
@@ -15,6 +16,16 @@ Singleton {
return Math.floor(root.now / 1000);
}
// Timer state (for countdown/stopwatch)
property bool timerRunning: false
property bool timerStopwatchMode: false
property int timerRemainingSeconds: 0
property int timerTotalSeconds: 0
property int timerElapsedSeconds: 0
property bool timerSoundPlaying: false
property int timerStartTimestamp: 0 // Unix timestamp when timer was started
property int timerPausedAt: 0 // Value when paused (for resuming)
Timer {
id: updateTimer
interval: 1000
@@ -25,6 +36,20 @@ Singleton {
var newTime = new Date();
root.now = newTime;
// Update timer if running
if (root.timerRunning && root.timerStartTimestamp > 0) {
const elapsedSinceStart = root.timestamp - root.timerStartTimestamp;
if (root.timerStopwatchMode) {
root.timerElapsedSeconds = root.timerPausedAt + elapsedSinceStart;
} else {
root.timerRemainingSeconds = root.timerTotalSeconds - elapsedSinceStart;
if (root.timerRemainingSeconds <= 0) {
root.timerOnFinished();
}
}
}
// Adjust next interval to sync with the start of the next second
var msIntoSecond = newTime.getMilliseconds();
if (msIntoSecond > 100) {
@@ -119,4 +144,63 @@ Singleton {
"diff": Math.floor(diff / 86400000)
});
}
// Timer functions
function timerStart() {
if (root.timerStopwatchMode) {
root.timerRunning = true;
root.timerStartTimestamp = root.timestamp;
root.timerPausedAt = root.timerElapsedSeconds;
} else {
if (root.timerRemainingSeconds <= 0) {
return;
}
root.timerRunning = true;
root.timerTotalSeconds = root.timerRemainingSeconds;
root.timerStartTimestamp = root.timestamp;
root.timerPausedAt = 0;
}
}
function timerPause() {
if (root.timerRunning) {
// Save current state
if (root.timerStopwatchMode) {
root.timerPausedAt = root.timerElapsedSeconds;
} else {
root.timerPausedAt = root.timerRemainingSeconds;
}
}
root.timerRunning = false;
root.timerStartTimestamp = 0;
// Stop any repeating notification sound when pausing
SoundService.stopSound("alarm-beep.wav");
root.timerSoundPlaying = false;
}
function timerReset() {
root.timerRunning = false;
root.timerStartTimestamp = 0;
if (root.timerStopwatchMode) {
root.timerElapsedSeconds = 0;
root.timerPausedAt = 0;
} else {
root.timerRemainingSeconds = 0;
root.timerTotalSeconds = 0;
root.timerPausedAt = 0;
}
// Stop any repeating notification sound
SoundService.stopSound("alarm-beep.wav");
root.timerSoundPlaying = false;
}
function timerOnFinished() {
root.timerRunning = false;
root.timerRemainingSeconds = 0;
// Play notification sound with repeat
root.timerSoundPlaying = true;
SoundService.playSound("alarm-beep.wav", {
repeat: true
});
}
}

View File

@@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import "."
import qs.Commons
import qs.Modules.MainScreen
import qs.Modules.Panels.ControlCenter.Cards
@@ -13,10 +14,12 @@ import qs.Widgets
SmartPanel {
id: root
readonly property var now: Time.now
preferredWidth: Math.round(440 * Style.uiScaleRatio)
// Calculate width based on settings
preferredWidth: Math.round((Settings.data.location.showWeekNumberInCalendar ? 460 : 440) * Style.uiScaleRatio)
// Use a reasonable fixed height that accommodates most layouts
preferredHeight: Math.round(700 * Style.uiScaleRatio)
// Helper function to calculate ISO week number
@@ -39,37 +42,29 @@ SmartPanel {
return duration === 86400 && isAtMidnight;
}
// Shared calendar state (month/year) accessible by all components
property int calendarMonth: now.getMonth()
property int calendarYear: now.getFullYear()
panelContent: Item {
anchors.fill: parent
// Dynamic sizing properties that SmartPanel will bind to
property real contentPreferredWidth: (Settings.data.location.showWeekNumberInCalendar ? 400 : 380) * Style.uiScaleRatio
// Use implicitHeight from content + margins to avoid binding loops
// Dynamic height based on actual content height
property real contentPreferredHeight: content.implicitHeight + Style.marginL * 2
property real calendarGridHeight: {
// Calculate number of weeks in the calendar grid
const numWeeks = grid.daysModel ? Math.ceil(grid.daysModel.length / 7) : 5;
// Calendar grid height (dynamic based on number of weeks)
const rowHeight = Style.baseWidgetSize * 0.9 + Style.marginXXS;
return numWeeks * rowHeight;
}
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: Style.marginL
width: parent.contentPreferredWidth - Style.marginL * 2
spacing: Style.marginM
x: Style.marginL
y: Style.marginL
width: parent.width - (Style.marginL * 2)
spacing: Style.marginL
readonly property int firstDayOfWeek: Settings.data.location.firstDayOfWeek === -1 ? I18n.locale.firstDayOfWeek : Settings.data.location.firstDayOfWeek
property bool isCurrentMonth: true
readonly property bool weatherReady: Settings.data.location.weatherEnabled && (LocationService.data.weather !== null)
function checkIsCurrentMonth() {
return (now.getMonth() === grid.month) && (now.getFullYear() === grid.year);
return (now.getMonth() === root.calendarMonth) && (now.getFullYear() === root.calendarYear);
}
Component.onCompleted: {
@@ -86,48 +81,80 @@ SmartPanel {
Connections {
target: I18n
function onLanguageChanged() {
// Force update of day names when language changes
grid.month = grid.month;
// Force update by toggling month
root.calendarMonth = root.calendarMonth;
}
}
// Banner with date/time/clock
// All calendar items (Banner, Calendar, Timer, Weather, etc.)
Repeater {
model: Settings.data.calendar.cards
Loader {
active: modelData.enabled && (modelData.id !== "weather-card" || Settings.data.location.weatherEnabled)
visible: active
Layout.fillWidth: true
Layout.topMargin: 0
Layout.bottomMargin: 0
sourceComponent: {
switch (modelData.id) {
case "banner-card":
return bannerCard;
case "calendar-card":
return calendarCard;
case "timer-card":
return timerCard;
case "weather-card":
return weatherCard;
default:
return null;
}
}
}
}
}
Component {
id: bannerCard
Rectangle {
id: banner
Layout.fillWidth: true
Layout.preferredHeight: capsuleColumn.implicitHeight + Style.marginM * 2
Layout.minimumHeight: (60 * Style.uiScaleRatio) + (Style.marginM * 2)
Layout.preferredHeight: (60 * Style.uiScaleRatio) + (Style.marginM * 2)
implicitHeight: (60 * Style.uiScaleRatio) + (Style.marginM * 2)
radius: Style.radiusL
color: Color.mPrimary
// Access parent properties
readonly property var now: root.now
readonly property bool isCurrentMonth: content.isCurrentMonth
readonly property bool weatherReady: content.weatherReady
ColumnLayout {
id: capsuleColumn
anchors.top: parent.top
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.topMargin: Style.marginM
anchors.bottomMargin: Style.marginM
anchors.rightMargin: clockLoader.width + (Style.marginXL * 2)
anchors.leftMargin: Style.marginXL
spacing: 0
// Combined layout for date, month year, locatio and time-zone
// Combined layout for date, month year, location and time-zone
RowLayout {
Layout.fillWidth: true
height: 60 * Style.uiScaleRatio
clip: true
spacing: Style.marginS
// Today day number - with simple, stable animation
// Today day number
NText {
opacity: content.isCurrentMonth ? 1.0 : 0.0
Layout.preferredWidth: content.isCurrentMonth ? implicitWidth : 0
opacity: banner.isCurrentMonth ? 1.0 : 0.0
Layout.preferredWidth: banner.isCurrentMonth ? implicitWidth : 0
elide: Text.ElideNone
clip: true
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
text: now.getDate()
text: banner.now.getDate()
pointSize: Style.fontSizeXXXL * 1.5
font.weight: Style.fontWeightBold
color: Color.mOnPrimary
@@ -137,6 +164,7 @@ SmartPanel {
duration: Style.animationFast
}
}
Behavior on Layout.preferredWidth {
NumberAnimation {
duration: Style.animationFast
@@ -157,7 +185,7 @@ SmartPanel {
spacing: Style.marginS
NText {
text: I18n.locale.monthName(grid.month, Locale.LongFormat).toUpperCase()
text: I18n.locale.monthName(root.calendarMonth, Locale.LongFormat).toUpperCase()
pointSize: Style.fontSizeXL * 1.1
font.weight: Style.fontWeightBold
color: Color.mOnPrimary
@@ -166,7 +194,7 @@ SmartPanel {
}
NText {
text: `${grid.year}`
text: `${root.calendarYear}`
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: Qt.alpha(Color.mOnPrimary, 0.7)
@@ -181,7 +209,7 @@ SmartPanel {
text: {
if (!Settings.data.location.weatherEnabled)
return "";
if (!content.weatherReady)
if (!banner.weatherReady)
return I18n.tr("calendar.weather.loading");
const chunks = Settings.data.location.name.split(",");
return chunks[0];
@@ -194,7 +222,7 @@ SmartPanel {
}
NText {
text: content.weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : ""
text: banner.weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : ""
pointSize: Style.fontSizeXS
font.weight: Style.fontWeightMedium
color: Qt.alpha(Color.mOnPrimary, 0.7)
@@ -202,7 +230,7 @@ SmartPanel {
}
}
// Spacer to push content left
// Spacer
Item {
Layout.fillWidth: true
}
@@ -218,34 +246,24 @@ SmartPanel {
clockStyle: Settings.data.location.analogClockInCalendar ? "analog" : "digital"
progressColor: Color.mOnPrimary
Layout.alignment: Qt.AlignVCenter
now: root.now
now: parent.now
}
}
}
// Calendar itself
Component {
id: calendarCard
NBox {
id: calendar
Layout.fillWidth: true
Layout.preferredHeight: {
const navigationHeight = Style.baseWidgetSize; // Navigation buttons row
const dayNamesHeight = Style.baseWidgetSize * 0.6; // Day names header row
const innerMargins = Style.marginM * 2; // Top and bottom margins inside NBox
const innerSpacing = Style.marginS * 2; // Spacing between nav, dayNames, and grid (2 gaps)
return navigationHeight + dayNamesHeight + calendarGridHeight + innerMargins + innerSpacing;
}
Behavior on Layout.preferredWidth {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.InOutQuad
}
}
implicitHeight: calendarContent.implicitHeight + Style.marginM * 2
ColumnLayout {
id: calendarContent
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginS
// Navigation row
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
@@ -257,17 +275,15 @@ SmartPanel {
NIconButton {
icon: "chevron-left"
onClicked: {
let newDate = new Date(grid.year, grid.month - 1, 1);
grid.year = newDate.getFullYear();
grid.month = newDate.getMonth();
let newDate = new Date(root.calendarYear, root.calendarMonth - 1, 1);
root.calendarYear = newDate.getFullYear();
root.calendarMonth = newDate.getMonth();
content.isCurrentMonth = content.checkIsCurrentMonth();
const now = new Date();
const monthStart = new Date(grid.year, grid.month, 1);
const monthEnd = new Date(grid.year, grid.month + 1, 0);
const monthStart = new Date(root.calendarYear, root.calendarMonth, 1);
const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0);
const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000)));
const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000)));
CalendarService.loadEvents(daysAhead + 30, daysBehind + 30);
}
}
@@ -275,8 +291,8 @@ SmartPanel {
NIconButton {
icon: "calendar"
onClicked: {
grid.month = now.getMonth();
grid.year = now.getFullYear();
root.calendarMonth = now.getMonth();
root.calendarYear = now.getFullYear();
content.isCurrentMonth = true;
CalendarService.loadEvents();
}
@@ -285,22 +301,21 @@ SmartPanel {
NIconButton {
icon: "chevron-right"
onClicked: {
let newDate = new Date(grid.year, grid.month + 1, 1);
grid.year = newDate.getFullYear();
grid.month = newDate.getMonth();
let newDate = new Date(root.calendarYear, root.calendarMonth + 1, 1);
root.calendarYear = newDate.getFullYear();
root.calendarMonth = newDate.getMonth();
content.isCurrentMonth = content.checkIsCurrentMonth();
const now = new Date();
const monthStart = new Date(grid.year, grid.month, 1);
const monthEnd = new Date(grid.year, grid.month + 1, 0);
const monthStart = new Date(root.calendarYear, root.calendarMonth, 1);
const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0);
const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000)));
const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000)));
CalendarService.loadEvents(daysAhead + 30, daysBehind + 30);
}
}
}
// Day names header
RowLayout {
Layout.fillWidth: true
spacing: 0
@@ -316,11 +331,13 @@ SmartPanel {
rows: 1
columnSpacing: 0
rowSpacing: 0
Repeater {
model: 7
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.fontSizeS * 2
NText {
anchors.centerIn: parent
text: {
@@ -338,108 +355,81 @@ SmartPanel {
}
}
// Calendar grid with week numbers
RowLayout {
Layout.fillWidth: true
spacing: 0
// Helper function to check if a date has events
// Helper functions
function hasEventsOnDate(year, month, day) {
if (!CalendarService.available || CalendarService.events.length === 0)
return false;
const targetDate = new Date(year, month, day);
const targetStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000;
const targetEnd = targetStart + 86400; // +24 hours
const targetEnd = targetStart + 86400;
return CalendarService.events.some(event => {
// Check if event starts or overlaps with this day
return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd);
});
}
// Helper function to get events for a specific date
function getEventsForDate(year, month, day) {
if (!CalendarService.available || CalendarService.events.length === 0)
return [];
const targetDate = new Date(year, month, day);
const targetStart = Math.floor(new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000);
const targetEnd = targetStart + 86400; // +24 hours
const targetEnd = targetStart + 86400;
return CalendarService.events.filter(event => {
return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd);
});
}
// Helper function to check if an event is multi-day
function isMultiDayEvent(event) {
if (isAllDayEvent(event)) {
if (root.isAllDayEvent(event)) {
return false;
}
const startDate = new Date(event.start * 1000);
const endDate = new Date(event.end * 1000);
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return startDateOnly.getTime() !== endDateOnly.getTime();
}
// Helper function to get color for a specific event
function getEventColor(event, isToday) {
if (isMultiDayEvent(event)) {
return isToday ? Color.mOnSecondary : Color.mTertiary;
} else if (isAllDayEvent(event)) {
} else if (root.isAllDayEvent(event)) {
return isToday ? Color.mOnSecondary : Color.mSecondary;
} else {
return isToday ? Color.mOnSecondary : Color.mPrimary;
}
}
// Column of week numbers
// Week numbers column
ColumnLayout {
visible: Settings.data.location.showWeekNumberInCalendar
Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0
Layout.preferredHeight: {
const numWeeks = weekNumbers ? weekNumbers.length : 5;
const rowHeight = Style.baseWidgetSize * 0.9 + Style.marginXXS;
return numWeeks * rowHeight;
}
Layout.alignment: Qt.AlignTop
spacing: Style.marginXXS
Behavior on Layout.preferredHeight {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.InOutQuad
}
}
property var weekNumbers: {
if (!grid.daysModel || grid.daysModel.length === 0)
return [];
const weeks = [];
const numWeeks = Math.ceil(grid.daysModel.length / 7);
for (var i = 0; i < numWeeks; i++) {
const dayIndex = i * 7;
if (dayIndex < grid.daysModel.length) {
const weekDay = grid.daysModel[dayIndex];
const date = new Date(weekDay.year, weekDay.month, weekDay.day);
// Get Thursday of this week for ISO week calculation
const firstDayOfWeek = content.firstDayOfWeek;
let thursday = new Date(date);
if (firstDayOfWeek === 0) {
if (content.firstDayOfWeek === 0) {
thursday.setDate(date.getDate() + 4);
} else if (firstDayOfWeek === 1) {
} else if (content.firstDayOfWeek === 1) {
thursday.setDate(date.getDate() + 3);
} else {
let daysToThursday = (4 - firstDayOfWeek + 7) % 7;
let daysToThursday = (4 - content.firstDayOfWeek + 7) % 7;
thursday.setDate(date.getDate() + daysToThursday);
}
weeks.push(root.getISOWeekNumber(thursday));
}
}
@@ -451,6 +441,7 @@ SmartPanel {
Item {
Layout.preferredWidth: Style.baseWidgetSize * 0.7
Layout.preferredHeight: Style.baseWidgetSize * 0.9
NText {
anchors.centerIn: parent
color: Qt.alpha(Color.mPrimary, 0.7)
@@ -462,46 +453,26 @@ SmartPanel {
}
}
// Calendar grid
GridLayout {
id: grid
Layout.fillWidth: true
Layout.preferredHeight: {
const numWeeks = daysModel ? Math.ceil(daysModel.length / 7) : 5;
const rowHeight = Style.baseWidgetSize * 0.9 + Style.marginXXS;
return numWeeks * rowHeight;
}
columns: 7
columnSpacing: Style.marginXXS
rowSpacing: Style.marginXXS
property int month: now.getMonth()
property int year: now.getFullYear()
property int month: root.calendarMonth
property int year: root.calendarYear
Behavior on Layout.preferredHeight {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.InOutQuad
}
}
// Calculate days to display
property var daysModel: {
const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);
const daysInMonth = lastOfMonth.getDate();
// Get first day of week (0 = Sunday, 1 = Monday, etc.)
const firstDayOfWeek = content.firstDayOfWeek;
const firstOfMonthDayOfWeek = firstOfMonth.getDay();
// Calculate days before first of month
let daysBefore = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7;
// Calculate days after last of month to complete the week
const lastOfMonthDayOfWeek = lastOfMonth.getDay();
const daysAfter = (firstDayOfWeek - lastOfMonthDayOfWeek - 1 + 7) % 7;
// Build array of day objects
const days = [];
const today = new Date();
@@ -510,7 +481,6 @@ SmartPanel {
const prevMonthDays = prevMonth.getDate();
for (var i = daysBefore - 1; i >= 0; i--) {
const day = prevMonthDays - i;
const date = new Date(year, month - 1, day);
days.push({
"day": day,
"month": month - 1,
@@ -533,7 +503,7 @@ SmartPanel {
});
}
// Next month days (only if needed to complete the week)
// Next month days
for (var i = 1; i <= daysAfter; i++) {
days.push({
"day": i,
@@ -605,10 +575,9 @@ SmartPanel {
const events = parent.parent.parent.parent.getEventsForDate(modelData.year, modelData.month, modelData.day);
if (events.length > 0) {
const summaries = events.map(event => {
if (isAllDayEvent(event)) {
if (root.isAllDayEvent(event)) {
return event.summary;
} else {
// Always format with '0' padding to ensure proper horizontal alignment
const timeFormat = Settings.data.location.use12hourFormat ? "hh:mm AP" : "HH:mm";
const start = new Date(event.start * 1000);
const startFormatted = I18n.locale.toString(start, timeFormat);
@@ -646,18 +615,21 @@ SmartPanel {
}
}
}
}
Loader {
id: weatherLoader
active: Settings.data.location.weatherEnabled && Settings.data.location.showCalendarWeather
visible: active
Component {
id: timerCard
TimerCard {
Layout.fillWidth: true
}
}
sourceComponent: WeatherCard {
Layout.fillWidth: true
forecastDays: 5
showLocation: false
}
Component {
id: weatherCard
WeatherCard {
Layout.fillWidth: true
forecastDays: 5
showLocation: false
}
}
}

View File

@@ -0,0 +1,449 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.System
import qs.Widgets
// Timer card for the Calendar panel
NBox {
id: root
implicitHeight: content.implicitHeight + (Style.marginM * 2)
Layout.fillWidth: true
clip: true
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
clip: true
// Header
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NIcon {
icon: isStopwatchMode ? "clock" : "hourglass"
pointSize: Style.fontSizeL
color: Color.mPrimary
}
NText {
text: I18n.tr("calendar.timer.title")
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
}
// Timer display (editable when not running)
Item {
id: timerDisplayItem
Layout.fillWidth: true
Layout.preferredHeight: isRunning ? 160 * Style.uiScaleRatio : timerInput.implicitHeight
Layout.alignment: Qt.AlignHCenter
property string inputBuffer: ""
property bool isEditing: false
// Circular progress ring (only for countdown mode when running)
Canvas {
id: progressRing
anchors.fill: parent
anchors.margins: 12
visible: !isStopwatchMode && isRunning && totalSeconds > 0
z: -1
property real progressRatio: {
if (totalSeconds <= 0)
return 0;
// Inverted: show remaining time (starts at 1, goes to 0)
const ratio = remainingSeconds / totalSeconds;
return Math.max(0, Math.min(1, ratio));
}
onProgressRatioChanged: requestPaint()
onPaint: {
var ctx = getContext("2d");
if (width <= 0 || height <= 0) {
return;
}
var centerX = width / 2;
var centerY = height / 2;
var radius = Math.max(0, Math.min(width, height) / 2 - 6);
ctx.reset();
// Background circle (full track)
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.lineWidth = 4;
ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.2);
ctx.stroke();
// Progress arc (elapsed portion)
if (progressRatio > 0) {
ctx.beginPath();
ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progressRatio * 2 * Math.PI);
ctx.lineWidth = 4;
ctx.strokeStyle = Color.mPrimary;
ctx.lineCap = "round";
ctx.stroke();
}
}
}
TextInput {
id: timerInput
anchors.centerIn: parent
width: Math.max(implicitWidth, parent.width)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
selectByMouse: false
cursorVisible: false
cursorDelegate: Item {} // Empty cursor delegate to hide cursor
readOnly: isStopwatchMode || isRunning
enabled: !isRunning
font.family: Settings.data.ui.fontFixed
// Calculate if hours are being shown
readonly property bool showingHours: {
if (isStopwatchMode) {
return elapsedSeconds >= 3600;
}
// In edit mode, always show hours (HH:MM:SS format)
if (timerDisplayItem.isEditing) {
return true;
}
// When not editing, only show hours if >= 1 hour
return remainingSeconds >= 3600;
}
font.pointSize: {
if (!isRunning) {
return Style.fontSizeXXXL;
}
// When running, use smaller font if hours are shown
return showingHours ? Style.fontSizeXXL : (Style.fontSizeXXL * 1.2);
}
font.weight: Style.fontWeightBold
color: {
if (isRunning) {
return Color.mPrimary;
}
if (timerDisplayItem.isEditing) {
return Color.mPrimary;
}
return Color.mOnSurface;
}
// Display formatted time, but show input buffer when editing
text: {
if (isStopwatchMode) {
return formatTime(elapsedSeconds, false); // Stopwatch: only show hours if >= 1 hour
}
if (!timerDisplayItem.isEditing) {
// When not editing and not running, always show hours
// When running, only show hours if >= 1 hour
return formatTime(remainingSeconds, isRunning);
}
if (timerDisplayItem.inputBuffer !== "") {
return formatTimeFromDigits(timerDisplayItem.inputBuffer);
}
return formatTime(0, false);
}
// Only accept digit keys
Keys.onPressed: event => {
if (isRunning || isStopwatchMode) {
event.accepted = true;
return;
}
// Handle backspace
if (event.key === Qt.Key_Backspace) {
if (timerDisplayItem.isEditing && timerDisplayItem.inputBuffer.length > 0) {
timerDisplayItem.inputBuffer = timerDisplayItem.inputBuffer.slice(0, -1);
if (timerDisplayItem.inputBuffer !== "") {
parseDigitsToTime(timerDisplayItem.inputBuffer);
} else {
Time.timerRemainingSeconds = 0;
}
}
event.accepted = true;
return;
}
// Handle delete
if (event.key === Qt.Key_Delete) {
if (timerDisplayItem.isEditing) {
timerDisplayItem.inputBuffer = "";
Time.timerRemainingSeconds = 0;
}
event.accepted = true;
return;
}
// Allow navigation keys (but don't let them modify text)
if (event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Home || event.key === Qt.Key_End || (event.modifiers & Qt.ControlModifier) || (event.modifiers & Qt.ShiftModifier)) {
event.accepted = false; // Let default handling work for selection
return;
}
// Handle enter/return
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
applyTimeFromBuffer();
timerDisplayItem.isEditing = false;
focus = false;
event.accepted = true;
return;
}
// Handle escape
if (event.key === Qt.Key_Escape) {
timerDisplayItem.inputBuffer = "";
Time.timerRemainingSeconds = 0;
timerDisplayItem.isEditing = false;
focus = false;
event.accepted = true;
return;
}
// Only allow digits 0-9
if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
// Limit to 6 digits max
if (timerDisplayItem.inputBuffer.length >= 6) {
event.accepted = true; // Block if already at max
return;
}
// Add the digit to the buffer
timerDisplayItem.inputBuffer += String.fromCharCode(event.key);
// Update the display and parse
parseDigitsToTime(timerDisplayItem.inputBuffer);
event.accepted = true; // We handled it
} else {
event.accepted = true; // Block all other keys
}
}
Keys.onReturnPressed: {
applyTimeFromBuffer();
timerDisplayItem.isEditing = false;
focus = false;
}
Keys.onEscapePressed: {
timerDisplayItem.inputBuffer = "";
Time.timerRemainingSeconds = 0;
timerDisplayItem.isEditing = false;
focus = false;
}
onActiveFocusChanged: {
if (activeFocus) {
timerDisplayItem.isEditing = true;
timerDisplayItem.inputBuffer = "";
} else {
applyTimeFromBuffer();
timerDisplayItem.isEditing = false;
timerDisplayItem.inputBuffer = "";
}
}
MouseArea {
anchors.fill: parent
enabled: !isRunning && !isStopwatchMode
cursorShape: enabled ? Qt.IBeamCursor : Qt.ArrowCursor
onClicked: {
if (!isRunning && !isStopwatchMode) {
timerInput.forceActiveFocus();
}
}
}
}
}
// Control buttons
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
Rectangle {
Layout.fillWidth: true
Layout.preferredWidth: 0
implicitHeight: startButton.implicitHeight
color: Color.transparent
NButton {
id: startButton
anchors.fill: parent
text: isRunning ? I18n.tr("calendar.timer.pause") : I18n.tr("calendar.timer.start")
icon: isRunning ? "player-pause" : "player-play"
enabled: isStopwatchMode || remainingSeconds > 0
onClicked: {
if (isRunning) {
pauseTimer();
} else {
startTimer();
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredWidth: 0
implicitHeight: resetButton.implicitHeight
color: Color.transparent
NButton {
id: resetButton
anchors.fill: parent
text: I18n.tr("calendar.timer.reset")
icon: "refresh"
enabled: (isStopwatchMode && (elapsedSeconds > 0 || isRunning)) || (!isStopwatchMode && (remainingSeconds > 0 || isRunning || soundPlaying))
onClicked: {
resetTimer();
}
}
}
}
// Mode tabs (Android-style) - below buttons
NTabBar {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
visible: !isRunning
currentIndex: isStopwatchMode ? 1 : 0
onCurrentIndexChanged: {
const newMode = currentIndex === 1;
if (newMode !== isStopwatchMode) {
if (isRunning) {
pauseTimer();
}
// Stop any repeating notification sound when switching modes
SoundService.stopSound("alarm-beep.wav");
Time.timerSoundPlaying = false;
Time.timerStopwatchMode = newMode;
if (newMode) {
// Reset to 0 for stopwatch
Time.timerElapsedSeconds = 0;
} else {
Time.timerRemainingSeconds = 0;
}
}
}
spacing: Style.marginXS
NTabButton {
text: I18n.tr("calendar.timer.countdown")
tabIndex: 0
checked: !isStopwatchMode
}
NTabButton {
text: I18n.tr("calendar.timer.stopwatch")
tabIndex: 1
checked: isStopwatchMode
}
}
}
// Bind to Time for persistent timer state
readonly property bool isRunning: Time.timerRunning
property bool isStopwatchMode: Time.timerStopwatchMode
readonly property int remainingSeconds: Time.timerRemainingSeconds
readonly property int totalSeconds: Time.timerTotalSeconds
readonly property int elapsedSeconds: Time.timerElapsedSeconds
readonly property bool soundPlaying: Time.timerSoundPlaying
function formatTime(seconds, hideHoursWhenZero) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
// If hideHoursWhenZero is true (when running), only show hours if > 0
// Otherwise (when not running or editing), always show hours
if (hideHoursWhenZero && hours === 0) {
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function formatTimeFromDigits(digits) {
// Parse digits right-to-left: last 2 = seconds, next 2 = minutes, rest = hours
const len = digits.length;
let seconds = 0;
let minutes = 0;
let hours = 0;
if (len > 0) {
seconds = parseInt(digits.substring(Math.max(0, len - 2))) || 0;
}
if (len > 2) {
minutes = parseInt(digits.substring(Math.max(0, len - 4), len - 2)) || 0;
}
if (len > 4) {
hours = parseInt(digits.substring(0, len - 4)) || 0;
}
// Clamp values
seconds = Math.min(59, seconds);
minutes = Math.min(59, minutes);
hours = Math.min(99, hours);
// Always show HH:MM:SS format in edit mode
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
function parseDigitsToTime(digits) {
// Parse digits right-to-left: last 2 = seconds, next 2 = minutes, rest = hours
const len = digits.length;
let seconds = 0;
let minutes = 0;
let hours = 0;
if (len > 0) {
seconds = parseInt(digits.substring(Math.max(0, len - 2))) || 0;
}
if (len > 2) {
minutes = parseInt(digits.substring(Math.max(0, len - 4), len - 2)) || 0;
}
if (len > 4) {
hours = parseInt(digits.substring(0, len - 4)) || 0;
}
// Clamp values
seconds = Math.min(59, seconds);
minutes = Math.min(59, minutes);
hours = Math.min(99, hours);
Time.timerRemainingSeconds = (hours * 3600) + (minutes * 60) + seconds;
}
function applyTimeFromBuffer() {
if (timerDisplayItem.inputBuffer !== "") {
parseDigitsToTime(timerDisplayItem.inputBuffer);
timerDisplayItem.inputBuffer = "";
}
}
function startTimer() {
Time.timerStart();
}
function pauseTimer() {
Time.timerPause();
}
function resetTimer() {
Time.timerReset();
}
}

View File

@@ -9,6 +9,89 @@ ColumnLayout {
id: root
spacing: Style.marginL
property list<var> cardsModel: []
property list<var> cardsDefault: [
{
"id": "banner-card",
"text": I18n.tr("settings.location.calendar.banner.label"),
"enabled": true,
"required": false
},
{
"id": "calendar-card",
"text": I18n.tr("settings.location.calendar.calendar.label"),
"enabled": true,
"required": true
},
{
"id": "timer-card",
"text": I18n.tr("calendar.timer.title"),
"enabled": true,
"required": false
},
{
"id": "weather-card",
"text": I18n.tr("settings.location.weather.section.label"),
"enabled": true,
"required": false
}
]
function saveCards() {
var toSave = [];
for (var i = 0; i < cardsModel.length; i++) {
toSave.push({
"id": cardsModel[i].id,
"enabled": cardsModel[i].enabled
});
}
Settings.data.calendar.cards = toSave;
}
Component.onCompleted: {
// Starts empty
cardsModel = [];
// Add the cards available in settings
for (var i = 0; i < Settings.data.calendar.cards.length; i++) {
const settingCard = Settings.data.calendar.cards[i];
for (var j = 0; j < cardsDefault.length; j++) {
if (settingCard.id === cardsDefault[j].id) {
var card = cardsDefault[j];
card.enabled = settingCard.enabled;
// Auto-disable weather card if weather is disabled
if (card.id === "weather-card" && !Settings.data.location.weatherEnabled) {
card.enabled = false;
}
cardsModel.push(card);
}
}
}
// Add any missing cards from default
for (var i = 0; i < cardsDefault.length; i++) {
var found = false;
for (var j = 0; j < cardsModel.length; j++) {
if (cardsModel[j].id === cardsDefault[i].id) {
found = true;
break;
}
}
if (!found) {
var card = cardsDefault[i];
// Auto-disable weather card if weather is disabled
if (card.id === "weather-card" && !Settings.data.location.weatherEnabled) {
card.enabled = false;
}
cardsModel.push(card);
}
}
saveCards();
}
NHeader {
label: I18n.tr("settings.location.location.section.label")
description: I18n.tr("settings.location.location.section.description")
@@ -85,14 +168,6 @@ ColumnLayout {
enabled: Settings.data.location.weatherEnabled
}
NToggle {
label: I18n.tr("settings.location.weather.show-in-calendar.label")
description: I18n.tr("settings.location.weather.show-in-calendar.description")
checked: Settings.data.location.showCalendarWeather
onToggled: checked => Settings.data.location.showCalendarWeather = checked
enabled: Settings.data.location.weatherEnabled
}
NToggle {
label: I18n.tr("settings.location.weather.show-effects.label")
description: I18n.tr("settings.location.weather.show-effects.description")
@@ -108,6 +183,62 @@ ColumnLayout {
Layout.bottomMargin: Style.marginL
}
// Calendar Cards Management Section
ColumnLayout {
spacing: Style.marginXXS
Layout.fillWidth: true
NHeader {
label: I18n.tr("settings.location.calendar.cards.section.label")
description: I18n.tr("settings.location.calendar.cards.section.description")
}
Connections {
target: Settings.data.location
function onWeatherEnabledChanged() {
// Auto-disable weather card when weather is disabled
var newModel = cardsModel.slice();
for (var i = 0; i < newModel.length; i++) {
if (newModel[i].id === "weather-card") {
newModel[i] = Object.assign({}, newModel[i], {
"enabled": Settings.data.location.weatherEnabled
});
cardsModel = newModel;
saveCards();
break;
}
}
}
}
NReorderCheckboxes {
Layout.fillWidth: true
model: cardsModel
disabledIds: Settings.data.location.weatherEnabled ? [] : ["weather-card"]
onItemToggled: function (index, enabled) {
var newModel = cardsModel.slice();
newModel[index] = Object.assign({}, newModel[index], {
"enabled": enabled
});
cardsModel = newModel;
saveCards();
}
onItemsReordered: function (fromIndex, toIndex) {
var newModel = cardsModel.slice();
var item = newModel.splice(fromIndex, 1)[0];
newModel.splice(toIndex, 0, item);
cardsModel = newModel;
saveCards();
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginL
Layout.bottomMargin: Style.marginL
}
// Date & time section
ColumnLayout {
spacing: Style.marginM

View File

@@ -0,0 +1,113 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
Singleton {
id: root
Component.onCompleted: {
Logger.i("SoundService", "Service started");
}
/**
* Play a sound file
* @param soundPath - Path to the sound file (absolute, relative to shellDir, or just filename for Assets/Sounds/)
* @param options - Optional object with:
* - volume: Volume level (0.0 to 1.0, default: 1.0)
* - fallback: Whether to fallback to default notification sound if file not found (default: false)
* - repeat: Whether to repeat/loop the sound continuously (default: false)
*/
function playSound(soundPath, options) {
if (!soundPath || soundPath === "") {
Logger.w("SoundService", "No sound path provided");
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
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
// Build command with volume if supported
const volumeArg = volume < 1.0 ? Math.round(volume * 100) : "";
// Try different audio players in order of preference
let command = "";
if (repeat) {
// Repeat mode - use mpv or ffplay with loop, or paplay in a while loop
if (volumeArg && volumeArg > 0) {
command = `mpv --no-video --really-quiet --loop=inf --volume=${volumeArg} "${resolvedPath}" 2>/dev/null || ffplay -nodisp -loop -1 -loglevel quiet -volume ${volumeArg} "${resolvedPath}" 2>/dev/null || (while true; do paplay --volume=${volumeArg} "${resolvedPath}" 2>/dev/null || break; done)`;
} else {
command = `mpv --no-video --really-quiet --loop=inf "${resolvedPath}" 2>/dev/null || ffplay -nodisp -loop -1 -loglevel quiet "${resolvedPath}" 2>/dev/null || (while true; do paplay "${resolvedPath}" 2>/dev/null || break; done)`;
}
} else {
// Normal play once mode
if (volumeArg && volumeArg > 0) {
command = `paplay --volume=${volumeArg} "${resolvedPath}" 2>/dev/null || mpv --no-video --really-quiet --volume=${volumeArg} "${resolvedPath}" 2>/dev/null || ffplay -nodisp -autoexit -loglevel quiet -volume ${volumeArg} "${resolvedPath}" 2>/dev/null`;
} else {
command = `paplay "${resolvedPath}" 2>/dev/null || mpv --no-video --really-quiet "${resolvedPath}" 2>/dev/null || ffplay -nodisp -autoexit -loglevel quiet "${resolvedPath}" 2>/dev/null`;
}
}
// Add fallback to default notification sound if requested (only in non-repeat mode)
if (fallback && !repeat) {
const defaultSound = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
if (volumeArg && volumeArg > 0) {
command += ` || paplay --volume=${volumeArg} "${defaultSound}" 2>/dev/null || mpv --no-video --really-quiet --volume=${volumeArg} "${defaultSound}" 2>/dev/null || ffplay -nodisp -autoexit -loglevel quiet -volume ${volumeArg} "${defaultSound}" 2>/dev/null`;
} else {
command += ` || paplay "${defaultSound}" 2>/dev/null || mpv --no-video --really-quiet "${defaultSound}" 2>/dev/null || ffplay -nodisp -autoexit -loglevel quiet "${defaultSound}" 2>/dev/null`;
}
}
command += " || true"; // Always succeed
Logger.d("SoundService", "Playing sound:", resolvedPath, volumeArg ? `(volume: ${volumeArg}%)` : "", repeat ? "(repeat)" : "");
Quickshell.execDetached(["sh", "-c", command]);
}
/**
* Stop a playing sound by killing the audio player processes
* @param soundPath - Path to the sound file to stop (optional, if not provided stops all notification sounds)
*/
function stopSound(soundPath) {
let resolvedPath = soundPath;
if (soundPath) {
// Resolve path the same way as playSound
if (!soundPath.includes("/") && !soundPath.startsWith("file://")) {
resolvedPath = Quickshell.shellDir + "/Assets/Sounds/" + soundPath;
} else if (!soundPath.startsWith("/") && !soundPath.startsWith("file://")) {
resolvedPath = Quickshell.shellDir + "/" + soundPath;
} else if (soundPath.startsWith("file://")) {
resolvedPath = soundPath.substring(7);
}
// Kill processes playing this specific sound file
const command = `pkill -f "mpv.*${resolvedPath}" 2>/dev/null; pkill -f "ffplay.*${resolvedPath}" 2>/dev/null; pkill -f "paplay.*${resolvedPath}" 2>/dev/null; true`;
Logger.d("SoundService", "Stopping sound:", resolvedPath);
Quickshell.execDetached(["sh", "-c", command]);
} else {
// Kill all mpv/ffplay/paplay processes (be careful with this)
const command = `pkill -f "mpv.*--loop=inf" 2>/dev/null; pkill -f "ffplay.*-loop" 2>/dev/null; pkill -f "while true.*paplay" 2>/dev/null; true`;
Logger.d("SoundService", "Stopping all repeating sounds");
Quickshell.execDetached(["sh", "-c", command]);
}
}
}

View File

@@ -78,6 +78,7 @@ Item {
property bool enabled: modelData.enabled || false
property bool required: modelData.required || false
readonly property bool isDisabled: (root.disabledIds || []).indexOf(modelData.id) !== -1
readonly property bool canDrag: !delegateItem.isDisabled
property bool dragging: false
property int dragStartY: 0
property int dragStartIndex: -1
@@ -125,14 +126,14 @@ Item {
id: dragHandleMouseArea
anchors.fill: parent
cursorShape: (!delegateItem.required && !delegateItem.isDisabled) ? Qt.SizeVerCursor : Qt.ArrowCursor
cursorShape: delegateItem.canDrag ? Qt.SizeVerCursor : Qt.ArrowCursor
hoverEnabled: true
preventStealing: false
enabled: !delegateItem.required && !delegateItem.isDisabled
enabled: delegateItem.canDrag
z: 1000
onPressed: mouse => {
if (delegateItem.required || delegateItem.isDisabled) {
if (!delegateItem.canDrag) {
return;
}
delegateItem.dragStartIndex = delegateItem.index;