diff --git a/Assets/Sounds/alarm-beep.wav b/Assets/Sounds/alarm-beep.wav new file mode 100644 index 00000000..002c2355 Binary files /dev/null and b/Assets/Sounds/alarm-beep.wav differ diff --git a/Assets/Translations/de.json b/Assets/Translations/de.json index 321bf3e0..595143ea 100644 --- a/Assets/Translations/de.json +++ b/Assets/Translations/de.json @@ -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" + } + } + } } -} +} \ No newline at end of file diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index f9df474c..3b013225 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -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": { diff --git a/Assets/Translations/es.json b/Assets/Translations/es.json index c9b06176..8a8252f6 100644 --- a/Assets/Translations/es.json +++ b/Assets/Translations/es.json @@ -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" + } + } } -} +} \ No newline at end of file diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json index 8a366f98..61bb2741 100644 --- a/Assets/Translations/fr.json +++ b/Assets/Translations/fr.json @@ -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" + } + } } -} +} \ No newline at end of file diff --git a/Assets/Translations/nl.json b/Assets/Translations/nl.json index c73c4978..41de1621 100644 --- a/Assets/Translations/nl.json +++ b/Assets/Translations/nl.json @@ -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" + } + } } -} +} \ No newline at end of file diff --git a/Assets/Translations/pt.json b/Assets/Translations/pt.json index 540a9534..c37e99fd 100644 --- a/Assets/Translations/pt.json +++ b/Assets/Translations/pt.json @@ -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" + } + } } -} +} \ No newline at end of file diff --git a/Assets/Translations/ru.json b/Assets/Translations/ru.json index f361e5a5..c40224fd 100644 --- a/Assets/Translations/ru.json +++ b/Assets/Translations/ru.json @@ -2405,5 +2405,21 @@ "searching": "Поиск ближайших сетей...", "title": "Wi-Fi" } + }, + "location": { + "calendar": { + "cards": { + "section": { + "description": "Организуйте и включайте/отключайте карточки на панели календаря.", + "label": "Карточки календаря" + } + }, + "banner": { + "label": "Заголовок" + }, + "calendar": { + "label": "Календарь" + } + } } -} +} \ No newline at end of file diff --git a/Assets/Translations/tr.json b/Assets/Translations/tr.json index 53e9b477..d8ffdca8 100644 --- a/Assets/Translations/tr.json +++ b/Assets/Translations/tr.json @@ -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" + } + } } -} +} \ No newline at end of file diff --git a/Assets/Translations/uk-UA.json b/Assets/Translations/uk-UA.json index ad73408d..07c875cc 100644 --- a/Assets/Translations/uk-UA.json +++ b/Assets/Translations/uk-UA.json @@ -2405,5 +2405,21 @@ "searching": "Пошук близьких мереж...", "title": "Wi-Fi" } + }, + "location": { + "calendar": { + "cards": { + "section": { + "description": "Організуйте та увімкніть/вимкніть картки на панелі календаря.", + "label": "Картки календаря" + } + }, + "banner": { + "label": "Заголовок" + }, + "calendar": { + "label": "Календар" + } + } } -} +} \ No newline at end of file diff --git a/Assets/Translations/zh-CN.json b/Assets/Translations/zh-CN.json index 0f3b9926..03d7cd43 100644 --- a/Assets/Translations/zh-CN.json +++ b/Assets/Translations/zh-CN.json @@ -2405,5 +2405,21 @@ "searching": "正在搜索附近网络...", "title": "Wi-Fi" } + }, + "location": { + "calendar": { + "cards": { + "section": { + "description": "组织并启用/禁用日历面板中的卡片。", + "label": "日历卡片" + } + }, + "banner": { + "label": "标题" + }, + "calendar": { + "label": "日历" + } + } } -} +} \ No newline at end of file diff --git a/Assets/settings-default.json b/Assets/settings-default.json index 0cc60a46..5f1b3fdc 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -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, diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 2bbb1bae..16e44c8b 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -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 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: "" diff --git a/Commons/Time.qml b/Commons/Time.qml index dcf9d4ef..992e4d43 100644 --- a/Commons/Time.qml +++ b/Commons/Time.qml @@ -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 + }); + } } diff --git a/Modules/Panels/Calendar/CalendarPanel.qml b/Modules/Panels/Calendar/CalendarPanel.qml index dd148704..2ca8d5ae 100644 --- a/Modules/Panels/Calendar/CalendarPanel.qml +++ b/Modules/Panels/Calendar/CalendarPanel.qml @@ -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 } } } diff --git a/Modules/Panels/Calendar/TimerCard.qml b/Modules/Panels/Calendar/TimerCard.qml new file mode 100644 index 00000000..bf650d81 --- /dev/null +++ b/Modules/Panels/Calendar/TimerCard.qml @@ -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(); + } +} diff --git a/Modules/Panels/Settings/Tabs/LocationTab.qml b/Modules/Panels/Settings/Tabs/LocationTab.qml index f8ba711e..da93d66a 100644 --- a/Modules/Panels/Settings/Tabs/LocationTab.qml +++ b/Modules/Panels/Settings/Tabs/LocationTab.qml @@ -9,6 +9,89 @@ ColumnLayout { id: root spacing: Style.marginL + property list cardsModel: [] + property list 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 diff --git a/Services/System/SoundService.qml b/Services/System/SoundService.qml new file mode 100644 index 00000000..f43d3298 --- /dev/null +++ b/Services/System/SoundService.qml @@ -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]); + } + } +} diff --git a/Widgets/NReorderCheckboxes.qml b/Widgets/NReorderCheckboxes.qml index 8cd52944..38670f52 100644 --- a/Widgets/NReorderCheckboxes.qml +++ b/Widgets/NReorderCheckboxes.qml @@ -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;