<?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: Dino</title>
    <description>The latest articles on DEV Community by Dino (@dinoa92).</description>
    <link>https://dev.to/dinoa92</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%2F4021312%2Fa5a251d0-d699-4bc5-9801-f8f2bd705711.png</url>
      <title>DEV Community: Dino</title>
      <link>https://dev.to/dinoa92</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dinoa92"/>
    <language>en</language>
    <item>
      <title>Syncing Replayed Chat to a VOD Timeline: Why I Threw Away Binary Search</title>
      <dc:creator>Dino</dc:creator>
      <pubDate>Mon, 03 Aug 2026 11:08:31 +0000</pubDate>
      <link>https://dev.to/dinoa92/how-we-synced-replayed-stream-chat-with-hls-video-timelines-in-javascript-46nc</link>
      <guid>https://dev.to/dinoa92/how-we-synced-replayed-stream-chat-with-hls-video-timelines-in-javascript-46nc</guid>
      <description>&lt;p&gt;I build &lt;a href="https://nosubapp.com" rel="noopener noreferrer"&gt;NoSub&lt;/a&gt;, a browser-based VOD player for Twitch and Kick. It started as something narrow — paste a replay link, watch it in a cleaner player — and stayed narrow for about a week. Then I added chat replay, and that turned out to be the part worth writing about.&lt;/p&gt;

&lt;p&gt;Watching an old stream without its chat feels strangely flat. Inside jokes land without context, raids look unexplained, and you cannot tell whether a play was genuinely impressive or routine. The video is only half the broadcast. Putting the messages back, in time with the video, is what makes a recording feel like a stream again.&lt;/p&gt;

&lt;p&gt;It is also the part I lost the most sleep over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that framed the problem
&lt;/h2&gt;

&lt;p&gt;Someone asked me exactly the right question about it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;On a seek, do you keep the messages in a sorted structure and binary-search to the new timestamp, or replay from the last known point? And how do you handle the burst when someone scrubs into a hype moment with hundreds of messages landing in one second?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The honest answer is: neither, and nothing special. Both of those are reasonable guesses, and both describe a system more sophisticated than the one that actually shipped. What follows is what it really does, including the places where I chose the simpler thing on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anchor to the video timeline, never to the clock
&lt;/h2&gt;

&lt;p&gt;This is the decision everything else depends on.&lt;/p&gt;

&lt;p&gt;Each message carries a creation timestamp. The offset that matters is not "how long has this page been open" but "how far into the VOD is this message":&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;chatTimelineSecondsFromDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateValue&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;videoStartTime&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dateValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateValue&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;videoStartTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&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="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="mi"&gt;0&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;seconds&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the anchor is the VOD's own start time, pausing, seeking and quality switching become non-events. There is no drift to accumulate, because nothing is ever measured against the wall clock. A player that syncs against elapsed real time works beautifully until the first pause, and I did not want to spend the rest of the project fighting that.&lt;/p&gt;

&lt;h2&gt;
  
  
  On a seek, throw the buffer away
&lt;/h2&gt;

&lt;p&gt;Not rewound. Not searched. Discarded:&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;handleSeeking&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;chatActiveSession&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// invalidates any request still in flight&lt;/span&gt;
    &lt;span class="nx"&gt;chatMessages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;lastRenderedMsgId&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;span class="nf"&gt;startChatLoop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatActiveSession&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 session counter is the part that matters, and it is the bug I would have shipped if I had not hit it in testing. Requests fired &lt;em&gt;before&lt;/em&gt; the seek can still resolve &lt;em&gt;after&lt;/em&gt; it. Without that guard, a response for minute 12 arrives while you are watching minute 90 and quietly injects messages from the wrong place. Every async loop that can be restarted needs a way to tell its own stale replies apart, and a monotonic counter compared at the await boundary is the cheapest one I know:&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestUrl&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;chatActiveSession&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// a seek happened; this reply is stale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fresh request then goes out with the new target timestamp and the server paginates forward with a cursor. Scrubbing two hours ahead never replays the two hours in between — it starts clean at the new position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering is a linear reverse scan, on purpose
&lt;/h2&gt;

&lt;p&gt;The buffer is capped at 600 messages. Finding the last one at-or-before the current playback time therefore costs at most 600 comparisons:&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;let&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&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;for &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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chatMessages&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatMessages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&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;absTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;break&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chatMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;max&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="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;140&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;limit&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Binary search is the correct engineering for a large sorted buffer. At 600 entries, on a scan that runs from &lt;code&gt;timeupdate&lt;/code&gt;, it optimises something that never appeared in a profile. So this is a tradeoff with a stated condition rather than an oversight: &lt;strong&gt;if that 600 cap ever moves, this decision moves with it.&lt;/strong&gt; Writing the condition down is what separates a deliberate shortcut from technical debt you forgot about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The burst case is handled by the window, not by throttling
&lt;/h2&gt;

&lt;p&gt;This was the second half of the question, and the answer is almost anticlimactic.&lt;/p&gt;

&lt;p&gt;Scrub into a hype moment where several hundred messages land inside one second and you get the ~140 nearest your landing point — not 600 nodes injected into the DOM at once. The fixed window absorbs the spike because it never asks how many messages arrived, only which ones are near the current time.&lt;/p&gt;

&lt;p&gt;The same constraint doing two jobs is not a coincidence. Capping the buffer is what makes the linear scan cheap, and slicing a fixed window is what makes the burst harmless. One decision, two problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lookahead: 18 seconds playing, zero paused
&lt;/h2&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;desiredHorizon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&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="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CHAT_LOOKAHEAD_SECONDS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While playing, the loop keeps roughly 18 seconds of chat ahead of the playhead so messages are already in memory when the video reaches them. While paused, it fetches nothing. A paused player has no reason to keep pulling chat it may never show, and on a platform that rate-limits aggressively, the requests you &lt;em&gt;do not&lt;/em&gt; make are as important as the ones you do.&lt;/p&gt;

&lt;p&gt;That last point cost me a day, incidentally: one of these platforms signals throttling with &lt;code&gt;403&lt;/code&gt;, not &lt;code&gt;429&lt;/code&gt;, in bursts. A polling loop that treats every failure as retryable will happily make the situation worse. The loop now honours &lt;code&gt;Retry-After&lt;/code&gt; and backs off up to a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it honestly breaks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If the platform never stored the chat for that broadcast&lt;/strong&gt;, or serves it incomplete, the video plays and the chat comes up short. The player can only show what it receives, and no amount of client-side cleverness invents missing messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Very long broadcasts&lt;/strong&gt; stress the assumption behind the 600 cap more than short ones. It still holds, but it is the first thing I would re-measure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform chat archives are not equally reliable.&lt;/strong&gt; One of the two is noticeably patchier on older replays, and there is nothing to be done about it from the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Very little, which surprised me. The two things I would change are both about measurement rather than design: I would have profiled the render scan before assuming it was fine, and I would have written down the 600-message assumption next to the code that depends on it, instead of carrying it in my head for two months.&lt;/p&gt;

&lt;p&gt;The chat search came later and turned out to be the feature I use most — search the loaded messages by word or username, and the video seeks straight to that moment. It only works because of the same timeline anchoring: if messages already know where they live in the video, "jump to this message" is a one-line operation rather than a synchronisation problem.&lt;/p&gt;




&lt;p&gt;If you build video apps or work with HLS, I would genuinely like to hear where you would push back on this — particularly on the fixed-window rendering, which is the decision I am least sure survives contact with a much larger buffer.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>streaming</category>
    </item>
    <item>
      <title>Building NoSub: a browser-based VOD player for Twitch and Kick with synced chat</title>
      <dc:creator>Dino</dc:creator>
      <pubDate>Wed, 08 Jul 2026 12:15:21 +0000</pubDate>
      <link>https://dev.to/dinoa92/building-nosub-a-browser-based-vod-player-for-twitch-and-kick-with-synced-chat-3o58</link>
      <guid>https://dev.to/dinoa92/building-nosub-a-browser-based-vod-player-for-twitch-and-kick-with-synced-chat-3o58</guid>
      <description>&lt;p&gt;I recently built NoSub, a browser-based VOD player for Twitch and Kick.&lt;/p&gt;

&lt;p&gt;The goal was simple at first: paste a Twitch or Kick VOD link and watch it in a cleaner player. But the project quickly became more interesting once I started adding synced chat replay, clip creation, streamer pages, multilingual SEO pages, and safe ad integration.&lt;/p&gt;

&lt;p&gt;NoSub does not host VODs and does not recover deleted or expired replays. It only works when the replay source is still available.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Plays Twitch and Kick VODs in the browser&lt;/li&gt;
&lt;li&gt;Shows synced chat replay when available&lt;/li&gt;
&lt;li&gt;Lets users create clips from VODs&lt;/li&gt;
&lt;li&gt;Supports streamer search and profile pages&lt;/li&gt;
&lt;li&gt;Uses source quality when available&lt;/li&gt;
&lt;li&gt;Has multilingual landing pages for SEO&lt;/li&gt;
&lt;li&gt;Runs as a Flask app with a custom frontend player&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The hardest parts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  HLS playback
&lt;/h3&gt;

&lt;p&gt;The player has to deal with different stream sources, quality levels, buffering states, and browser differences.&lt;/p&gt;

&lt;p&gt;One thing I learned quickly: “the video URL exists” does not mean “the player experience is good.” You still need proper loading states, quality selection, error handling, and fallbacks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Synced chat replay
&lt;/h3&gt;

&lt;p&gt;Chat replay makes old streams feel alive again.&lt;/p&gt;

&lt;p&gt;Without chat, watching an old stream can feel weirdly empty. The challenge is keeping chat aligned with the video timeline, especially when users seek around or when the VOD is several hours long.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clip creation
&lt;/h3&gt;

&lt;p&gt;I wanted clip creation to feel closer to Twitch/Kick, not just a form with start/end inputs.&lt;/p&gt;

&lt;p&gt;The current approach is to let users choose a segment from the VOD timeline, add a title, preview it, and generate a shareable clip page.&lt;/p&gt;

&lt;h3&gt;
  
  
  SEO
&lt;/h3&gt;

&lt;p&gt;Because the app is mostly dynamic, I added server-rendered metadata, canonical URLs, hreflang tags, sitemap.xml, robots.txt, Open Graph tags, and landing pages for multiple languages.&lt;/p&gt;

&lt;p&gt;I also added clean 301 redirects for common search intents, but without creating doorway pages or duplicated content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ads without breaking the app
&lt;/h3&gt;

&lt;p&gt;I integrated ads carefully because VOD proxying and hosting can cost money.&lt;/p&gt;

&lt;p&gt;The difficult part was making sure ads don’t take over the main app. The solution was to isolate them in a separate frame/service so they can render while keeping the main app protected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would improve next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;More real-world testing on very long VODs&lt;/li&gt;
&lt;li&gt;Better mobile player ergonomics&lt;/li&gt;
&lt;li&gt;More robust clip export options&lt;/li&gt;
&lt;li&gt;Better observability for failed VOD loads&lt;/li&gt;
&lt;li&gt;Cleaner modularization of the frontend code&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Live demo
&lt;/h2&gt;

&lt;p&gt;NoSub is available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nosubapp.com" rel="noopener noreferrer"&gt;https://nosubapp.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’d love feedback from people who build video apps, work with HLS, or edit Twitch/Kick content.&lt;/p&gt;

&lt;p&gt;What would you improve first: playback reliability, chat sync, clip creation, or mobile UX?&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>flask</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
