Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/provider-teardown-stops-playback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@karnstack/kino": patch
---

Mux and native: a destroyed provider leaves nothing playing. `destroy()` removed its listeners and detached the element, but detaching a media element does not stop it. A removed `<mux-video>` keeps playing and keeps pulling HLS segments, and nothing on the page can reach it any more, because every control talks to the provider that owned it. React invokes a mount effect twice in development, so a player created with `autoPlay` would mount, tear down and mount again, and the discarded element played on underneath the live one, a beat apart, until the tab closed. The same thing happened to any player that was already playing when a route change, a remount on a changed `key` or a fast refresh tore it down. A source change is not one of these: it flows through `swapSource`, which keeps the element and never calls `destroy()`.

Both providers now pause the element and drop its source before removing it. For mux that means clearing the `src` attribute, which is what makes `mux-video` tear its playback engine down; the element's own `disconnectedCallback` cannot cover this, because `mux-video` finishes its setup a microtask after mount and a mount and teardown that land in the same tick get there first. The native provider already released its source, and now pauses as well, so its stop no longer rides entirely on `load()`.

The YouTube, Vimeo and scenes providers were already clean. The first two hand teardown to the SDK's own `destroy()`, which takes the player iframe with it, and scenes removes the host iframe, which discards the document and the audio element inside it. A detached iframe stops. A detached media element does not.
87 changes: 87 additions & 0 deletions src/mux/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect, test, vi } from "vitest"
import { createMuxProvider } from "./provider"

// jsdom hands back a plain array for HTMLMediaElement.textTracks rather than a
// TextTrackList, and mount() registers track listeners on it. Give the list the
// two methods it needs so the provider can mount here at all.
const mediaProto = HTMLMediaElement.prototype
const textTracks = Object.getOwnPropertyDescriptor(mediaProto, "textTracks")!
Object.defineProperty(mediaProto, "textTracks", {
configurable: true,
get(this: HTMLMediaElement) {
const list = textTracks.get!.call(this) as TextTrackList
const target = list as unknown as Record<string, unknown>
if (list && typeof target.addEventListener !== "function") {
target.addEventListener = () => {}
target.removeEventListener = () => {}
}
return list
},
})

// jsdom decodes no media, so playback here is a stand-in for it: the element
// reports itself playing until something calls pause() on it, which is the
// contract destroy() has to meet.
function mountPlaying() {
const host = document.createElement("div")
document.body.appendChild(host)
const provider = createMuxProvider({ playbackId: "abc123", autoPlay: true })
provider.mount(host)
const el = host.querySelector("mux-video") as HTMLVideoElement
let paused = false
const pause = vi.fn(() => {
paused = true
})
Object.defineProperty(el, "paused", { configurable: true, get: () => paused })
Object.defineProperty(el, "pause", { configurable: true, value: pause })
return { host, provider, el, pause }
}

test("destroy pauses the element it created", () => {
const { provider, el, pause } = mountPlaying()
expect(el.paused).toBe(false)
provider.destroy()
expect(pause).toHaveBeenCalled()
expect(el.paused).toBe(true)
})

test("destroy detaches the element", () => {
const { host, provider, el } = mountPlaying()
provider.destroy()
expect(host.querySelector("mux-video")).toBeNull()
expect(el.isConnected).toBe(false)
})

test("destroy releases the source so the engine stops fetching", () => {
const { provider, el } = mountPlaying()
expect(el.getAttribute("src")).toContain("abc123")
provider.destroy()
expect(el.hasAttribute("src")).toBe(false)
})

test("the released source stays released once mux-video finishes its setup", async () => {
// mux-video loads its source a microtask after the attribute lands, so an
// element destroyed in the same tick it was mounted would otherwise come up
// playing after it was already thrown away.
const { provider, el } = mountPlaying()
provider.destroy()
await new Promise((r) => setTimeout(r, 0))
expect(el.hasAttribute("src")).toBe(false)
})

test("mount, destroy, mount leaves the first element stopped", () => {
// React invokes a mount effect twice in development: mount, destroy, mount.
// The first element is unreachable afterwards, because every control on the
// page talks to the provider that owned it, so anything it is still doing
// plays over the second one until the tab closes.
const first = mountPlaying()
first.provider.destroy()
const second = mountPlaying()
expect(second.el).not.toBe(first.el)
expect(first.pause).toHaveBeenCalled()
expect(first.el.paused).toBe(true)
expect(first.el.isConnected).toBe(false)
expect(first.el.hasAttribute("src")).toBe(false)
expect(second.el.isConnected).toBe(true)
second.provider.destroy()
})
10 changes: 10 additions & 0 deletions src/mux/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,16 @@ export function createMuxProvider(opts: MuxProviderOptions): Provider {
el.textTracks?.removeEventListener("addtrack", onTextTracksChanged)
el.textTracks?.removeEventListener("removetrack", onTextTracksChanged)
el.textTracks?.removeEventListener("change", onTextTracksChanged)
// Removing the element does not stop it: a detached <mux-video> keeps
// playing and keeps pulling segments, and nothing on the page can reach
// it any more. Pause, then drop the src, which is what makes mux-video
// tear its playback engine down. Both belong here rather than in the
// element's own disconnectedCallback, which is too late: mux-video
// finishes its setup a microtask after mount, so an element mounted and
// destroyed in the same tick (React runs a mount effect twice in
// development) comes up playing after it was already thrown away.
el.pause()
el.removeAttribute("src")
el.remove()
}
el = null
Expand Down
19 changes: 19 additions & 0 deletions src/native/provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { expect, test, vi } from "vitest"
import { createNativeProvider } from "./provider"

function mount(provider: ReturnType<typeof createNativeProvider>) {
Expand Down Expand Up @@ -150,3 +151,21 @@ test("destroy removes the video element from its host", () => {
p.destroy()
expect(host.querySelector("video")).toBeNull()
})

test("destroy pauses the element before dropping it", () => {
// jsdom decodes no media, so playback here is a stand-in for it: the element
// reports itself playing until something calls pause() on it.
const p = createNativeProvider({ src: "clip.mp4", autoPlay: true })
const { el } = mount(p)
let paused = false
const pause = vi.fn(() => {
paused = true
})
Object.defineProperty(el, "paused", { configurable: true, get: () => paused })
Object.defineProperty(el, "pause", { configurable: true, value: pause })
p.destroy()
expect(pause).toHaveBeenCalled()
expect(el.paused).toBe(true)
expect(el.isConnected).toBe(false)
expect(el.hasAttribute("src")).toBe(false)
})
6 changes: 6 additions & 0 deletions src/native/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,12 @@ export function createNativeProvider(opts: NativeProviderOptions): Provider {
tt.removeEventListener("removetrack", onTextTracksChanged)
tt.removeEventListener("change", onTextTracksChanged)
}
// Removing the element does not stop it: a detached <video> keeps
// playing, out of reach of the controls that were pointed at it.
// Dropping the source below releases it, but the stop that comes with
// it rides on load(), which we swallow where it is unavailable, so
// pause first and teardown stops playback on its own.
el.pause()
el.removeAttribute("src")
reload()
el.remove()
Expand Down
Loading