visar.log
Technical notes from building things
← all posts

The Stale Filename Marker: Why Swipe Navigation Showed Black Screens

The Symptom

Swipe down to navigate to the next stream. The title in the top bar updates. The player index rotates. Everything looks correct. But the screen is black — no video.

The bug was intermittent. Sometimes it worked fine. Sometimes three swipes in a row would black-screen. The pattern wasn’t obvious.

VideoPlayer maintains 3 <video> elements that rotate like a carousel. Player 0 is active, player 1 has the next stream preloaded, player 2 has the previous. On swipe, the active index shifts: newIndex = (current + direction + 3) % 3. The next player is already loaded, so playback should be instant.

Each player tracks what it has loaded via el.dataset.loadedFilename. When loadStream is called, it checks this marker first:

if (el.dataset.loadedFilename === v.filename) {
  // Already loaded — skip
  resolve();
  return;
}

This is the skip optimization. If the player already has the right stream loaded, don’t tear it down and reload. Makes sense.

The Problem

HLS instances can be destroyed without clearing dataset.loadedFilename.

Two paths lead here:

1. TL 404 error handler. When a stream goes offline, HLS fires a fatal network error. The error handler destroys the HLS instance and removes it from the hlsInstances Map. But it didn’t touch dataset.loadedFilename:

if (videoListStore.selectedProvider === 'tl') {
  hls.destroy();
  hlsInstances.delete(el);
  // dataset.loadedFilename still says "sunny_vika"
  return;
}

2. Component lifecycle. The hlsInstances Map is a plain new Map() in the component script — not $state, not persisted. If the component’s reactive context shifts (effects re-run, cleanup runs), the Map can lose entries while the DOM elements retain their dataset.loadedFilename from the previous cycle.

Either way, the result is the same: dataset.loadedFilename says the stream is loaded, but there’s no HLS instance backing it. No HLS means no MediaSource, no blob URL, no el.src. The video element sits there with readyState=0.

Why It Was Intermittent

The skip optimization only causes a black screen when loadStream is called for a stream whose HLS was previously destroyed on that specific player element. This depends on:

  • Whether the preloaded stream had a 404 between preload and activation
  • Whether the player rotation happened to land on an element with a stale marker
  • Whether preloadAdjacent cleared and reloaded the element in between

Sometimes the stars aligned and the stale marker was still there. Sometimes a reload had already happened and the marker was fresh.

The Fix

Two changes, both in VideoPlayer.svelte.

1. Triple validation in loadStream. Don’t trust the filename marker alone. Verify the stream is actually alive:

const hasActiveStream = hlsInstances.has(el) || nativeAbortControllers.has(el);
const streamAlive =
  el.dataset.loadedFilename === v.filename &&
  hasActiveStream &&
  !!el.src;

if (streamAlive) {
  // Genuinely loaded — skip
  syncLiveStatus(el, v, isActivePlayer);
  if (startTime > 0) el.currentTime = startTime;
  resolve();
  return;
}

// Stale marker — clear it and reload
if (el.dataset.loadedFilename === v.filename) {
  delete el.dataset.loadedFilename;
}

Three conditions must all be true to skip: filename matches, an HLS (or native) instance exists for this element, and the element has a src. If the marker matches but the stream is dead, clear the marker and fall through to a full reload.

This covers both HLS.js (desktop) and native HLS (iOS) paths.

2. Clear marker in TL 404 handler. Clean up at the source so the stale state doesn’t accumulate:

if (videoListStore.selectedProvider === 'tl') {
  hls.destroy();
  hlsInstances.delete(el);
  delete el.dataset.loadedFilename;
  return;
}

The “Remount” Red Herring

During debugging, console logs showed the activate effect firing with videoChanged=true for a stream that was supposedly already loaded. The initial theory was that the component was periodically remounting — resetting currentFilename to null and recreating the hlsInstances Map.

90 seconds of DOM mutation monitoring with 2-second resolution showed zero remounts. The component is rendered unconditionally (<VideoPlayer />) with no {#key} block, no conditional wrapper. SvelteKit reuses the same component instance for param changes within the same route.

The “remount” appearance was the stale marker bug itself: hlsInstances was empty for that element (destroyed by a 404 handler), while dataset.loadedFilename still had a value. It looked like the Map was recreated, but it was just missing one entry.

Verification

Three consecutive swipe navigations after the fix, all confirmed working:

  • sunny_vika → ami-238: video playing, readyState=4
  • ami-238 → cuddly-lion-14256: video playing, readyState=4
  • cuddly-lion-14256 → zeynep7445: video playing, readyState=4

Takeaway

Skip optimizations that use markers need to verify the thing being skipped is actually still alive. A dataset.loadedFilename is just a string on a DOM element — it doesn’t know whether the HLS instance behind it was destroyed by an error handler ten seconds ago. The marker is a cache key, and like all caches, it can go stale. The fix is the same as any cache: validate before trusting.