<?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: hammi</title>
    <description>The latest articles on DEV Community by hammi (@hammiwor).</description>
    <link>https://dev.to/hammiwor</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4177732%2F3cd92fc7-635e-4d54-ba93-55561d1b2b58.png</url>
      <title>DEV Community: hammi</title>
      <link>https://dev.to/hammiwor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hammiwor"/>
    <language>en</language>
    <item>
      <title>My 1.17 MB player's catalog grid: the channelId trap, overlapping pages, and a cursor echo</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Sat, 07 Nov 2026 22:35:40 +0000</pubDate>
      <link>https://dev.to/hammiwor/my-117-mb-players-catalog-grid-the-channelid-trap-overlapping-pages-and-a-cursor-echo-2k2p</link>
      <guid>https://dev.to/hammiwor/my-117-mb-players-catalog-grid-the-channelid-trap-overlapping-pages-and-a-cursor-echo-2k2p</guid>
      <description>&lt;h1&gt;
  
  
  My 1.17 MB player's catalog grid: the channelId trap, overlapping pages, and a cursor echo
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;RKMovies&lt;/a&gt;, a 1.17 MB Android 4K media player (plus a &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;Windows build&lt;/a&gt;). This is a dev diary about the listing layer. Get the &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;Android APK&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The player is a thin WebView over a catalog API. There is no backend of ours for listings, the app calls the upstream &lt;code&gt;/api/filter&lt;/code&gt; through its proxy. Getting the grid right taught me three things about that API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. channelId is not optional.&lt;/strong&gt; The routes map to channels: &lt;code&gt;/movies&lt;/code&gt; is &lt;code&gt;channelId: 1&lt;/code&gt;, &lt;code&gt;/tv-series&lt;/code&gt; is &lt;code&gt;2&lt;/code&gt;, &lt;code&gt;/animated-series&lt;/code&gt; is &lt;code&gt;1006&lt;/code&gt; with &lt;code&gt;genre=Anime&lt;/code&gt; pinned. Omit the param and the upstream silently defaults the channel, so you get movies and TV mixed together with no error anywhere. Tile links carry &lt;code&gt;tabId=2&lt;/code&gt; / &lt;code&gt;tabId=5&lt;/code&gt; instead of a channel, so the client derives it (&lt;code&gt;tabId=2&lt;/code&gt; becomes &lt;code&gt;channelId=1&lt;/code&gt;, &lt;code&gt;tabId=5&lt;/code&gt; becomes &lt;code&gt;2&lt;/code&gt;). One more quirk: the Movies lane never sends &lt;code&gt;year&lt;/code&gt; at all, because the upstream year feeds are junk-heavy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pages overlap, and the real cursor hides one level down.&lt;/strong&gt; The proxy consumes several upstream pages per client page (30 cards each) because it filters out junk re-uploads server side. It hands back an upstream cursor, &lt;code&gt;pager.upNext&lt;/code&gt;, which the client has to echo as &lt;code&gt;up=&lt;/code&gt; on every request with &lt;code&gt;page &amp;gt; 1&lt;/code&gt;. Skip that and infinite scroll re-serves the same raw pages. And upstream pages overlap by a card or two, so before appending anything we dedupe by &lt;code&gt;detailPath&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;have&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detailPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detailPath&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;have&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detailPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upCursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pager&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;upNext&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Infinite scroll is just an IntersectionObserver with a loading guard.&lt;/strong&gt; A sentinel div, &lt;code&gt;rootMargin: '300px'&lt;/code&gt; so pages load before you hit the bottom, and a &lt;code&gt;_scrollLoading&lt;/code&gt; flag flipped while a fetch is in flight so the observer cannot fire twice.&lt;/p&gt;

&lt;p&gt;The filter bar is a custom dropdown component: button plus menu plus a hidden native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; for semantics, closed on outside click or Escape. The whole listing layer is about 280 lines in &lt;code&gt;parts/app-listing.js&lt;/code&gt; with zero dependencies, which is a big part of why the APK stays at 1.17 MB.&lt;/p&gt;

&lt;p&gt;If you are building against someone else's undocumented JSON API: send the params they imply but never document, expect overlapping pages, and echo back cursors you do not understand. The real pagination contract usually hides one level down.&lt;/p&gt;

&lt;p&gt;Try the player: &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;a sub-2 MB webview player for old phones&lt;/a&gt;, &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;install it on Android&lt;/a&gt;, or &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;run it on your laptop&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>android</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How my 1.17 MB PWA updates itself without an app store (7-endpoint config fan-out + a strict service worker)</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Sat, 07 Nov 2026 01:53:35 +0000</pubDate>
      <link>https://dev.to/hammiwor/how-my-117-mb-pwa-updates-itself-without-an-app-store-7-endpoint-config-fan-out-a-strict-1ed6</link>
      <guid>https://dev.to/hammiwor/how-my-117-mb-pwa-updates-itself-without-an-app-store-7-endpoint-config-fan-out-a-strict-1ed6</guid>
      <description>&lt;p&gt;I build RKMovies, a small 4K media player that ships as a 1.17 MB Android APK and as a PWA on our own site. No Play Store means no update channel. So when we want to change a notice, a download link, or bump the version prompt, the app has to fetch config at runtime.&lt;/p&gt;

&lt;p&gt;Here is what we landed on in &lt;code&gt;updater.js&lt;/code&gt; and &lt;code&gt;sw.js&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The config fan-out
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;fetchConfig()&lt;/code&gt; fires 7 GET requests concurrently: raw.githubusercontent.com, cdn.jsdelivr.net, our own domain, a Cloudflare Pages mirror, rentry, paste.ee, and a local &lt;code&gt;/config.json&lt;/code&gt;. Every request gets an &lt;code&gt;AbortController&lt;/code&gt; with a 4-second timeout and &lt;code&gt;cache: "no-store"&lt;/code&gt;. A response only counts if it parses as JSON and has at least one of &lt;code&gt;platforms&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;, or &lt;code&gt;notice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The point of 7 endpoints is that any single host can be blocked, slow, or wrong. If our domain is unreachable from some ISP, jsdelivr still answers. If GitHub raw is slow, the Pages mirror answers. We then compare all valid configs, take the highest &lt;code&gt;latest_version&lt;/code&gt;, and prefer the platform-specific version (android vs desktop) over the top-level one. The winner gets written to localStorage, and if all 7 fail we boot from that cache instead of nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The service worker side
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sw.js&lt;/code&gt; precaches an 8-file shell (index, styles, &lt;code&gt;app.js&lt;/code&gt;, &lt;code&gt;adgate.js&lt;/code&gt;, &lt;code&gt;updater.js&lt;/code&gt;, the ArtPlayer vendor bundle, the manifest) under a versioned cache name like &lt;code&gt;rkmovies-shell-v43&lt;/code&gt;, then &lt;code&gt;skipWaiting()&lt;/code&gt; + &lt;code&gt;clients.claim()&lt;/code&gt; so the new shell takes over on next load.&lt;/p&gt;

&lt;p&gt;Fetch handling is network-first for same-origin static files, with one hard rule: code assets (&lt;code&gt;app.js&lt;/code&gt;, &lt;code&gt;style.css&lt;/code&gt;, &lt;code&gt;vendor/&lt;/code&gt;, &lt;code&gt;parts/&lt;/code&gt;) are only ever answered from an exact cache match. A new &lt;code&gt;?v=&lt;/code&gt; must never be served an old build, so a missed exact match returns a hard 503 instead of stale JS. Only non-code assets fall back to the cached shell with &lt;code&gt;ignoreSearch&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this buys
&lt;/h3&gt;

&lt;p&gt;A 1.17 MB APK that can change its own download links, notices, and update prompts without a store release. The whole updater is one dependency-free file, and the config fetch is a plain GET of a static JSON file. No device ID, no analytics, no fingerprint. Update checks that don't track you are possible; we just had to make the "phone home" a dumb static file read.&lt;/p&gt;

&lt;p&gt;Play with it: &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;the tiny native player I build&lt;/a&gt;, &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;sideload the Android APK&lt;/a&gt;, or grab &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;the Windows setup exe&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Disclosure: I'm the developer of RKMovies. Size numbers come from the landing page's comparison table, not from marketing.&lt;/p&gt;

</description>
      <category>android</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>SRT is not WebVTT: shipping a subtitle converter in a 1.17 MB player</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Fri, 06 Nov 2026 22:48:02 +0000</pubDate>
      <link>https://dev.to/hammiwor/srt-is-not-webvtt-shipping-a-subtitle-converter-in-a-117-mb-player-475</link>
      <guid>https://dev.to/hammiwor/srt-is-not-webvtt-shipping-a-subtitle-converter-in-a-117-mb-player-475</guid>
      <description>&lt;h1&gt;
  
  
  SRT is not WebVTT: shipping a subtitle converter in a 1.17 MB player
&lt;/h1&gt;

&lt;p&gt;I'm the developer of &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;RKMovies&lt;/a&gt;, an ultra-lightweight 4K media player client for Android and Windows (1.17 MB APK, 29.5 MB PC installer). This is a dev diary about the part nobody blogs about: subtitles.&lt;/p&gt;

&lt;p&gt;The upstream sources serve a mix of &lt;code&gt;.srt&lt;/code&gt; and &lt;code&gt;.vtt&lt;/code&gt; files. SRT uses comma decimal separators in timestamps (&lt;code&gt;00:00:01,500 --&amp;gt; 00:00:03,200&lt;/code&gt;), WebVTT requires dots. The player library only speaks VTT, so every caption track goes through a converter before it reaches the renderer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;captionVtt()&lt;/code&gt; fetches the track text. If it already starts with &lt;code&gt;WEBVTT&lt;/code&gt;, it passes the header through but still cleans each cue line. Otherwise it parses SRT blocks: split on blank lines, drop the numeric index line, keep the block only if the timing line contains &lt;code&gt;--&amp;gt;&lt;/code&gt;, convert commas to dots, and prefix the output with &lt;code&gt;WEBVTT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cleaning half is &lt;code&gt;cleanSubtitleText()&lt;/code&gt;. It strips SSA/ASS override tags like &lt;code&gt;{\an8}&lt;/code&gt;, removes HTML-ish markup (&lt;code&gt;&amp;lt;i&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;font color=...&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;v Speaker&amp;gt;&lt;/code&gt;), decodes entities (&lt;code&gt;&amp;amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;amp;nbsp;&lt;/code&gt;, &lt;code&gt;&amp;amp;#39;&lt;/code&gt;), and turns &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt; into real newlines. Most "broken subtitle" reports we got early on were just leftover markup tags rendering as literal text on screen.&lt;/p&gt;

&lt;p&gt;For local files (&lt;code&gt;.srt&lt;/code&gt;, &lt;code&gt;.vtt&lt;/code&gt;, &lt;code&gt;.ass&lt;/code&gt; uploads), &lt;code&gt;fileToVtt()&lt;/code&gt; runs the same SRT parser over the file contents, so a downloaded subtitle file works with zero server round-trips.&lt;/p&gt;

&lt;p&gt;On the render side I hook &lt;code&gt;subtitleBeforeUpdate&lt;/code&gt; plus a &lt;code&gt;MutationObserver&lt;/code&gt; on the &lt;code&gt;.art-subtitle-line&lt;/code&gt; elements to re-clean whatever the library injects into the DOM. Font size, bottom offset and color are driven through CSS variables (&lt;code&gt;--art-subtitle-font-size&lt;/code&gt;, &lt;code&gt;--art-subtitle-bottom&lt;/code&gt;, &lt;code&gt;--art-subtitle-color&lt;/code&gt;) that get recomputed on resize and orientation change, because subtitles on a 6-inch phone held at arm's length need to be bigger than on a TV.&lt;/p&gt;

&lt;p&gt;Total cost: about 80 lines of JavaScript. Which is the point. In a player this small, features don't cost APK size, dependencies do. &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;Subtitles in Hindi&lt;/a&gt;, Tamil, Telugu and English ship in the box, and &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;the desktop app for the TV&lt;/a&gt; runs the same pipeline.&lt;/p&gt;

&lt;p&gt;If you want to poke at it: the Android APK is 1.17 MB with MediaCodec hardware decode, and there is no account and no telemetry.&lt;/p&gt;

</description>
      <category>android</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Dead stream mid-episode? My 1.17 MB player just falls down the quality list</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Fri, 06 Nov 2026 20:07:26 +0000</pubDate>
      <link>https://dev.to/hammiwor/dead-stream-mid-episode-my-117-mb-player-just-falls-down-the-quality-list-36ep</link>
      <guid>https://dev.to/hammiwor/dead-stream-mid-episode-my-117-mb-player-just-falls-down-the-quality-list-36ep</guid>
      <description>&lt;p&gt;Disclosure: I built RKMovies, a small 4K media player client for Android and Windows. This is a dev diary about one of its error paths. The landing page is &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;RKMovies&lt;/a&gt;, with &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;the 1.17 MB Android APK&lt;/a&gt; and &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;the PC build&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Stream URLs die. A CDN edge returns 410 Gone, a signed URL expires an hour in, a proxy node has a bad day. In a 1.17 MB APK you do not get to ship ffmpeg. You get the system's MediaCodec through the WebView's video element, and its error event. So the player treats &lt;code&gt;error&lt;/code&gt; as a signal, not a failure.&lt;/p&gt;

&lt;p&gt;The handler is small (public/parts/app-player.js):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seeking&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seeking&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// MEDIA_ERR_ABORTED is a normal seek abort&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;curTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Stream failed - trying another quality&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updatedRec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;curTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;watched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;renderPlayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updatedRec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This source failed to play.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&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 first guard matters most. MEDIA_ERR_ABORTED (code 1) is filtered out because every seek fires an aborted load on the old source. Without that check the player would "recover" from every scrub the user does.&lt;/p&gt;

&lt;p&gt;Position is preserved. curTime gets written into the watch record, and renderPlayer re-seeks on ready when pos is past 10 seconds. The user loses about a second of buffer, nothing else.&lt;/p&gt;

&lt;p&gt;The stream list is sorted by resolution descending, so &lt;code&gt;list[i + 1]&lt;/code&gt; is the next lower quality. Falling down the ladder beats dying: a 1080p stream with a hiccup still plays, while nothing at all does not. If the error fires on the last entry in the list, the UI just says the source failed.&lt;/p&gt;

&lt;p&gt;One level up, the play call itself gets a silent retry (public/app.js): two attempts with a 2.6 second pause between them, because the proxy already retries internally and this only covers a full 502 burst. If both attempts fail, the UI shows "Stream not available for this title" and adds "(limited region)" when the API flags it.&lt;/p&gt;

&lt;p&gt;The honest trade-off: on very old phones, software decode of a high bitrate fallback can still stutter. Hardware decode is what makes the fallback actually smooth, which is also why the APK stays at 1.17 MB. No bundled codec libraries, system codecs only (MediaCodec on Android, NVDEC/QuickSync in the Windows build).&lt;/p&gt;

&lt;p&gt;If you want to poke at the error path yourself, grab &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;the APK&lt;/a&gt; or &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;the Windows installer&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>android</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The 12-second 'up next' countdown in my 1.17 MB player: auto-advance without the annoyance</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:43:30 +0000</pubDate>
      <link>https://dev.to/hammiwor/the-12-second-up-next-countdown-in-my-117-mb-player-auto-advance-without-the-annoyance-30md</link>
      <guid>https://dev.to/hammiwor/the-12-second-up-next-countdown-in-my-117-mb-player-auto-advance-without-the-annoyance-30md</guid>
      <description>&lt;p&gt;i'm the dev behind RKMovies, a 1.17 MB Android media player with a Windows build. this is how the auto-next-episode thing works.&lt;/p&gt;

&lt;p&gt;most players either jump to the next episode instantly or leave you to dig through menus. i wanted something in between: a small overlay on the video that counts down "up next S1E12 in 12s", with a button if you're impatient.&lt;/p&gt;

&lt;p&gt;how it works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;progressTick()&lt;/code&gt; runs on every &lt;code&gt;timeupdate&lt;/code&gt;. at 92% of the episode it marks the ep watched and saves progress. every 15 seconds of playback it saves position too, so a crash never costs more than that.&lt;/li&gt;
&lt;li&gt;when playback ends, &lt;code&gt;onEpisodeEnd()&lt;/code&gt; fires once (a flag guards the double-fire, since both ArtPlayer's &lt;code&gt;video:ended&lt;/code&gt; and the raw video element emit it). if auto-next is off in settings, that's the end of it.&lt;/li&gt;
&lt;li&gt;otherwise &lt;code&gt;showNextOverlay()&lt;/code&gt; paints the countdown inside the player host. a 1s ticker counts down from 12; at zero, &lt;code&gt;autoNext()&lt;/code&gt; takes over.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autoNext()&lt;/code&gt; picks the next ep in the current season, or rolls over to ep 1 of the next season, then calls &lt;code&gt;watchEp()&lt;/code&gt;. movies (single season, se=0) never auto-advance, and if you've left the detail page it bails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the edge cases that actually bit me: the overlay has to live inside the re-parented fullscreen node or it vanishes in fullscreen. a scrubbing guard skips progress ticks while you drag the seek bar. and if you seek back below the end after the countdown started, the timer cancels and the ep can re-arm it when it really ends.&lt;/p&gt;

&lt;p&gt;try it out: &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;this 1.17 MB player&lt;/a&gt;, the &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;tiny Android build&lt;/a&gt;, and the &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;Windows app&lt;/a&gt; for the TV setup.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>showdev</category>
      <category>android</category>
    </item>
    <item>
      <title>how i got real picture-in-picture in a 1.17 mb webview player without shipping a native lib</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Fri, 06 Nov 2026 04:08:28 +0000</pubDate>
      <link>https://dev.to/hammiwor/how-i-got-real-picture-in-picture-in-a-117-mb-webview-player-without-shipping-a-native-lib-220g</link>
      <guid>https://dev.to/hammiwor/how-i-got-real-picture-in-picture-in-a-117-mb-webview-player-without-shipping-a-native-lib-220g</guid>
      <description>&lt;p&gt;i'm the developer of rkmovies, a 4k media player that runs as a web app inside a thin android webview shell. the whole apk is 1.17 mb because it bundles zero native media libraries. playback uses the system's mediacodec decoders through artplayer.&lt;/p&gt;

&lt;p&gt;one feature people asked for constantly: picture-in-picture. you open whatsapp mid-episode and the video keeps playing in a floating window.&lt;/p&gt;

&lt;p&gt;in a webview you can't call an android api directly. you only get what the browser engine exposes. so the pip toggle in the settings menu is a four-layer fallback chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;if document.pictureInPictureElement is set, the video is already in pip. call exitPictureInPicture() to toggle off.&lt;/li&gt;
&lt;li&gt;otherwise try art.video.requestPictureInPicture(). that's the w3c api, and chrome supports it from android 8. inside a webview it gives you the real system pip window with the app icon, not a canvas hack.&lt;/li&gt;
&lt;li&gt;if that's missing (older webview, weird oem rom), fall back to webkitSetPresentationMode("picture-in-picture"), the safari-style api some chromium builds still expose.&lt;/li&gt;
&lt;li&gt;if all three are absent, flip art.pip. that's artplayer's own software pip, which re-parents the video element into a small overlay. it works everywhere but it's not a real system window, so no controls outside the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;every step is wrapped in try/catch because webview implementations throw different errors instead of returning undefined. i learned that the hard way on a budget micromax device.&lt;/p&gt;

&lt;p&gt;the whole chain costs zero bytes of apk size because it only calls apis the platform already has. that's the general pattern in this app: features don't cost size, dependencies do.&lt;/p&gt;

&lt;p&gt;if you want to poke at it, this little player lives at &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;this little player&lt;/a&gt;, grab it on android at &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;grab it on Android&lt;/a&gt;, or take the pc installer from &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;the PC installer&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>artplayer v5 re-parented my fullscreen node and moved my controls on phones</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Wed, 04 Nov 2026 22:15:47 +0000</pubDate>
      <link>https://dev.to/hammiwor/artplayer-v5-re-parented-my-fullscreen-node-and-moved-my-controls-on-phones-3nbg</link>
      <guid>https://dev.to/hammiwor/artplayer-v5-re-parented-my-fullscreen-node-and-moved-my-controls-on-phones-3nbg</guid>
      <description>&lt;p&gt;Disclosure: I'm the developer of RKMovies, an ultra-lightweight 4K media player client for Android and Windows (&lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;a player this small&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Upgrading ArtPlayer from v4 to v5 looked like a one-line change. It was not. v5 restructured the player DOM: the control bar is now split into a top strip (&lt;code&gt;.art-mobile-top&lt;/code&gt;) and a bottom strip (&lt;code&gt;.art-bottom&lt;/code&gt;), and fullscreen no longer keeps the player node where you put it. &lt;code&gt;fullscreenWeb&lt;/code&gt; re-parents &lt;code&gt;template.$player&lt;/code&gt; into &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first bug was invisible. Our gesture HUD and overlays lived next to the player node, so in fullscreen-web they just vanished while the video kept playing. The fix was a &lt;code&gt;playerHost()&lt;/code&gt; helper that resolves the node ArtPlayer actually fullscreens (&lt;code&gt;art.template.$player&lt;/code&gt;, falling back to &lt;code&gt;.artplayer-app .art-video-player&lt;/code&gt;) and mounts every overlay inside it. Native fullscreen and web fullscreen now behave the same.&lt;/p&gt;

&lt;p&gt;Second surprise: on phones the library renders its own top bar, which collided with our HUD. So under &lt;code&gt;@media(max-width:767px)&lt;/code&gt; we hide &lt;code&gt;.art-control-cc&lt;/code&gt;, &lt;code&gt;.art-control-pip&lt;/code&gt; and &lt;code&gt;.art-mobile-top&lt;/code&gt;, pin &lt;code&gt;.art-bottom&lt;/code&gt; to &lt;code&gt;bottom: 0&lt;/code&gt;, and re-anchor the settings panel above the control bar with &lt;code&gt;calc(var(--art-control-height, 44px) + 6px)&lt;/code&gt;. The settings max height is per-device too: &lt;code&gt;art.cssVar("--art-settings-max-height", isMobileDevice() ? "150px" : "260px")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Third: v5 takes &lt;code&gt;type&lt;/code&gt; as a plain string now (&lt;code&gt;"m3u8"&lt;/code&gt; / &lt;code&gt;"mp4"&lt;/code&gt;) instead of a custom type function, so source detection collapsed to one ternary: &lt;code&gt;(format === "HLS" || /\.m3u8/.test(url)) ? "m3u8" : "mp4"&lt;/code&gt;. Touch-only controls are gated on device class as well: &lt;code&gt;fastForward: isMobileDevice()&lt;/code&gt;, &lt;code&gt;flip: !isMobileDevice()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;All of this ships inside &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;the sub-megabyte android apk&lt;/a&gt;. The DOM gymnastics cost zero bytes because we borrow the system codecs and the player library instead of bundling our own. There's also a &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;tv-friendly windows build&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Lesson: when a player library changes its DOM, read its template source, not just the changelog. The fullscreen re-parenting was only documented in a source comment, and it cost us a day of "why did my overlays disappear" bug reports.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>showdev</category>
      <category>android</category>
    </item>
    <item>
      <title>Parsing SRT subtitles in the browser: the 30-line converter that makes every WebVTT player happy</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Wed, 04 Nov 2026 21:13:18 +0000</pubDate>
      <link>https://dev.to/hammiwor/parsing-srt-subtitles-in-the-browser-the-30-line-converter-that-makes-every-webvtt-player-happy-4d7c</link>
      <guid>https://dev.to/hammiwor/parsing-srt-subtitles-in-the-browser-the-30-line-converter-that-makes-every-webvtt-player-happy-4d7c</guid>
      <description>&lt;h1&gt;
  
  
  Parsing SRT subtitles in the browser: the 30-line converter that makes every WebVTT player happy
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I'm building RKMovies — an ultra-lightweight 4K media player client (0.91 MB APK, hardware-accelerated decoding, zero telemetry). Site: &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;https://rkmovies.dpdns.org&lt;/a&gt; · Android APK: &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;https://rkmovies.dpdns.org/apk&lt;/a&gt; · Windows app: &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;https://rkmovies.dpdns.org/desktop&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every video player on Android that is worth using speaks WebVTT. Almost every subtitle file floating around the internet is SRT. They look identical at a glance, but the parsers do not forgive the differences.&lt;/p&gt;

&lt;p&gt;The gap is small and annoying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SRT uses a comma in timestamps (&lt;code&gt;00:00:01,500&lt;/code&gt;), WebVTT demands a dot (&lt;code&gt;00:00:01.500&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SRT cues start with an optional numeric index line, WebVTT does not have one&lt;/li&gt;
&lt;li&gt;SRT files often ship with a BOM and CRLF line endings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So instead of pulling in a subtitle library (remember: the whole APK is 0.91 MB, every dependency is a size audit), I wrote a tiny converter that runs in the browser when a local subtitle file is loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fileToVtt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^﻿/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*WEBVTT/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\n{2,}&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/,/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WEBVTT&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&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 whole thing is ~20 lines. It reuses the same cleanup pass we already run for inline captions (stripping tags like &lt;code&gt;&amp;lt;i&amp;gt;&lt;/code&gt; and positioning junk), then hands the result to the player as a Blob URL. From the user's side: pick an &lt;code&gt;.srt&lt;/code&gt; file, it just works.&lt;/p&gt;

&lt;p&gt;Two edge cases that actually bit me in testing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Some SRT files use LF only, some CRLF, some have a BOM. Normalizing all three up front saves a "why are my subtitles empty" bug report.&lt;/li&gt;
&lt;li&gt;Multi-line cues must stay multi-line. If you flatten them, dialogue and sound effects merge into one unreadable line.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are shipping a small player and want subtitles without a dependency, this is the whole trick. WebVTT is forgiving once the commas become dots.&lt;/p&gt;

&lt;p&gt;RKMovies runs this on Android (0.91 MB APK with hardware decoding and multi-audio support: &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;https://rkmovies.dpdns.org/apk&lt;/a&gt;) and on Windows (&lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;https://rkmovies.dpdns.org/desktop&lt;/a&gt;). No account, no telemetry.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>our windows build ships no browser: how a ~31 mb launcher gets free nvdec/quicksync 4k decode</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Wed, 04 Nov 2026 20:04:07 +0000</pubDate>
      <link>https://dev.to/hammiwor/our-windows-build-ships-no-browser-how-a-31-mb-launcher-gets-free-nvdecquicksync-4k-decode-4675</link>
      <guid>https://dev.to/hammiwor/our-windows-build-ships-no-browser-how-a-31-mb-launcher-gets-free-nvdecquicksync-4k-decode-4675</guid>
      <description>&lt;p&gt;Disclosure: I'm the developer of RKMovies, an ultra-lightweight 4K media player client for Android and Windows (&lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;the landing page&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Every Electron app bundles its own Chromium, which is why "desktop video player" installers weigh 150-300 MB. We went the other way.&lt;/p&gt;

&lt;p&gt;Our Windows installer (~31 MB) is a PyInstaller bundle of the FastAPI backend plus a launcher. On startup it finds the real Chrome or Edge through the registry (&lt;code&gt;App Paths\chrome.exe&lt;/code&gt; / &lt;code&gt;msedge.exe&lt;/code&gt;), then spawns it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;--&lt;span class="n"&gt;app&lt;/span&gt;=&lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="m"&gt;127&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;:&amp;lt;&lt;span class="n"&gt;port&lt;/span&gt;&amp;gt;
--&lt;span class="n"&gt;user&lt;/span&gt;-&lt;span class="n"&gt;data&lt;/span&gt;-&lt;span class="n"&gt;dir&lt;/span&gt;=%&lt;span class="n"&gt;APPDATA&lt;/span&gt;%\&lt;span class="n"&gt;RKMovies&lt;/span&gt;\&lt;span class="n"&gt;profile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: a chromeless window that looks native, our own taskbar icon (&lt;code&gt;SetCurrentProcessExplicitAppUserModelID&lt;/code&gt;), an isolated profile, and zero bundled browser engine.&lt;/p&gt;

&lt;p&gt;The payoff is decode. Chrome and Edge on Windows already drive NVDEC (NVIDIA), QuickSync (Intel) and DXVA (AMD) for H.264 / HEVC / AV1. Since the video element lives in that borrowed browser, 4K hardware decode is simply there. We ship no decoder of our own. A 10-year-old laptop plays 4K HEVC smoothly because the GPU work happens inside Chrome, not inside our 31 MB exe.&lt;/p&gt;

&lt;p&gt;Fallback chain when neither browser exists: pywebview, then the system default browser. The Android side is the mirror image: MediaCodec through ArtPlayer, no engine bundled either, which is how &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;the 0.9 MB APK&lt;/a&gt; stays under a megabyte.&lt;/p&gt;

&lt;p&gt;Trade-offs we accepted: desktop mode depends on a system browser being installed, and the "app" is really a local server plus a window. In exchange the installer stays ~31 MB and every update is a small download instead of a full Chromium re-bundle. &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;Grab the Windows exe here&lt;/a&gt; if you want to see.&lt;/p&gt;

&lt;p&gt;If you're building a small desktop tool: don't bundle a browser unless you have to. The system one is already there, already GPU-accelerated, and already updated.&lt;/p&gt;

</description>
      <category>android</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Shipping multi-audio dubs in a 0.91 MB player without adding a single dependency</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Wed, 04 Nov 2026 18:51:22 +0000</pubDate>
      <link>https://dev.to/hammiwor/shipping-multi-audio-dubs-in-a-091-mb-player-without-adding-a-single-dependency-fdd</link>
      <guid>https://dev.to/hammiwor/shipping-multi-audio-dubs-in-a-091-mb-player-without-adding-a-single-dependency-fdd</guid>
      <description>&lt;p&gt;I'm building &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;RKMovies&lt;/a&gt;, a tiny 4K player (0.91 MB APK on Android, a native Windows build, hardware decode, no account, no tracking). Indian titles usually ship with several audio dubs — Hindi, Tamil, Telugu, English — and users expect to switch between them mid-playback without losing their position. Here's how that fits inside a sub-megabyte APK.&lt;/p&gt;

&lt;p&gt;The whole feature is three pieces, all inside the player module:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Filter the dub list.&lt;/strong&gt; Upstream play payloads mix real audio dubs with subtitle-language variants ("Arabic sub"). Anything ending in &lt;code&gt;sub&lt;/code&gt; is not an audio option, so it gets filtered out before the picker renders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probe before you promise.&lt;/strong&gt; When the audio panel opens, the app background-probes the other dubs and hides any that have no stream for the current episode. No dead buttons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap in place.&lt;/strong&gt; Switching audio does not reload the page. It fetches the dub's play data (cached), picks the best non-VIP stream, and hands it to the player's existing quality switch at the same timestamp, so playback continues from the exact position. Subtitles reset and refetch, because they belong to the dub's resource, not the video.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;switchDubStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dubPlayCache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detailPath&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;API&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`play?subjectId=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subjectId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;se=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;se&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;ep=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ep&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;detailPath=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detailPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;dubPlayCache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detailPath&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streams&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streams&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hls&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vipLocked&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolutions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolutions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;smoothSwitchQuality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// same position, no navigation&lt;/span&gt;
  &lt;span class="nf"&gt;loadSubtitles&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                    &lt;span class="c1"&gt;// subs belong to the dub, not the video&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The size cost is basically zero: no HLS demuxer, no extra library. The player already has a quality switcher, so a dub switch is just "pick a different stream URL and send it down the same path". The picker UI is a plain DOM list, not a component framework.&lt;/p&gt;

&lt;p&gt;Lesson: features don't cost APK size, dependencies do. Reuse the machinery you already have.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;media player for low-end Android&lt;/a&gt;, &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;native Windows app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>android</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>My auto-hide controls kept flickering in ArtPlayer v5: the dual-timer fight and the 1.5s immunity fix</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Tue, 03 Nov 2026 21:54:18 +0000</pubDate>
      <link>https://dev.to/hammiwor/my-auto-hide-controls-kept-flickering-in-artplayer-v5-the-dual-timer-fight-and-the-15s-immunity-4bf8</link>
      <guid>https://dev.to/hammiwor/my-auto-hide-controls-kept-flickering-in-artplayer-v5-the-dual-timer-fight-and-the-15s-immunity-4bf8</guid>
      <description>&lt;p&gt;I'm building RKMovies (&lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;a tiny 4K player for old phones&lt;/a&gt;) — 0.91 MB APK, hardware decode, no accounts, no tracking. The player UI runs on ArtPlayer v5, and I replaced its default controls with my own auto-hide layer. That layer flickered for weeks. Here's why.&lt;/p&gt;

&lt;p&gt;ArtPlayer hides its controls on its own timer. Every &lt;code&gt;timeupdate&lt;/code&gt; it checks &lt;code&gt;controls.timer&lt;/code&gt;; if the controls have been idle for a few seconds it hides them and hides the cursor.&lt;/p&gt;

&lt;p&gt;I wanted different rules: never hide while the settings panel is open, while paused, or while the user is scrubbing, plus a minimum visible time of 1s. So I added my own &lt;code&gt;hideTimer&lt;/code&gt; (3.5s) with those guards.&lt;/p&gt;

&lt;p&gt;Two timers, two sources of truth. ArtPlayer's internal check and my &lt;code&gt;setTimeout&lt;/code&gt; disagreed about when to hide, so the controls flickered: my layer showed them, the library's next &lt;code&gt;timeupdate&lt;/code&gt; hid them, mine showed them again.&lt;/p&gt;

&lt;p&gt;The fix wasn't a second timer fighting the first. It was a 1.5s immunity lock. Every time my layer shows the controls it sets &lt;code&gt;wakeLockUntil = Date.now() + 1500&lt;/code&gt;, and &lt;code&gt;hideControls()&lt;/code&gt; refuses to run inside that window unless forced (video ended). Then, on every &lt;code&gt;video:timeupdate&lt;/code&gt;, if we're inside the immunity window I poke the library's own timer: &lt;code&gt;art.controls.timer = Date.now()&lt;/code&gt;. The library thinks the user just interacted and leaves the controls alone. One timer owns hiding; the other just gets told to stay quiet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HIDE_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;wakeLockUntil&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;showControls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;wakeLockUntil&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// immunity lock&lt;/span&gt;
  &lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;scheduleHide&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hideControls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;wakeLockUntil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastShowTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isMenuOpen&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// settings / context menu&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isScrubbing&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...actually hide&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video:timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;wakeLockUntil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// keep the library's idle check happy&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guards that matter in practice: the settings panel (&lt;code&gt;art-setting-show&lt;/code&gt; class or clientHeight), the context menu, paused state, and the &lt;code&gt;.art-scrubbing&lt;/code&gt; class on the player element. Miss one and the controls vanish mid-drag, which feels broken.&lt;/p&gt;

&lt;p&gt;I also overrode &lt;code&gt;art.controls.toggle&lt;/code&gt; to route through my show/hide instead of the default flip, so taps behave the same everywhere.&lt;/p&gt;

&lt;p&gt;Lesson: when you wrap a player library's internals, don't run a parallel timer. Patch the value the library reads (&lt;code&gt;controls.timer&lt;/code&gt;) and keep a single owner for the hide decision. The code lives in &lt;code&gt;setupControlsAutoHide()&lt;/code&gt; in the app's player module.&lt;/p&gt;

&lt;p&gt;If you want to try it: &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;gesture-controlled video player&lt;/a&gt; for Android, &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;the Windows edition&lt;/a&gt; for PC.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>showdev</category>
      <category>android</category>
      <category>performance</category>
    </item>
    <item>
      <title>I fake screen brightness with a CSS filter, and only use the real API when the shell allows it</title>
      <dc:creator>hammi</dc:creator>
      <pubDate>Tue, 03 Nov 2026 20:05:48 +0000</pubDate>
      <link>https://dev.to/hammiwor/i-fake-screen-brightness-with-a-css-filter-and-only-use-the-real-api-when-the-shell-allows-it-1iil</link>
      <guid>https://dev.to/hammiwor/i-fake-screen-brightness-with-a-css-filter-and-only-use-the-real-api-when-the-shell-allows-it-1iil</guid>
      <description>&lt;p&gt;I'm the developer of &lt;a href="https://rkmovies.dpdns.org" rel="noopener noreferrer"&gt;RKMovies&lt;/a&gt;, a 0.91 MB media player for Android and Windows. One of the small features people ask about is the brightness gesture: swipe up on the right half of the player and the screen dims.&lt;/p&gt;

&lt;p&gt;Here's the thing though. There is no one brightness API that works everywhere my app runs. So I built a two-layer one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: the native path, when it exists
&lt;/h2&gt;

&lt;p&gt;On Android the WebView shell exposes a JS bridge. If &lt;code&gt;window.AndroidBridge.setBrightness&lt;/code&gt; exists, I call it and I'm done. That changes the actual system brightness, which is what you want most of the time.&lt;/p&gt;

&lt;p&gt;On the Windows build there's a local backend, so I debounce and fire &lt;code&gt;GET /api/system/brightness?val=...&lt;/code&gt; instead. 50 ms debounce, because the gesture fires dozens of times per second and I don't want to flood the IPC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: the CSS filter fallback
&lt;/h2&gt;

&lt;p&gt;WebViews and browser contexts can't touch system brightness. So every time the gesture moves, I also set a filter directly on the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyPlayerBrightness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_brightness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;art&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;brightness(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;setSystemBrightness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// native layer, clamped to 0..1&lt;/span&gt;
  &lt;span class="c1"&gt;// ...HUD update&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the math: user-facing range is 10% to 150%, but the filter maps it to 0.4x to 1.45x. I never go below 0.4. A pure black screen feels broken, not dim, and people think the app crashed. 0.4 is the floor where the picture is still readable.&lt;/p&gt;

&lt;p&gt;The filter only darkens/brightens the video itself, not the player UI. That's actually a feature: the controls stay readable while the movie dims, which is exactly what you want at 2am under a blanket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use the native API everywhere
&lt;/h2&gt;

&lt;p&gt;Because "everywhere" is a lie. Android WebView: no. Windows WebView2: no (hence the local backend). Plain browser PWA: no. If I depended on the native path alone, the gesture would silently do nothing on two of my three platforms.&lt;/p&gt;

&lt;p&gt;The filter runs on the compositor, so it costs basically nothing on the CPU. On a &lt;a href="https://rkmovies.dpdns.org/apk" rel="noopener noreferrer"&gt;low-battery video player&lt;/a&gt; that's the whole point. I'd rather spend zero battery on brightness and keep the decoder on the GPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge case nobody warns you about
&lt;/h2&gt;

&lt;p&gt;CSS &lt;code&gt;filter&lt;/code&gt; on a hardware-decoded video can force a software compositing path on some old GPUs, which tanks battery. I tested on a 2GB Redmi and a Moto G4 and the filter stayed on the compositor, but if you ship this, test on your lowest-end device. If it falls off the fast path, drop the filter layer and keep only the native API.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://rkmovies.dpdns.org/desktop" rel="noopener noreferrer"&gt;desktop edition&lt;/a&gt; uses the same two-layer code, just with the local &lt;code&gt;/api/system/brightness&lt;/code&gt; endpoint as layer 1 instead of the Android bridge.&lt;/p&gt;

&lt;p&gt;If you're building a player and want gesture brightness without a native app, this split is the cheapest version that actually works everywhere. Ship the filter first, add the native path when the shell gives you one.&lt;/p&gt;

</description>
      <category>android</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
