feat: n94 poc
This commit is contained in:
parent
f9f0cf213d
commit
03a5820140
@ -24,6 +24,7 @@
|
||||
"hls-video-element": "^1.2.7",
|
||||
"marked": "^12.0.2",
|
||||
"media-chrome": "^3.2.4",
|
||||
"mpegts.js": "^1.7.3",
|
||||
"qr-code-styling": "^1.6.0-rc.1",
|
||||
"react": "^18.3.1",
|
||||
"react-confetti": "^6.1.0",
|
||||
|
@ -20,6 +20,8 @@ import {
|
||||
} from "media-chrome/react";
|
||||
import "hls-video-element";
|
||||
import { StreamState } from "@/const";
|
||||
import Nip94Player from "./n94-player";
|
||||
import { NostrLink } from "@snort/system";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
title?: string;
|
||||
@ -27,9 +29,17 @@ type VideoPlayerProps = {
|
||||
stream?: string;
|
||||
poster?: string;
|
||||
muted?: boolean;
|
||||
link: NostrLink;
|
||||
} & 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 (
|
||||
<MediaController
|
||||
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">
|
||||
{title}
|
||||
</div>
|
||||
{/* @ts-ignore Web Componenet */}
|
||||
<hls-video {...props} slot="media" src={stream} playsInline={true} />
|
||||
{innerPlayer()}
|
||||
<MediaRenditionMenu hidden anchor="auto" />
|
||||
{poster && <MediaPosterImage slot="poster" src={poster} />}
|
||||
<MediaControlBar>
|
||||
|
158
src/element/stream/n94-player.tsx
Normal file
158
src/element/stream/n94-player.tsx
Normal 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>
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { EventKind, RequestBuilder } from "@snort/system";
|
||||
import { RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { LIVE_STREAM } from "@/const";
|
||||
|
||||
export function useStreamsFeed(tag?: string) {
|
||||
const liveStreamKinds = [LIVE_STREAM];
|
||||
const rb = useMemo(() => {
|
||||
const rb = new RequestBuilder(tag ? `streams:${tag}` : "streams");
|
||||
rb.withOptions({
|
||||
@ -11,26 +13,26 @@ export function useStreamsFeed(tag?: string) {
|
||||
if (import.meta.env.VITE_SINGLE_PUBLISHER) {
|
||||
if (tag) {
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.LiveEvent])
|
||||
.kinds(liveStreamKinds)
|
||||
.tag("t", [tag])
|
||||
.authors([import.meta.env.VITE_SINGLE_PUBLISHER]);
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.LiveEvent])
|
||||
.kinds(liveStreamKinds)
|
||||
.tag("t", [tag])
|
||||
.tag("p", [import.meta.env.VITE_SINGLE_PUBLISHER]);
|
||||
} else {
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.LiveEvent])
|
||||
.kinds(liveStreamKinds)
|
||||
.authors([import.meta.env.VITE_SINGLE_PUBLISHER]);
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.LiveEvent])
|
||||
.kinds(liveStreamKinds)
|
||||
.tag("p", [import.meta.env.VITE_SINGLE_PUBLISHER]);
|
||||
}
|
||||
} else {
|
||||
if (tag) {
|
||||
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", [tag]);
|
||||
rb.withFilter().kinds(liveStreamKinds).tag("t", [tag]);
|
||||
} else {
|
||||
rb.withFilter().kinds([EventKind.LiveEvent]);
|
||||
rb.withFilter().kinds(liveStreamKinds);
|
||||
}
|
||||
}
|
||||
return rb;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DAY, StreamState } from "@/const";
|
||||
import { DAY, LIVE_STREAM, StreamState } from "@/const";
|
||||
import { findTag, getHost } from "@/utils";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
|
||||
@ -24,12 +24,20 @@ export function useSortedStreams(feed: Array<TaggedNostrEvent>, oldest?: number)
|
||||
return [];
|
||||
}, [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
|
||||
.filter(a => {
|
||||
try {
|
||||
return (
|
||||
findTag(a, "status") === StreamState.Live &&
|
||||
a.tags.some(a => a[0] === "streaming" && new URL(a[1]).pathname.includes(".m3u8"))
|
||||
findTag(a, "status") === StreamState.Live && canPlayEvent(a)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
@ -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 { getEventFromLocationState } from "@/utils";
|
||||
import { NostrPrefix, EventKind } from "@snort/system";
|
||||
import { NostrPrefix } from "@snort/system";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { StreamPage } from "./stream-page";
|
||||
import { VideoPage } from "./video";
|
||||
@ -25,7 +25,7 @@ export function LinkHandler() {
|
||||
<NostrEventElement link={link} />
|
||||
</div>
|
||||
);
|
||||
} else if (link.kind === EventKind.LiveEvent || link.type === NostrPrefix.PublicKey) {
|
||||
} else if (link.kind === LIVE_STREAM || link.type === NostrPrefix.PublicKey) {
|
||||
return (
|
||||
<div className={classNames(layoutContext.showHeader ? "h-[calc(100dvh-44px)]" : "h-[calc(100dvh)]", "w-full")}>
|
||||
<StreamPage link={link} evPreload={evPreload} />
|
||||
|
@ -10,7 +10,7 @@ import { useZapGoal } from "@/hooks/goals";
|
||||
import { StreamCards } from "@/element/stream-cards";
|
||||
import { ContentWarningOverlay, useContentWarning } from "@/element/nsfw";
|
||||
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 { StreamContextProvider } from "@/element/stream/stream-state";
|
||||
|
||||
@ -46,23 +46,25 @@ export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent;
|
||||
<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">
|
||||
<Helmet>
|
||||
<title>{`${title} - zap.stream`}</title>
|
||||
<title>{`${title ?? "Untitled"} - zap.stream`}</title>
|
||||
<meta name="description" content={descriptionContent} />
|
||||
<meta property="og:url" content={`https://${window.location.host}/${link.encode()}`} />
|
||||
<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:image" content={image ?? ""} />
|
||||
</Helmet>
|
||||
<div className="flex flex-col gap-2 xl:overflow-y-auto scrollbar-hidden">
|
||||
<Suspense>
|
||||
<LiveVideoPlayer
|
||||
title={title}
|
||||
stream={status === StreamState.Live ? stream : recording}
|
||||
poster={image}
|
||||
status={status}
|
||||
className="max-xl:max-h-[30vh] xl:w-full xl:max-h-[85dvh] mx-auto"
|
||||
/>
|
||||
{ev?.kind === LIVE_STREAM &&
|
||||
<LiveVideoPlayer
|
||||
title={title}
|
||||
stream={status === StreamState.Live ? stream : recording}
|
||||
poster={image}
|
||||
status={status}
|
||||
link={evLink}
|
||||
className="max-xl:max-h-[30vh] xl:w-full xl:max-h-[85dvh] mx-auto"
|
||||
/>}
|
||||
</Suspense>
|
||||
<div className="lg:px-5 max-lg:px-2">
|
||||
<StreamInfo ev={ev as TaggedNostrEvent} goal={goal} />
|
||||
|
14
src/utils.ts
14
src/utils.ts
@ -115,6 +115,7 @@ export interface StreamInfo {
|
||||
host?: string;
|
||||
gameId?: string;
|
||||
gameInfo?: GameInfo;
|
||||
streams: Array<string>
|
||||
}
|
||||
|
||||
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, "thumbnail", v => (ret.thumbnail = v));
|
||||
matchTag(t, "status", v => (ret.status = v as StreamState));
|
||||
if (t[0] === "streaming" && t[1].startsWith("http")) {
|
||||
matchTag(t, "streaming", v => (ret.stream = v));
|
||||
if (t[0] === "streaming") {
|
||||
ret.streams ??= [];
|
||||
ret.streams.push(t[1]);
|
||||
}
|
||||
matchTag(t, "recording", v => (ret.recording = v));
|
||||
matchTag(t, "url", v => (ret.recording = v));
|
||||
@ -154,6 +156,14 @@ export function extractStreamInfo(ev?: NostrEvent) {
|
||||
ret.gameId = gameId;
|
||||
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;
|
||||
}
|
||||
|
||||
|
25
yarn.lock
25
yarn.lock
@ -4435,6 +4435,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 3.1.4
|
||||
resolution: "es6-symbol@npm:3.1.4"
|
||||
@ -6219,6 +6226,16 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.0.0
|
||||
resolution: "ms@npm:2.0.0"
|
||||
@ -7661,6 +7678,7 @@ __metadata:
|
||||
hls-video-element: "npm:^1.2.7"
|
||||
marked: "npm:^12.0.2"
|
||||
media-chrome: "npm:^3.2.4"
|
||||
mpegts.js: "npm:^1.7.3"
|
||||
postcss: "npm:^8.4.38"
|
||||
prettier: "npm:^3.2.5"
|
||||
prop-types: "npm:^15.8.1"
|
||||
@ -8505,6 +8523,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 7.1.0
|
||||
resolution: "whatwg-url@npm:7.1.0"
|
||||
|
Loading…
x
Reference in New Issue
Block a user