bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.
Vendor hls.js (npm run build:hls) and route HLS sources through it:
- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
returns "maybe" for every HLS type, so a native-first branch would hang.
Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
audio stream controller and real ladders demux audio into an EXT-X-MEDIA
group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
bare-<video> probe could not load it and on Chrome emitted no events at all,
burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
mid-startup: the bundle, master playlist, variant playlist, init segment and
first media segment are four-plus serial round trips.
Also fixes two bugs found while verifying this:
- A stale MediaError was reported as the current failure. Teardown clears `src`
and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
on the element; a later watchdog fallback rendered that as "Hosted playback
failed: source not supported or blocked." over a stream that was playing
fine. Only a MediaError produced by the current attempt is surfaced now.
Sources are also bound before the watchdogs are armed, so hls.js's own
attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
published with it as podcasts and dropped them from every video feed.
Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Follow-up to the audio-only feed guard. Real case: a Nodesignal-Talk podcast
published with `imeta m audio/mp4` + a `.f4a` URL. It was already filtered via
its imeta hint (audio/mp4 → hasAudio), but unlike the .ogg/.mp3 ones it actually
PLAYS in a <video> element (AAC-in-MP4 = audio + poster), which is why it looked
like it "worked." It's still a podcast, so it stays out of the video grid.
Closes the fallback gap for audio/mp4 notes that lack an imeta audio hint: added
.f4a/.m4b/.mka to AUDIO_ONLY_URL_PATTERN (AAC-in-MP4 audio / audiobooks /
Matroska audio). .mp4/.m4v remain video and are explicitly asserted not to match.
Tests: video-schema-and-conversion (+2, 13/13). Lint + build clean. TODO #60.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The convert-time guard (17b48cbf) only blocks FRESH events. Audio notes cached
before it existed are rehydrated straight from IndexedDB/localStorage as
already-converted video objects (`sig: "unavailable (cached)"`), bypassing
convertEventToVideo — so they kept showing in the main feed and channel grids.
Extract the audio check into a reusable object-level predicate
`isAudioOnlyVideoObject(video)` (url + magnet + imeta tags) and apply it at every
boundary a cached object can slip through:
- convertEventToVideo now delegates to it (fresh ingest, unchanged behavior)
- PersistenceManager.applyCachedPayload drops audio-only on hydration (purges
the cache going forward)
- NostrClient.getActiveVideos filters it (main feed, immediate effect)
- nostrService.shouldIncludeVideo filters it above the viewer-is-author
short-circuit (channel/author grids, incl. the creator's own channel)
Audio-only = no magnet AND no imeta `m video/*` AND (imeta `m audio/*` OR an
unambiguous audio url); `.ogg` alone stays video. Bumped client.js size baseline
(+6 lines). Tests: video-schema-and-conversion (+3 predicate scenarios, 11/11);
nostr-service + ingest-adapter suites green. Lint + build clean. TODO #60.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A podcast/music note published as a native kind-30078 (content url = an .ogg/
.mp3 audio file, imeta `m audio/*`) was accepted by convertEventToVideo (it has
a url) and rendered as a broken <video> — black frame + audio on Chrome/FF,
MEDIA_ERR_SRC_NOT_SUPPORTED on Safari/iOS — since bitvid has no <audio> player.
The existing audio guard only covered the NIP-71 ingest path, not native events.
convertEventToVideo now marks such notes invalid ("audio-only source (no video)")
so every ingestion path that already drops invalid filters them: main feed buffer,
author/channel batch fetch, and local ingest. Audio-only = no magnet AND no
`imeta m video/*` AND (an `imeta m audio/*` variant OR an unambiguous audio URL
.mp3/.m4a/.aac/.wav/.flac/.opus/.oga/.weba). `.ogg` alone is NOT filtered
(ambiguous → video/ogg); only when imeta says audio. A video imeta variant wins.
Direct open-by-nevent is intentionally unaffected — an explicit link still loads.
Near-term half of TODO #60 (the future dedicated Audio tab remains open).
Tests: tests/video-schema-and-conversion.test.mjs (+5, 8/8). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>