Life is a math equation. In order to gain the most, you have to know how to convert negatives into positives, Anonymous.
This Python script, bible_gui.py, serves as a comprehensive desktop prayer tool, built with the power of PySide6 (Qt for Python). It is designed to centralize your daily devotional workflow into a single, elegantly styled window featuring four distinct tabs.
Verse Tab: This is your daily bread. It fetches a Bible verse of the day, prioritizing offline sources first (your local reading plan daily_plan.txt combined with the diatheke CLI tool and freebible library) to ensure you always have a verse even without an internet connection. If offline lookups fail, it gracefully falls back to online APIs like YouVersion and OurManna.
Videos Tab: A dedicated player for curated YouTube prayer videos. To circumvent the common “Refused to display…” error (Error 152) that plagues desktop apps trying to embed YouTube directly, this tab implements a clever local HTTP server strategy. It spins up a tiny server on localhost to serve the embed page, satisfying YouTube’s strict X-Frame-Options requirements and allowing seamless playback within the interface.
Online Tab: This tab extends your prayer resources by embedding external websites directly into the application. It loads a curated list of online prayer sites (such as Sacred Space, Pray As You Go, and Universalis) from a configuration file and displays them using a built-in browser widget. It intelligently handles site restrictions; if a site refuses to be embedded due to security headers (X-Frame-Options), the app detects the failure and provides a prominent button to open the content in your default system browser instead.
Diary Tab: A Markdown-based prayer journal. It allows you to write your daily prayers, gratitude, intentions, and reflections. It includes a live preview mode (rendering Markdown to HTML) and manages your entries as individual files sorted by date, creating a searchable history of your spiritual journey.
The application is built with user experience in mind: it auto-creates configuration files if they are missing, handles missing dependencies (like bible_verse.py) with functional fallbacks, and uses a consistent, dark-themed color palette.

# ══════════════════════════════════════════════════════════════════════════════
# HTML TEMPLATES FOR THE VIDEO PLAYER
# ══════════════════════════════════════════════════════════════════════════════
#
# When you embed a YouTube video on a website, YouTube checks the "origin" (the domain name of the website)
# to make sure embedding is allowed.
# Desktop apps use browser widgets (like QWebEngineView in Qt) to display web content. If you
# try to directly inject a YouTube iframe into this widget using a command like setHtml(), the
# underlying Chromium browser engine reports the origin as "null"
# Because "null" is not a real web address, YouTube’s strict security rules (Content-Security-Policy)
# block the video to prevent piracy and malicious embedding. You end up with a black screen that says
# "Video unavailable" (Error 152).
#
# NOTE: some videos have embedding disabled by the uploader regardless of
# origin.
# To trick YouTube into thinking the video is being played on a legitimate website, this code spins up a tiny,
# invisible web server right inside your application.
# Instead of injecting the HTML directly, the app tells the browser widget to visit http://127.0.0.1 (your local machine).
# Because this is a standard HTTP web address, YouTube's security check passes, and the video plays perfectly.
# Placeholder page shown when no video is selected.
# The message gently guides the user to pick a video from the list.
_PLACEHOLDER = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
background: #111827;
color: #9ca3af;
font-family: "Noto Sans", sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
text-align: center;
}
.msg { opacity: 0.8; font-size: 17px; line-height: 1.6; }
.icon { font-size: 40px; margin-bottom: 14px; }
</style>
</head>
<body>
<div class="msg">
<div class="icon">🙏</div>
Select a video from the list<br>to begin prayer.
</div>
</body>
</html>"""
# Template for the actual YouTube embed. It contains an IFRAME pointing to YouTube.
# {vid} is replaced at runtime with the YouTube video ID.
# autoplay=1 starts playback immediately when the video is selected.
# modestbranding=1 hides the YouTube logo in the player controls.
# rel=0 suppresses related-video recommendations at the end of playback.
# controls=1 shows the standard playback bar (play/pause, volume, etc.).
_EMBED = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
background: #000;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}}
iframe {{
width: 100%;
height: 100%;
border: none;
}}
</style>
</head>
<body>
<iframe
src="https://www.youtube.com/embed/{vid}?autoplay=1&modestbranding=1&rel=0&controls=1"
allow="autoplay; encrypted-media; fullscreen"
allowfullscreen>
</iframe>
</body>
</html>"""
def load_videos() -> list:
"""
Read VIDEOS_FILE and return n a list of video dictionaries, [{id, title}, ...].
File format — one video per line:
VIDEO_ID Title of the video
# comment lines are skipped
The video ID is the part after youtube.com/watch?v= in the URL.
Example:
lHIrBNqE6nI Peaceful Morning Prayer Music - 1 Hour
xSGnxQBPyBg 10 Minute Morning Prayer
If VIDEOS_FILE does not exist, a sample file is created so the user
can see the expected format immediately. This is a helpful first‑run
behaviour: the app never fails just because a config file is missing.
Returns:
A list of dictionaries, each with keys:
- 'id' (str) the YouTube video ID
- 'title' (str) the human‑readable title
Returns an empty list if the file is empty or contains only comments.
[
{"id":"Ac_nVJASVfc", "title":"Peaceful Morning Prayer Music - 1 Hour"},
{"id":"yyyyy", "title":"another title"},
]
"""
# If the file doesn't exist, write a sample one so the app is usable.
# Instead of: "Error: file missing", the app says: "No problem. I'll create an example."
if not VIDEOS_FILE.exists():
VIDEOS_FILE.parent.mkdir(parents=True, exist_ok=True)
VIDEOS_FILE.write_text(
"# prayer_videos.txt — YouTube video IDs for the Prayer tab\n"
"# Format: VIDEO_ID Title of the video\n"
"# Get the ID from: youtube.com/watch?v=VIDEO_ID\n"
"#\n"
"Ac_nVJASVfc Peaceful Morning Prayer Music - 1 Hour\n"
"eYdmcZUSt0s 10 Minute Morning Prayer with Scripture\n"
"3ixV9raPKMo Gregorian Chant - Ave Maria\n"
"nQWFzMvCfAU The Rosary - Joyful Mysteries\n"
"JkFbYoFJiSY Evening Prayer - Taizé Community\n",
encoding="utf-8",
)
videos = []
for line in VIDEOS_FILE.read_text("utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Split on first whitespace to separate ID from title.
# None means: split on ANY whitespace, 1 means split only once.
# "abc123 My Beautiful Prayer Video" becomes [ "abc123", "My Beautiful Prayer Video" ]
# Not ["abc123","My","Beautiful"...]
# If there is no title, the ID is used as the title as a fallback.
parts = line.split(None, 1)
if parts:
videos.append({
"id": parts[0].strip(),
"title": parts[1].strip() if len(parts) > 1 else parts[0],
})
return videos
# ══════════════════════════════════════════════════════════════════════════════
# LOCAL HTTP SERVER FOR YOUTUBE EMBEDS
# ══════════════════════════════════════════════════════════════════════════════
#
# THE PROBLEM
# -----------
# YouTube embeds (youtube.com/embed/ID) only work when the *parent page* has
# a real HTTP origin. Qt's setHtml() loads content with origin "null" (a
# data: URL internally), and Chromium enforces YouTube's X-Frame-Options /
# Content-Security-Policy, blocking the embed with error 152/153.
#
# Running a Hugo server in local host works because Hugo serves it over HTTP — the iframe lives
# inside a page with origin http://localhost:1313 or https://yoursite.com.
# Navigating directly to the embed URL in a browser also fails (error 153)
# for the same reason — the embed URL is not meant to be a top-level page.
#
# THE FIX
# -------
# QWebEngineView Python thread
# │ │
# │ GET /player.html ────────────►│ _VideoHTMLHandler
# │◄──── 200 OK + HTML ────────────│ (serves _EMBED with {vid} filled in)
# │ │
# │ renders the page │
# │ iframe src=youtube.com/embed/ │
# │ origin = http://127.0.0.1:N │ ← real HTTP origin ✅
# │ YouTube allows it │
#
# We start a tiny Python HTTP server on a random localhost port
# QWebEngineView loads http://localhost:PORT/player.html — a real HTTP URL.
# The embed iframe now has origin http://localhost:PORT, which satisfies
# YouTube's embedding requirements identically to your Hugo page.
#
# The server runs in a daemon thread so it exits automatically when the
# app closes. It serves a single in-memory HTML page that is rebuilt each
# time the user selects a video.
import threading
import http.server
import urllib.parse
import socketserver
# class _VideoHTMLHandler(http.server.BaseHTTPRequestHandler) means when browser makes HTTP request, use this class.
# Like Flask router handler.
class _VideoHTMLHandler(http.server.BaseHTTPRequestHandler):
"""
Minimal HTTP handler that serves one HTML page at /player.html.
The page content is stored as a class variable so VideoServer can
update it without restarting the server. This is thread‑safe because
the write happens before a request comes in; during a request the
content is read only.
This is the "traffic cop" for our tiny local server.
"""
# Class variable. Shared by all instances.
html_content: bytes = b""
def do_GET(self) -> None:
"""
It intercepts GET requests!
Serve the embedded page at /player.html (and optionally at /).
All other paths return a plain 404.
"""
# When the Qt widget navigates to [http://] + [127.0.0.1:PORT| + /player.html, this handler intercepts the GET request.
# Accept / or /prayer.html.
if self.path in ("/player.html", "/"):
# It responds with a 200 OK success message HTTP/1.1 200 OK ...
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8") # Tell browser HTML page
self.send_header("Content-Length", str(len(self.html_content)))
# Allow the embedded iframe to autoplay and use encrypted media.
# Without this header, Chromium might block autoplay on localhost.
self.send_header(
"Permissions-Policy",
"autoplay=*, encrypted-media=*",
)
self.end_headers()
# ... and spits out the HTML from the _EMBED template.
self.wfile.write(self.html_content)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *args):
"""
Suppress access log output to keep the console clean.
The default handler prints every request; we don't need that noise.
"""
pass
class VideoServer:
"""
Singleton local HTTP server that serves the YouTube embed page.
Because YouTube requires a real HTTP origin, we cannot simply use
QWebEngineView.setHtml(). Instead, we run a tiny server on localhost
and point the web view at http://127.0.0.1:/player.html.
Usage:
server = VideoServer.get() # start on first call (once)
server.serve(video_id) # update page content
url = server.url() # http://localhost:PORT/player.html
"""
_instance: "VideoServer | None" = None
def __init__(self) -> None:
"""
Create a TCP server on a random port and start it in a daemon thread.
The server listens only on 127.0.0.1 (localhost) so it cannot be
accessed from other machines. Port 0 tells the OS to pick a free one.
"""
# Creates actual TCP server. 127.0.0.1 = localmachine only; 0 = Let the OS pick a free port (port=0)
self._httpd = socketserver.TCPServer(("127.0.0.1", 0), _VideoHTMLHandler)
# Then, we save the port.
self._port = self._httpd.server_address[1]
# daemon=True; Daemon Thread: It runs the server on a separate "daemon thread."
# This means the server runs in the background and won't freeze the app's user interface.
# Because it's a daemon, it will automatically shut itself down when the user closes the main app.
# target=self._httpd.serve_forever = Infinite server loop.
t = threading.Thread(target=self._httpd.serve_forever, daemon=True)
# Launch background thread. Now server runs independently.
t.start()
# @classmethod means method belongs to class.
@classmethod
def get(cls) -> "VideoServer":
"""
Return the single VideoServer instance, starting it if necessary.
This guarantees only one server is running, no matter how many
times the video tab is used.
"""
# First time?
if cls._instance is None:
# Create
cls._instance = cls()
return cls._instance
def serve(self, video_id: str) -> None:
"""
When you want to play a new video: server.serve(video_id)
Build the embed HTML for `video_id` and store it for the next GET.
This should be called *before* loading the URL so the page is
ready immediately. The video ID is URL‑encoded to be safe inside
the iframe src attribute.
"""
# Builds the HTML from our _EMBED template
# URL‑encodes the video ID. urllib.parse.quote escapes unsafe chars.
html = _EMBED.format(vid=urllib.parse.quote(video_id, safe=""))
# Stores the HTML in the handler’s class variable
_VideoHTMLHandler.html_content = html.encode("utf-8")
# No request has happened yet — we’re just preparing the page.
# Now next browser request gets new page.
def url(self) -> str:
"""Return the local URL that the web view should navigate to.
Example: [http://] + 127.0.0.1:55321 + [/player.html]
"""
return f"http://127.0.0.1:{self._port}/player.html"
# ── VideoTab ──────────────────────────────────────────────────────────────────
class VideoTab(QWidget):
"""
Left panel — list of videos from prayer_videos.txt.
Right panel — YouTube embed via QWebEngineView served from localhost.
We use a local HTTP server (VideoServer) so the embed page has a real
HTTP origin (http://127.0.0.1:PORT), which satisfies YouTube's
X-Frame-Options and CSP requirements — the same reason a Hugo‑powered
local site works but `setHtml()` does not.
The server starts once on first use (VideoServer.get()) and stays
alive for the lifetime of the application.
"""
def __init__(self):
# Initialize QWidget.
super().__init__()
# Load the list of videos from the config file (auto‑creates if missing)
self.videos = load_videos()
# Horizontal root layout: left list, right player
# +---------+-------------------+
# | left | right |
# +---------+-------------------+
root = QHBoxLayout(self)
root.setContentsMargins(14, 14, 14, 14)
root.setSpacing(14)
# ── Left: video list ──────────────────────────────────────────────
# Vertical stack.
left = QVBoxLayout()
# Heading label
cap = QLabel("Prayer Videos")
cap.setStyleSheet(f"color:{ACCENT};font-weight:bold;font-size:12px;")
left.addWidget(cap)
# List widget displaying video titles
self.lst = QListWidget()
# Populate
self.lst.setFixedWidth(240) # fixed width for consistent layout
for v in self.videos:
self.lst.addItem(v["title"]) # Display title.
# Clicking an item triggers playback of the corresponding video
self.lst.itemClicked.connect(
lambda _: self._play(self.lst.currentRow())
)
left.addWidget(self.lst, 1) # stretch factor 1: takes available vertical space
# Reload button: re-reads prayer_videos.txt without restarting the app
reload_btn = QPushButton("↺ Reload list")
reload_btn.setToolTip(str(VIDEOS_FILE)) # show file path (~/dotfiles/myArch/config/bible/prayer_videos.txt) on hover
reload_btn.clicked.connect(self._reload_list)
left.addWidget(reload_btn)
# "Edit video list" button – opens the file in the default text editor
edit_btn = QPushButton("✏ Edit video list")
edit_btn.setToolTip(str(VIDEOS_FILE))
edit_btn.clicked.connect(self._open_list_file)
left.addWidget(edit_btn)
root.addLayout(left)
# ── Right: WebEngine player ───────────────────────────────────────
right = QVBoxLayout()
right.setSpacing(6)
# Status label shows the currently playing video title and server port
self.status_lbl = QLabel("Select a video to begin prayer.")
self.status_lbl.setStyleSheet(f"color:{MUTED};font-size:12px;")
right.addWidget(self.status_lbl)
# Web engine view – the actual YouTube player will be loaded here
self.web = QWebEngineView()
# Prepare the placeholder page on the local server and load it
srv = VideoServer.get() # Start the server once (on first access)
_VideoHTMLHandler.html_content = _PLACEHOLDER.encode("utf-8") # Initial page.
self.web.load(QUrl(srv.url())) # load /player.html from the local server
# Browser requests: GET /player.html, Server responds
right.addWidget(self.web, 1) # the web view fills the remaining space
root.addLayout(right, 1)
def _play(self, idx: int) -> None:
"""
Play the video at index idx.
Sequence:
1. Update the server's HTML content with the new embed.
2. Call web.reload() — the browser re-fetches /player.html
from localhost with the new embed src.
WHY load() instead of setHtml():
load() fetches http://127.0.0.1:PORT/player.html — a real HTTP
URL with a real origin. YouTube's embedding checks pass because
the referrer/origin is an HTTP address, not "null".
WHY reload() instead of load(url) again:
We already updated the server content before triggering the
browser. A reload() re-fetches the same URL without creating
a new navigation entry in the back/forward stack.
"""
# Bounds check. We want to avoid crashes.
if not (0 <= idx < len(self.videos)):
return
# Get selected video.
vid = self.videos[idx]["id"]
title = self.videos[idx]["title"]
# Update the local server
srv = VideoServer.get()
srv.serve(vid) # Build the embed HTML page and store it for the next GET.
self.web.reload() # tell the browser to get the new page (refresh browser)
# Browser asks GET /prayer.html and gets updated page.
# Update status. Show the video title and the localhost port (local server HTTP) for debugging
self.status_lbl.setText(f"▶ {title} (localhost:{srv._port})")
def _reload_list(self) -> None:
"""
Re‑read `prayer_videos.txt` and refresh the list widget.
This allows the user to add/remove videos in the text file without
restarting the application. The status label is updated to confirm
the action.
"""
self.videos = load_videos() # re‑read the file
self.lst.clear()
for v in self.videos:
self.lst.addItem(v["title"])
self.status_lbl.setText("List reloaded from file.")
def _open_list_file(self) -> None:
"""
Open `prayer_videos.txt` in the default text editor.
Attempts to use `kate`, `gedit`, or `xdg-open` (which respects the
system's default application). The editor process is detached so
it runs independently of the application.
"""
# Choose a known editor or fall back to xdg-open
editor = shutil.which("kate") or shutil.which("gedit") or "xdg-open"
# start_new_session=True detaches the editor from this process
subprocess.Popen([editor, str(VIDEOS_FILE)], start_new_session=True)
# ══════════════════════════════════════════════════════════════════════════════
# PRAYER ONLINE HELPERS + TAB
# File‑based site list and automatic first‑time setup
# ══════════════════════════════════════════════════════════════════════════════
def load_prayer_sites() -> list:
"""
Read PRAYER_FILE and return a list of site dicts.
File format — one site per line:
url:name:description
# lines starting with # are comments
Example:
https://sacredspace.com:Sacred Space:Daily Ignatian prayer by the Irish Jesuits
https://pray-as-you-go.org:Pray As You Go:Daily audio prayer from Jesuit Media
If the file does not exist a sample is created automatically.
"""
# The first run of the application will never throw a FileNotFoundError.
# The directory is created if missing, and a sensible default set of prayer sites is written into the file.
# This makes the app immediately usable.
if not PRAYER_FILE.exists():
PRAYER_FILE.parent.mkdir(parents=True, exist_ok=True)
# The default sites are curated but user‑friendly: Sacred Space, Pray As You Go, Universalis, etc.
# All are highly relevant for a prayer app.
PRAYER_FILE.write_text(
"# prayeronline.txt — online prayer sites\n"
"# Format: url:name:description\n"
"#\n"
"https://www.sacredspace.com:Sacred Space:"
"Daily Ignatian prayer by the Irish Jesuits (Loyola Press)\n"
"https://pray-as-you-go.org:Pray As You Go:"
"Daily audio prayer from Jesuit Media — 10 minutes with music\n"
"https://www.universalis.com:Universalis:"
"Liturgy of the Hours — Morning, Evening and Night Prayer\n"
"https://www.pray.com:Pray.com:"
"Guided prayers, bedtime stories and devotionals\n"
"https://www.biblegateway.com:Bible Gateway:"
"Full Bible in hundreds of translations with daily reading plans\n"
"https://www.olivetree.com:Olive Tree:"
"Bible study resources and daily devotionals\n"
"https://www.jesuitprayer.org:Jesuit Prayer:"
"Daily Examen and Ignatian prayer resources\n"
"https://www.catholic.org/prayers:Catholic Online:"
"Large library of Catholic prayers for all occasions\n",
# The file is encoded in UTF‑8, which matches the Qt environment and guarantees
# that any special characters (e.g., accented names) appear correctly.
encoding="utf-8",
)
# Parsing the prayer‑sites file
sites = []
for line in PRAYER_FILE.read_text("utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# The tricky URL reconstruction
parts = line.split(":", 3) # at most 4 parts; URL may contain ://
# parts after splitting "https://example.com:Name:Desc" on ":" gives
# ["https", "//example.com", "Name", "Desc"]
# We reconstruct the URL from parts[0]+":"+parts[1] because the first two pieces together
# always contain the scheme (https) and the domain‑path portion (// + domain.com + / + something).
if len(parts) >= 2:
url = parts[0] + ":" + parts[1] # https://...
name = parts[2].strip() if len(parts) > 2 else url
desc = parts[3].strip() if len(parts) > 3 else ""
sites.append({"url": url, "name": name, "desc": desc})
return sites
# Potential edge case
# If a site’s description itself contains a colon, the description will be split further because
# split(":", 3) only limits the number of splits, not the characters inside the resulting strings.
# https://example.com:Name:Desc: extra colon here
# would produce parts = [..., "Desc", " extra colon here"].
# The code takes parts[3] as the description, so the final text would be "Desc" only,
# losing everything after the second colon.
class PrayerOnlineTab(QWidget):
"""
Tab 4 — Online Prayer Sites
Layout:
Left pane (fixed 230 px) — list of sites from prayeronline.txt.
A static‑width QListWidget makes the navigation clear and always accessible.
The fixed width (230 px) prevents the list from eating too much space on large screens.
Buttons below the site list
↺ Reload list – re‑reads the text file and repopulates the list widget.
This is useful after manually editing the file.
✏ Edit sites file – launches the system’s default text editor (or kate, gedit, or xdg-open
as fallback) to modify the file directly. The tooltip displays the full file path.
Right pane (stretch) — split vertically: uses all remaining horizontal space.
Top half : a “card” showing the site name, description, and the URL,
along with an “Open in browser” button.
Bottom half : QWebEngineView embedded browser,
which expands to fill the rest of the window height.
Embedding strategy:
We load the URL directly in QWebEngineView. Most modern prayer
sites work fine when loaded in an embedded browser because
QWebEngineView uses a full Chromium engine that respects cookies,
JavaScript, and HTTPS.
Some sites send X-Frame-Options: DENY which prevents them from
being embedded. We detect the loadFinished signal and check the
URL:
• If the final URL matches what we requested → page loaded ✅
• If Chromium blocked it (ERR_BLOCKED_BY_RESPONSE) → we show
an "Open in browser" button so the user can continue praying
in their default browser.
The embedded browser is intentionally minimal — no address bar, no
back/forward buttons — so the tab feels like part of the prayer app
rather than a general browser. The "Open externally" button is always
visible so the user can jump out at any time.
"""
def __init__(self):
super().__init__()
self.sites = load_prayer_sites()
self._current_url = ""
root = QHBoxLayout(self)
root.setContentsMargins(14, 14, 14, 14)
root.setSpacing(14)
# ── Left: site list ───────────────────────────────────────────────
left = QVBoxLayout()
cap = QLabel("Prayer Sites")
cap.setStyleSheet(f"color:{ACCENT};font-weight:bold;font-size:12px;")
left.addWidget(cap)
self.lst = QListWidget()
self.lst.setFixedWidth(230)
for s in self.sites:
# Site name
self.lst.addItem(s["name"])
self.lst.itemClicked.connect(
lambda _: self._select(self.lst.currentRow())
)
left.addWidget(self.lst, 1)
reload_btn = QPushButton("↺ Reload list")
reload_btn.clicked.connect(self._reload)
left.addWidget(reload_btn)
edit_btn = QPushButton("✏ Edit sites file")
edit_btn.setToolTip(str(PRAYER_FILE))
edit_btn.clicked.connect(self._edit_file)
left.addWidget(edit_btn)
root.addLayout(left)
# ── Right: info card + browser ────────────────────────────────────
right = QVBoxLayout()
right.setSpacing(10)
# Site name
self.name_lbl = QLabel("Select a prayer site")
# Name – large, bold, gold‑coloured (GOLD). This makes it visually stand out as the primary heading.
self.name_lbl.setStyleSheet(
f"color:{GOLD};font-size:19px;font-weight:bold;"
)
self.name_lbl.setWordWrap(True)
right.addWidget(self.name_lbl)
# Description
self.desc_lbl = QLabel("")
# Description – subtle, muted colour, one line below. The label wraps automatically so longer descriptions don’t cause layout problems.
self.desc_lbl.setStyleSheet(f"color:{MUTED};font-size:13px;")
self.desc_lbl.setWordWrap(True)
right.addWidget(self.desc_lbl)
# URL + open-externally button in one row
# URL row – combines the actual URL label with the “Open in browser” button.
url_row = QHBoxLayout()
self.url_lbl = QLabel("")
# The URL uses the accent colour (likely a blue) to indicate it is clickable (though it is not directly interactive; the button beside it handles the action).
self.url_lbl.setStyleSheet(f"color:{ACCENT};font-size:12px;")
self.url_lbl.setWordWrap(True)
url_row.addWidget(self.url_lbl, 1)
self.ext_btn = QPushButton("↗ Open in browser")
# The button is fixed at 170 px to prevent it from stretching.
self.ext_btn.setFixedWidth(170)
self.ext_btn.setEnabled(False)
self.ext_btn.clicked.connect(self._open_external)
url_row.addWidget(self.ext_btn)
right.addLayout(url_row)
# The horizontal rule (QFrame.HLine) separates the static information from the live browser below.
hr = QFrame(); hr.setFrameShape(QFrame.HLine)
hr.setStyleSheet(f"background:{BORDER};max-height:1px;")
right.addWidget(hr)
# Embedded browser
# QWebEngineView is a full Chromium‑based browser. It respects HTTPS, cookies, and JavaScript
# – exactly what is needed for modern web‑based prayer resources.
self.web = QWebEngineView()
# Initial placeholder – a minimal HTML page that instructs the user to select a site.
# This is stored as a static method for cleanliness.
self.web.setHtml(self._placeholder_html())
# Connect "loadFinished" signal to _on_load_finished so we can detect blocked pages.
# This is normally because the target site disallows embedding.
self.web.loadFinished.connect(self._on_load_finished)
right.addWidget(self.web, 1)
root.addLayout(right, 1)
# ── Helpers ───────────────────────────────────────────────────────────
@staticmethod
def _placeholder_html() -> str:
return (
f"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"<style>body{{background:{BG};color:{MUTED};"
f"font:16px sans-serif;display:flex;align-items:center;"
f"justify-content:center;height:100vh;margin:0;text-align:center;}}"
f".msg{{opacity:.8;line-height:1.6;}}</style></head>"
f"<body><div class=\"msg\">Select a prayer site<br>"
f"from the list to begin.</div></body></html>"
)
# Selecting a site
def _select(self, idx: int) -> None:
"""Load the site at index idx into the embedded browser.
"""
if not (0 <= idx < len(self.sites)):
return
site = self.sites[idx]
self._current_url = site["url"]
# The method updates the information card...
self.name_lbl.setText(site["name"])
self.desc_lbl.setText(site["desc"])
self.url_lbl.setText(site["url"])
# ... enables the external‑open button,
self.ext_btn.setEnabled(True)
# ... and triggers a full navigation in the web view.
self.web.load(QUrl(site["url"]))
# Handling blocked embeds – the _on_load_finished callback
def _on_load_finished(self, ok: bool) -> None:
"""
Called by Qt when the page finishes loading.
If ok=False the load was blocked (ERR_BLOCKED_BY_RESPONSE,
ERR_ABORTED, X-Frame-Options DENY, network errors, etc.). We show a friendly
fallback page that explains the situation and offers a button to
open the site externally.
Most prayer sites DO allow embedding.
"""
if not ok and self._current_url:
# When a page fails to load, ok will be False, e.g., ERR_BLOCKED_BY_RESPONSE, ERR_ABORTED, etc.
# Show a helpful blocked page ...
# Repeats the site name
name = self.name_lbl.text()
url = self._current_url
# Explains that embedding is not allowed
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
background: {BG};
color: {TEXT};
font: 15px "Noto Sans", sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
text-align: center;
}}
.card {{
max-width: 460px;
padding: 32px;
background: {PANEL};
border-radius: 12px;
border: 1px solid {BORDER};
}}
.icon {{ font-size: 36px; margin-bottom: 16px; }}
h2 {{ color: {GOLD}; margin-bottom: 8px; }}
p {{ color: {MUTED}; line-height: 1.6; margin-bottom: 20px; }}
a {{
display: inline-block;
background: {ACCENT};
color: white;
padding: 10px 24px;
border-radius: 6px;
text-decoration: none;
font-size: 14px;
}}
a:hover {{ background: #7aaee0; }}
</style>
</head>
<body>
<div class="card">
<div class="icon">🔒</div>
<h2>{name}</h2>
<p>
This site does not allow embedding in external applications.<br>
Press the button below to open it in your browser.
</p>
Provides **a prominent link** that opens the site in the external browser** (using target="_blank" inside the injected HTML).
This gives the user a second way to reach the site without having to find the external button above.
<a href="{url}" target="_blank">Open {name} in browser</a>
</div>
</body>
</html>"""
self.web.setHtml(html)
# Highlight the external button
self.ext_btn.setStyleSheet(
f"background:{GOLD};color:#1a1f2e;font-weight:bold;"
)
def _open_external(self) -> None:
"""Open the current URL in the system default browser."""
if self._current_url:
import subprocess as _sp
# xdg-open is the standard desktop‑independent command on Linux (and similar on other Unix‑like systems)
# to launch the user’s preferred browser.
# start_new_session=True detaches the child process from the prayer app so that
# closing the application does not accidentally kill the user’s browser window.
_sp.Popen(
["xdg-open", self._current_url],
start_new_session=True,
)
def _reload(self) -> None:
# Completely refreshes the internal site list from the file
self.sites = load_prayer_sites()
# Then, it rebuilds the list widget. Any newly added sites appear immediately.
self.lst.clear()
for s in self.sites:
self.lst.addItem(s["name"])
def _edit_file(self) -> None:
# Tries to use a known graphical editor first (kate on KDE, gedit on GNOME).
# If neither is available, it falls back to xdg-open, which will use the system’s file‑type association to open the text file (likely a plain‑text editor).
editor = shutil.which("kate") or shutil.which("gedit") or "xdg-open"
subprocess.Popen([editor, str(PRAYER_FILE)], start_new_session=True)