Skip to content

Commit adb6760

Browse files
authored
feat: Player (#135)
1 parent 0040d16 commit adb6760

File tree

95 files changed

+1870
-3190
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

95 files changed

+1870
-3190
lines changed

bun.lockb

-13 KB
Binary file not shown.

fixtures/media-chrome/index.html

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta name="viewport" content="width=device-width, initial-scale=1">
5+
<link
6+
rel="stylesheet"
7+
href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css"
8+
>
9+
<style>
10+
media-controller {
11+
display: block;
12+
width: 100%;
13+
aspect-ratio: 16 / 9;
14+
background: #000;
15+
}
16+
17+
media-controller[interstitial="1"] media-time-range {
18+
display: none;
19+
}
20+
</style>
21+
</head>
22+
<body>
23+
<script async src="https://cdn.jsdelivr.net/npm/es-module-shims"></script>
24+
<script type="importmap">
25+
{
26+
"imports": {
27+
"super-media-element": "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
28+
"media-tracks": "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
29+
"@superstreamer/player": "/packages/player/dist/index.js",
30+
"hls.js": "https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.mjs"
31+
}
32+
}
33+
</script>
34+
<script type="module" src="./superstreamer-video-element.js"></script>
35+
<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome/+esm"></script>
36+
<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome/menu/+esm"></script>
37+
38+
<media-controller>
39+
<superstreamer-video
40+
slot="media"
41+
src="https://stitcher.superstreamer.xyz/session/6c127f2a-ca33-4eca-aff2-29ab3c3aac7c/master.m3u8">
42+
</superstreamer-video>
43+
<media-loading-indicator slot="centered-chrome" noautohide></media-loading-indicator>
44+
<media-rendition-menu hidden anchor="auto"></media-rendition-menu>
45+
<media-audio-track-menu hidden anchor="auto"></media-audio-track-menu>
46+
<media-captions-menu hidden anchor="auto"></media-captions-menu>
47+
<media-control-bar>
48+
<media-play-button></media-play-button>
49+
<media-seek-forward-button></media-seek-forward-button>
50+
<media-mute-button></media-mute-button>
51+
<media-volume-range></media-volume-range>
52+
<media-time-range></media-time-range>
53+
<media-time-display showduration></media-time-display>
54+
<media-rendition-menu-button></media-rendition-menu-button>
55+
<media-audio-track-menu-button></media-audio-track-menu-button>
56+
<media-captions-menu-button></media-captions-menu-button>
57+
<media-fullscreen-button></media-fullscreen-button>
58+
</media-control-bar>
59+
</media-controller>
60+
</body>
61+
</html>
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { Events, HlsPlayer } from "@superstreamer/player";
2+
import { MediaTracksMixin } from "media-tracks";
3+
4+
function getTemplateHTML() {
5+
return `
6+
<style>
7+
:host {
8+
width: 100%;
9+
height: 100%;
10+
}
11+
</style>
12+
<div class="container"></div>
13+
`;
14+
}
15+
16+
const symbolTrackId_ = Symbol("superstreamer.trackId");
17+
18+
class SuperstreamerVideoElement extends MediaTracksMixin(
19+
globalThis.HTMLElement,
20+
) {
21+
static getTemplateHTML = getTemplateHTML;
22+
23+
static shadowRootOptions = {
24+
mode: "open",
25+
};
26+
27+
static observedAttributes = ["src"];
28+
29+
#player;
30+
31+
#readyState = 0;
32+
33+
#video;
34+
35+
constructor() {
36+
super();
37+
38+
if (!this.shadowRoot) {
39+
this.attachShadow({
40+
mode: "open",
41+
});
42+
this.shadowRoot.innerHTML = getTemplateHTML();
43+
}
44+
45+
const container = this.shadowRoot.querySelector(".container");
46+
this.#player = new HlsPlayer(container);
47+
48+
this.#video = document.createElement("video");
49+
50+
this.#bindListeners();
51+
}
52+
53+
#bindListeners() {
54+
this.#player.on(Events.PLAYHEAD_CHANGE, () => {
55+
switch (this.#player.playhead) {
56+
case "play":
57+
this.dispatchEvent(new Event("play"));
58+
break;
59+
case "playing":
60+
this.dispatchEvent(new Event("playing"));
61+
break;
62+
case "pause":
63+
this.dispatchEvent(new Event("pause"));
64+
break;
65+
}
66+
});
67+
68+
this.#player.on(Events.TIME_CHANGE, () => {
69+
this.dispatchEvent(new Event("timeupdate"));
70+
});
71+
72+
this.#player.on(Events.VOLUME_CHANGE, () => {
73+
this.dispatchEvent(new Event("volumechange"));
74+
});
75+
76+
this.#player.on(Events.SEEKING_CHANGE, () => {
77+
if (this.#player.seeking) {
78+
this.dispatchEvent(new Event("seeking"));
79+
} else {
80+
this.dispatchEvent(new Event("seeked"));
81+
}
82+
});
83+
84+
this.#player.on(Events.READY, async () => {
85+
this.#readyState = 1;
86+
87+
this.dispatchEvent(new Event("loadedmetadata"));
88+
this.dispatchEvent(new Event("durationchange"));
89+
this.dispatchEvent(new Event("volumechange"));
90+
this.dispatchEvent(new Event("loadcomplete"));
91+
92+
this.#createVideoTracks();
93+
this.#createAudioTracks();
94+
this.#createTextTracks();
95+
});
96+
97+
this.#player.on(Events.STARTED, () => {
98+
this.#readyState = 3;
99+
});
100+
101+
this.#player.on(Events.ASSET_CHANGE, () => {
102+
const controller = this.closest("media-controller");
103+
if (controller) {
104+
controller.setAttribute("interstitial", this.#player.asset ? "1" : "0");
105+
}
106+
});
107+
}
108+
109+
get src() {
110+
return this.getAttribute("src");
111+
}
112+
113+
set src(val) {
114+
if (this.src === val) {
115+
return;
116+
}
117+
this.setAttribute("src", val);
118+
}
119+
120+
get textTracks() {
121+
return this.#video.textTracks;
122+
}
123+
124+
attributeChangedCallback(attrName, oldValue, newValue) {
125+
if (attrName === "src" && oldValue !== newValue) {
126+
this.load();
127+
}
128+
}
129+
130+
async load() {
131+
this.#readyState = 0;
132+
133+
while (this.#video.firstChild) {
134+
this.#video.firstChild.remove();
135+
}
136+
137+
for (const videoTrack of this.videoTracks) {
138+
this.removeVideoTrack(videoTrack);
139+
}
140+
for (const audioTrack of this.audioTracks) {
141+
this.removeAudioTrack(audioTrack);
142+
}
143+
144+
this.#player.unload();
145+
146+
this.dispatchEvent(new Event("emptied"));
147+
148+
if (this.src) {
149+
this.dispatchEvent(new Event("loadstart"));
150+
this.#player.load(this.src);
151+
}
152+
}
153+
154+
get currentTime() {
155+
return this.#player.time;
156+
}
157+
158+
set currentTime(val) {
159+
this.#player.seekTo(val);
160+
}
161+
162+
get duration() {
163+
return this.#player.duration;
164+
}
165+
166+
get paused() {
167+
const { playhead } = this.#player;
168+
if (playhead === "play" || playhead === "playing") {
169+
return false;
170+
}
171+
return true;
172+
}
173+
174+
get readyState() {
175+
return this.#readyState;
176+
}
177+
178+
get muted() {
179+
return this.#player.volume === 0;
180+
}
181+
182+
set muted(val) {
183+
this.#player.setVolume(val ? 0 : 1);
184+
}
185+
186+
get volume() {
187+
return this.#player.volume;
188+
}
189+
190+
set volume(val) {
191+
this.#player.setVolume(val);
192+
}
193+
194+
async play() {
195+
this.#player.playOrPause();
196+
await Promise.resolve();
197+
}
198+
199+
pause() {
200+
this.#player.playOrPause();
201+
}
202+
203+
#createVideoTracks() {
204+
let videoTrack = this.videoTracks.getTrackById("main");
205+
206+
if (!videoTrack) {
207+
videoTrack = this.addVideoTrack("main");
208+
videoTrack.id = "main";
209+
videoTrack.selected = true;
210+
}
211+
212+
this.#player.qualities.forEach((quality) => {
213+
videoTrack.addRendition(
214+
undefined,
215+
quality.height,
216+
quality.height,
217+
undefined,
218+
undefined,
219+
);
220+
});
221+
222+
this.videoRenditions.addEventListener("change", (event) => {
223+
if (event.target.selectedIndex < 0) {
224+
this.#player.setQuality(null);
225+
} else {
226+
const rendition = this.videoRenditions[event.target.selectedIndex];
227+
this.#player.setQuality(rendition.height);
228+
}
229+
});
230+
}
231+
232+
#createAudioTracks() {
233+
this.#player.audioTracks.forEach((a) => {
234+
const audioTrack = this.addAudioTrack("main", a.label, a.label);
235+
audioTrack[symbolTrackId_] = a.id;
236+
audioTrack.enabled = a.active;
237+
});
238+
239+
this.audioTracks.addEventListener("change", () => {
240+
const track = [...this.audioTracks].find((a) => a.enabled);
241+
if (track) {
242+
const id = track[symbolTrackId_];
243+
this.#player.setAudioTrack(id);
244+
}
245+
});
246+
}
247+
248+
#createTextTracks() {
249+
this.#player.subtitleTracks.forEach((s) => {
250+
const textTrack = this.addTextTrack("subtitles", s.label, s.track.lang);
251+
textTrack[symbolTrackId_] = s.id;
252+
});
253+
254+
this.textTracks.addEventListener("change", () => {
255+
const track = [...this.textTracks].find((t) => t.mode === "showing");
256+
if (track) {
257+
const id = track[symbolTrackId_];
258+
this.#player.setSubtitleTrack(id);
259+
} else {
260+
this.#player.setSubtitleTrack(null);
261+
}
262+
});
263+
}
264+
265+
addTextTrack(kind, label, language) {
266+
const trackEl = document.createElement("track");
267+
trackEl.kind = kind;
268+
trackEl.label = label;
269+
trackEl.srclang = language;
270+
trackEl.track.mode = "hidden";
271+
this.#video.append(trackEl);
272+
return trackEl.track;
273+
}
274+
}
275+
276+
if (!globalThis.customElements?.get("superstreamer-video")) {
277+
globalThis.customElements.define(
278+
"superstreamer-video",
279+
SuperstreamerVideoElement,
280+
);
281+
}
282+
283+
export default SuperstreamerVideoElement;

packages/app/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>Superstreamer</title>
7+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
78
</head>
89
<body>
910
<div id="root"></div>

packages/app/src/components/CodeEditor.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export function CodeEditor({
4646

4747
return (
4848
<MonacoEditor
49-
className="h-full w-full"
5049
defaultLanguage="json"
5150
defaultValue={value}
5251
onMount={onMount}

packages/app/src/components/DataDump.tsx

Lines changed: 0 additions & 22 deletions
This file was deleted.

0 commit comments

Comments
 (0)