feat: n94 poc

This commit is contained in:
kieran 2024-11-19 17:14:16 +00:00
parent f9f0cf213d
commit 03a5820140
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
9 changed files with 243 additions and 28 deletions

View File

@ -24,6 +24,7 @@
"hls-video-element": "^1.2.7", "hls-video-element": "^1.2.7",
"marked": "^12.0.2", "marked": "^12.0.2",
"media-chrome": "^3.2.4", "media-chrome": "^3.2.4",
"mpegts.js": "^1.7.3",
"qr-code-styling": "^1.6.0-rc.1", "qr-code-styling": "^1.6.0-rc.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-confetti": "^6.1.0", "react-confetti": "^6.1.0",

View File

@ -20,6 +20,8 @@ import {
} from "media-chrome/react"; } from "media-chrome/react";
import "hls-video-element"; import "hls-video-element";
import { StreamState } from "@/const"; import { StreamState } from "@/const";
import Nip94Player from "./n94-player";
import { NostrLink } from "@snort/system";
type VideoPlayerProps = { type VideoPlayerProps = {
title?: string; title?: string;
@ -27,9 +29,17 @@ type VideoPlayerProps = {
stream?: string; stream?: string;
poster?: string; poster?: string;
muted?: boolean; muted?: boolean;
link: NostrLink;
} & HTMLProps<HTMLVideoElement>; } & HTMLProps<HTMLVideoElement>;
export default function LiveVideoPlayer({ title, stream, status, poster, ...props }: VideoPlayerProps) { export default function LiveVideoPlayer({ title, stream, status, poster, link, ...props }: VideoPlayerProps) {
function innerPlayer() {
if (stream === "nip94") {
return <Nip94Player link={link} />
}
{/* @ts-ignore Web Componenet */ }
return <hls-video {...props} slot="media" src={stream} playsInline={true} autoPlay={true} />
}
return ( return (
<MediaController <MediaController
className={classNames(props.className, "h-inherit aspect-video w-full")} className={classNames(props.className, "h-inherit aspect-video w-full")}
@ -42,8 +52,7 @@ export default function LiveVideoPlayer({ title, stream, status, poster, ...prop
<div slot="top-chrome" className="py-1 text-center w-full text-2xl bg-primary"> <div slot="top-chrome" className="py-1 text-center w-full text-2xl bg-primary">
{title} {title}
</div> </div>
{/* @ts-ignore Web Componenet */} {innerPlayer()}
<hls-video {...props} slot="media" src={stream} playsInline={true} />
<MediaRenditionMenu hidden anchor="auto" /> <MediaRenditionMenu hidden anchor="auto" />
{poster && <MediaPosterImage slot="poster" src={poster} />} {poster && <MediaPosterImage slot="poster" src={poster} />}
<MediaControlBar> <MediaControlBar>

View File

@ -0,0 +1,158 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { EventKind, NostrLink, QueryLike, RequestBuilder, SystemInterface, parseNostrLink } from "@snort/system";
import { useContext, useEffect, useRef } from "react"
import mpegts from "mpegts.js";
import { SnortContext } from "@snort/system-react";
import { findTag } from "@/utils";
interface MediaSegment {
created: number,
sha256: string,
url: string,
duration: number,
loaded: boolean
}
enum LoaderStatus {
kIdle = 0,
kConnecting = 1,
kBuffering = 2,
kError = 3,
kComplete = 4
}
class Nip94Loader implements mpegts.BaseLoader {
#system!: SystemInterface
#query?: QueryLike
#mediaChunks: Map<string, MediaSegment> = new Map();
#status: LoaderStatus = LoaderStatus.kIdle;
#bytes = 0;
constructor(_seekHandler: mpegts.SeekHandler, config: mpegts.Config) {
if ("system" in config) {
this.#system = config.system as SystemInterface;
} else {
throw "Invalid config, missing system"
}
}
// @ts-expect-error
onContentLengthKnown: (contentLength: number) => void;
// @ts-expect-error
onURLRedirect: (redirectedURL: string) => void;
// @ts-expect-error
onDataArrival: (chunk: ArrayBuffer, byteStart: number, receivedLength?: number | undefined) => void;
// @ts-expect-error
onError: (errorType: mpegts.LoaderErrors, errorInfo: mpegts.LoaderErrorMessage) => void;
// @ts-expect-error
onComplete: (rangeFrom: number, rangeTo: number) => void;
get _status() { return this.#status }
get status() { return this.#status }
destroy(): void {
throw new Error("Method not implemented.");
}
isWorking(): boolean {
throw new Error("Method not implemented.");
}
type = "nip94"
get needStashBuffer() {
return false;
}
get _needStash() { return this.needStashBuffer }
open(dataSource: mpegts.MediaSegment, range: mpegts.Range) {
const link = parseNostrLink(dataSource.url);
if (!link) {
throw new Error("Datasource.url is invalid")
}
const rb = new RequestBuilder(`n94-stream-${link.encode()}`);
rb.withOptions({
leaveOpen: true,
});
rb.withFilter()
.replyToLink([link])
.kinds([EventKind.FileHeader]);
this.#status = LoaderStatus.kConnecting;
this.#query = this.#system.Query(rb);
this.#query.on("event", (evs) => {
for (const ev of evs) {
const seg = {
created: ev.created_at,
url: findTag(ev, "url")!,
sha256: findTag(ev, "x")!,
} as MediaSegment;
this.#addSegment(seg);
}
this.#loadNext();
});
}
abort() {
this.#query?.cancel();
}
#addSegment(seg: MediaSegment) {
if (!this.#mediaChunks.has(seg.sha256)) {
this.#mediaChunks.set(seg.sha256, seg);
}
}
async #loadNext() {
if (this.#status === LoaderStatus.kConnecting || this.#status === LoaderStatus.kIdle) {
this.#status = LoaderStatus.kBuffering;
}
if (this.#status !== LoaderStatus.kBuffering) {
return;
}
const orderedLoad = [...this.#mediaChunks.values()].sort((a, b) => a.created - b.created).filter(a => !a.loaded);
for (const s of orderedLoad) {
const result = await fetch(s.url);
const buf = await result.arrayBuffer();
this.onDataArrival(buf, this.#bytes, buf.byteLength);
console.debug("pushing bytes", this.#bytes, buf.byteLength);
this.#bytes += buf.byteLength;
this.#mediaChunks.set(s.sha256, {
...s,
loaded: true
});
}
this.#status = LoaderStatus.kIdle;
}
}
export default function Nip94Player({ link }: { link: NostrLink }) {
const ref = useRef(null);
const system = useContext(SnortContext);
useEffect(() => {
if (ref.current) {
const player = ref.current as HTMLVideoElement & {
__streamer?: mpegts.Player
};
if (!player.__streamer) {
player.__streamer = new mpegts.MSEPlayer({
type: "mse",
isLive: true,
url: link.encode()
}, {
system,
customLoader: Nip94Loader
} as unknown as mpegts.Config);
player.__streamer.attachMediaElement(player);
player.__streamer.load();
player.__streamer.play();
}
}
}, [ref]);
return <video slot="media" ref={ref}></video>
}

View File

@ -1,8 +1,10 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { EventKind, RequestBuilder } from "@snort/system"; import { RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { LIVE_STREAM } from "@/const";
export function useStreamsFeed(tag?: string) { export function useStreamsFeed(tag?: string) {
const liveStreamKinds = [LIVE_STREAM];
const rb = useMemo(() => { const rb = useMemo(() => {
const rb = new RequestBuilder(tag ? `streams:${tag}` : "streams"); const rb = new RequestBuilder(tag ? `streams:${tag}` : "streams");
rb.withOptions({ rb.withOptions({
@ -11,26 +13,26 @@ export function useStreamsFeed(tag?: string) {
if (import.meta.env.VITE_SINGLE_PUBLISHER) { if (import.meta.env.VITE_SINGLE_PUBLISHER) {
if (tag) { if (tag) {
rb.withFilter() rb.withFilter()
.kinds([EventKind.LiveEvent]) .kinds(liveStreamKinds)
.tag("t", [tag]) .tag("t", [tag])
.authors([import.meta.env.VITE_SINGLE_PUBLISHER]); .authors([import.meta.env.VITE_SINGLE_PUBLISHER]);
rb.withFilter() rb.withFilter()
.kinds([EventKind.LiveEvent]) .kinds(liveStreamKinds)
.tag("t", [tag]) .tag("t", [tag])
.tag("p", [import.meta.env.VITE_SINGLE_PUBLISHER]); .tag("p", [import.meta.env.VITE_SINGLE_PUBLISHER]);
} else { } else {
rb.withFilter() rb.withFilter()
.kinds([EventKind.LiveEvent]) .kinds(liveStreamKinds)
.authors([import.meta.env.VITE_SINGLE_PUBLISHER]); .authors([import.meta.env.VITE_SINGLE_PUBLISHER]);
rb.withFilter() rb.withFilter()
.kinds([EventKind.LiveEvent]) .kinds(liveStreamKinds)
.tag("p", [import.meta.env.VITE_SINGLE_PUBLISHER]); .tag("p", [import.meta.env.VITE_SINGLE_PUBLISHER]);
} }
} else { } else {
if (tag) { if (tag) {
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", [tag]); rb.withFilter().kinds(liveStreamKinds).tag("t", [tag]);
} else { } else {
rb.withFilter().kinds([EventKind.LiveEvent]); rb.withFilter().kinds(liveStreamKinds);
} }
} }
return rb; return rb;

View File

@ -1,4 +1,4 @@
import { DAY, StreamState } from "@/const"; import { DAY, LIVE_STREAM, StreamState } from "@/const";
import { findTag, getHost } from "@/utils"; import { findTag, getHost } from "@/utils";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { NostrEvent, TaggedNostrEvent } from "@snort/system"; import { NostrEvent, TaggedNostrEvent } from "@snort/system";
@ -24,12 +24,20 @@ export function useSortedStreams(feed: Array<TaggedNostrEvent>, oldest?: number)
return []; return [];
}, [feed]); }, [feed]);
function canPlayEvent(ev: NostrEvent) {
if (ev.kind === LIVE_STREAM) {
const isHls = ev.tags.some(a => a[0] === "streaming" && a[1].includes(".m3u8"));
const isN94 = ev.tags.some(a => a[0] === "streaming" && a[1] == "nip94");
return isHls || isN94;
}
return false;
}
const live = feedSorted const live = feedSorted
.filter(a => { .filter(a => {
try { try {
return ( return (
findTag(a, "status") === StreamState.Live && findTag(a, "status") === StreamState.Live && canPlayEvent(a)
a.tags.some(a => a[0] === "streaming" && new URL(a[1]).pathname.includes(".m3u8"))
); );
} catch { } catch {
return false; return false;

View File

@ -1,7 +1,7 @@
import { SHORTS_KIND, VIDEO_KIND } from "@/const"; import { LIVE_STREAM, SHORTS_KIND, VIDEO_KIND } from "@/const";
import { useStreamLink } from "@/hooks/stream-link"; import { useStreamLink } from "@/hooks/stream-link";
import { getEventFromLocationState } from "@/utils"; import { getEventFromLocationState } from "@/utils";
import { NostrPrefix, EventKind } from "@snort/system"; import { NostrPrefix } from "@snort/system";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { StreamPage } from "./stream-page"; import { StreamPage } from "./stream-page";
import { VideoPage } from "./video"; import { VideoPage } from "./video";
@ -25,7 +25,7 @@ export function LinkHandler() {
<NostrEventElement link={link} /> <NostrEventElement link={link} />
</div> </div>
); );
} else if (link.kind === EventKind.LiveEvent || link.type === NostrPrefix.PublicKey) { } else if (link.kind === LIVE_STREAM || link.type === NostrPrefix.PublicKey) {
return ( return (
<div className={classNames(layoutContext.showHeader ? "h-[calc(100dvh-44px)]" : "h-[calc(100dvh)]", "w-full")}> <div className={classNames(layoutContext.showHeader ? "h-[calc(100dvh-44px)]" : "h-[calc(100dvh)]", "w-full")}>
<StreamPage link={link} evPreload={evPreload} /> <StreamPage link={link} evPreload={evPreload} />

View File

@ -10,7 +10,7 @@ import { useZapGoal } from "@/hooks/goals";
import { StreamCards } from "@/element/stream-cards"; import { StreamCards } from "@/element/stream-cards";
import { ContentWarningOverlay, useContentWarning } from "@/element/nsfw"; import { ContentWarningOverlay, useContentWarning } from "@/element/nsfw";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed"; import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { StreamState } from "@/const"; import { LIVE_STREAM, StreamState } from "@/const";
import { StreamInfo } from "@/element/stream/stream-info"; import { StreamInfo } from "@/element/stream/stream-info";
import { StreamContextProvider } from "@/element/stream/stream-state"; import { StreamContextProvider } from "@/element/stream/stream-state";
@ -46,23 +46,25 @@ export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent;
<StreamContextProvider link={link}> <StreamContextProvider link={link}>
<div className="xl:grid xl:grid-cols-[auto_450px] 2xl:xl:grid-cols-[auto_500px] max-xl:flex max-xl:flex-col xl:gap-4 max-xl:gap-1 h-full"> <div className="xl:grid xl:grid-cols-[auto_450px] 2xl:xl:grid-cols-[auto_500px] max-xl:flex max-xl:flex-col xl:gap-4 max-xl:gap-1 h-full">
<Helmet> <Helmet>
<title>{`${title} - zap.stream`}</title> <title>{`${title ?? "Untitled"} - zap.stream`}</title>
<meta name="description" content={descriptionContent} /> <meta name="description" content={descriptionContent} />
<meta property="og:url" content={`https://${window.location.host}/${link.encode()}`} /> <meta property="og:url" content={`https://${window.location.host}/${link.encode()}`} />
<meta property="og:type" content="video" /> <meta property="og:type" content="video" />
<meta property="og:title" content={title} /> <meta property="og:title" content={title ?? "Untitled"} />
<meta property="og:description" content={descriptionContent} /> <meta property="og:description" content={descriptionContent} />
<meta property="og:image" content={image ?? ""} /> <meta property="og:image" content={image ?? ""} />
</Helmet> </Helmet>
<div className="flex flex-col gap-2 xl:overflow-y-auto scrollbar-hidden"> <div className="flex flex-col gap-2 xl:overflow-y-auto scrollbar-hidden">
<Suspense> <Suspense>
<LiveVideoPlayer {ev?.kind === LIVE_STREAM &&
title={title} <LiveVideoPlayer
stream={status === StreamState.Live ? stream : recording} title={title}
poster={image} stream={status === StreamState.Live ? stream : recording}
status={status} poster={image}
className="max-xl:max-h-[30vh] xl:w-full xl:max-h-[85dvh] mx-auto" status={status}
/> link={evLink}
className="max-xl:max-h-[30vh] xl:w-full xl:max-h-[85dvh] mx-auto"
/>}
</Suspense> </Suspense>
<div className="lg:px-5 max-lg:px-2"> <div className="lg:px-5 max-lg:px-2">
<StreamInfo ev={ev as TaggedNostrEvent} goal={goal} /> <StreamInfo ev={ev as TaggedNostrEvent} goal={goal} />

View File

@ -115,6 +115,7 @@ export interface StreamInfo {
host?: string; host?: string;
gameId?: string; gameId?: string;
gameInfo?: GameInfo; gameInfo?: GameInfo;
streams: Array<string>
} }
const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i; const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i;
@ -135,8 +136,9 @@ export function extractStreamInfo(ev?: NostrEvent) {
matchTag(t, "image", v => (ret.image = v)); matchTag(t, "image", v => (ret.image = v));
matchTag(t, "thumbnail", v => (ret.thumbnail = v)); matchTag(t, "thumbnail", v => (ret.thumbnail = v));
matchTag(t, "status", v => (ret.status = v as StreamState)); matchTag(t, "status", v => (ret.status = v as StreamState));
if (t[0] === "streaming" && t[1].startsWith("http")) { if (t[0] === "streaming") {
matchTag(t, "streaming", v => (ret.stream = v)); ret.streams ??= [];
ret.streams.push(t[1]);
} }
matchTag(t, "recording", v => (ret.recording = v)); matchTag(t, "recording", v => (ret.recording = v));
matchTag(t, "url", v => (ret.recording = v)); matchTag(t, "url", v => (ret.recording = v));
@ -154,6 +156,14 @@ export function extractStreamInfo(ev?: NostrEvent) {
ret.gameId = gameId; ret.gameId = gameId;
ret.gameInfo = gameInfo; ret.gameInfo = gameInfo;
if (ret.streams) {
const isN94 = ret.streams.includes("nip94");
if (isN94) {
ret.stream = "nip94";
} else {
ret.stream = ret.streams.find(a => a.includes(".m3u8"));
}
}
return ret; return ret;
} }

View File

@ -4435,6 +4435,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"es6-promise@npm:^4.2.5":
version: 4.2.8
resolution: "es6-promise@npm:4.2.8"
checksum: 10c0/2373d9c5e9a93bdd9f9ed32ff5cb6dd3dd785368d1c21e9bbbfd07d16345b3774ae260f2bd24c8f836a6903f432b4151e7816a7fa8891ccb4e1a55a028ec42c3
languageName: node
linkType: hard
"es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3": "es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3":
version: 3.1.4 version: 3.1.4
resolution: "es6-symbol@npm:3.1.4" resolution: "es6-symbol@npm:3.1.4"
@ -6219,6 +6226,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mpegts.js@npm:^1.7.3":
version: 1.7.3
resolution: "mpegts.js@npm:1.7.3"
dependencies:
es6-promise: "npm:^4.2.5"
webworkify-webpack: "npm:^2.1.5"
checksum: 10c0/6a0f5f5815114d1d32ea6ae4902f787bdb4e467e9537f8fd12152eeedc1f82fb9ca260c64891fabebf0af37229ba184f6e35136cbc77b670f9cf6e63720448d0
languageName: node
linkType: hard
"ms@npm:2.0.0": "ms@npm:2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "ms@npm:2.0.0" resolution: "ms@npm:2.0.0"
@ -7661,6 +7678,7 @@ __metadata:
hls-video-element: "npm:^1.2.7" hls-video-element: "npm:^1.2.7"
marked: "npm:^12.0.2" marked: "npm:^12.0.2"
media-chrome: "npm:^3.2.4" media-chrome: "npm:^3.2.4"
mpegts.js: "npm:^1.7.3"
postcss: "npm:^8.4.38" postcss: "npm:^8.4.38"
prettier: "npm:^3.2.5" prettier: "npm:^3.2.5"
prop-types: "npm:^15.8.1" prop-types: "npm:^15.8.1"
@ -8505,6 +8523,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"webworkify-webpack@npm:^2.1.5":
version: 2.1.5
resolution: "webworkify-webpack@npm:2.1.5"
checksum: 10c0/672108eef17df3d01d7a499509d722a09dd65dd6e7f9316c21cd559c106fb162a1304b5f326977748aa92ad2bab917a0891ce26d244da083a5e16b752ab91058
languageName: node
linkType: hard
"whatwg-url@npm:^7.0.0": "whatwg-url@npm:^7.0.0":
version: 7.1.0 version: 7.1.0
resolution: "whatwg-url@npm:7.1.0" resolution: "whatwg-url@npm:7.1.0"