diff --git a/mawaqit/OFL.txt b/mawaqit/OFL.txt new file mode 100644 index 0000000..55f55c9 --- /dev/null +++ b/mawaqit/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015-2022 The Reem Kufi Project Authors (https://github.com/aliftype/reem-kufi), with Reserved Font Name "Josefin Sans". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/mawaqit/README.md b/mawaqit/README.md new file mode 100644 index 0000000..704fc13 --- /dev/null +++ b/mawaqit/README.md @@ -0,0 +1,119 @@ +# Mawaqit + +Prayer times for Noctalia, with a bar widget and panel: live countdown to the next +prayer, notifications, optional azan playback, Hijri date, and per-prayer time +offsets. + +## Plugin + +| Field | Value | +| ------- | ------------------------------------------------------------ | +| ID | `ycf/mawaqit` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `fetcher` | + +## Requirements + +Install `paplay` (PipeWire/PulseAudio) **or** `pw-cat` on `PATH` — only one is +required, used for azan playback. If neither is installed, azan is skipped (a +line is logged) and everything else — countdown, panel, notifications — works +normally. + +Also requires `pkill` (part of `procps`, present on virtually every distro by +default) — used to stop azan playback, since neither player exposes its own +stop control. + +Azan audio is **not bundled**. To enable it: + +1. Get your own azan `.mp3` file(s) from wherever you like. +2. Copy them into this plugin's `assets/` folder, named exactly `azan1.mp3`, + `azan2.mp3`, and/or `azan3.mp3` (only the ones you want to use — you don't + need all three). +3. In Settings → Plugins → Mawaqit, turn on **Play Azan** and pick which of + the three slots to play from **Azan audio**. + +If the selected file isn't present, azan is silently skipped and a line is +logged — nothing else is affected. + +## Usage + +- **Left click** the bar widget → open the prayer times panel. +- **Right click** the bar widget → cycle its display mode: live countdown → + static time → prayer name only. + +Toggle the panel directly: + +```sh +noctalia msg panel-toggle ycf/mawaqit:panel +``` + +The panel shows all five daily prayers plus Sunrise and, during Ramadan, Imsak, +with a live countdown banner to whichever is next, the Gregorian and Hijri +date, and a refresh button. If azan is playing, a stop button appears next to +it. + +## Settings + +Plugin-level (Settings → Plugins → Mawaqit): + +| Setting | Type | Default | Description | +| -------------------- | -------- | ------- | -------------------------------------------------------------------------- | +| `city` | `string` | `London` | Your city name in English. | +| `country` | `string` | `UK` | Country name or 2-letter code. | +| `method` | `select` | `3` (MWL) | Calculation authority followed in your region. | +| `school` | `select` | `0` (Shafi/Maliki/Hanbali) | Asr convention — Hanafi uses a later shadow factor. | +| `hijriDayOffset` | `select` | `0` | Shift the displayed Hijri day by −1/0/+1 if it doesn't match local moon sighting. | +| `twelveHourFormat` | `bool` | `false` | Show prayer times as 12-hour (e.g. `5:23 AM`) instead of 24-hour. | +| `showNotifications` | `bool` | `true` | Show a system notification when each prayer time begins. | +| `playAzan` | `bool` | `false` | Play an azan audio file when each prayer time begins. | +| `azanFile` | `select` | `azan1.mp3` | Which bundled azan track to play (see Requirements for setup). | +| `tune` | `bool` | `false` | Enable the per-prayer minute offsets below. | +| `tuneFajr` | `int` | `0` | Fajr offset, in minutes (−60 to 60). | +| `tuneDhuhr` | `int` | `0` | Dhuhr offset, in minutes. | +| `tuneAsr` | `int` | `0` | Asr offset, in minutes. | +| `tuneMaghrib` | `int` | `0` | Maghrib offset, in minutes. | +| `tuneIsha` | `int` | `0` | Isha offset, in minutes. | + +Bar widget settings (from the widget's own settings menu): + +| Setting | Type | Default | Description | +| ------------------- | -------- | ------------------- | ------------------------------------------------------------------ | +| `showCountdown` | `bool` | `true` | Show a live countdown to the next prayer instead of the static time. | +| `showElapsed` | `bool` | `false` | After a prayer begins, count up (`+`) for up to 1 hour. | +| `hidePrayerName` | `bool` | `false` | Show only the time or countdown, without the prayer name. | +| `widgetIcon` | `glyph` | `building-mosque` | Bar icon. | +| `dynamicIcon` | `bool` | `false` | Show a sun/moon icon matching the current prayer instead of the fixed icon. | +| `textColor` | `color` | `on_surface` | Bar text color. | +| `iconColor` | `color` | `on_surface` | Bar icon color. | +| `activeColor` | `color` | `primary` | Color used when a prayer is happening now or during elapsed mode. | + +## IPC + +Force an immediate refetch (both the service and the bar widget respond): + +```sh +noctalia msg plugin ycf/mawaqit:fetcher all refresh +``` + +Set the bar widget's display mode directly: + +```sh +noctalia msg plugin ycf/mawaqit:bar all mode countdown|static|name +``` + +## Notes + +- The background service fetches prayer times once daily from + `api.aladhan.com`, sending the configured city/country/method/school as + query parameters, plus a second request for the next day's Fajr time (used + for the countdown after Isha). +- Azan playback runs `paplay` or `pw-cat` against a file **you supply** (see + Requirements) — no audio is bundled with this plugin. Playback is stopped + by matching the exact file path being played (via `pkill -f`), not a + generic pattern — this is the only termination method available since the + plugin API doesn't currently expose a PID or stop handle for spawned + processes. Stopping happens when the plugin exits or is disabled, or + manually from the panel while azan is playing. +- The Arabic Hijri date and prayer-time banner are rendered with the bundled + Reem Kufi font (`ReemKufi.ttf`), licensed under the SIL Open Font License — + see `OFL.txt`. +- No compositor-specific behavior — works anywhere Noctalia's bar and panels do. diff --git a/mawaqit/ReemKufi.ttf b/mawaqit/ReemKufi.ttf new file mode 100644 index 0000000..ad1a769 Binary files /dev/null and b/mawaqit/ReemKufi.ttf differ diff --git a/mawaqit/bar_widget.luau b/mawaqit/bar_widget.luau new file mode 100644 index 0000000..07fd0f1 --- /dev/null +++ b/mawaqit/bar_widget.luau @@ -0,0 +1,289 @@ +--!nonstrict +-- bar_widget.luau — Mawaqit bar widget +-- Left click → toggle panel +-- Right click → cycle display mode: countdown → static time → prayer name only + +-- ── Config ──────────────────────────────────────────────────────────────────── + +local showCountdown = noctalia.getConfig("showCountdown") +local showElapsed = noctalia.getConfig("showElapsed") +local hidePrayerName = noctalia.getConfig("hidePrayerName") +local widgetIcon = noctalia.getConfig("widgetIcon") or "building-mosque" +local dynamicIcon = noctalia.getConfig("dynamicIcon") +local textColor = noctalia.getConfig("textColor") or "on_surface" +local iconColor = noctalia.getConfig("iconColor") or "on_surface" +local activeColor = noctalia.getConfig("activeColor") or "primary" + +showCountdown = (showCountdown ~= false and showCountdown ~= "false") +showElapsed = (showElapsed == true or showElapsed == "true") +hidePrayerName = (hidePrayerName == true or hidePrayerName == "true") +dynamicIcon = (dynamicIcon == true or dynamicIcon == "true") + +-- ── Constants ───────────────────────────────────────────────────────────────── + +local PRAYER_ICONS = { + Fajr = "moon-stars", + Sunrise = "sunrise", + Dhuhr = "sun-high", + Asr = "sun-low", + Maghrib = "sunset", + Isha = "moon", +} + +local MODE_COUNTDOWN = 1 +local MODE_STATIC = 2 +local MODE_NAME_ONLY = 3 + +-- ── State ───────────────────────────────────────────────────────────────────── + +local prayers = {} +local tomorrowFajr = nil +local displayMode = MODE_COUNTDOWN +local hasData = false +local errorMsg = "" +local isJumuah = false + +-- ── Helpers ─────────────────────────────────────────────────────────────────── + +local function nowSeconds() + local t = os.date("*t") + return t.hour * 3600 + t.min * 60 + t.sec +end + +local function formatCountdown(secs) + if secs < 0 then secs = 0 end + local h = math.floor(secs / 3600) + local m = math.floor((secs % 3600) / 60) + local s = secs % 60 + if h > 0 then return string.format("%d:%02d:%02d", h, m, s) end + return string.format("%d:%02d", m, s) +end + +local function formatCountdownShort(secs) + if secs < 0 then secs = 0 end + local h = math.floor(secs / 3600) + local m = math.floor((secs % 3600) / 60) + if h > 0 then return string.format("%dh %dm", h, m) end + if m > 0 then return string.format("%dm", m) end + return "soon" +end + +local function getIcon(prayerName) + if dynamicIcon and prayerName and PRAYER_ICONS[prayerName] then + return PRAYER_ICONS[prayerName] + end + return widgetIcon ~= "" and widgetIcon or "building-mosque" +end + +local function setColors(isActive) + if isActive then + barWidget.setColor(activeColor ~= "" and activeColor or "primary") + barWidget.setGlyphColor(activeColor ~= "" and activeColor or "primary") + else + barWidget.setColor(textColor ~= "" and textColor or "on_surface") + barWidget.setGlyphColor(iconColor ~= "" and iconColor or "on_surface") + end +end + +local function prayerLabel(name) + if name == "Dhuhr" and isJumuah then return "Jumu'ah" end + return name +end + +-- ── Prayer logic ────────────────────────────────────────────────────────────── + +local function findCurrentPrayer() + if #prayers == 0 then return nil, nil, nil, nil end + local now = nowSeconds() + local currentIdx = nil + for i, p in ipairs(prayers) do + if now >= p.seconds then currentIdx = i end + end + + local nextIdx, secondsToNext, secondsElapsed + + if currentIdx == nil then + nextIdx = 1 + secondsToNext = prayers[1].seconds - now + secondsElapsed = nil + elseif currentIdx == #prayers then + nextIdx = nil + secondsElapsed = now - prayers[currentIdx].seconds + secondsToNext = tomorrowFajr and (tomorrowFajr + 86400 - now) or nil + else + nextIdx = currentIdx + 1 + secondsToNext = prayers[nextIdx].seconds - now + secondsElapsed = now - prayers[currentIdx].seconds + end + + return currentIdx, nextIdx, secondsToNext, secondsElapsed +end + +-- ── Display ─────────────────────────────────────────────────────────────────── + +local function updateDisplay() + if not hasData then + barWidget.setGlyph(widgetIcon ~= "" and widgetIcon or "building-mosque") + barWidget.setText(errorMsg ~= "" and "!" or "...") + barWidget.setColor("on_surface") + barWidget.setGlyphColor("on_surface") + barWidget.setTooltip(errorMsg ~= "" and errorMsg or noctalia.tr("panel.tooltip.no_data")) + return + end + + local currentIdx, nextIdx, secondsToNext, secondsElapsed = findCurrentPrayer() + local displayPrayer, isActive, timeStr = nil, false, "" + + local showingElapsed = false + if showElapsed and currentIdx ~= nil and secondsElapsed ~= nil then + local cap = math.min(3600, secondsToNext or 3600) + if secondsElapsed >= 0 and secondsElapsed <= cap then + showingElapsed = true + end + end + + if nextIdx ~= nil then + displayPrayer = prayers[nextIdx].name + if showingElapsed and currentIdx ~= nil then + displayPrayer = prayers[currentIdx].name + isActive = true + if displayMode == MODE_COUNTDOWN then + timeStr = "+" .. formatCountdown(secondsElapsed) + elseif displayMode == MODE_STATIC then + timeStr = prayers[currentIdx].time + end + elseif secondsToNext ~= nil and secondsToNext <= 60 then + isActive = true + if displayMode ~= MODE_NAME_ONLY then timeStr = "now" end + else + if displayMode == MODE_COUNTDOWN and showCountdown and secondsToNext then + timeStr = formatCountdown(secondsToNext) + elseif displayMode == MODE_STATIC then + timeStr = prayers[nextIdx].time + end + end + else + displayPrayer = "Fajr" + if showingElapsed and currentIdx ~= nil then + isActive = true + displayPrayer = prayers[currentIdx].name + if displayMode == MODE_COUNTDOWN then + timeStr = "+" .. formatCountdown(secondsElapsed) + elseif displayMode == MODE_STATIC then + timeStr = prayers[currentIdx].time + end + elseif secondsToNext ~= nil then + if displayMode == MODE_COUNTDOWN and showCountdown then + timeStr = formatCountdown(secondsToNext) + elseif displayMode == MODE_STATIC then + timeStr = tomorrowFajr and prayers[1] and prayers[1].time or "?" + end + end + end + + local nameLabel = prayerLabel(displayPrayer or "") + local label + if displayMode == MODE_NAME_ONLY then + label = nameLabel + elseif hidePrayerName then + label = timeStr + else + label = timeStr ~= "" and (nameLabel .. " " .. timeStr) or nameLabel + end + + barWidget.setGlyph(getIcon(displayPrayer)) + barWidget.setText(label) + setColors(isActive) + + if nextIdx ~= nil and secondsToNext ~= nil then + barWidget.setTooltip({ + { key = noctalia.tr("panel.tooltip.next"), value = prayerLabel(prayers[nextIdx].name) }, + { key = noctalia.tr("panel.tooltip.time"), value = prayers[nextIdx].time }, + { key = noctalia.tr("panel.tooltip.in"), value = formatCountdownShort(secondsToNext) }, + }) + elseif tomorrowFajr ~= nil and secondsToNext ~= nil then + barWidget.setTooltip({ + { key = noctalia.tr("panel.tooltip.next"), value = noctalia.tr("panel.tooltip.tomorrow_fajr") }, + { key = noctalia.tr("panel.tooltip.in"), value = formatCountdownShort(secondsToNext) }, + }) + end +end + +-- ── State watchers ──────────────────────────────────────────────────────────── + +noctalia.state.watch("prayers", function(val) + if type(val) == "table" and #val > 0 then + prayers = val + hasData = true + errorMsg = "" + end + updateDisplay() +end) + +noctalia.state.watch("tomorrowFajr", function(val) + tomorrowFajr = tonumber(val) +end) + +noctalia.state.watch("isJumuah", function(val) + isJumuah = (val == true) +end) + +noctalia.state.watch("error", function(val) + if type(val) == "string" and val ~= "" then + errorMsg = val + if not hasData then updateDisplay() end + end +end) + +-- ── Lifecycle ───────────────────────────────────────────────────────────────── + +noctalia.setUpdateInterval(1000) + +function update() updateDisplay() end + +function onClick() + noctalia.togglePanel("ycf/mawaqit:panel") +end + +function onRightClick() + if displayMode == MODE_COUNTDOWN then + displayMode = MODE_STATIC + elseif displayMode == MODE_STATIC then + displayMode = MODE_NAME_ONLY + else + displayMode = MODE_COUNTDOWN + end + updateDisplay() +end + +function onIpc(event, payload) + if event == "refresh" then + noctalia.state.set("command", { action = "refresh" }) + elseif event == "mode" then + if payload == "countdown" then displayMode = MODE_COUNTDOWN + elseif payload == "static" then displayMode = MODE_STATIC + elseif payload == "name" then displayMode = MODE_NAME_ONLY + end + updateDisplay() + end +end + +-- ── Init ────────────────────────────────────────────────────────────────────── + +barWidget.setGlyph(widgetIcon ~= "" and widgetIcon or "building-mosque") +barWidget.setText("...") +barWidget.setColor("on_surface") +barWidget.setGlyphColor("on_surface") +barWidget.setTooltip(noctalia.tr("plugin_name")) + +local existing = noctalia.state.get("prayers") +if type(existing) == "table" and #existing > 0 then + prayers = existing + hasData = true +end +local existingTomorrow = noctalia.state.get("tomorrowFajr") +if existingTomorrow then tomorrowFajr = tonumber(existingTomorrow) end +local existingJumuah = noctalia.state.get("isJumuah") +if existingJumuah ~= nil then isJumuah = (existingJumuah == true) end + +updateDisplay() diff --git a/mawaqit/panel.luau b/mawaqit/panel.luau new file mode 100644 index 0000000..b175fac --- /dev/null +++ b/mawaqit/panel.luau @@ -0,0 +1,319 @@ +--!nonstrict +-- panel.luau — Mawaqit prayer times panel (clean, no azan controls beyond stop) + +-- ── State ───────────────────────────────────────────────────────────────────── + +local prayers = {} +local tomorrowFajr = nil +local hijriDate = "" +local hijriDateAr = "" +local gregorianDate = "" +local errorMsg = "" +local hasData = false +local hijriMonth = 0 +local isJumuah = false +local sunriseTime = "" +local imsakTime = "" +local azanPlaying = false + +local decoFont = noctalia.loadFont("ReemKufi.ttf") + +local PRAYER_ICONS = { + Fajr = "moon-stars", + Sunrise = "sunrise", + Dhuhr = "sun-high", + Asr = "sun-low", + Maghrib = "sunset", + Isha = "moon", + Imsak = "moon-off", +} + +local ARABIC_NAMES = { + Fajr = "الفجر", + Dhuhr = "الظهر", + Asr = "العصر", + Maghrib = "المغرب", + Isha = "العشاء", + Imsak = "الإمساك", +} + +-- ── Helpers ─────────────────────────────────────────────────────────────────── + +local function nowSeconds() + local t = os.date("*t") + return t.hour * 3600 + t.min * 60 + t.sec +end + +local function formatCountdown(secs) + if secs < 0 then secs = 0 end + local h = math.floor(secs / 3600) + local m = math.floor((secs % 3600) / 60) + local s = secs % 60 + if h > 0 then return string.format("%dh %02dm %02ds", h, m, s) end + if m > 0 then return string.format("%dm %02ds", m, s) end + return string.format("%ds", s) +end + +local function findNext() + if #prayers == 0 then return nil, nil end + local now = nowSeconds() + for _, p in ipairs(prayers) do + if p.seconds > now then return p, p.seconds - now end + end + if tomorrowFajr then + return { name = "Fajr", time = prayers[1] and prayers[1].time or "?", seconds = tomorrowFajr }, + (86400 - now) + tomorrowFajr + end + return prayers[1], nil +end + +local function prayerLabel(name) + if name == "Dhuhr" and isJumuah then return "Jumu'ah" end + return name +end + +local function accentFor(name) + if hijriMonth == 9 then + if name == "Imsak" then return "secondary" end + if name == "Maghrib" then return "tertiary" end + end + return "primary" +end + +-- ── Render ──────────────────────────────────────────────────────────────────── + +local function render() + local nextPrayer, secondsToNext = findNext() + + -- ── Header ──────────────────────────────────────────────────────────────── + local header = ui.row({ align = "center", justify = "space_between" }, { + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = "building-mosque", size = 18, color = "primary" }), + ui.label({ text = noctalia.tr("panel.title"), fontSize = 15, fontWeight = "semibold", color = "on_surface" }), + }), + ui.row({ align = "center", gap = 4 }, { + azanPlaying and ui.button({ + glyph = "player-stop-filled", + variant = "destructive", + onClick = "onStopAzan", + }) or ui.spacer({ width = 0 }), + ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }), + ui.button({ glyph = "x", variant = "ghost", onClick = "onClose" }), + }), + }) + + -- ── Date row ────────────────────────────────────────────────────────────── + local dateRow = nil + if gregorianDate ~= "" or hijriDate ~= "" then + local hijriLabel + if decoFont and decoFont ~= "" and hijriDateAr ~= "" then + hijriLabel = ui.label({ + text = hijriDateAr, + fontSize = 26, + fontFamily = decoFont, + color = hijriMonth == 9 and "primary" or "secondary", + }) + else + hijriLabel = ui.label({ + text = hijriDate, + fontSize = 13, + fontWeight = "semibold", + color = hijriMonth == 9 and "primary" or "secondary", + }) + end + dateRow = ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = gregorianDate, fontSize = 12, color = "on_surface_variant" }), + hijriLabel, + }) + end + + -- ── Countdown banner — plain text, no card ──────────────────────────────── + local banner = nil + if nextPrayer and secondsToNext then + local isNow = secondsToNext <= 60 + local aColor = accentFor(nextPrayer.name) + local label = prayerLabel(nextPrayer.name) + + if isNow then + local arName = ARABIC_NAMES[nextPrayer.name] or nextPrayer.name + local bannerChildren = {} + if decoFont and decoFont ~= "" then + bannerChildren[#bannerChildren+1] = ui.label({ + text = "حان الآن موعد صلاة " .. arName, + fontSize = 20, + fontFamily = decoFont, + color = aColor, + }) + end + bannerChildren[#bannerChildren+1] = ui.label({ + text = label .. " — " .. noctalia.tr("panel.now"), + fontSize = 12, + color = "on_surface_variant", + }) + banner = ui.column({ align = "center", gap = 4, paddingV = 6 }, bannerChildren) + else + banner = ui.column({ align = "center", gap = 2, paddingV = 6 }, { + ui.label({ text = label .. " " .. noctalia.tr("panel.in"), fontSize = 12, color = "on_surface_variant" }), + ui.label({ text = formatCountdown(secondsToNext), fontSize = 26, + fontWeight = "bold", color = aColor }), + }) + end + elseif not hasData then + banner = ui.column({ align = "center", paddingV = 6 }, { + ui.label({ + text = errorMsg ~= "" and errorMsg or noctalia.tr("panel.loading"), + fontSize = 13, + color = errorMsg ~= "" and "error" or "on_surface_variant", + }), + }) + end + + -- ── Prayer rows ─────────────────────────────────────────────────────────── + local rows = {} + + -- Imsak (Ramadan only) + if hijriMonth == 9 and imsakTime ~= "" then + rows[#rows+1] = ui.row({ + key = "Imsak", align = "center", justify = "space_between", + paddingH = 12, paddingV = 8, + fill = "secondary/0.15", radius = 8, + }, { + ui.row({ align = "center", gap = 10 }, { + ui.glyph({ name = "moon-off", size = 14, color = "secondary" }), + ui.label({ text = "Imsak", fontSize = 14, color = "secondary" }), + }), + ui.label({ text = imsakTime, fontSize = 14, color = "secondary" }), + }) + end + + for _, p in ipairs(prayers) do + local isNext = nextPrayer and p.name == nextPrayer.name + local aColor = accentFor(p.name) + local nameClr = isNext and aColor or "on_surface" + local timeClr = isNext and aColor or "on_surface_variant" + local weight = isNext and "semibold" or "normal" + local icon = PRAYER_ICONS[p.name] or "sun" + local label = prayerLabel(p.name) + + rows[#rows+1] = ui.row({ + key = p.name, + align = "center", justify = "space_between", + paddingH = 12, + paddingV = isNext and 10 or 8, + fill = isNext and "surface_variant" or nil, + radius = isNext and 10 or nil, + }, { + ui.row({ align = "center", gap = 10 }, { + ui.glyph({ name = icon, size = 14, color = nameClr }), + ui.label({ text = label, fontSize = 14, fontWeight = weight, color = nameClr }), + }), + ui.label({ text = p.time, fontSize = 14, fontWeight = weight, color = timeClr }), + }) + + -- Sunrise between Fajr and Dhuhr + if p.name == "Fajr" and sunriseTime ~= "" then + rows[#rows+1] = ui.row({ + key = "Sunrise", align = "center", justify = "space_between", + paddingH = 12, paddingV = 8, + }, { + ui.row({ align = "center", gap = 10 }, { + ui.glyph({ name = "sunrise", size = 14, color = "on_surface_variant" }), + ui.label({ text = noctalia.tr("panel.sunrise"), fontSize = 14, color = "on_surface_variant" }), + }), + ui.label({ text = sunriseTime, fontSize = 14, color = "on_surface_variant" }), + }) + end + end + + -- ── Assemble ────────────────────────────────────────────────────────────── + local children = { header } + if dateRow then children[#children+1] = dateRow end + children[#children+1] = ui.separator({ spacing = 4, color = "outline", opacity = 0.2 }) + if banner then children[#children+1] = banner end + children[#children+1] = ui.separator({ spacing = 4, color = "outline", opacity = 0.15 }) + if #rows > 0 then children[#children+1] = ui.column({ gap = 1 }, rows) end + + panel.render(ui.column({ flexGrow = 1, gap = 8 }, children)) +end + +-- ── State watchers ──────────────────────────────────────────────────────────── + +noctalia.state.watch("prayers", function(val) + if type(val) == "table" then prayers = val; hasData = #val > 0 end + render() +end) +noctalia.state.watch("tomorrowFajr", function(val) + tomorrowFajr = tonumber(val); render() +end) +noctalia.state.watch("hijriDate", function(val) + if type(val) == "string" then hijriDate = val end; render() +end) +noctalia.state.watch("hijriDateAr", function(val) + if type(val) == "string" then hijriDateAr = val end; render() +end) +noctalia.state.watch("gregorianDate", function(val) + if type(val) == "string" then gregorianDate = val end; render() +end) +noctalia.state.watch("hijriMonth", function(val) + hijriMonth = tonumber(val) or 0; render() +end) +noctalia.state.watch("isJumuah", function(val) + isJumuah = (val == true); render() +end) +noctalia.state.watch("sunriseTime", function(val) + if type(val) == "string" then sunriseTime = val end; render() +end) +noctalia.state.watch("imsakTime", function(val) + if type(val) == "string" then imsakTime = val end; render() +end) +noctalia.state.watch("azanPlaying", function(val) + azanPlaying = (val == true); render() +end) +noctalia.state.watch("error", function(val) + if type(val) == "string" then errorMsg = val end + if not hasData then render() end +end) + +-- ── Callbacks ───────────────────────────────────────────────────────────────── + +function onOpen(_context) + panel.setWantsSecondTicks(true) + local p = noctalia.state.get("prayers") + if type(p) == "table" then prayers = p; hasData = #p > 0 end + local tf = noctalia.state.get("tomorrowFajr") + if tf then tomorrowFajr = tonumber(tf) end + local hd = noctalia.state.get("hijriDate") + if type(hd) == "string" then hijriDate = hd end + local hdar = noctalia.state.get("hijriDateAr") + if type(hdar) == "string" then hijriDateAr = hdar end + local gd = noctalia.state.get("gregorianDate") + if type(gd) == "string" then gregorianDate = gd end + local hm = noctalia.state.get("hijriMonth") + if hm then hijriMonth = tonumber(hm) or 0 end + local jm = noctalia.state.get("isJumuah") + if jm ~= nil then isJumuah = (jm == true) end + local sr = noctalia.state.get("sunriseTime") + if type(sr) == "string" then sunriseTime = sr end + local im = noctalia.state.get("imsakTime") + if type(im) == "string" then imsakTime = im end + local ap = noctalia.state.get("azanPlaying") + if ap ~= nil then azanPlaying = (ap == true) end + render() +end + +function onClose() panel.close() end +function onRefresh() + noctalia.state.set("command", { action = "refresh" }) +end + +function onStopAzan() + noctalia.state.set("command", { action = "stopAzan" }) + azanPlaying = false + noctalia.state.set("azanPlaying", false) + render() +end + +-- update() is called every second when panel is open (via panel.setWantsSecondTicks) +function update() + if hasData then render() end +end diff --git a/mawaqit/plugin.toml b/mawaqit/plugin.toml new file mode 100644 index 0000000..36c3ffc --- /dev/null +++ b/mawaqit/plugin.toml @@ -0,0 +1,247 @@ +id = "ycf/mawaqit" +name = "Mawaqit" +version = "1.0.0" +plugin_api = 3 +author = "ycf-anon" +license = "MIT" # Applies to the plugin code. Bundled font (ReemKufi.ttf) is + # SIL Open Font License — see OFL.txt. +tags = ["bar", "panel", "service", "time", "utility"] +icon = "building-mosque" +description = "Prayer times with live countdown, notifications, and azan." +dependencies = ["paplay", "pw-cat", "pkill"] + +# ── Plugin-level settings ───────────────────────────────────────────────────── + +[[setting]] +key = "city" +type = "string" +label_key = "settings.city.label" +description_key = "settings.city.description" +default = "London" + +[[setting]] +key = "country" +type = "string" +label_key = "settings.country.label" +description_key = "settings.country.description" +default = "UK" + +[[setting]] +key = "method" +type = "select" +label_key = "settings.method.label" +description_key = "settings.method.description" +default = "3" +options = [ + { value = "1", label_key = "settings.method.karachi" }, + { value = "2", label_key = "settings.method.isna" }, + { value = "3", label_key = "settings.method.mwl" }, + { value = "4", label_key = "settings.method.makkah" }, + { value = "5", label_key = "settings.method.egypt" }, + { value = "7", label_key = "settings.method.tehran" }, + { value = "8", label_key = "settings.method.gulf" }, + { value = "9", label_key = "settings.method.kuwait" }, + { value = "10", label_key = "settings.method.qatar" }, + { value = "11", label_key = "settings.method.singapore" }, + { value = "12", label_key = "settings.method.france" }, + { value = "13", label_key = "settings.method.turkey" }, + { value = "14", label_key = "settings.method.russia" }, + { value = "15", label_key = "settings.method.moonsighting" }, + { value = "16", label_key = "settings.method.dubai" }, + { value = "17", label_key = "settings.method.malaysia" }, + { value = "18", label_key = "settings.method.tunisia" }, + { value = "19", label_key = "settings.method.algeria" }, + { value = "20", label_key = "settings.method.indonesia" }, + { value = "21", label_key = "settings.method.morocco" }, + { value = "22", label_key = "settings.method.portugal" }, + { value = "23", label_key = "settings.method.jordan" }, +] + +[[setting]] +key = "school" +type = "select" +label_key = "settings.school.label" +description_key = "settings.school.description" +default = "0" +options = [ + { value = "0", label_key = "settings.school.shafi" }, + { value = "1", label_key = "settings.school.hanafi" }, +] + +[[setting]] +key = "hijriDayOffset" +type = "select" +label_key = "settings.hijri_day_offset.label" +description_key = "settings.hijri_day_offset.description" +default = "0" +options = [ + { value = "-1", label_key = "settings.hijri_day_offset.minus1" }, + { value = "0", label_key = "settings.hijri_day_offset.default" }, + { value = "1", label_key = "settings.hijri_day_offset.plus1" }, +] + +[[setting]] +key = "twelveHourFormat" +type = "bool" +label_key = "settings.twelve_hour_format.label" +description_key = "settings.twelve_hour_format.description" +default = false + +[[setting]] +key = "showNotifications" +type = "bool" +label_key = "settings.show_notifications.label" +description_key = "settings.show_notifications.description" +default = true + +[[setting]] +key = "playAzan" +type = "bool" +label_key = "settings.play_azan.label" +description_key = "settings.play_azan.description" +default = false + +[[setting]] +key = "azanFile" +type = "select" +label_key = "settings.azan_file.label" +description_key = "settings.azan_file.description" +default = "azan1.mp3" +options = [ + { value = "azan1.mp3", label_key = "settings.azan_file.azan1" }, + { value = "azan2.mp3", label_key = "settings.azan_file.azan2" }, + { value = "azan3.mp3", label_key = "settings.azan_file.azan3" }, +] + +[[setting]] +key = "tune" +type = "bool" +label_key = "settings.tune.label" +description_key = "settings.tune.description" +default = false + +[[setting]] +key = "tuneFajr" +type = "int" +label_key = "settings.tune_fajr.label" +default = 0 +min = -60 +max = 60 +advanced = true +visible_when = { key = "tune", values = ["true"] } + +[[setting]] +key = "tuneDhuhr" +type = "int" +label_key = "settings.tune_dhuhr.label" +default = 0 +min = -60 +max = 60 +advanced = true +visible_when = { key = "tune", values = ["true"] } + +[[setting]] +key = "tuneAsr" +type = "int" +label_key = "settings.tune_asr.label" +default = 0 +min = -60 +max = 60 +advanced = true +visible_when = { key = "tune", values = ["true"] } + +[[setting]] +key = "tuneMaghrib" +type = "int" +label_key = "settings.tune_maghrib.label" +default = 0 +min = -60 +max = 60 +advanced = true +visible_when = { key = "tune", values = ["true"] } + +[[setting]] +key = "tuneIsha" +type = "int" +label_key = "settings.tune_isha.label" +default = 0 +min = -60 +max = 60 +advanced = true +visible_when = { key = "tune", values = ["true"] } + +# ── Background service ──────────────────────────────────────────────────────── + +[[service]] +id = "fetcher" +entry = "service.luau" + +# ── Panel ───────────────────────────────────────────────────────────────────── + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 380 +height = 510 +placement = "floating" +position = "auto" +open_near_click = true + +# ── Bar widget ──────────────────────────────────────────────────────────────── + +[[widget]] +id = "bar" +entry = "bar_widget.luau" + + [[widget.setting]] + key = "showCountdown" + type = "bool" + label_key = "settings.show_countdown.label" + description_key = "settings.show_countdown.description" + default = true + + [[widget.setting]] + key = "showElapsed" + type = "bool" + label_key = "settings.show_elapsed.label" + description_key = "settings.show_elapsed.description" + default = false + + [[widget.setting]] + key = "hidePrayerName" + type = "bool" + label_key = "settings.hide_prayer_name.label" + description_key = "settings.hide_prayer_name.description" + default = false + + [[widget.setting]] + key = "widgetIcon" + type = "glyph" + label_key = "settings.widget_icon.label" + default = "building-mosque" + + [[widget.setting]] + key = "dynamicIcon" + type = "bool" + label_key = "settings.dynamic_icon.label" + description_key = "settings.dynamic_icon.description" + default = false + + [[widget.setting]] + key = "textColor" + type = "color" + label_key = "settings.text_color.label" + default = "on_surface" + + [[widget.setting]] + key = "iconColor" + type = "color" + label_key = "settings.icon_color.label" + default = "on_surface" + + [[widget.setting]] + key = "activeColor" + type = "color" + label_key = "settings.active_color.label" + description_key = "settings.active_color.description" + default = "primary" diff --git a/mawaqit/service.luau b/mawaqit/service.luau new file mode 100644 index 0000000..ffc34c2 --- /dev/null +++ b/mawaqit/service.luau @@ -0,0 +1,408 @@ +--!nonstrict +-- service.luau — Mawaqit background fetcher + +noctalia.setUpdateInterval(5000) + +-- ── Config ──────────────────────────────────────────────────────────────────── + +local city, country, method, school, hijriDayOffset +local tune, tuneFajr, tuneDhuhr, tuneAsr, tuneMaghrib, tuneIsha +local showNotifications, playAzan, azanFile, twelveHourFormat + +-- Safely quotes strings for shell execution to prevent injection. +local function shellQuote(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" +end + +-- Escapes POSIX ERE metacharacters so a path can be used as a `pkill -f` +-- pattern without accidentally behaving as a regex. +local function ereEscape(s) + return (s:gsub("([%.%*%+%?%(%)%[%]%^%$|\\{}])", "\\%1")) +end + +local function reloadConfig() + local c_city = noctalia.getConfig("city") + local c_country = noctalia.getConfig("country") + local c_method = noctalia.getConfig("method") + local c_school = noctalia.getConfig("school") + local c_hijriDayOffset = noctalia.getConfig("hijriDayOffset") + local c_tune = noctalia.getConfig("tune") + local c_tuneFajr = noctalia.getConfig("tuneFajr") + local c_tuneDhuhr = noctalia.getConfig("tuneDhuhr") + local c_tuneAsr = noctalia.getConfig("tuneAsr") + local c_tuneMaghrib = noctalia.getConfig("tuneMaghrib") + local c_tuneIsha = noctalia.getConfig("tuneIsha") + local c_showNotifications = noctalia.getConfig("showNotifications") + local c_playAzan = noctalia.getConfig("playAzan") + local c_azanFile = noctalia.getConfig("azanFile") + local c_twelveHourFormat = noctalia.getConfig("twelveHourFormat") + + city = (type(c_city) == "string" and c_city ~= "") and c_city or "London" + country = (type(c_country) == "string" and c_country ~= "") and c_country or "UK" + method = (type(c_method) == "string" and c_method ~= "") and c_method or "3" + school = (type(c_school) == "string" and c_school ~= "") and c_school or "0" + hijriDayOffset = tonumber(c_hijriDayOffset) or 0 + tune = (c_tune == true or c_tune == "true") + tuneFajr = tonumber(c_tuneFajr) or 0 + tuneDhuhr = tonumber(c_tuneDhuhr) or 0 + tuneAsr = tonumber(c_tuneAsr) or 0 + tuneMaghrib = tonumber(c_tuneMaghrib) or 0 + tuneIsha = tonumber(c_tuneIsha) or 0 + showNotifications = (c_showNotifications ~= false and c_showNotifications ~= "false") + playAzan = (c_playAzan == true or c_playAzan == "true") + azanFile = (type(c_azanFile) == "string" and c_azanFile ~= "") and c_azanFile or "azan1.mp3" + twelveHourFormat = (c_twelveHourFormat == true or c_twelveHourFormat == "true") +end + +reloadConfig() +noctalia.log("Mawaqit: city=" .. city .. " country=" .. country .. " method=" .. method) + +-- Check azan player availability at startup +local paplayAvailable = noctalia.commandExists("paplay") +local pwcatAvailable = noctalia.commandExists("pw-cat") +if playAzan and not paplayAvailable and not pwcatAvailable then + noctalia.log("Mawaqit: azan enabled but neither paplay nor pw-cat found") +end + +-- ── State ───────────────────────────────────────────────────────────────────── + +-- Prayers for countdown (no Sunrise — not a salah) +local PRAYER_NAMES = { "Fajr", "Dhuhr", "Asr", "Maghrib", "Isha" } +local AZAN_PRAYERS = { true, true, true, true, true } + +local loadedDate = "" +local fetchPending = false +local retryCount = 0 +local MAX_RETRIES = 5 +local retryTicks = 0 +local tomorrowFetched = false +local lastNotified = "" +local lastAzanPlayed = "" +local playingAzanPath = "" -- absolute path of the azan file currently playing, if any + +-- ── Helpers ─────────────────────────────────────────────────────────────────── + +local function parseTime(str) + if not str then return nil end + local h, m = str:match("^(%d+):(%d+)") + if not h then return nil end + return tonumber(h) * 3600 + tonumber(m) * 60 +end + +local function todayStr() + return os.date("%Y-%m-%d") +end + +-- Converts a 24-hour "HH:MM" string to 12-hour display, when the setting is on. +-- Returns the input unchanged (24-hour) otherwise, or if it doesn't parse. +local function formatDisplayTime(clean) + if not twelveHourFormat then return clean end + local h, m = clean:match("^(%d+):(%d+)") + if not h then return clean end + local hourNum = tonumber(h) + local period = hourNum < 12 and "AM" or "PM" + local displayHour = hourNum % 12 + if displayHour == 0 then displayHour = 12 end + return string.format("%d:%s %s", displayHour, m, period) +end + +local function nowSeconds() + local t = os.date("*t") + return t.hour * 3600 + t.min * 60 + t.sec +end + +local function isJumuah() + return os.date("*t").wday == 6 +end + +local function dateParam(offsetDays) + local t = os.time() + (offsetDays * 86400) + local d = os.date("*t", t) + return string.format("%02d-%02d-%04d", d.day, d.month, d.year) +end + +local function applyTune(name, secs) + if not tune then return secs end + local offset = 0 + if name == "Fajr" then offset = tuneFajr + elseif name == "Dhuhr" then offset = tuneDhuhr + elseif name == "Asr" then offset = tuneAsr + elseif name == "Maghrib" then offset = tuneMaghrib + elseif name == "Isha" then offset = tuneIsha + end + return secs + offset * 60 +end + +local function buildUrl(offsetDays) + local dateStr = dateParam(offsetDays or 0) + return string.format( + "https://api.aladhan.com/v1/timingsByCity/%s?city=%s&country=%s&method=%s&school=%s", + dateStr, + noctalia.string.urlEncode(city), + noctalia.string.urlEncode(country), + method, school + ) +end + +-- Resolves the bundled azan file's absolute path from this plugin's own +-- install directory (works for git-source and path-source installs alike). +local function azanPath() + local pluginDir = noctalia.pluginDir() + if type(pluginDir) ~= "string" or pluginDir == "" then + noctalia.log("Mawaqit: could not resolve plugin directory for azan playback") + return "" + end + local safe = azanFile:match("^[%w%.%-_]+$") and azanFile or "azan1.mp3" + return pluginDir .. "/assets/" .. safe +end + +-- ── Fetch ───────────────────────────────────────────────────────────────────── + +local function publishError(msg) + noctalia.state.set("error", msg) +end + +local function processPrayerData(data) + if not data or data.code ~= 200 then + publishError("API error: " .. tostring(data and data.status or "unknown")) + return false + end + local timings = data.data and data.data.timings + if not timings then + publishError("Parse error: no timings in response") + return false + end + + local prayers = {} + for i, name in ipairs(PRAYER_NAMES) do + local raw = timings[name] + if raw then + local clean = raw:match("^(%d+:%d+)") or raw + local secs = parseTime(clean) + if secs then + secs = applyTune(name, secs) + prayers[#prayers+1] = { + name = name, + time = formatDisplayTime(clean), + seconds = secs, + azanPrayer = AZAN_PRAYERS[i], + } + end + end + end + + -- Imsak (Ramadan only) + local imsakRaw = timings["Imsak"] + local imsakTime = imsakRaw and formatDisplayTime(imsakRaw:match("^(%d+:%d+)") or imsakRaw) or "" + + -- Sunrise (display only, not a prayer) + local sunriseRaw = timings["Sunrise"] + local sunriseTime = sunriseRaw and formatDisplayTime(sunriseRaw:match("^(%d+:%d+)") or sunriseRaw) or "" + + -- Hijri date + local hijri = data.data.date and data.data.date.hijri + local greg = (data.data.date and data.data.date.readable) or "" + local hijriDay = 0 + local hijriMonth = 0 + local hijriYear = 0 + local hijriDateStr = "" + local hijriDateAr = "" + + if hijri then + hijriDay = math.max(1, math.min(30, (tonumber(hijri.day) or 0) + hijriDayOffset)) + hijriMonth = (hijri.month and hijri.month.number) or 0 + hijriYear = tonumber(hijri.year) or 0 + local monthEn = (hijri.month and hijri.month.en) or "" + local monthAr = (hijri.month and hijri.month.ar) or "" + hijriDateStr = string.format("%d %s %d AH", hijriDay, monthEn, hijriYear) + + local function toArabicNumerals(n) + local arabic = {"٠","١","٢","٣","٤","٥","٦","٧","٨","٩"} + return tostring(n):gsub("%d", function(d) + return arabic[tonumber(d) + 1] + end) + end + hijriDateAr = toArabicNumerals(hijriDay) .. " " .. monthAr .. " " .. toArabicNumerals(hijriYear) + end + + noctalia.state.set("prayers", prayers) + noctalia.state.set("sunriseTime", sunriseTime) + noctalia.state.set("imsakTime", imsakTime) + noctalia.state.set("hijriDate", hijriDateStr) + noctalia.state.set("hijriDateAr", hijriDateAr) + noctalia.state.set("gregorianDate", greg) + noctalia.state.set("hijriDay", hijriDay) + noctalia.state.set("hijriMonth", hijriMonth) + noctalia.state.set("hijriYear", hijriYear) + noctalia.state.set("isJumuah", isJumuah()) + noctalia.state.set("error", "") + + loadedDate = todayStr() + lastNotified = "" + lastAzanPlayed = "" + retryCount = 0 + + noctalia.log("Mawaqit: loaded " .. #prayers .. " prayers for " .. city .. ", " .. country + .. " hijriMonth=" .. hijriMonth) + return true +end + +local function fetchTimes() + if fetchPending then return end + reloadConfig() + fetchPending = true + local url = buildUrl(0) + noctalia.log("Mawaqit: fetching " .. url) + noctalia.http({ url = url, follow_redirects = true }, function(res) + fetchPending = false + if not res.ok or res.body == "" then + retryCount += 1 + publishError("Network error (status=" .. tostring(res.status) .. ")") + return + end + local ok, data = pcall(function() return noctalia.json.decode(res.body) end) + if not ok or not data then + retryCount += 1 + publishError("Parse error") + return + end + processPrayerData(data) + end) +end + +local function fetchTomorrowFajr() + local url = buildUrl(1) + noctalia.http({ url = url, follow_redirects = true }, function(res) + if not res.ok or res.body == "" then return end + local ok, data = pcall(function() return noctalia.json.decode(res.body) end) + if not ok or not data or data.code ~= 200 then return end + local timings = data.data and data.data.timings + if not timings then return end + local raw = timings["Fajr"] + if raw then + local clean = raw:match("^(%d+:%d+)") or raw + local secs = parseTime(clean) + if secs then + secs = applyTune("Fajr", secs) + noctalia.state.set("tomorrowFajr", secs) + end + end + end) +end + +-- ── Azan playback ───────────────────────────────────────────────────────────── + +-- Kills azan playback in progress. Matches on the exact absolute path this +-- plugin passed to paplay/pw-cat (tracked in playingAzanPath), not a generic +-- substring — so it can't touch an unrelated process that happens to share +-- part of that path. This is an interim measure: runAsync doesn't return a +-- PID, so command-line matching is the only way to terminate a detached +-- process with the current API. +local function stopAzan() + if playingAzanPath ~= "" then + local pattern = shellQuote(ereEscape(playingAzanPath)) + noctalia.runAsync("pkill -f -- " .. pattern .. " 2>/dev/null || true") + playingAzanPath = "" + end + noctalia.state.set("azanPlaying", false) +end + +-- ── Prayer events ───────────────────────────────────────────────────────────── + +local cachedPrayers = {} + +local function checkPrayerEvents() + if #cachedPrayers == 0 then return end + local now = nowSeconds() + for _, p in ipairs(cachedPrayers) do + if now >= p.seconds and now < p.seconds + 30 and p.azanPrayer then + local key = loadedDate .. "_" .. p.name + local title = (p.name == "Dhuhr" and isJumuah()) and "Jumu'ah" or p.name + + if showNotifications and lastNotified ~= key then + lastNotified = key + noctalia.notify(title .. " prayer time", "It is time for " .. title) + end + + if playAzan and lastAzanPlayed ~= key then + lastAzanPlayed = key + local path = azanPath() + if path ~= "" and noctalia.fileExists(path) then + noctalia.state.set("azanPlaying", true) + playingAzanPath = path + local cmd + if paplayAvailable then + cmd = "paplay " .. shellQuote(path) .. " 2>/dev/null" + elseif pwcatAvailable then + cmd = "pw-cat -p " .. shellQuote(path) .. " 2>/dev/null" + else + noctalia.log("Mawaqit: no azan player available (install paplay or pw-cat)") + noctalia.state.set("azanPlaying", false) + return + end + noctalia.runAsync(cmd, function(_res) + playingAzanPath = "" + noctalia.state.set("azanPlaying", false) + end) + elseif path ~= "" then + noctalia.log("Mawaqit: azan file not found: " .. path) + end + end + end + end +end + +noctalia.state.watch("prayers", function(val) + if type(val) == "table" then cachedPrayers = val end +end) + +-- Listen to panel commands via state (cleaner than shell IPC) +noctalia.state.watch("command", function(cmd) + if type(cmd) ~= "table" then return end + if cmd.action == "refresh" then onConfigChanged() + elseif cmd.action == "stopAzan" then stopAzan() + end +end) + +-- ── Main tick ───────────────────────────────────────────────────────────────── + +function update() + local today = todayStr() + if loadedDate ~= today then + tomorrowFetched = false + fetchTimes() + end + if loadedDate == today and not tomorrowFetched then + tomorrowFetched = true + fetchTomorrowFajr() + end + if retryCount > 0 and retryCount <= MAX_RETRIES then + retryTicks += 1 + if retryTicks >= 6 then + retryTicks = 0 + fetchTimes() + end + end + checkPrayerEvents() +end + +function onConfigChanged() + noctalia.log("Mawaqit: settings changed, reloading config and refreshing") + reloadConfig() + loadedDate = "" + retryCount = 0 + cachedPrayers = {} + noctalia.state.set("prayers", {}) + fetchTimes() +end + +function onIpc(event, _payload) + if event == "refresh" then + onConfigChanged() + end +end + +function onExit() + -- Clean up: stop any playing azan when plugin is disabled or noctalia exits + stopAzan() +end diff --git a/mawaqit/thumbnail.webp b/mawaqit/thumbnail.webp new file mode 100644 index 0000000..b0e194d Binary files /dev/null and b/mawaqit/thumbnail.webp differ diff --git a/mawaqit/translations/en.json b/mawaqit/translations/en.json new file mode 100644 index 0000000..c39509a --- /dev/null +++ b/mawaqit/translations/en.json @@ -0,0 +1,135 @@ +{ + "plugin_name": "Mawaqit", + "plugin_description": "Prayer times with live countdown, notifications, and azan.", + "panel": { + "title": "Prayer Times", + "sunrise": "Sunrise", + "now": "Now", + "in": "in", + "jumuah": "Jumu'ah", + "loading": "Loading prayer times…", + "tooltip": { + "next": "Next", + "time": "Time", + "in": "In", + "tomorrow_fajr": "Fajr (tomorrow)", + "no_data": "Fetching prayer times…" + } + }, + "settings": { + "city": { + "label": "City", + "description": "Your city name in English (e.g. London)." + }, + "country": { + "label": "Country", + "description": "Country name or 2-letter code (e.g. UK, DZ, FR)." + }, + "method": { + "label": "Calculation Method", + "description": "Choose the calculation authority followed in your region.", + "karachi": "University of Islamic Sciences, Karachi", + "isna": "Islamic Society of North America (ISNA)", + "mwl": "Muslim World League (MWL)", + "makkah": "Umm Al-Qura University, Makkah", + "egypt": "Egyptian General Authority of Survey", + "tehran": "Institute of Geophysics, Tehran", + "gulf": "Gulf Region", + "kuwait": "Kuwait", + "qatar": "Qatar", + "singapore": "Majlis Ugama Islam Singapura (MUIS)", + "france": "Union Organization Islamic de France", + "turkey": "Diyanet İşleri Başkanlığı, Turkey", + "russia": "Spiritual Administration of Muslims of Russia", + "moonsighting": "Moonsighting Committee Worldwide", + "dubai": "Dubai (experimental)", + "malaysia": "JAKIM, Malaysia", + "tunisia": "Tunisia", + "algeria": "Algeria / MESRS", + "indonesia": "KEMENAG, Indonesia", + "morocco": "Morocco", + "portugal": "Comunidade Islamica de Lisboa", + "jordan": "Ministry of Awqaf, Jordan" + }, + "school": { + "label": "Asr Calculation School", + "description": "Shafi/Maliki/Hanbali uses shadow factor 1. Hanafi uses factor 2, giving a later Asr.", + "shafi": "Shafi / Maliki / Hanbali (default)", + "hanafi": "Hanafi" + }, + "hijri_day_offset": { + "label": "Hijri Day Adjustment", + "description": "Shift the displayed Hijri day if the API date doesn't match your local moon sighting.", + "minus1": "−1 day", + "default": "Default (from API)", + "plus1": "+1 day" + }, + "twelve_hour_format": { + "label": "12-Hour Format", + "description": "Show prayer times in 12-hour format (e.g. 5:23 AM) instead of 24-hour." + }, + "show_notifications": { + "label": "Prayer notifications", + "description": "Show a system notification when each prayer time begins." + }, + "play_azan": { + "label": "Play Azan", + "description": "Play an azan audio file when each prayer time begins." + }, + "azan_file": { + "label": "Azan audio", + "description": "Which azan to play. Name your files azan1.mp3 / azan2.mp3 / azan3.mp3 and place them in the plugin's assets/ folder (see README).", + "azan1": "Azan 1", + "azan2": "Azan 2", + "azan3": "Azan 3" + }, + "tune": { + "label": "Enable per-prayer time offsets", + "description": "Apply per-prayer minute adjustments to fine-tune times." + }, + "tune_fajr": { + "label": "Fajr offset (minutes)" + }, + "tune_dhuhr": { + "label": "Dhuhr offset (minutes)" + }, + "tune_asr": { + "label": "Asr offset (minutes)" + }, + "tune_maghrib": { + "label": "Maghrib offset (minutes)" + }, + "tune_isha": { + "label": "Isha offset (minutes)" + }, + "show_countdown": { + "label": "Show countdown", + "description": "Show a live countdown to the next prayer instead of the static time." + }, + "show_elapsed": { + "label": "Show elapsed time after prayer", + "description": "After a prayer begins, count up (+) for up to 1 hour." + }, + "hide_prayer_name": { + "label": "Hide prayer name in bar", + "description": "Show only the time or countdown without the prayer name." + }, + "widget_icon": { + "label": "Bar icon" + }, + "dynamic_icon": { + "label": "Dynamic prayer icon", + "description": "Show a sun/moon icon matching the current prayer instead of the fixed icon." + }, + "text_color": { + "label": "Bar text color" + }, + "icon_color": { + "label": "Bar icon color" + }, + "active_color": { + "label": "Active color", + "description": "Color used when a prayer is happening now or in elapsed mode." + } + } +}