<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: AlexDesign420</title>
    <description>The latest articles on DEV Community by AlexDesign420 (@alexdesign420).</description>
    <link>https://dev.to/alexdesign420</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3959197%2F7e8131aa-100b-473b-8283-7394bafd53dc.png</url>
      <title>DEV Community: AlexDesign420</title>
      <link>https://dev.to/alexdesign420</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexdesign420"/>
    <language>en</language>
    <item>
      <title>Building a real-time F1 dashboard on macOS with free public APIs</title>
      <dc:creator>AlexDesign420</dc:creator>
      <pubDate>Fri, 29 May 2026 23:37:57 +0000</pubDate>
      <link>https://dev.to/alexdesign420/building-a-real-time-f1-dashboard-on-macos-with-free-public-apis-4722</link>
      <guid>https://dev.to/alexdesign420/building-a-real-time-f1-dashboard-on-macos-with-free-public-apis-4722</guid>
      <description>&lt;p&gt;I wanted to see live Formula 1 data on my desktop while watching the race — not buried in an app or a browser tab, but always visible in the corner of my screen. So I built a widget that pulls from two free public APIs and renders everything directly on the macOS desktop.&lt;/p&gt;

&lt;p&gt;Here's what went into it and what I learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;During a live session the widget shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live standings&lt;/strong&gt; — position, driver code, gap to leader, last lap time, tyre compound and age, pit stop count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Race Control banner&lt;/strong&gt; — Safety Car, Virtual Safety Car, Red Flag with colour-coded flashing overlay&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side panel&lt;/strong&gt; — Team radio recordings (playable), all RC messages, track weather&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio streams&lt;/strong&gt; — ARD, BBC Radio 5, talkSPORT, ORF Sport Plus and more, played via mpv&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Outside of race weekends it shows the championship standings, the full calendar and a countdown to the next event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Widget host&lt;/td&gt;
&lt;td&gt;
&lt;a href="http://tracesof.net/uebersicht/" rel="noopener noreferrer"&gt;Übersicht&lt;/a&gt; — renders JSX widgets directly on the macOS desktop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;JSX (React-like syntax, no build step)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Python 3 + Flask on &lt;code&gt;127.0.0.1:9877&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live F1 data&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://openf1.org/" rel="noopener noreferrer"&gt;OpenF1 API&lt;/a&gt; — completely free, no key needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calendar &amp;amp; standings&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://jolpi.ca/" rel="noopener noreferrer"&gt;Jolpica API&lt;/a&gt; — the Ergast successor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;mpv via Unix socket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS&lt;/td&gt;
&lt;td&gt;macOS &lt;code&gt;say -v Anna&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The two APIs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  OpenF1
&lt;/h3&gt;

&lt;p&gt;OpenF1 is the standout discovery here. It exposes granular, real-time F1 telemetry for free with no authentication. The endpoints I use most:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /v1/position?session_key=9158&amp;amp;date&amp;gt;2024-05-18T13:00:00
GET /v1/intervals?session_key=9158&amp;amp;date&amp;gt;2024-05-18T13:00:00
GET /v1/laps?session_key=9158
GET /v1/stints?session_key=9158
GET /v1/pit?session_key=9158
GET /v1/race_control?session_key=9158
GET /v1/team_radio?session_key=9158
GET /v1/weather?session_key=9158
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few gotchas that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Date filters must be ISO 8601&lt;/strong&gt;, not Unix timestamps: &lt;code&gt;date&amp;gt;2024-05-18T13:00:00&lt;/code&gt; ✅, &lt;code&gt;date&amp;gt;1716033600&lt;/code&gt; ❌&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rainfall&lt;/code&gt; is an integer&lt;/strong&gt; (0 = dry, higher = wetter), not a boolean&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gap_to_leader&lt;/code&gt;&lt;/strong&gt; from &lt;code&gt;/intervals&lt;/code&gt; is a float for normal gaps but a string like &lt;code&gt;"+1 LAP"&lt;/code&gt; for lapped cars — always &lt;code&gt;parseFloat()&lt;/code&gt; and guard before calling &lt;code&gt;.toFixed()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;compound&lt;/code&gt;&lt;/strong&gt; from &lt;code&gt;/stints&lt;/code&gt; is one of &lt;code&gt;SOFT&lt;/code&gt;, &lt;code&gt;MEDIUM&lt;/code&gt;, &lt;code&gt;HARD&lt;/code&gt;, &lt;code&gt;INTERMEDIATE&lt;/code&gt;, &lt;code&gt;WET&lt;/code&gt; (uppercase strings)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Jolpica
&lt;/h3&gt;

&lt;p&gt;Jolpica is a drop-in replacement for the now-deprecated Ergast API. The JSON structure is identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.jolpi.ca/ergast/f1/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SEASON&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/driverStandings.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;lists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MRData&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StandingsTable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StandingsLists&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lists&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DriverStandings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Constructors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# plural array, not Constructor.name
&lt;/span&gt;    &lt;span class="n"&gt;driver_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Driver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;points&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;points&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one trap: it's &lt;code&gt;Constructors[0]&lt;/code&gt; (plural, array) — not &lt;code&gt;Constructor.name&lt;/code&gt; as you might expect from the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: why a local Flask server?
&lt;/h2&gt;

&lt;p&gt;Übersicht widgets run a shell command on a timer. Shell commands can &lt;code&gt;curl&lt;/code&gt; an API directly, but you lose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Response caching (hitting OpenF1 20 times per minute per endpoint is unnecessary)&lt;/li&gt;
&lt;li&gt;Background stream health checks&lt;/li&gt;
&lt;li&gt;mpv process management via Unix socket&lt;/li&gt;
&lt;li&gt;State persistence between widget refresh cycles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Flask server runs as a daemon and handles all of that. The widget shell command just calls &lt;code&gt;engine.py&lt;/code&gt; (event detection, TTS triggers) and &lt;code&gt;curl&lt;/code&gt;s the local server for the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;f1.jsx  (refreshes every 3 seconds)
  └── shell: python3 ~/.f1/engine.py
  └── fetch: http://127.0.0.1:9877/api/session   ← live standings + RC
  └── fetch: http://127.0.0.1:9877/api/radio      ← team radio
  └── fetch: http://127.0.0.1:9877/api/schedule   ← calendar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The HLS stream headache
&lt;/h2&gt;

&lt;p&gt;Several ARD/ZDF/MDR backup renditions use &lt;code&gt;-b/&lt;/code&gt; path segments that return 404 in ffmpeg (which mpv uses under the hood). I wrote a resolver that parses the HLS master playlist and picks the first clean audio rendition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_hls_audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.m3u8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;

    &lt;span class="c1"&gt;# Prefer explicit audio renditions
&lt;/span&gt;    &lt;span class="n"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#EXT-X-MEDIA:TYPE=AUDIO[^\n]*?URI=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cand&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urljoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-b/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-b.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt;

    &lt;span class="c1"&gt;# Fall back to lowest-bandwidth video variant
&lt;/span&gt;    &lt;span class="n"&gt;variants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#EXT-X-STREAM-INF&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;bw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;BANDWIDTH=(\d+)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;bw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;urljoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Übersicht-specific constraints
&lt;/h2&gt;

&lt;p&gt;Writing JSX for Übersicht has a few rules that differ from a normal React project:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;${VAR}&lt;/code&gt; in the &lt;code&gt;command&lt;/code&gt; string&lt;/strong&gt; — the backtick template literal is evaluated by the shell, and &lt;code&gt;${}&lt;/code&gt; crashes the widget. Use &lt;code&gt;$(subshell)&lt;/code&gt; for shell substitutions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;child_process&lt;/code&gt;&lt;/strong&gt; — use the built-in &lt;code&gt;run()&lt;/code&gt; helper instead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;//&lt;/code&gt; comments inside the bash heredoc&lt;/strong&gt; — they cause parse errors&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;refreshFrequency&lt;/code&gt; is in milliseconds&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd add next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sprint weekend detection (Sprint Shootout sessions have a different session name)&lt;/li&gt;
&lt;li&gt;Driver headshot images from OpenF1's &lt;code&gt;headshot_url&lt;/code&gt; field&lt;/li&gt;
&lt;li&gt;Push notifications for race start / chequered flag via macOS Notification Center&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full source is on GitHub — pull requests welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/AlexDesign420/f1-widget" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openf1.org/" rel="noopener noreferrer"&gt;OpenF1 API docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jolpi.ca/" rel="noopener noreferrer"&gt;Jolpica API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="http://tracesof.net/uebersicht/" rel="noopener noreferrer"&gt;Übersicht&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>macos</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a live FIFA World Cup 2026 desktop widget for macOS</title>
      <dc:creator>AlexDesign420</dc:creator>
      <pubDate>Fri, 29 May 2026 22:46:41 +0000</pubDate>
      <link>https://dev.to/alexdesign420/i-built-a-live-fifa-world-cup-2026-desktop-widget-for-macos-526h</link>
      <guid>https://dev.to/alexdesign420/i-built-a-live-fifa-world-cup-2026-desktop-widget-for-macos-526h</guid>
      <description>&lt;p&gt;I just open-sourced a macOS desktop widget for the FIFA World Cup 2026. Here's what it does and how it's built.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🔴 &lt;strong&gt;Live scores&lt;/strong&gt; — updated every 3 seconds via the ESPN public API (no API key needed)&lt;/li&gt;
&lt;li&gt;📅 &lt;strong&gt;Full schedule&lt;/strong&gt; — all 104 games grouped by day, with venues and round labels&lt;/li&gt;
&lt;li&gt;📻 &lt;strong&gt;20+ radio streams&lt;/strong&gt; — ARD, ZDF, BBC, NPR and more, played via mpv&lt;/li&gt;
&lt;li&gt;🗣 &lt;strong&gt;German TTS commentary&lt;/strong&gt; — macOS &lt;code&gt;say&lt;/code&gt; announces goals, kick-offs and final whistles&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;Play-by-play&lt;/strong&gt; — ESPN event feed with goal / card / substitution highlights&lt;/li&gt;
&lt;li&gt;📺 &lt;strong&gt;Live ticker panel&lt;/strong&gt; — slide-out side panel with real-time scores for all live games&lt;/li&gt;
&lt;li&gt;⏳ &lt;strong&gt;Countdown&lt;/strong&gt; — days · hours · minutes until the tournament kicks off (June 11, 2026)&lt;/li&gt;
&lt;li&gt;🖥 &lt;strong&gt;Responsive&lt;/strong&gt; — adapts width for 1440p · 1080p · 2560p · 4K displays&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The widget has three parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. wm2026.jsx&lt;/strong&gt; — An &lt;a href="https://tracesof.net/uebersicht/" rel="noopener noreferrer"&gt;Übersicht&lt;/a&gt; widget (JSX/React-like syntax). Runs a shell command every 3 seconds to fetch ESPN data and writes it to &lt;code&gt;~/.wm2026/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. wm2026_server.py&lt;/strong&gt; — A Flask backend on &lt;code&gt;127.0.0.1:9876&lt;/code&gt;. Manages mpv audio playback via Unix socket, caches ESPN API responses, and scrapes live commentary from kicker.de.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. engine.py&lt;/strong&gt; — Runs every 3 seconds via the widget shell command. Detects goal events by diffing the score state, triggers macOS TTS (&lt;code&gt;say -v Anna&lt;/code&gt;) and &lt;code&gt;afplay&lt;/code&gt; sound effects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wm2026.jsx  (shell every 3s)
  └─ curl ESPN API  →  today.json / schedule.json
  └─ python3 engine.py  →  feed.json, state.json
  └─ fetch()  →  Flask server (port 9876)
       ├─ /api/status      mpv state
       ├─ /api/play        start stream
       ├─ /api/ticker      live scoreboard
       └─ /api/commentary  play-by-play events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Übersicht&lt;/strong&gt; — macOS widget host&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python 3 + Flask&lt;/strong&gt; — local backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ESPN public API&lt;/strong&gt; — scores &amp;amp; schedule (no key required)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mpv&lt;/strong&gt; — HLS audio playback via Unix socket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS &lt;code&gt;say&lt;/code&gt;&lt;/strong&gt; — text-to-speech for goal announcements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BeautifulSoup&lt;/strong&gt; — kicker.de live ticker scraping&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/AlexDesign420/wm2026-widget.git
&lt;span class="nb"&gt;cd &lt;/span&gt;wm2026-widget
./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requires: macOS 12+, &lt;a href="https://tracesof.net/uebersicht/" rel="noopener noreferrer"&gt;Übersicht&lt;/a&gt;, Python 3.9+, &lt;code&gt;brew install mpv&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub
&lt;/h2&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/AlexDesign420/wm2026-widget" rel="noopener noreferrer"&gt;AlexDesign420/wm2026-widget&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Would love feedback — especially on the HLS stream resolution workaround for ARD/ZDF backup renditions.&lt;/p&gt;

</description>
      <category>python</category>
      <category>macos</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
