diff --git a/mawaqit/README.md b/mawaqit/README.md index 704fc134..4f7ffe95 100644 --- a/mawaqit/README.md +++ b/mawaqit/README.md @@ -22,23 +22,16 @@ 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: +Three Azan tracks are bundled in `assets/azan1.mp3`, `assets/azan2.mp3`, and +`assets/azan3.mp3`, matching the legacy V4 plugin. In Settings → Plugins → +Mawaqit, turn on **Play Azan** and select a track from **Azan audio**. -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. + static time → prayer name only. While azan is playing, right click stops it. Toggle the panel directly: @@ -48,8 +41,15 @@ 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. +date, and a refresh button. Its Calendar tab provides Hijri-month navigation, +Gregorian day overlays, and a configurable week start. If azan is playing, a +stop button appears next to it. The panel header also opens this plugin's +settings. + +Calendar conversion data is fetched by the background service and cached for +30 days (up to 24 Hijri months). The calendar marks Islamic events, shows an +upcoming-event hint, rotates a daily hadith, and displays the Ramadan last-ten- +nights message when applicable. ## Settings @@ -60,12 +60,15 @@ Plugin-level (Settings → Plugins → Mawaqit): | `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. | +| `fajrAngle` | `string` | `""` | Fajr angle for Custom Method, a decimal greater than 0 and less than 90. | +| `ishaAngle` | `string` | `""` | Isha angle for Custom Method, a decimal greater than 0 and less than 90. | | `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. | +| `weekStartDay` | `select` | `1` (Monday) | First day of the Hijri calendar week. | | `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. | +| `showNotifications` | `bool` | `true` | Show a system notification when each prayer time and Ramadan Imsak begin. | | `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). | +| `azanFile` | `select` | `azan1.mp3` | Which user-supplied azan file slot 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. | @@ -100,19 +103,28 @@ Set the bar widget's display mode directly: noctalia msg plugin ycf/mawaqit:bar all mode countdown|static|name ``` +Preview the selected local azan file without enabling **Play Azan**: + +```sh +noctalia msg plugin ycf/mawaqit:fetcher all preview-azan +``` + ## 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 background service fetches the current month's prayer calendar once + daily from `api.aladhan.com`, sending the configured + city/country/method/school as query parameters. It falls back to a + single-day request and retries failures with bounded backoff. A second + request fetches the next day's Fajr time for the countdown after Isha. +- The current month's calendar is stored in the plugin data directory and is + reused immediately after a restart or while the network is unavailable, + provided the location, calculation method, school, and month still match. +- Azan playback runs `paplay` or `pw-cat` against the selected bundled file. + 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`. diff --git a/mawaqit/assets/azan1.mp3 b/mawaqit/assets/azan1.mp3 new file mode 100644 index 00000000..3551e32d Binary files /dev/null and b/mawaqit/assets/azan1.mp3 differ diff --git a/mawaqit/assets/azan2.mp3 b/mawaqit/assets/azan2.mp3 new file mode 100644 index 00000000..1e6eebb5 Binary files /dev/null and b/mawaqit/assets/azan2.mp3 differ diff --git a/mawaqit/assets/azan3.mp3 b/mawaqit/assets/azan3.mp3 new file mode 100644 index 00000000..8f9c415e Binary files /dev/null and b/mawaqit/assets/azan3.mp3 differ diff --git a/mawaqit/bar_widget.luau b/mawaqit/bar_widget.luau index 07fd0f1f..65900e1b 100644 --- a/mawaqit/bar_widget.luau +++ b/mawaqit/bar_widget.luau @@ -42,6 +42,7 @@ local displayMode = MODE_COUNTDOWN local hasData = false local errorMsg = "" local isJumuah = false +local azanPlaying = false -- ── Helpers ─────────────────────────────────────────────────────────────────── @@ -122,6 +123,15 @@ end -- ── Display ─────────────────────────────────────────────────────────────────── local function updateDisplay() + if azanPlaying then + barWidget.setGlyph("player-stop-filled") + barWidget.setText("Azan") + barWidget.setColor(activeColor ~= "" and activeColor or "primary") + barWidget.setGlyphColor(activeColor ~= "" and activeColor or "primary") + barWidget.setTooltip("Azan playing - right-click to stop") + return + end + if not hasData then barWidget.setGlyph(widgetIcon ~= "" and widgetIcon or "building-mosque") barWidget.setText(errorMsg ~= "" and "!" or "...") @@ -133,6 +143,8 @@ local function updateDisplay() local currentIdx, nextIdx, secondsToNext, secondsElapsed = findCurrentPrayer() local displayPrayer, isActive, timeStr = nil, false, "" + local isNow = currentIdx ~= nil and secondsElapsed ~= nil + and secondsElapsed >= 0 and secondsElapsed < 300 local showingElapsed = false if showElapsed and currentIdx ~= nil and secondsElapsed ~= nil then @@ -152,6 +164,10 @@ local function updateDisplay() elseif displayMode == MODE_STATIC then timeStr = prayers[currentIdx].time end + elseif isNow and currentIdx ~= nil then + displayPrayer = prayers[currentIdx].name + isActive = true + if displayMode ~= MODE_NAME_ONLY then timeStr = "now" end elseif secondsToNext ~= nil and secondsToNext <= 60 then isActive = true if displayMode ~= MODE_NAME_ONLY then timeStr = "now" end @@ -172,6 +188,10 @@ local function updateDisplay() elseif displayMode == MODE_STATIC then timeStr = prayers[currentIdx].time end + elseif isNow and currentIdx ~= nil then + displayPrayer = prayers[currentIdx].name + isActive = true + if displayMode ~= MODE_NAME_ONLY then timeStr = "now" end elseif secondsToNext ~= nil then if displayMode == MODE_COUNTDOWN and showCountdown then timeStr = formatCountdown(secondsToNext) @@ -228,6 +248,11 @@ noctalia.state.watch("isJumuah", function(val) isJumuah = (val == true) end) +noctalia.state.watch("azanPlaying", function(val) + azanPlaying = (val == true) + updateDisplay() +end) + noctalia.state.watch("error", function(val) if type(val) == "string" and val ~= "" then errorMsg = val @@ -246,6 +271,10 @@ function onClick() end function onRightClick() + if azanPlaying then + noctalia.state.set("command", { action = "stopAzan" }) + return + end if displayMode == MODE_COUNTDOWN then displayMode = MODE_STATIC elseif displayMode == MODE_STATIC then @@ -285,5 +314,7 @@ 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 +local existingAzan = noctalia.state.get("azanPlaying") +if existingAzan ~= nil then azanPlaying = (existingAzan == true) end updateDisplay() diff --git a/mawaqit/panel.luau b/mawaqit/panel.luau index b175fac9..e23e42a2 100644 --- a/mawaqit/panel.luau +++ b/mawaqit/panel.luau @@ -11,10 +11,18 @@ local gregorianDate = "" local errorMsg = "" local hasData = false local hijriMonth = 0 +local hijriYear = 0 local isJumuah = false local sunriseTime = "" local imsakTime = "" local azanPlaying = false +local activeTab = "prayers" +local calendarMonth = 0 +local calendarYear = 0 +local calendarKey = "" +local calendarData = {} +local calendarStatus = "idle" +local calendarError = "" local decoFont = noctalia.loadFont("ReemKufi.ttf") @@ -37,6 +45,59 @@ local ARABIC_NAMES = { Imsak = "الإمساك", } +local HIJRI_MONTHS = { + "Muharram", "Safar", "Rabi al-Awwal", "Rabi ath-Thani", "Jumada al-Ula", "Jumada al-Akhira", + "Rajab", "Shaban", "Ramadan", "Shawwal", "Dhu al-Qadah", "Dhu al-Hijjah", +} +local WEEKDAYS = { "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" } +local ISLAMIC_EVENTS = { + ["1-1"] = { en = "Islamic New Year", ar = "رأس السنة الهجرية" }, + ["1-10"] = { en = "Ashura", ar = "عاشوراء" }, + ["3-12"] = { en = "Mawlid an-Nabi", ar = "المولد النبوي" }, + ["7-27"] = { en = "Isra wal Miraj", ar = "الإسراء والمعراج" }, + ["9-1"] = { en = "Start of Ramadan", ar = "بداية رمضان" }, + ["9-21"] = { en = "Laylat al-Qadr", ar = "ليلة القدر" }, + ["9-23"] = { en = "Laylat al-Qadr", ar = "ليلة القدر" }, + ["9-25"] = { en = "Laylat al-Qadr", ar = "ليلة القدر" }, + ["9-27"] = { en = "Laylat al-Qadr", ar = "ليلة القدر" }, + ["9-29"] = { en = "Laylat al-Qadr", ar = "ليلة القدر" }, + ["10-1"] = { en = "Eid al-Fitr", ar = "عيد الفطر" }, + ["12-9"] = { en = "Day of Arafah", ar = "يوم عرفة" }, + ["12-10"] = { en = "Eid al-Adha", ar = "عيد الأضحى" }, +} +local HADITH = { + { text = "إنما الأعمال بالنيات", source = "البخاري" }, + { text = "المسلم من سلم المسلمون من لسانه ويده", source = "البخاري" }, + { text = "لا يؤمن أحدكم حتى يحب لأخيه ما يحب لنفسه", source = "البخاري" }, + { text = "الطهور شطر الإيمان", source = "مسلم" }, + { text = "خيركم من تعلم القرآن وعلمه", source = "البخاري" }, + { text = "الدين النصيحة", source = "مسلم" }, + { text = "من كان يؤمن بالله واليوم الآخر فليقل خيراً أو ليصمت", source = "البخاري" }, + { text = "البر حسن الخلق", source = "مسلم" }, + { text = "إن الله رفيق يحب الرفق في الأمر كله", source = "البخاري" }, + { text = "الكلمة الطيبة صدقة", source = "البخاري" }, + { text = "خير الناس أنفعهم للناس", source = "الطبراني" }, + { text = "إن الله جميل يحب الجمال", source = "مسلم" }, + { text = "أحب الأعمال إلى الله أدومها وإن قل", source = "البخاري" }, + { text = "اتق الله حيثما كنت", source = "الترمذي" }, + { text = "إن من أحبكم إليّ وأقربكم مني مجلساً يوم القيامة أحاسنكم أخلاقاً", source = "الترمذي" }, + { text = "من صام رمضان إيماناً واحتساباً غُفر له ما تقدم من ذنبه", source = "البخاري" }, + { text = "أفضل الصيام بعد رمضان شهر الله المحرم", source = "مسلم" }, + { text = "ابتغوا ليلة القدر في الوتر من العشر الأواخر من رمضان", source = "البخاري" }, + { text = "تبسمك في وجه أخيك صدقة", source = "الترمذي" }, + { text = "الصلوات الخمس كفارة لما بينهن ما اجتنبت الكبائر", source = "البخاري" }, + { text = "أعوذُ بكلماتِ اللهِ التّامّاتِ مِن شرِّ ما خَلَق", source = "حصن المسلم" }, + { text = "بِسْمِ اللَّهِ الَّذِي لَا يَضُرُّ مَعَ اسْمِهِ شَيْءٌ فِي الْأَرْضِ وَلَا فِي السَّمَاءِ وَهُوَ السَّمِيعُ الْعَلِيمُ", source = "حصن المسلم" }, + { text = "سُبْحَانَ اللهِ وَبِحَمْدِهِ", source = "حصن المسلم" }, + { text = "حَسْبِيَ اللَّهُ لاَ إِلَهَ إِلاَّ هُوَ عَلَيهِ تَوَكَّلْتُ وَهُوَ رَبُّ الْعَرْشِ الْعَظِيمِ", source = "حصن المسلم" }, + { text = "اللَّهُمَّ إِنِّي أَعُوذُ بِكَ مِنَ الْكُفْرِ وَالفَقْرِ وَأَعُوذُ بِكَ مِنْ عَذَابِ القَبْرِ لاَ إِلَهَ إِلاَّ أَنْتَ", source = "حصن المسلم" }, + { text = "الْحَمْدُ لِلَّهِ الَّذِي أَحْيَانَا بَعْدَ مَا أَمَاتَنَا وَإِلَيْهِ النُّشُورُ", source = "حصن المسلم" }, + { text = "بِسْمِ اللهِ تَوَكَّلْتُ عَلَى اللهِ وَلاَ حَوْلَ وَلاَ قُوَّةَ إِلاَّ بِاللهِ", source = "حصن المسلم" }, + { text = "بِسْمِ اللهِ وَالصَّلاَةُ وَالسَّلاَمُ عَلَى رَسُولِ اللهِ اللَّهُمَّ افْتَحْ لِي أَبْوَابَ رَحْمَتِكَ", source = "حصن المسلم" }, + { text = "لاَ إِلَهَ إِلاَّ اللهُ العَظِيمُ الحَلِيمُ لاَ إِلَهَ إِلاَّ اللهُ رَبُّ العَرْشِ العَظِيمِ لاَ إِلَهَ إِلاَّ اللهُ رَبُّ السَّمَاوَاتِ وَرَبُّ الأَرْضِ وَرَبُّ العَرْشِ الكَرِيمِ", source = "حصن المسلم" }, + { text = "اللَّهُمَّ لاَ سَهْلَ إِلاَّ مَا جَعَلْتَهُ سَهْلاً وَأَنْتَ تَجْعَلُ الْحَزْنَ إِذَا شِئْتَ سَهْلاً", source = "حصن المسلم" }, +} + -- ── Helpers ─────────────────────────────────────────────────────────────────── local function nowSeconds() @@ -57,9 +118,16 @@ end local function findNext() if #prayers == 0 then return nil, nil end local now = nowSeconds() + local current = nil for _, p in ipairs(prayers) do - if p.seconds > now then return p, p.seconds - now end + if p.seconds <= now then + current = p + else + if current and now < current.seconds + 300 then return current, 0 end + return p, p.seconds - now + end end + if current and now < current.seconds + 300 then return current, 0 end if tomorrowFajr then return { name = "Fajr", time = prayers[1] and prayers[1].time or "?", seconds = tomorrowFajr }, (86400 - now) + tomorrowFajr @@ -80,6 +148,171 @@ local function accentFor(name) return "primary" end +local function calendarRequestKey() + return tostring(calendarYear) .. "-" .. string.format("%02d", calendarMonth) +end + +local function requestCalendar(force) + if calendarMonth < 1 or calendarYear < 1 then return end + noctalia.state.set("command", { + action = "calendar", month = calendarMonth, year = calendarYear, force = force == true, + }) +end + +local function weekdayIndex(date) + local day, month, year = date:match("^(%d+)%-(%d+)%-(%d+)$") + if not day then return 0 end + return os.date("*t", os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = 12 })).wday - 1 +end + +local function weekStartDay() + local value = tonumber(noctalia.getConfig("weekStartDay")) + if value == 0 or value == 1 or value == 6 then return value end + return 1 +end + +local function eventFor(month, day) + local event = ISLAMIC_EVENTS[tostring(month) .. "-" .. tostring(day)] + if event then return event end + if (day == 13 or day == 14 or day == 15) and month ~= 9 and month ~= 10 and month ~= 12 then + return { en = "Ayyam al-Bid", ar = "أيام البيض" } + end + if day == 29 then return { en = "Laylat al-Shakk", ar = "ليلة الشك" } end + return nil +end + +local function currentHijriDay() + return tonumber(hijriDate:match("^(%d+)")) or 0 +end + +local function dailyHadith() + local day = os.date("*t").yday or 1 + return HADITH[(day - 1) % #HADITH + 1] +end + +local function upcomingEvent() + local month, day = hijriMonth, currentHijriDay() + if month == 0 or day == 0 then return nil end + local today = eventFor(month, day) + if today then return today, 0 end + if isJumuah then return { en = "Friday", ar = "جمعة مباركة" }, 0 end + + for daysAway = 1, 30 do + day += 1 + local monthLength = month % 2 == 1 and 30 or 29 + if day > monthLength then + day = 1 + month = month % 12 + 1 + end + local event = eventFor(month, day) + if event then return event, daysAway end + end + return nil +end + +local function calendarView() + local expectedKey = calendarRequestKey() + local days = calendarKey == expectedKey and calendarData or {} + local monthTitle = HIJRI_MONTHS[calendarMonth] or "" + if calendarMonth < 1 or calendarYear < 1 then + return ui.column({ align = "center", paddingV = 24 }, { + ui.label({ text = noctalia.tr("calendar.loading"), color = "on_surface_variant" }), + }) + end + local content = { + ui.row({ align = "center", justify = "space_between" }, { + ui.button({ glyph = "chevron-left", variant = "ghost", tooltip = noctalia.tr("calendar.previous"), onClick = "onCalendarPrevious" }), + ui.label({ text = monthTitle .. " " .. tostring(calendarYear), fontSize = 15, + fontWeight = "semibold", color = calendarMonth == 9 and "primary" or "on_surface" }), + ui.button({ glyph = "chevron-right", variant = "ghost", tooltip = noctalia.tr("calendar.next"), onClick = "onCalendarNext" }), + }), + } + + if calendarMonth ~= hijriMonth or calendarYear ~= hijriYear then + content[#content + 1] = ui.button({ text = noctalia.tr("calendar.today"), variant = "ghost", onClick = "onCalendarToday" }) + end + + local event, daysAway = upcomingEvent() + if event then + local when = daysAway == 0 and noctalia.tr("calendar.today_label") + or noctalia.tr("calendar.in_days", { days = daysAway }) + content[#content + 1] = ui.label({ text = when .. " " .. event.ar, fontFamily = decoFont, + fontSize = 13, color = "tertiary", align = "center" }) + end + + local start = weekStartDay() + local headers = {} + for index = 0, 6 do + local weekday = WEEKDAYS[(start + index) % 7 + 1] + headers[#headers + 1] = ui.label({ text = weekday, flexGrow = 1, align = "center", + color = weekday == "Fr" and "primary" or "on_surface_variant", fontSize = 11 }) + end + content[#content + 1] = ui.row({ gap = 2, align = "center" }, headers) + + if #days == 0 then + content[#content + 1] = ui.label({ + text = calendarStatus == "error" and calendarError or noctalia.tr("calendar.loading"), + color = calendarStatus == "error" and "error" or "on_surface_variant", align = "center", paddingV = 24, + }) + return ui.column({ gap = 8 }, content) + end + + local first = days[1].gregorian and days[1].gregorian.date or "" + local leading = (weekdayIndex(first) - start + 7) % 7 + local cells, rows = {}, {} + for _ = 1, leading do cells[#cells + 1] = ui.spacer({ flexGrow = 1 }) end + for index, entry in ipairs(days) do + local day = tonumber(entry.hijri and entry.hijri.day) or index + local gregorianDay = entry.gregorian and entry.gregorian.day or "" + local isToday = calendarMonth == hijriMonth and calendarYear == hijriYear and day == currentHijriDay() + local event = eventFor(calendarMonth, day) + local text = tostring(day) .. (event and " *" or "") .. (gregorianDay ~= "" and "\n" .. gregorianDay or "") + cells[#cells + 1] = ui.button({ key = "calendar-" .. expectedKey .. "-" .. day, text = text, + variant = isToday and "primary" or "ghost", flexGrow = 1, height = 40, + tooltip = event and event.ar or nil }) + if #cells == 7 then + rows[#rows + 1] = ui.row({ gap = 2, align = "center" }, cells) + cells = {} + end + end + while #cells > 0 and #cells < 7 do cells[#cells + 1] = ui.spacer({ flexGrow = 1 }) end + if #cells > 0 then rows[#rows + 1] = ui.row({ gap = 2, align = "center" }, cells) end + content[#content + 1] = ui.column({ gap = 2 }, rows) + if calendarStatus == "loading" then + content[#content + 1] = ui.label({ text = noctalia.tr("calendar.loading"), fontSize = 11, + color = "on_surface_variant", align = "center" }) + end + if calendarMonth == hijriMonth and calendarYear == hijriYear then + local day = currentHijriDay() + local lastTenNights = calendarMonth == 9 and day >= 20 and (day ~= 29 or #days == 30) + if lastTenNights then + content[#content + 1] = ui.label({ + text = "العشر الأواخر\nأفضل ليالي رمضان، فيها ليلة القدر خير من ألف شهر", + fontFamily = decoFont, fontSize = 15, color = "primary", align = "center", paddingV = 8, + }) + else + local event = eventFor(calendarMonth, day) + if event then + content[#content + 1] = ui.label({ text = "* " .. event.ar, fontFamily = decoFont, + fontSize = 15, color = "tertiary", align = "center" }) + end + local hadith = dailyHadith() + content[#content + 1] = ui.separator({ spacing = 2, color = "outline", opacity = 0.2 }) + content[#content + 1] = ui.label({ text = hadith.text, fontFamily = decoFont, + fontSize = 13, color = "on_surface", align = "center" }) + content[#content + 1] = ui.label({ text = "- " .. hadith.source, fontSize = 11, + color = "on_surface_variant", align = "center" }) + end + end + return ui.column({ gap = 8 }, content) +end + +local function initializeCalendar() + if activeTab ~= "calendar" or calendarMonth ~= 0 or hijriMonth < 1 or hijriYear < 1 then return end + calendarMonth, calendarYear = hijriMonth, hijriYear + requestCalendar(false) +end + -- ── Render ──────────────────────────────────────────────────────────────────── local function render() @@ -96,7 +329,8 @@ local function render() glyph = "player-stop-filled", variant = "destructive", onClick = "onStopAzan", - }) or ui.spacer({ width = 0 }), + }) or ui.button({ glyph = "player-play-filled", variant = "ghost", onClick = "onPreviewAzan" }), + ui.button({ glyph = "settings", variant = "ghost", onClick = "onSettings" }), ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }), ui.button({ glyph = "x", variant = "ghost", onClick = "onClose" }), }), @@ -229,9 +463,22 @@ local function render() 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.row({ gap = 4, align = "center" }, { + ui.button({ text = noctalia.tr("panel.tabs.prayers"), variant = activeTab == "prayers" and "primary" or "ghost", + flexGrow = 1, onClick = "onTabPrayers" }), + ui.button({ text = noctalia.tr("panel.tabs.calendar"), variant = activeTab == "calendar" and "primary" or "ghost", + flexGrow = 1, onClick = "onTabCalendar" }), + }) 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 + + if activeTab == "prayers" then + local prayerContent = {} + if banner then prayerContent[#prayerContent + 1] = banner end + if #rows > 0 then prayerContent[#prayerContent + 1] = ui.column({ gap = 1 }, rows) end + children[#children+1] = ui.scroll({ key = "mawaqit-prayers", flexGrow = 1, gap = 8, align = "stretch" }, prayerContent) + else + children[#children+1] = ui.scroll({ key = "mawaqit-calendar", flexGrow = 1, gap = 8, align = "stretch" }, { calendarView() }) + end panel.render(ui.column({ flexGrow = 1, gap = 8 }, children)) end @@ -255,7 +502,19 @@ 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() + hijriMonth = tonumber(val) or 0; initializeCalendar(); render() +end) +noctalia.state.watch("hijriYear", function(val) + hijriYear = tonumber(val) or 0; initializeCalendar(); render() +end) +noctalia.state.watch("hijriCalendar", function(val) + if type(val) == "table" then + calendarKey = type(val.key) == "string" and val.key or "" + calendarData = type(val.data) == "table" and val.data or {} + calendarStatus = type(val.status) == "string" and val.status or "idle" + calendarError = type(val.error) == "string" and val.error or "" + end + render() end) noctalia.state.watch("isJumuah", function(val) isJumuah = (val == true); render() @@ -290,6 +549,8 @@ function onOpen(_context) if type(gd) == "string" then gregorianDate = gd end local hm = noctalia.state.get("hijriMonth") if hm then hijriMonth = tonumber(hm) or 0 end + local hy = noctalia.state.get("hijriYear") + if hy then hijriYear = tonumber(hy) or 0 end local jm = noctalia.state.get("isJumuah") if jm ~= nil then isJumuah = (jm == true) end local sr = noctalia.state.get("sunriseTime") @@ -298,12 +559,56 @@ function onOpen(_context) if type(im) == "string" then imsakTime = im end local ap = noctalia.state.get("azanPlaying") if ap ~= nil then azanPlaying = (ap == true) end + local calendar = noctalia.state.get("hijriCalendar") + if type(calendar) == "table" then + calendarKey = type(calendar.key) == "string" and calendar.key or "" + calendarData = type(calendar.data) == "table" and calendar.data or {} + calendarStatus = type(calendar.status) == "string" and calendar.status or "idle" + calendarError = type(calendar.error) == "string" and calendar.error or "" + end + initializeCalendar() render() end function onClose() panel.close() end +function onSettings() + panel.close() + noctalia.runAsync("noctalia msg settings-open-plugin ycf/mawaqit") +end function onRefresh() - noctalia.state.set("command", { action = "refresh" }) + if activeTab == "calendar" then requestCalendar(true) + else noctalia.state.set("command", { action = "refresh" }) end +end + +function onTabPrayers() + activeTab = "prayers" + render() +end + +function onTabCalendar() + activeTab = "calendar" + initializeCalendar() + render() +end + +function onCalendarPrevious() + calendarMonth -= 1 + if calendarMonth < 1 then calendarMonth, calendarYear = 12, calendarYear - 1 end + requestCalendar(false) + render() +end + +function onCalendarNext() + calendarMonth += 1 + if calendarMonth > 12 then calendarMonth, calendarYear = 1, calendarYear + 1 end + requestCalendar(false) + render() +end + +function onCalendarToday() + calendarMonth, calendarYear = hijriMonth, hijriYear + requestCalendar(false) + render() end function onStopAzan() @@ -313,6 +618,10 @@ function onStopAzan() render() end +function onPreviewAzan() + noctalia.state.set("command", { action = "previewAzan" }) +end + -- update() is called every second when panel is open (via panel.setWantsSecondTicks) function update() if hasData then render() end diff --git a/mawaqit/plugin.toml b/mawaqit/plugin.toml index 36c3ffc6..20f8f39f 100644 --- a/mawaqit/plugin.toml +++ b/mawaqit/plugin.toml @@ -1,10 +1,10 @@ id = "ycf/mawaqit" name = "Mawaqit" -version = "1.0.0" +version = "1.3.7" 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. + # SIL Open Font License — see OFL.txt. Azan provenance is in ASSET_SOURCES.md. tags = ["bar", "panel", "service", "time", "utility"] icon = "building-mosque" description = "Prayer times with live countdown, notifications, and azan." @@ -55,8 +55,27 @@ options = [ { value = "21", label_key = "settings.method.morocco" }, { value = "22", label_key = "settings.method.portugal" }, { value = "23", label_key = "settings.method.jordan" }, + { value = "99", label_key = "settings.method.custom" }, ] +[[setting]] +key = "fajrAngle" +type = "string" +label_key = "settings.fajr_angle.label" +description_key = "settings.fajr_angle.description" +default = "" +advanced = true +visible_when = { key = "method", values = ["99"] } + +[[setting]] +key = "ishaAngle" +type = "string" +label_key = "settings.isha_angle.label" +description_key = "settings.isha_angle.description" +default = "" +advanced = true +visible_when = { key = "method", values = ["99"] } + [[setting]] key = "school" type = "select" @@ -80,6 +99,18 @@ options = [ { value = "1", label_key = "settings.hijri_day_offset.plus1" }, ] +[[setting]] +key = "weekStartDay" +type = "select" +label_key = "settings.week_start_day.label" +description_key = "settings.week_start_day.description" +default = "1" +options = [ + { value = "6", label_key = "settings.week_start_day.saturday" }, + { value = "0", label_key = "settings.week_start_day.sunday" }, + { value = "1", label_key = "settings.week_start_day.monday" }, +] + [[setting]] key = "twelveHourFormat" type = "bool" diff --git a/mawaqit/service.luau b/mawaqit/service.luau index ffc34c23..d35a44e0 100644 --- a/mawaqit/service.luau +++ b/mawaqit/service.luau @@ -5,7 +5,7 @@ noctalia.setUpdateInterval(5000) -- ── Config ──────────────────────────────────────────────────────────────────── -local city, country, method, school, hijriDayOffset +local city, country, method, school, hijriDayOffset, fajrAngle, ishaAngle local tune, tuneFajr, tuneDhuhr, tuneAsr, tuneMaghrib, tuneIsha local showNotifications, playAzan, azanFile, twelveHourFormat @@ -26,6 +26,8 @@ local function reloadConfig() local c_method = noctalia.getConfig("method") local c_school = noctalia.getConfig("school") local c_hijriDayOffset = noctalia.getConfig("hijriDayOffset") + local c_fajrAngle = noctalia.getConfig("fajrAngle") + local c_ishaAngle = noctalia.getConfig("ishaAngle") local c_tune = noctalia.getConfig("tune") local c_tuneFajr = noctalia.getConfig("tuneFajr") local c_tuneDhuhr = noctalia.getConfig("tuneDhuhr") @@ -42,6 +44,17 @@ local function reloadConfig() 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 + + local function validAngle(value) + local angle = tonumber(value) + if angle and angle == angle and angle > 0 and angle < 90 then + return tostring(angle) + end + return nil + end + + fajrAngle = validAngle(c_fajrAngle) + ishaAngle = validAngle(c_ishaAngle) tune = (c_tune == true or c_tune == "true") tuneFajr = tonumber(c_tuneFajr) or 0 tuneDhuhr = tonumber(c_tuneDhuhr) or 0 @@ -69,16 +82,24 @@ end -- 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 PRAYER_ARABIC = { + Fajr = "الفجر", Dhuhr = "الظهر", Jumuah = "الجمعة", Asr = "العصر", Maghrib = "المغرب", Isha = "العشاء", +} local loadedDate = "" local fetchPending = false local retryCount = 0 -local MAX_RETRIES = 5 -local retryTicks = 0 +local retryAt = 0 local tomorrowFetched = false local lastNotified = "" local lastAzanPlayed = "" local playingAzanPath = "" -- absolute path of the azan file currently playing, if any +local azanGeneration = 0 +local RETRY_DELAYS = { 5, 10, 15, 30, 60 } +local CALENDAR_CACHE_TTL = 30 * 24 * 60 * 60 +local CALENDAR_CACHE_LIMIT = 24 +local calendarFetchPending = false +local queuedCalendarRequest = nil -- ── Helpers ─────────────────────────────────────────────────────────────────── @@ -106,6 +127,15 @@ local function formatDisplayTime(clean) return string.format("%d:%s %s", displayHour, m, period) end +-- Formats a time-of-day value after applying a local offset. Display wraps at +-- midnight while the unwrapped value remains available for event scheduling. +local function formatAdjustedTime(secs) + local timeOfDay = secs % 86400 + local h = math.floor(timeOfDay / 3600) + local m = math.floor((timeOfDay % 3600) / 60) + return formatDisplayTime(string.format("%02d:%02d", h, m)) +end + local function nowSeconds() local t = os.date("*t") return t.hour * 3600 + t.min * 60 + t.sec @@ -135,13 +165,171 @@ end local function buildUrl(offsetDays) local dateStr = dateParam(offsetDays or 0) - return string.format( + local url = 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 ) + if method == "99" then + local settings = (fajrAngle or "null") .. ",null," .. (ishaAngle or "null") + url ..= "&methodSettings=" .. noctalia.string.urlEncode(settings) + end + return url +end + +local function buildCalendarUrl() + local today = os.date("*t") + local url = string.format( + "https://api.aladhan.com/v1/calendarByCity/%d/%02d?city=%s&country=%s&method=%s&school=%s", + today.year, today.month, + noctalia.string.urlEncode(city), + noctalia.string.urlEncode(country), + method, school + ) + if method == "99" then + local settings = (fajrAngle or "null") .. ",null," .. (ishaAngle or "null") + url ..= "&methodSettings=" .. noctalia.string.urlEncode(settings) + end + return url +end + +local function calendarCacheKey() + local today = os.date("*t") + return table.concat({ + city, country, method, school, fajrAngle or "", ishaAngle or "", + tostring(today.year), tostring(today.month), + }, "\0") +end + +local function calendarCachePath() + local dir, err = noctalia.pluginDataDir() + if not dir then + noctalia.log("Mawaqit: cache unavailable: " .. tostring(err)) + return nil + end + return dir .. "/prayer-calendar.json" +end + +local function saveCalendarCache(data) + if type(data.data) ~= "table" or data.data[1] == nil then return end + local path = calendarCachePath() + if not path then return end + + local encoded, err = noctalia.json.encode({ + version = 1, + key = calendarCacheKey(), + fetchedAt = os.time(), + calendar = data, + }, true) + if not encoded then + noctalia.log("Mawaqit: failed to encode calendar cache: " .. tostring(err)) + return + end + + local written, writeErr = noctalia.writeFile(path .. ".tmp", encoded) + if not written then + noctalia.log("Mawaqit: failed to write calendar cache: " .. tostring(writeErr)) + return + end + local renamed, renameErr = noctalia.renameFile(path .. ".tmp", path) + if not renamed then + noctalia.log("Mawaqit: failed to finalize calendar cache: " .. tostring(renameErr)) + end +end + +local function loadCalendarCache() + local path = calendarCachePath() + if not path then return nil end + local raw, readErr = noctalia.readFile(path) + if not raw then + if readErr then noctalia.log("Mawaqit: failed to read calendar cache: " .. tostring(readErr)) end + return nil + end + local cached, decodeErr = noctalia.json.decode(raw) + if type(cached) ~= "table" then + noctalia.log("Mawaqit: invalid calendar cache: " .. tostring(decodeErr)) + return nil + end + if cached.version ~= 1 or cached.key ~= calendarCacheKey() or type(cached.calendar) ~= "table" then + return nil + end + return cached.calendar +end + +local function hijriCalendarCachePath() + local dir, err = noctalia.pluginDataDir() + if not dir then + noctalia.log("Mawaqit: calendar cache unavailable: " .. tostring(err)) + return nil + end + return dir .. "/hijri-calendars.json" +end + +local function hijriCalendarKey(year, month) + return tostring(year) .. "-" .. string.format("%02d", month) +end + +local function readHijriCalendarCache() + local path = hijriCalendarCachePath() + if not path then return { version = 1, months = {} }, nil end + local raw = noctalia.readFile(path) + if not raw then return { version = 1, months = {} }, path end + local cache, err = noctalia.json.decode(raw) + if type(cache) ~= "table" or cache.version ~= 1 or type(cache.months) ~= "table" then + noctalia.log("Mawaqit: invalid Hijri calendar cache: " .. tostring(err)) + return { version = 1, months = {} }, path + end + return cache, path +end + +local function writeHijriCalendarCache(cache, path) + if not path then return end + local encoded, err = noctalia.json.encode(cache, true) + if not encoded then + noctalia.log("Mawaqit: failed to encode Hijri calendar cache: " .. tostring(err)) + return + end + local written, writeErr = noctalia.writeFile(path .. ".tmp", encoded) + if not written then + noctalia.log("Mawaqit: failed to write Hijri calendar cache: " .. tostring(writeErr)) + return + end + local renamed, renameErr = noctalia.renameFile(path .. ".tmp", path) + if not renamed then + noctalia.log("Mawaqit: failed to finalize Hijri calendar cache: " .. tostring(renameErr)) + end +end + +local function cachedHijriCalendar(year, month) + local cache, _path = readHijriCalendarCache() + local entry = cache.months[hijriCalendarKey(year, month)] + if type(entry) ~= "table" or type(entry.fetchedAt) ~= "number" or type(entry.data) ~= "table" then + return nil, 0, false + end + return entry.data, entry.fetchedAt, os.time() - entry.fetchedAt <= CALENDAR_CACHE_TTL +end + +local function saveHijriCalendar(year, month, data) + local cache, path = readHijriCalendarCache() + if not path then return end + local now = os.time() + cache.months[hijriCalendarKey(year, month)] = { fetchedAt = now, data = data } + + local entries = {} + for key, entry in pairs(cache.months) do + if type(entry) ~= "table" or type(entry.fetchedAt) ~= "number" or type(entry.data) ~= "table" then + cache.months[key] = nil + else + entries[#entries + 1] = { key = key, fetchedAt = entry.fetchedAt } + end + end + table.sort(entries, function(a, b) return a.fetchedAt > b.fetchedAt end) + for index = CALENDAR_CACHE_LIMIT + 1, #entries do + cache.months[entries[index].key] = nil + end + writeHijriCalendarCache(cache, path) end -- Resolves the bundled azan file's absolute path from this plugin's own @@ -167,7 +355,21 @@ local function processPrayerData(data) publishError("API error: " .. tostring(data and data.status or "unknown")) return false end - local timings = data.data and data.data.timings + + local entry = data.data + if type(entry) == "table" and entry.timings == nil then + local today = dateParam(0) + for _, candidate in ipairs(entry) do + local gregorian = candidate.date and candidate.date.gregorian + if gregorian and gregorian.date == today then + entry = candidate + break + end + end + if entry.timings == nil then entry = entry[1] end + end + + local timings = entry and entry.timings if not timings then publishError("Parse error: no timings in response") return false @@ -183,7 +385,7 @@ local function processPrayerData(data) secs = applyTune(name, secs) prayers[#prayers+1] = { name = name, - time = formatDisplayTime(clean), + time = formatAdjustedTime(secs), seconds = secs, azanPrayer = AZAN_PRAYERS[i], } @@ -193,15 +395,17 @@ local function processPrayerData(data) -- Imsak (Ramadan only) local imsakRaw = timings["Imsak"] - local imsakTime = imsakRaw and formatDisplayTime(imsakRaw:match("^(%d+:%d+)") or imsakRaw) or "" + local imsakClean = imsakRaw and (imsakRaw:match("^(%d+:%d+)") or imsakRaw) + local imsakTime = imsakClean and formatDisplayTime(imsakClean) or "" + local imsakSeconds = imsakClean and parseTime(imsakClean) or 0 -- 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 hijri = entry.date and entry.date.hijri + local greg = (entry.date and entry.date.readable) or "" local hijriDay = 0 local hijriMonth = 0 local hijriYear = 0 @@ -228,6 +432,7 @@ local function processPrayerData(data) noctalia.state.set("prayers", prayers) noctalia.state.set("sunriseTime", sunriseTime) noctalia.state.set("imsakTime", imsakTime) + noctalia.state.set("imsakSeconds", hijriMonth == 9 and imsakSeconds or 0) noctalia.state.set("hijriDate", hijriDateStr) noctalia.state.set("hijriDateAr", hijriDateAr) noctalia.state.set("gregorianDate", greg) @@ -241,33 +446,73 @@ local function processPrayerData(data) lastNotified = "" lastAzanPlayed = "" retryCount = 0 + retryAt = 0 noctalia.log("Mawaqit: loaded " .. #prayers .. " prayers for " .. city .. ", " .. country .. " hijriMonth=" .. hijriMonth) return true end -local function fetchTimes() +local function loadCachedCalendar() + local cached = loadCalendarCache() + if cached and processPrayerData(cached) then + noctalia.log("Mawaqit: loaded prayer times from calendar cache") + return true + end + return false +end + +local function scheduleRetry() + if retryCount >= #RETRY_DELAYS then + noctalia.log("Mawaqit: retry limit reached") + return + end + local delay = RETRY_DELAYS[retryCount + 1] + retryCount += 1 + retryAt = os.time() + delay + noctalia.log("Mawaqit: retry " .. retryCount .. " in " .. delay .. " seconds") +end + +local fetchTimes + +local function handleFetchFailure(message, fallback) + if message then publishError(message) end + if not fallback then + noctalia.log("Mawaqit: calendar request failed, falling back to a single-day request") + fetchTimes(true) + else + loadCachedCalendar() + scheduleRetry() + end +end + +fetchTimes = function(fallback) if fetchPending then return end reloadConfig() fetchPending = true - local url = buildUrl(0) + local url = fallback and buildUrl(0) or buildCalendarUrl() noctalia.log("Mawaqit: fetching " .. url) - noctalia.http({ url = url, follow_redirects = true }, function(res) + local accepted = 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) .. ")") + handleFetchFailure("Network error (status=" .. tostring(res.status) .. ")", fallback) 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") + handleFetchFailure("Parse error", fallback) return end - processPrayerData(data) + if processPrayerData(data) then + if not fallback then saveCalendarCache(data) end + else + handleFetchFailure(nil, fallback) + end end) + if accepted == false then + fetchPending = false + handleFetchFailure("Network request rejected", fallback) + end end local function fetchTomorrowFajr() @@ -290,6 +535,72 @@ local function fetchTomorrowFajr() end) end +local requestHijriCalendar + +local function publishHijriCalendar(year, month, status, data, fetchedAt, error) + noctalia.state.set("hijriCalendar", { + key = hijriCalendarKey(year, month), + status = status, + data = data or {}, + fetchedAt = fetchedAt or 0, + error = error or "", + }) +end + +local function drainCalendarQueue(year, month) + local queued = queuedCalendarRequest + queuedCalendarRequest = nil + if queued and (queued.year ~= year or queued.month ~= month or queued.force) then + requestHijriCalendar(queued.year, queued.month, queued.force) + end +end + +requestHijriCalendar = function(year, month, force) + year = tonumber(year) + month = tonumber(month) + if not year or not month or year < 1 or month < 1 or month > 12 then + noctalia.log("Mawaqit: invalid Hijri calendar request") + return + end + year, month = math.floor(year), math.floor(month) + + if calendarFetchPending then + queuedCalendarRequest = { year = year, month = month, force = force == true } + return + end + + local cached, fetchedAt, fresh = cachedHijriCalendar(year, month) + if cached and fresh and not force then + publishHijriCalendar(year, month, "ready", cached, fetchedAt) + return + end + + publishHijriCalendar(year, month, "loading", cached or {}, fetchedAt) + calendarFetchPending = true + local url = "https://api.aladhan.com/v1/hToGCalendar/" .. month .. "/" .. year + local accepted = noctalia.http({ url = url, follow_redirects = true }, function(res) + calendarFetchPending = false + if res.ok and res.body ~= "" then + local ok, data = pcall(function() return noctalia.json.decode(res.body) end) + if ok and data and data.code == 200 and type(data.data) == "table" then + saveHijriCalendar(year, month, data.data) + publishHijriCalendar(year, month, "ready", data.data, os.time()) + else + publishHijriCalendar(year, month, "error", cached or {}, fetchedAt, "Calendar data unavailable") + end + else + publishHijriCalendar(year, month, "error", cached or {}, fetchedAt, "Calendar request failed") + end + + drainCalendarQueue(year, month) + end) + if accepted == false then + calendarFetchPending = false + publishHijriCalendar(year, month, "error", cached or {}, fetchedAt, "Calendar request rejected") + drainCalendarQueue(year, month) + end +end + -- ── Azan playback ───────────────────────────────────────────────────────────── -- Kills azan playback in progress. Matches on the exact absolute path this @@ -299,6 +610,7 @@ end -- PID, so command-line matching is the only way to terminate a detached -- process with the current API. local function stopAzan() + azanGeneration += 1 if playingAzanPath ~= "" then local pattern = shellQuote(ereEscape(playingAzanPath)) noctalia.runAsync("pkill -f -- " .. pattern .. " 2>/dev/null || true") @@ -307,46 +619,79 @@ local function stopAzan() noctalia.state.set("azanPlaying", false) end +local function startAzan() + stopAzan() + local path = azanPath() + if path == "" or not noctalia.fileExists(path) then + if path ~= "" then noctalia.log("Mawaqit: azan file not found: " .. path) end + return false + end + + 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)") + return false + end + + azanGeneration += 1 + local generation = azanGeneration + playingAzanPath = path + noctalia.state.set("azanPlaying", true) + local finished = "noctalia msg plugin " .. shellQuote("ycf/mawaqit:fetcher") + .. " all azan-finished " .. generation .. " >/dev/null 2>&1" + local launched = noctalia.runAsync("(" .. cmd .. "; " .. finished .. ") &") + if not launched then + playingAzanPath = "" + noctalia.state.set("azanPlaying", false) + return false + end + return true +end + +local function previewAzan() + reloadConfig() + if not startAzan() then + noctalia.notify("Mawaqit Azan preview", "Selected azan file or audio player is unavailable") + end +end + -- ── Prayer events ───────────────────────────────────────────────────────────── local cachedPrayers = {} +local cachedImsakSeconds = 0 +local cachedHijriMonth = 0 +local cachedImsakTime = "" local function checkPrayerEvents() - if #cachedPrayers == 0 then return end local now = nowSeconds() + + if cachedHijriMonth == 9 and cachedImsakSeconds > 0 + and now >= cachedImsakSeconds and now < cachedImsakSeconds + 30 then + local key = loadedDate .. "_Imsak" + if showNotifications and lastNotified ~= key then + lastNotified = key + noctalia.notify("Imsak - " .. cachedImsakTime, "حان وقت الإمساك") + end + end + 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 + local arabicName = p.name == "Dhuhr" and isJumuah() and PRAYER_ARABIC.Jumuah or PRAYER_ARABIC[p.name] if showNotifications and lastNotified ~= key then lastNotified = key - noctalia.notify(title .. " prayer time", "It is time for " .. title) + noctalia.notify(title .. " - " .. p.time, "حان الآن موعد صلاة " .. (arabicName or 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 + startAzan() end end end @@ -356,11 +701,25 @@ noctalia.state.watch("prayers", function(val) if type(val) == "table" then cachedPrayers = val end end) +noctalia.state.watch("imsakSeconds", function(val) + cachedImsakSeconds = tonumber(val) or 0 +end) + +noctalia.state.watch("imsakTime", function(val) + cachedImsakTime = type(val) == "string" and val or "" +end) + +noctalia.state.watch("hijriMonth", function(val) + cachedHijriMonth = tonumber(val) or 0 +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() + elseif cmd.action == "previewAzan" then previewAzan() + elseif cmd.action == "calendar" then requestHijriCalendar(cmd.year, cmd.month, cmd.force) end end) @@ -376,12 +735,9 @@ function update() tomorrowFetched = true fetchTomorrowFajr() end - if retryCount > 0 and retryCount <= MAX_RETRIES then - retryTicks += 1 - if retryTicks >= 6 then - retryTicks = 0 - fetchTimes() - end + if retryAt > 0 and os.time() >= retryAt then + retryAt = 0 + fetchTimes() end checkPrayerEvents() end @@ -391,14 +747,22 @@ function onConfigChanged() reloadConfig() loadedDate = "" retryCount = 0 + retryAt = 0 cachedPrayers = {} noctalia.state.set("prayers", {}) + noctalia.state.set("imsakSeconds", 0) + noctalia.state.set("hijriMonth", 0) fetchTimes() end -function onIpc(event, _payload) +function onIpc(event, payload) if event == "refresh" then onConfigChanged() + elseif event == "preview-azan" then + previewAzan() + elseif event == "azan-finished" and tonumber(payload) == azanGeneration then + playingAzanPath = "" + noctalia.state.set("azanPlaying", false) end end @@ -406,3 +770,6 @@ function onExit() -- Clean up: stop any playing azan when plugin is disabled or noctalia exits stopAzan() end + +loadCachedCalendar() +fetchTimes() diff --git a/mawaqit/translations/en.json b/mawaqit/translations/en.json index f7353323..89faf4e4 100644 --- a/mawaqit/translations/en.json +++ b/mawaqit/translations/en.json @@ -5,6 +5,10 @@ "loading": "Loading prayer times…", "now": "Now", "sunrise": "Sunrise", + "tabs": { + "calendar": "Calendar", + "prayers": "Prayer Times" + }, "title": "Prayer Times", "tooltip": { "in": "In", @@ -16,6 +20,14 @@ }, "plugin_description": "Prayer times with live countdown, notifications, and azan.", "plugin_name": "Mawaqit", + "calendar": { + "in_days": "in {days} days", + "loading": "Loading calendar…", + "next": "Next month", + "previous": "Previous month", + "today": "Back to today", + "today_label": "Today" + }, "settings": { "active_color": { "description": "Color used when a prayer is happening now or in elapsed mode.", @@ -36,6 +48,10 @@ "description": "Country name or 2-letter code (e.g. UK, DZ, FR).", "label": "Country" }, + "fajr_angle": { + "description": "Fajr sun angle for Custom Method. Use a decimal greater than 0 and less than 90.", + "label": "Fajr angle" + }, "dynamic_icon": { "description": "Show a sun/moon icon matching the current prayer instead of the fixed icon.", "label": "Dynamic prayer icon" @@ -56,6 +72,7 @@ }, "method": { "algeria": "Algeria / MESRS", + "custom": "Custom angles", "description": "Choose the calculation authority followed in your region.", "dubai": "Dubai (experimental)", "egypt": "Egyptian General Authority of Survey", @@ -80,6 +97,10 @@ "tunisia": "Tunisia", "turkey": "Diyanet İşleri Başkanlığı, Turkey" }, + "isha_angle": { + "description": "Isha sun angle for Custom Method. Use a decimal greater than 0 and less than 90.", + "label": "Isha angle" + }, "play_azan": { "description": "Play an azan audio file when each prayer time begins.", "label": "Play Azan" @@ -130,6 +151,13 @@ }, "widget_icon": { "label": "Bar icon" + }, + "week_start_day": { + "description": "Choose which day starts the calendar week.", + "label": "First day of week", + "monday": "Monday", + "saturday": "Saturday", + "sunday": "Sunday" } } }