refactor: video player context
This commit is contained in:
parent
bae19e0f53
commit
66721cacfa
@ -43,6 +43,7 @@
|
||||
"recharts": "^2.12.7",
|
||||
"semantic-sdp": "^3.27.1",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"web-vitals": "^4.0.0",
|
||||
"webrtc-adapter": "^9.0.1",
|
||||
"workbox-core": "^7.1.0",
|
||||
@ -84,6 +85,7 @@
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@types/uuid": "^9",
|
||||
"@typescript-eslint/eslint-plugin": "^7.9.0",
|
||||
"@typescript-eslint/parser": "^7.9.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
|
@ -1,5 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconButton } from "./buttons";
|
||||
|
||||
@ -49,8 +49,9 @@ export default function Modal(props: ModalProps) {
|
||||
className={
|
||||
props.bodyClassName ??
|
||||
classNames(
|
||||
"relative bg-layer-1 p-8 transition max-xl:translate-y-[50vh] max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto lg:w-[500px] max-lg:w-full",
|
||||
"relative bg-layer-1 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto lg:w-[500px] max-lg:w-full",
|
||||
{ "max-xl:translate-y-0": props.ready ?? true },
|
||||
{ "max-xl:translate-y-[50vh]": !(props.ready ?? true) },
|
||||
)
|
||||
}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
|
@ -1,22 +1,22 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { getName } from "./profile";
|
||||
import { NostrEvent, NostrLink } from "@snort/system";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getName } from "./profile";
|
||||
|
||||
import { StatePill } from "./state-pill";
|
||||
import { extractStreamInfo, getHost, profileLink } from "@/utils";
|
||||
import { formatSats } from "@/number";
|
||||
import { StreamState } from "@/const";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
import { formatSats } from "@/number";
|
||||
import { extractStreamInfo, getHost, profileLink } from "@/utils";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import classNames from "classnames";
|
||||
import Logo from "./logo";
|
||||
import { useContentWarning } from "./nsfw";
|
||||
import { useState } from "react";
|
||||
import { Avatar } from "./avatar";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { VideoDuration } from "./video/duration";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
import Logo from "./logo";
|
||||
import { useContentWarning } from "./nsfw";
|
||||
import PillOpaque from "./pill-opaque";
|
||||
import { RelativeTime } from "./relative-time";
|
||||
import { StatePill } from "./state-pill";
|
||||
import { VideoDuration } from "./video/duration";
|
||||
|
||||
export function VideoTile({
|
||||
ev,
|
||||
@ -91,7 +91,7 @@ export function VideoTile({
|
||||
<Avatar pubkey={host} user={hostProfile} />
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col break-words min-w-0">
|
||||
<span className="font-medium" title={title}>
|
||||
{(title?.length ?? 0) > 50 ? `${title?.slice(0, 47)}...` : title}
|
||||
</span>
|
||||
|
33
src/element/video/context.tsx
Normal file
33
src/element/video/context.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { VideoInfo } from "@/service/video/info";
|
||||
import { ReactNode, createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface VideoPlayerContext {
|
||||
video?: VideoInfo;
|
||||
widePlayer: boolean;
|
||||
update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => void;
|
||||
}
|
||||
|
||||
const VPContext = createContext<VideoPlayerContext>({
|
||||
widePlayer: false,
|
||||
update: () => {},
|
||||
});
|
||||
|
||||
export function useVideoPlayerContext() {
|
||||
return useContext(VPContext);
|
||||
}
|
||||
|
||||
export function VideoPlayerContextProvider({ info, children }: { info: VideoInfo; children?: ReactNode }) {
|
||||
const [state, setState] = useState<VideoPlayerContext>({
|
||||
video: info,
|
||||
widePlayer: localStorage.getItem("wide-player") === "true",
|
||||
update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => {
|
||||
setState(fn);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("wide-player", String(state.widePlayer));
|
||||
}, [state.widePlayer]);
|
||||
|
||||
return <VPContext.Provider value={state}>{children}</VPContext.Provider>;
|
||||
}
|
55
src/element/video/player.tsx
Normal file
55
src/element/video/player.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
MediaController,
|
||||
MediaControlBar,
|
||||
MediaTimeRange,
|
||||
MediaTimeDisplay,
|
||||
MediaVolumeRange,
|
||||
MediaPlayButton,
|
||||
MediaMuteButton,
|
||||
MediaFullscreenButton,
|
||||
MediaPipButton,
|
||||
MediaPlaybackRateButton,
|
||||
} from "media-chrome/react";
|
||||
import { MediaPlayerSizeButtonReact } from "@/element/video/video-size-button";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { useVideoPlayerContext } from "./context";
|
||||
|
||||
export default function VideoPlayer() {
|
||||
const isDesktop = useMediaQuery("(min-width: 1280px)");
|
||||
const ctx = useVideoPlayerContext();
|
||||
|
||||
const { proxy } = useImgProxy();
|
||||
return (
|
||||
<MediaController className="min-w-0 w-full" mediaStreamType="on-demand">
|
||||
<video
|
||||
className="max-h-[80dvh] aspect-video"
|
||||
slot="media"
|
||||
autoPlay={true}
|
||||
controls={false}
|
||||
poster={proxy(ctx.video?.bestPoster()?.url ?? "")}>
|
||||
{ctx.video?.sources().map(a => <source src={a.url} type={a.mimeType} />)}
|
||||
</video>
|
||||
<MediaControlBar>
|
||||
<MediaPlayButton />
|
||||
<MediaPlaybackRateButton />
|
||||
<MediaTimeRange />
|
||||
<MediaTimeDisplay showDuration></MediaTimeDisplay>
|
||||
<MediaMuteButton />
|
||||
<MediaVolumeRange />
|
||||
<MediaPipButton />
|
||||
<MediaFullscreenButton />
|
||||
{isDesktop && (
|
||||
<MediaPlayerSizeButtonReact
|
||||
onClick={() =>
|
||||
ctx.update(c => ({
|
||||
...c,
|
||||
widePlayer: !c.widePlayer,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</MediaControlBar>
|
||||
</MediaController>
|
||||
);
|
||||
}
|
@ -196,6 +196,9 @@
|
||||
<symbol id="plus-circle" viewBox="0 0 22 22" fill="none" >
|
||||
<path d="M11 7V15M7 11H15M21 11C21 16.5228 16.5228 21 11 21C5.47715 21 1 16.5228 1 11C1 5.47715 5.47715 1 11 1C16.5228 1 21 5.47715 21 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="film" viewBox="0 0 22 22" fill="none">
|
||||
<path d="M1 11H21M1 6H6M16 6H21M1 16H6M16 16H21M6 21V1M16 21V1M5.8 21H16.2C17.8802 21 18.7202 21 19.362 20.673C19.9265 20.3854 20.3854 19.9265 20.673 19.362C21 18.7202 21 17.8802 21 16.2V5.8C21 4.11984 21 3.27976 20.673 2.63803C20.3854 2.07354 19.9265 1.6146 19.362 1.32698C18.7202 1 17.8802 1 16.2 1H5.8C4.11984 1 3.27976 1 2.63803 1.32698C2.07354 1.6146 1.6146 2.07354 1.32698 2.63803C1 3.27976 1 4.11984 1 5.8V16.2C1 17.8802 1 18.7202 1.32698 19.362C1.6146 19.9265 2.07354 20.3854 2.63803 20.673C3.27976 21 4.11984 21 5.8 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
@ -42,6 +42,7 @@ import { VideosPage } from "./pages/videos";
|
||||
import { LinkHandler } from "./pages/link-handler";
|
||||
import { UploadPage } from "./pages/upload";
|
||||
import { DebugPage } from "./pages/debug";
|
||||
import { ShortsPage } from "./pages/shorts";
|
||||
|
||||
const hasWasm = "WebAssembly" in globalThis;
|
||||
const workerRelay = new WorkerRelayInterface(
|
||||
@ -105,6 +106,10 @@ const router = createBrowserRouter([
|
||||
path: "/videos",
|
||||
element: <VideosPage />,
|
||||
},
|
||||
{
|
||||
path: "/shorts",
|
||||
element: <ShortsPage />,
|
||||
},
|
||||
{
|
||||
path: "/upload",
|
||||
element: <UploadPage />,
|
||||
|
@ -86,6 +86,9 @@
|
||||
"3df560": {
|
||||
"defaultMessage": "Login with private key"
|
||||
},
|
||||
"3kbIhS": {
|
||||
"defaultMessage": "Untitled"
|
||||
},
|
||||
"3yk8fB": {
|
||||
"defaultMessage": "Wallet"
|
||||
},
|
||||
@ -170,6 +173,9 @@
|
||||
"9WRlF4": {
|
||||
"defaultMessage": "Send"
|
||||
},
|
||||
"9ZoFpI": {
|
||||
"defaultMessage": "Delete file"
|
||||
},
|
||||
"9a9+ww": {
|
||||
"defaultMessage": "Title"
|
||||
},
|
||||
@ -291,6 +297,9 @@
|
||||
"H/bNs9": {
|
||||
"defaultMessage": "Save this and keep it safe! If you lose this key, you won't be able to access your account ever again. Yep, it's that serious!"
|
||||
},
|
||||
"H1fdc9": {
|
||||
"defaultMessage": "Please add a thumbnail"
|
||||
},
|
||||
"H4hJvF": {
|
||||
"defaultMessage": "Choose a category"
|
||||
},
|
||||
@ -348,6 +357,9 @@
|
||||
"K7AkdL": {
|
||||
"defaultMessage": "Show"
|
||||
},
|
||||
"KH2ayq": {
|
||||
"defaultMessage": "Upload Video"
|
||||
},
|
||||
"KkIL3s": {
|
||||
"defaultMessage": "No, I am under 18"
|
||||
},
|
||||
@ -381,9 +393,15 @@
|
||||
"My6HwN": {
|
||||
"defaultMessage": "Ok, it's safe"
|
||||
},
|
||||
"NnHu0L": {
|
||||
"defaultMessage": "Please upload at least 1 video"
|
||||
},
|
||||
"O2Cy6m": {
|
||||
"defaultMessage": "Yes, I am over 18"
|
||||
},
|
||||
"O7AeYh": {
|
||||
"defaultMessage": "Description.."
|
||||
},
|
||||
"OEW7yJ": {
|
||||
"defaultMessage": "Zaps"
|
||||
},
|
||||
@ -416,6 +434,9 @@
|
||||
"PXAur5": {
|
||||
"defaultMessage": "Withdraw"
|
||||
},
|
||||
"Pc+tM3": {
|
||||
"defaultMessage": "Generate"
|
||||
},
|
||||
"Pe0ogR": {
|
||||
"defaultMessage": "Theme"
|
||||
},
|
||||
@ -449,6 +470,9 @@
|
||||
"R72je0": {
|
||||
"defaultMessage": "Stream Title"
|
||||
},
|
||||
"RGYBjE": {
|
||||
"defaultMessage": "Thumbnail"
|
||||
},
|
||||
"RJOmzk": {
|
||||
"defaultMessage": "I have read and agree with {provider}''s {terms}."
|
||||
},
|
||||
@ -507,6 +531,9 @@
|
||||
"W7DNWx": {
|
||||
"defaultMessage": "Stream Forwarding"
|
||||
},
|
||||
"W7IRLs": {
|
||||
"defaultMessage": "Your title is too short"
|
||||
},
|
||||
"W9355R": {
|
||||
"defaultMessage": "Unmute"
|
||||
},
|
||||
@ -551,6 +578,9 @@
|
||||
"YwzT/0": {
|
||||
"defaultMessage": "Clip title"
|
||||
},
|
||||
"YyXVHf": {
|
||||
"defaultMessage": "Clear Draft"
|
||||
},
|
||||
"Z8ZOEY": {
|
||||
"defaultMessage": "This method is insecure. We recommend using a {nostrlink}"
|
||||
},
|
||||
@ -670,6 +700,9 @@
|
||||
"ieKb+k": {
|
||||
"defaultMessage": "What does it cost to stream?"
|
||||
},
|
||||
"ipTKP3": {
|
||||
"defaultMessage": "Chat Popout"
|
||||
},
|
||||
"itPgxd": {
|
||||
"defaultMessage": "Profile"
|
||||
},
|
||||
@ -679,6 +712,9 @@
|
||||
"j/jueq": {
|
||||
"defaultMessage": "Raiding {name}"
|
||||
},
|
||||
"jDDeA0": {
|
||||
"defaultMessage": "Your title is very long, please make sure its less than {n} chars."
|
||||
},
|
||||
"jJLRgo": {
|
||||
"defaultMessage": "Publish Clip"
|
||||
},
|
||||
@ -697,6 +733,9 @@
|
||||
"k21gTS": {
|
||||
"defaultMessage": "e.g. about me"
|
||||
},
|
||||
"kAEQyV": {
|
||||
"defaultMessage": "OK"
|
||||
},
|
||||
"kc5EOy": {
|
||||
"defaultMessage": "Username is too long"
|
||||
},
|
||||
@ -706,6 +745,9 @@
|
||||
"kp0NPF": {
|
||||
"defaultMessage": "Planned"
|
||||
},
|
||||
"lBm7CY": {
|
||||
"defaultMessage": "Upload to:"
|
||||
},
|
||||
"lXbG97": {
|
||||
"defaultMessage": "Most Zapped Streamers"
|
||||
},
|
||||
@ -778,6 +820,9 @@
|
||||
"rgsbu9": {
|
||||
"defaultMessage": "Current Viewers"
|
||||
},
|
||||
"rleUgy": {
|
||||
"defaultMessage": "Uploading.."
|
||||
},
|
||||
"s+ORFl": {
|
||||
"defaultMessage": "{m}d {ago}",
|
||||
"description": "Number of day(s) relative to now"
|
||||
@ -794,6 +839,9 @@
|
||||
"sj6WAe": {
|
||||
"defaultMessage": "Click the “Stream” button in the top right corner"
|
||||
},
|
||||
"syEQFE": {
|
||||
"defaultMessage": "Publish"
|
||||
},
|
||||
"tG1ST3": {
|
||||
"defaultMessage": "Incoming Zap"
|
||||
},
|
||||
@ -834,6 +882,9 @@
|
||||
"vrTOHJ": {
|
||||
"defaultMessage": "{amount} sats"
|
||||
},
|
||||
"w+2Vw7": {
|
||||
"defaultMessage": "Shorts"
|
||||
},
|
||||
"w0Xm2F": {
|
||||
"defaultMessage": "Start typing"
|
||||
},
|
||||
|
@ -215,7 +215,7 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
|
||||
{streamLink && status === StreamState.Live && (
|
||||
<>
|
||||
<DashboardZapColumn ev={streamEvent!} link={streamLink} feed={feed} />
|
||||
<div className="border border-layer-2 rounded-xl px-4 py-3 flex flex-col min-h-0">
|
||||
<div className="border border-layer-2 rounded-xl px-4 py-3 flex flex-col gap-2 min-h-0">
|
||||
<Layer1Button
|
||||
onClick={() => {
|
||||
window.open(
|
||||
|
@ -22,6 +22,13 @@ export function LeftNav() {
|
||||
</span>
|
||||
)}
|
||||
</NavLinkIcon>
|
||||
<NavLinkIcon name="film" route="/shorts" className="flex gap-2 items-center">
|
||||
{layout.leftNavExpand && (
|
||||
<span className="pr-3">
|
||||
<FormattedMessage defaultMessage="Shorts" />
|
||||
</span>
|
||||
)}
|
||||
</NavLinkIcon>
|
||||
<NavLinkIcon name="grid" route="/category" className="flex gap-2 items-center">
|
||||
{layout.leftNavExpand && (
|
||||
<span className="pr-3">
|
||||
|
3
src/pages/shorts.tsx
Normal file
3
src/pages/shorts.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function ShortsPage() {
|
||||
return <>Coming soon...</>;
|
||||
}
|
@ -1,8 +1,456 @@
|
||||
import { VIDEO_KIND } from "@/const";
|
||||
import { DefaultButton, IconButton, PrimaryButton, WarningButton } from "@/element/buttons";
|
||||
import { Icon } from "@/element/icon";
|
||||
import { Profile } from "@/element/profile";
|
||||
import Spinner from "@/element/spinner";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { Nip94Tags, UploadResult, nip94TagsToIMeta } from "@/service/upload";
|
||||
import { Nip96Uploader } from "@/service/upload/nip96";
|
||||
import { openFile } from "@/utils";
|
||||
import { ExternalStore, removeUndefined, unixNow, unwrap } from "@snort/shared";
|
||||
import { EventPublisher, NostrLink } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { useContext, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
interface UploadStatus {
|
||||
type: "video" | "thumb";
|
||||
name: string;
|
||||
size: number;
|
||||
server: string;
|
||||
result?: UploadResult;
|
||||
}
|
||||
|
||||
interface UploadDraft {
|
||||
id: string;
|
||||
uploads: Array<UploadStatus>;
|
||||
}
|
||||
|
||||
class UploadManager extends ExternalStore<Array<UploadStatus>> {
|
||||
#uploaders: Map<string, Nip96Uploader> = new Map();
|
||||
#uploads: Map<string, UploadStatus> = new Map();
|
||||
#id: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#id = uuid();
|
||||
const draft = localStorage.getItem("upload-draft");
|
||||
if (draft) {
|
||||
const saved = JSON.parse(draft) as UploadDraft;
|
||||
this.#uploads = new Map(saved.uploads.map(a => [`${a.name}:${a.server}:${a.type}`, a]));
|
||||
this.#id = saved.id;
|
||||
}
|
||||
this.on("change", () =>
|
||||
localStorage.setItem(
|
||||
"upload-draft",
|
||||
JSON.stringify({
|
||||
id: this.#id,
|
||||
uploads: this.snapshot(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
removeUpload(server: string, name: string, type: UploadStatus["type"]) {
|
||||
const uploadKey = `${name}:${server}:${type}`;
|
||||
if (this.#uploads.delete(uploadKey)) {
|
||||
this.notifyChange();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadTo(server: string, file: File, pub: EventPublisher, type: UploadStatus["type"]) {
|
||||
let uploader = this.#uploaders.get(server);
|
||||
if (!uploader) {
|
||||
uploader = new Nip96Uploader(server, pub);
|
||||
this.#uploaders.set(server, uploader);
|
||||
}
|
||||
|
||||
const uploadKey = `${file.name}:${server}:${type}`;
|
||||
if (this.#uploads.has(uploadKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = {
|
||||
type,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
server: server,
|
||||
};
|
||||
this.#uploads.set(uploadKey, status);
|
||||
this.notifyChange();
|
||||
try {
|
||||
await uploader.loadInfo();
|
||||
uploader.upload(file, file.name).then(res => {
|
||||
this.#uploads.set(uploadKey, {
|
||||
...status,
|
||||
result: res,
|
||||
});
|
||||
this.notifyChange();
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
this.#uploads.set(uploadKey, {
|
||||
...status,
|
||||
result: {
|
||||
error: e.message,
|
||||
},
|
||||
});
|
||||
this.notifyChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the grouped videos/images by resolution
|
||||
*/
|
||||
resolutions() {
|
||||
const uploads = this.snapshot();
|
||||
const resGroup = uploads.reduce(
|
||||
(acc, v) => {
|
||||
const dim = v.result?.metadata?.dimensions?.join("x");
|
||||
if (dim) {
|
||||
acc[dim] ??= [];
|
||||
acc[dim].push(v);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Array<UploadStatus>>,
|
||||
);
|
||||
return resGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `imeta` tag for this upload
|
||||
*/
|
||||
makeIMeta() {
|
||||
const tags: Array<Array<string>> = [];
|
||||
for (const vGroup of Object.values(this.resolutions())) {
|
||||
const uploadsSuccess = vGroup.filter(a => a.result?.url && a.type === "video");
|
||||
const firstUpload = uploadsSuccess.at(0);
|
||||
if (firstUpload?.result) {
|
||||
const res = firstUpload.result;
|
||||
const images = vGroup.filter(a => a.type === "thumb" && a.result?.url).map(a => unwrap(a.result?.url));
|
||||
const metaTag: Nip94Tags = {
|
||||
...res.metadata,
|
||||
image: images,
|
||||
fallback: removeUndefined(uploadsSuccess.filter(a => a.result?.url !== res.url).map(a => a.result?.url)),
|
||||
};
|
||||
tags.push(nip94TagsToIMeta(metaTag));
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#id = uuid();
|
||||
this.#uploads = new Map();
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
takeSnapshot(): Array<UploadStatus> {
|
||||
return [...this.#uploads.values()];
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new UploadManager();
|
||||
|
||||
export function UploadPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const login = useLogin();
|
||||
const system = useContext(SnortContext);
|
||||
const [selectedServers, setSelectedServers] = useState<Array<string>>([]);
|
||||
const [error, setError] = useState<Array<string>>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [thumb, setThumb] = useState("");
|
||||
const { proxy } = useImgProxy();
|
||||
const uploads = useSyncExternalStore(
|
||||
c => manager.hook(c),
|
||||
() => manager.snapshot(),
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const servers = [
|
||||
"https://media.zap.stream",
|
||||
"https://cdn.satellite.earth",
|
||||
"https://files.v0l.io",
|
||||
"https://nostrcheck.me",
|
||||
"https://void.cat",
|
||||
"https://nostr.build",
|
||||
];
|
||||
|
||||
function canPublish() {
|
||||
return error.length == 0 && uploads.length > 0 && uploads.every(a => a.result !== undefined);
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const pub = login?.publisher();
|
||||
if (!pub) return;
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(VIDEO_KIND);
|
||||
eb.tag(["d", manager.id]);
|
||||
eb.tag(["title", title]);
|
||||
eb.tag(["published_at", unixNow().toString()]);
|
||||
eb.content(summary);
|
||||
|
||||
const imeta = manager.makeIMeta();
|
||||
imeta.forEach(a => eb.tag(a));
|
||||
|
||||
return eb;
|
||||
});
|
||||
console.debug(ev);
|
||||
await system.BroadcastEvent(ev);
|
||||
navigate(`/${NostrLink.fromEvent(ev).encode()}`);
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
const pub = login?.publisher();
|
||||
const f = await openFile();
|
||||
if (f && pub) {
|
||||
selectedServers.forEach(b => manager.uploadTo(b, f, pub, "video"));
|
||||
}
|
||||
}
|
||||
|
||||
// use imgproxy to generate video thumbnail
|
||||
async function generateThumb() {
|
||||
const vid = uploads.find(a => a.result?.url);
|
||||
if (!vid) return;
|
||||
|
||||
const rsp = await fetch(proxy(vid!.result!.url!), {
|
||||
headers: {
|
||||
accept: "image/jpg",
|
||||
},
|
||||
});
|
||||
if (rsp.ok) {
|
||||
const data = await rsp.blob();
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
selectedServers.forEach(b => manager.uploadTo(b, new File([data], "thumb.jpg"), pub, "thumb"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadThumb() {
|
||||
const f = await openFile();
|
||||
if (f) {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
selectedServers.forEach(b => manager.uploadTo(b, new File([f], "thumb.jpg"), pub, "thumb"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const thumb = uploads.find(a => a.type === "thumb" && a.result?.url);
|
||||
if (thumb?.result?.url) {
|
||||
setThumb(thumb.result?.url);
|
||||
} else {
|
||||
setThumb("");
|
||||
}
|
||||
}, [uploads]);
|
||||
|
||||
const videos = uploads.filter(a => a.type === "video").length;
|
||||
const thumbs = uploads.filter(a => a.type === "thumb").length;
|
||||
function validate() {
|
||||
const maxTitle = 50;
|
||||
const err = [];
|
||||
if (title.length > maxTitle) {
|
||||
err.push(
|
||||
formatMessage(
|
||||
{
|
||||
defaultMessage: "Your title is very long, please make sure its less than {n} chars.",
|
||||
},
|
||||
{
|
||||
n: maxTitle,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (title.length < 5) {
|
||||
err.push(
|
||||
formatMessage({
|
||||
defaultMessage: "Your title is too short",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (videos === 0) {
|
||||
err.push(
|
||||
formatMessage({
|
||||
defaultMessage: "Please upload at least 1 video",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (thumbs === 0) {
|
||||
err.push(
|
||||
formatMessage({
|
||||
defaultMessage: "Please add a thumbnail",
|
||||
}),
|
||||
);
|
||||
}
|
||||
setError(err);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
}, [title, summary, uploads, thumb]);
|
||||
|
||||
const uploadButton = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-4 bg-layer-3 rounded-lg p-4">
|
||||
<Icon name="upload" />
|
||||
<FormattedMessage defaultMessage="Upload Video" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<h1>Upload</h1>
|
||||
<b>Coming Soon..</b>
|
||||
<div className="max-xl:w-full xl:w-[1200px] xl:mx-auto grid gap-6 xl:grid-cols-[auto_350px] max-xl:px-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Upload to:" />
|
||||
</p>
|
||||
<select multiple={true} onChange={e => setSelectedServers([...e.target.selectedOptions].map(a => a.value))}>
|
||||
{servers.map(a => (
|
||||
<option selected={selectedServers.includes(a)}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => uploadFile()}
|
||||
className="relative bg-layer-2 rounded-xl w-full aspect-video cursor-pointer overflow-hidden">
|
||||
{videos > 0 && (
|
||||
<video
|
||||
className="w-full h-full absolute"
|
||||
controls
|
||||
src={uploads.find(a => a.result?.url && a.type === "video")?.result?.url}
|
||||
/>
|
||||
)}
|
||||
{videos === 0 && (
|
||||
<div className="absolute w-full h-full flex items-center justify-center">{uploadButton()}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
className="reset bg-layer-2 text-xl px-3 py-2 rounded-xl"
|
||||
placeholder={formatMessage({
|
||||
defaultMessage: "Untitled",
|
||||
})}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
<Profile pubkey={login?.pubkey ?? ""} avatarSize={40} linkToProfile={false} />
|
||||
<textarea
|
||||
className="reset bg-layer-2 px-3 py-2 rounded-xl"
|
||||
placeholder={formatMessage({
|
||||
defaultMessage: "Description..",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{error.map((a, i) => (
|
||||
<b className="text-warning">
|
||||
#{i + 1}: {a}
|
||||
</b>
|
||||
))}
|
||||
</div>
|
||||
{videos > 0 && (
|
||||
<div onClick={() => uploadFile()} className="cursor-pointer">
|
||||
{uploadButton()}
|
||||
</div>
|
||||
)}
|
||||
{uploads.length > 0 && (
|
||||
<div className="flex flex-col gap-2 min-w-0 w-full">
|
||||
{uploads.map(a => (
|
||||
<UploadProgress status={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="bg-layer-1 rounded-xl flex flex-col gap-4 px-5 py-4">
|
||||
<div className="text-xl font-semibold">
|
||||
<FormattedMessage defaultMessage="Thumbnail" />
|
||||
</div>
|
||||
<div className="border border-layer-3 border-dashed border-2 rounded-xl aspect-video overflow-hidden">
|
||||
{thumb && <img src={proxy(thumb)} className="w-full h-full" />}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<DefaultButton onClick={() => uploadThumb()}>
|
||||
<Icon name="upload" />
|
||||
<FormattedMessage defaultMessage="Upload" />
|
||||
</DefaultButton>
|
||||
<DefaultButton onClick={() => generateThumb()}>
|
||||
<Icon name="repost" />
|
||||
<FormattedMessage defaultMessage="Generate" />
|
||||
</DefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PrimaryButton onClick={() => publish()} disabled={!canPublish()}>
|
||||
<FormattedMessage defaultMessage="Publish" />
|
||||
</PrimaryButton>
|
||||
<WarningButton
|
||||
onClick={() => {
|
||||
manager.clear();
|
||||
setThumb("");
|
||||
setSummary("");
|
||||
setTitle("");
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Clear Draft" />
|
||||
</WarningButton>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-xs font-mono overflow-wrap text-pretty">
|
||||
{JSON.stringify(manager.makeIMeta(), undefined, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadProgress({ status }: { status: UploadStatus }) {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="rounded-xl bg-layer-2 px-3 py-2 flex flex-col gap-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>Upload "{status.name}"</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="text-layer-4">{status.server}</div>
|
||||
<IconButton
|
||||
iconName="x"
|
||||
iconSize={16}
|
||||
title={formatMessage({
|
||||
defaultMessage: "Delete file",
|
||||
})}
|
||||
onClick={() => {
|
||||
manager.removeUpload(status.server, status.name, status.type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!status.result && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<FormattedMessage defaultMessage="Uploading.." />
|
||||
</div>
|
||||
)}
|
||||
{status.result && !status.result.error && (
|
||||
<div className="flex gap-4">
|
||||
<FormattedMessage defaultMessage="OK" />
|
||||
<div className="flex gap-2 text-layer-4">
|
||||
<div>{status.result.metadata?.dimensions?.join("x")}</div>
|
||||
<div>{status.result.metadata?.mimeType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status.result && status.result.error && <b className="text-warning">{status.result.error}</b>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -6,81 +6,57 @@ import { ShareMenu } from "@/element/share-menu";
|
||||
import { StreamSummary } from "@/element/stream/summary";
|
||||
import VideoComments from "@/element/video/comments";
|
||||
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
import { getHost, extractStreamInfo, findTag } from "@/utils";
|
||||
import { getHost, findTag } from "@/utils";
|
||||
import { NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
||||
import { useRequestBuilder, useUserProfile } from "@snort/system-react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import {
|
||||
MediaController,
|
||||
MediaControlBar,
|
||||
MediaTimeRange,
|
||||
MediaTimeDisplay,
|
||||
MediaVolumeRange,
|
||||
MediaPlayButton,
|
||||
MediaMuteButton,
|
||||
MediaFullscreenButton,
|
||||
MediaPipButton,
|
||||
MediaPlaybackRateButton,
|
||||
} from "media-chrome/react";
|
||||
import { MediaPlayerSizeButtonReact } from "@/element/video/video-size-button";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { VideoTile } from "@/element/video-tile";
|
||||
import { VIDEO_KIND } from "@/const";
|
||||
import { VideoInfo } from "@/service/video/info";
|
||||
import { VideoPlayerContextProvider, useVideoPlayerContext } from "@/element/video/context";
|
||||
import VideoPlayer from "@/element/video/player";
|
||||
|
||||
export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
|
||||
const ev = useCurrentStreamFeed(link, true, evPreload);
|
||||
const host = getHost(ev);
|
||||
const [widePlayer, setWidePlayer] = useState(localStorage.getItem("wide-player") === "true");
|
||||
const { title, summary, image, recording } = extractStreamInfo(ev);
|
||||
const profile = useUserProfile(host);
|
||||
const { proxy } = useImgProxy();
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
const isDesktop = useMediaQuery("(min-width: 1280px)");
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("wide-player", String(widePlayer));
|
||||
}, [widePlayer]);
|
||||
if (!ev) return;
|
||||
const video = VideoInfo.parse(ev);
|
||||
|
||||
return (
|
||||
<VideoPlayerContextProvider info={video}>
|
||||
<VideoPageInner ev={ev} />
|
||||
</VideoPlayerContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoPageInner({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const host = getHost(ev);
|
||||
const ctx = useVideoPlayerContext();
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
|
||||
const profile = useUserProfile(host);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("xl:p-4 grow xl:grid xl:gap-2 xl:grid-cols-[auto_450px]", {
|
||||
"xl:w-[1600px] xl:max-w-[1600px] mx-auto": !widePlayer,
|
||||
"xl:w-[1600px] xl:max-w-[1600px] mx-auto": !ctx.widePlayer,
|
||||
})}>
|
||||
<div
|
||||
className={classNames("min-w-0 w-full max-h-[80dvh] aspect-video mx-auto bg-black", {
|
||||
"col-span-2": widePlayer,
|
||||
"col-span-2": ctx.widePlayer,
|
||||
})}>
|
||||
<MediaController className="min-w-0 w-full" mediaStreamType="on-demand">
|
||||
<video
|
||||
className="max-h-[80dvh] aspect-video"
|
||||
slot="media"
|
||||
src={recording}
|
||||
autoPlay={true}
|
||||
controls={false}
|
||||
poster={proxy(image ?? recording ?? "")}
|
||||
/>
|
||||
<MediaControlBar>
|
||||
<MediaPlayButton />
|
||||
<MediaPlaybackRateButton />
|
||||
<MediaTimeRange />
|
||||
<MediaTimeDisplay showDuration></MediaTimeDisplay>
|
||||
<MediaMuteButton />
|
||||
<MediaVolumeRange />
|
||||
<MediaPipButton />
|
||||
<MediaFullscreenButton />
|
||||
{isDesktop && <MediaPlayerSizeButtonReact onClick={() => setWidePlayer(w => !w)} />}
|
||||
</MediaControlBar>
|
||||
</MediaController>
|
||||
<VideoPlayer />
|
||||
</div>
|
||||
{/* VIDEO INFO & COMMENTS */}
|
||||
<div
|
||||
className={classNames("row-start-2 col-start-1 max-xl:px-4 flex flex-col gap-4", {
|
||||
"mx-auto w-[40dvw]": widePlayer,
|
||||
"mx-auto w-[40dvw]": ctx.widePlayer,
|
||||
})}>
|
||||
<div className="font-medium text-xl">{title}</div>
|
||||
<div className="font-medium text-xl">{ctx.video?.title}</div>
|
||||
<div className="flex justify-between">
|
||||
{/* PROFILE SECTION */}
|
||||
<div className="flex gap-2 items-center">
|
||||
@ -104,7 +80,7 @@ export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: Ta
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{summary && <StreamSummary text={summary} />}
|
||||
{ctx.video?.summary && <StreamSummary text={ctx.video.summary} />}
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Comments" />
|
||||
</h3>
|
||||
@ -115,8 +91,8 @@ export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: Ta
|
||||
</div>
|
||||
<div
|
||||
className={classNames("p-2 col-start-2", {
|
||||
"row-start-1 row-span-3": !widePlayer,
|
||||
"row-start-2": widePlayer,
|
||||
"row-start-1 row-span-3": !ctx.widePlayer,
|
||||
"row-start-2": ctx.widePlayer,
|
||||
})}>
|
||||
<UpNext pubkey={host} exclude={[link]} />
|
||||
</div>
|
||||
|
137
src/service/upload/index.ts
Normal file
137
src/service/upload/index.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { NostrEvent } from "@snort/system";
|
||||
|
||||
export const FileExtensionRegex = /\.([\w]{1,7})$/i;
|
||||
|
||||
export interface Nip94Tags {
|
||||
url?: string;
|
||||
mimeType?: string;
|
||||
hash?: string;
|
||||
originalHash?: string;
|
||||
size?: number;
|
||||
dimensions?: [number, number];
|
||||
magnet?: string;
|
||||
blurHash?: string;
|
||||
thumb?: string;
|
||||
image?: Array<string>;
|
||||
summary?: string;
|
||||
alt?: string;
|
||||
fallback?: Array<string>;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
url?: string;
|
||||
error?: string;
|
||||
|
||||
/**
|
||||
* NIP-94 File Header
|
||||
*/
|
||||
header?: NostrEvent;
|
||||
|
||||
/**
|
||||
* Media metadata
|
||||
*/
|
||||
metadata?: Nip94Tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read NIP-94 tags from `imeta` tag
|
||||
*/
|
||||
export function readNip94TagsFromIMeta(tag: Array<string>) {
|
||||
const asTags = tag.slice(1).map(a => a.split(" ", 2));
|
||||
return readNip94Tags(asTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read NIP-94 tags from event tags
|
||||
*/
|
||||
export function readNip94Tags(tags: Array<Array<string>>) {
|
||||
const res: Nip94Tags = {};
|
||||
for (const tx of tags) {
|
||||
const [k, v] = tx;
|
||||
switch (k) {
|
||||
case "url": {
|
||||
res.url = v;
|
||||
break;
|
||||
}
|
||||
case "m": {
|
||||
res.mimeType = v;
|
||||
break;
|
||||
}
|
||||
case "x": {
|
||||
res.hash = v;
|
||||
break;
|
||||
}
|
||||
case "ox": {
|
||||
res.originalHash = v;
|
||||
break;
|
||||
}
|
||||
case "size": {
|
||||
res.size = Number(v);
|
||||
break;
|
||||
}
|
||||
case "dim": {
|
||||
res.dimensions = v.split("x").map(Number) as [number, number];
|
||||
break;
|
||||
}
|
||||
case "magnet": {
|
||||
res.magnet = v;
|
||||
break;
|
||||
}
|
||||
case "blurhash": {
|
||||
res.blurHash = v;
|
||||
break;
|
||||
}
|
||||
case "thumb": {
|
||||
res.thumb = v;
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
res.image ??= [];
|
||||
res.image.push(v);
|
||||
break;
|
||||
}
|
||||
case "summary": {
|
||||
res.summary = v;
|
||||
break;
|
||||
}
|
||||
case "alt": {
|
||||
res.alt = v;
|
||||
break;
|
||||
}
|
||||
case "fallback": {
|
||||
res.fallback ??= [];
|
||||
res.fallback.push(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function nip94TagsToIMeta(meta: Nip94Tags) {
|
||||
const ret: Array<string> = ["imeta"];
|
||||
const ifPush = (key: string, value?: string | number) => {
|
||||
if (value) {
|
||||
ret.push(`${key} ${value}`);
|
||||
}
|
||||
};
|
||||
ifPush("url", meta.url);
|
||||
ifPush("m", meta.mimeType);
|
||||
ifPush("x", meta.hash);
|
||||
ifPush("ox", meta.originalHash);
|
||||
ifPush("size", meta.size);
|
||||
ifPush("dim", meta.dimensions?.join("x"));
|
||||
ifPush("magnet", meta.magnet);
|
||||
ifPush("blurhash", meta.blurHash);
|
||||
ifPush("thumb", meta.thumb);
|
||||
ifPush("summary", meta.summary);
|
||||
ifPush("alt", meta.alt);
|
||||
if (meta.image) {
|
||||
meta.image.forEach(a => ifPush("image", a));
|
||||
}
|
||||
if (meta.fallback) {
|
||||
meta.fallback.forEach(a => ifPush("fallback", a));
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
118
src/service/upload/nip96.ts
Normal file
118
src/service/upload/nip96.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { base64 } from "@scure/base";
|
||||
import { throwIfOffline } from "@snort/shared";
|
||||
import { EventKind, EventPublisher } from "@snort/system";
|
||||
|
||||
import { FileExtensionRegex, UploadResult, readNip94Tags } from ".";
|
||||
|
||||
export class Nip96Uploader {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher,
|
||||
) {
|
||||
this.url = new URL(this.url).toString();
|
||||
}
|
||||
|
||||
get progress() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async loadInfo() {
|
||||
const u = new URL(this.url);
|
||||
|
||||
const rsp = await fetch(`${u.protocol}//${u.host}/.well-known/nostr/nip96.json`);
|
||||
return (await rsp.json()) as Nip96Info;
|
||||
}
|
||||
|
||||
async upload(file: File | Blob, filename: string): Promise<UploadResult> {
|
||||
throwIfOffline();
|
||||
const auth = async (url: string, method: string) => {
|
||||
const auth = await this.publisher.generic(eb => {
|
||||
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
|
||||
});
|
||||
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
|
||||
};
|
||||
|
||||
const info = await this.loadInfo();
|
||||
const fd = new FormData();
|
||||
fd.append("size", file.size.toString());
|
||||
fd.append("caption", filename);
|
||||
fd.append("media_type", file.type);
|
||||
fd.append("file", file);
|
||||
|
||||
let u = info.api_url;
|
||||
if (u.startsWith("/")) {
|
||||
u = `${this.url}${u.slice(1)}`;
|
||||
}
|
||||
const rsp = await fetch(u, {
|
||||
body: fd,
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: await auth(u, "POST"),
|
||||
},
|
||||
});
|
||||
if (rsp.ok) {
|
||||
throwIfOffline();
|
||||
const data = (await rsp.json()) as Nip96Result;
|
||||
if (data.status === "success") {
|
||||
const meta = readNip94Tags(data.nip94_event.tags);
|
||||
if (
|
||||
meta.dimensions === undefined ||
|
||||
meta.dimensions.length !== 2 ||
|
||||
meta.dimensions[0] === 0 ||
|
||||
meta.dimensions[1] === 0
|
||||
) {
|
||||
return {
|
||||
error: `Invalid dimensions: "${meta.dimensions?.join("x")}"`,
|
||||
};
|
||||
}
|
||||
if (!meta.url?.match(FileExtensionRegex) && meta.mimeType) {
|
||||
switch (meta.mimeType) {
|
||||
case "image/webp": {
|
||||
meta.url += ".webp";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
meta.url += ".jpg";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: meta.url,
|
||||
metadata: meta,
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: data.message,
|
||||
};
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
try {
|
||||
const obj = JSON.parse(text) as Nip96Result;
|
||||
return {
|
||||
error: obj.message,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
error: `Upload failed: ${text}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Nip96Info {
|
||||
api_url: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
export interface Nip96Result {
|
||||
status: string;
|
||||
message: string;
|
||||
processing_url?: string;
|
||||
nip94_event: {
|
||||
tags: Array<Array<string>>;
|
||||
content: string;
|
||||
};
|
||||
}
|
124
src/service/video/info.ts
Normal file
124
src/service/video/info.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { GameInfo } from "../game-database";
|
||||
import { Nip94Tags, readNip94Tags, readNip94TagsFromIMeta } from "../upload";
|
||||
import { getHost, sortStreamTags, extractGameTag, findTag } from "@/utils";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
export interface MediaPayload {
|
||||
url: string;
|
||||
dimensions?: [number, number];
|
||||
mimeType?: string;
|
||||
hash?: string;
|
||||
alternatives: Array<string>;
|
||||
}
|
||||
|
||||
export class VideoInfo {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
contentWarning?: string;
|
||||
goal?: string;
|
||||
gameId?: string;
|
||||
gameInfo?: GameInfo;
|
||||
duration?: number;
|
||||
publishedAt?: number;
|
||||
|
||||
constructor(
|
||||
readonly host: string,
|
||||
readonly id: string,
|
||||
readonly tags: Array<string>,
|
||||
readonly media: Array<Nip94Tags>,
|
||||
) {}
|
||||
|
||||
static parse(ev: NostrEvent) {
|
||||
const { regularTags, prefixedTags } = sortStreamTags(ev.tags);
|
||||
const ret = new VideoInfo(getHost(ev), unwrap(findTag(ev, "d")), regularTags, VideoInfo.#parseMediaTags(ev.tags));
|
||||
|
||||
const matchInto = <K extends keyof VideoInfo>(
|
||||
tag: Array<string>,
|
||||
key: string,
|
||||
into: K,
|
||||
fn?: (v: string) => never,
|
||||
) => {
|
||||
if (tag[0] === key) {
|
||||
ret[into] = fn ? fn(tag[1]) : (tag[1] as never);
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of ev.tags) {
|
||||
matchInto(t, "d", "id");
|
||||
matchInto(t, "title", "title");
|
||||
matchInto(t, "summary", "summary");
|
||||
matchInto(t, "content-warning", "contentWarning");
|
||||
matchInto(t, "goal", "goal");
|
||||
matchInto(t, "duration", "duration");
|
||||
matchInto(t, "published_at", "publishedAt");
|
||||
}
|
||||
|
||||
const { gameInfo, gameId } = extractGameTag(prefixedTags);
|
||||
ret.gameId = gameId;
|
||||
ret.gameInfo = gameInfo;
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
static #parseMediaTags(tags: Array<Array<string>>) {
|
||||
// parse imeta
|
||||
const iMetaTags = tags.filter(a => a[0] === "imeta") ?? [];
|
||||
if (iMetaTags.length > 0) {
|
||||
return iMetaTags.map(a => readNip94TagsFromIMeta(a));
|
||||
} else {
|
||||
const meta = readNip94Tags(tags);
|
||||
meta.url ??= tags.find(a => a[0] === "url")?.[1];
|
||||
return [meta];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapped sources
|
||||
*/
|
||||
sources(): Array<MediaPayload> {
|
||||
return this.media
|
||||
.filter(a => a.url)
|
||||
.sort((a, b) => {
|
||||
const aSize = a.dimensions ? a.dimensions[0] * a.dimensions[1] : 0;
|
||||
const bSize = b.dimensions ? b.dimensions[0] * b.dimensions[1] : 0;
|
||||
return aSize > bSize ? -1 : 1;
|
||||
})
|
||||
.map(
|
||||
a =>
|
||||
({
|
||||
url: a.url,
|
||||
dimensions: a.dimensions,
|
||||
mimeType: a.mimeType,
|
||||
hash: a.hash,
|
||||
alternatives: a.fallback ?? [],
|
||||
}) as MediaPayload,
|
||||
);
|
||||
}
|
||||
|
||||
// Pick best video
|
||||
bestVideo(): MediaPayload | undefined {
|
||||
return this.sources().at(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick highest resolution image
|
||||
*/
|
||||
bestPoster(): MediaPayload | undefined {
|
||||
const best = this.media
|
||||
.filter(a => a.dimensions && (a.image?.length ?? 0) > 0)
|
||||
.sort((a, b) => {
|
||||
const aSize = a.dimensions![0] * a.dimensions![1];
|
||||
const bSize = b.dimensions![0] * b.dimensions![1];
|
||||
return aSize > bSize ? -1 : 1;
|
||||
})
|
||||
.at(0);
|
||||
const first = best?.image?.at(0);
|
||||
if (first) {
|
||||
return {
|
||||
url: first,
|
||||
alternatives: best?.image?.filter(a => a != first) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -28,6 +28,7 @@
|
||||
"3HwrQo": "Zap!",
|
||||
"3adEeb": "{n} viewers",
|
||||
"3df560": "Login with private key",
|
||||
"3kbIhS": "Untitled",
|
||||
"3yk8fB": "Wallet",
|
||||
"47FYwb": "Cancel",
|
||||
"4RhY4O": "Example settings in OBS (Apple M1 Mac)",
|
||||
@ -56,6 +57,7 @@
|
||||
"8aAwpp": "For manual hosting all you need is the HLS URL for the Stream URL field. You should be ale to find this in your hosting setup.",
|
||||
"8xVdjn": "Video Codec",
|
||||
"9WRlF4": "Send",
|
||||
"9ZoFpI": "Delete file",
|
||||
"9a9+ww": "Title",
|
||||
"9pMqYs": "Nostr Address",
|
||||
"9rmSgv": "OBS (Open Broadcaster Software) is a free and open source software for video recording and live streaming on Windows, Mac and Linux. It is a popular choice with streamers. You'll need to install this to capture your video, audio and anything else you'd like to add to your stream. Once installed and configured to preference, add your Stream URL and Stream Key from the Stream settings to OBS to form a connection with zap.stream.",
|
||||
@ -96,6 +98,7 @@
|
||||
"Gvxoji": "Name is required",
|
||||
"GwbTAz": "Streams",
|
||||
"H/bNs9": "Save this and keep it safe! If you lose this key, you won't be able to access your account ever again. Yep, it's that serious!",
|
||||
"H1fdc9": "Please add a thumbnail",
|
||||
"H4hJvF": "Choose a category",
|
||||
"H5+NAX": "Balance",
|
||||
"HAlOn1": "Name",
|
||||
@ -115,6 +118,7 @@
|
||||
"Jq3FDz": "Content",
|
||||
"K3r6DQ": "Delete",
|
||||
"K7AkdL": "Show",
|
||||
"KH2ayq": "Upload Video",
|
||||
"KkIL3s": "No, I am under 18",
|
||||
"KtrGU6": "If you already have an account, you can use a nostr extension to log in. If you already use a nostr extension, you will be automatically logged in. If you don't have a nostr extension set up, you can use nos2x or Alby.",
|
||||
"KxfKnW": "Withdraw funds",
|
||||
@ -126,7 +130,9 @@
|
||||
"LxSJOb": "Go to Dashboard",
|
||||
"MTHO1W": "Start Raid",
|
||||
"My6HwN": "Ok, it's safe",
|
||||
"NnHu0L": "Please upload at least 1 video",
|
||||
"O2Cy6m": "Yes, I am over 18",
|
||||
"O7AeYh": "Description..",
|
||||
"OEW7yJ": "Zaps",
|
||||
"OKhRC6": "Share",
|
||||
"ObZZEz": "No clips yet",
|
||||
@ -137,6 +143,7 @@
|
||||
"PHE60k": "Leave blank if you do not wish to set up any goals.",
|
||||
"PUymyQ": "Come check out {name} stream on zap.stream! {link}",
|
||||
"PXAur5": "Withdraw",
|
||||
"Pc+tM3": "Generate",
|
||||
"Pe0ogR": "Theme",
|
||||
"Q3au2v": "About {estimate}",
|
||||
"Q8Qw5B": "Description",
|
||||
@ -148,6 +155,7 @@
|
||||
"Qe1MJu": "{name} with {amount}",
|
||||
"Qf0ugr": "Here you have a few options, using our in-house hosting, or your own (such as Cloudflare).",
|
||||
"R72je0": "Stream Title",
|
||||
"RGYBjE": "Thumbnail",
|
||||
"RJOmzk": "I have read and agree with {provider}''s {terms}.",
|
||||
"RS6smY": "Raid Message",
|
||||
"RXQdxR": "Please login to write messages!",
|
||||
@ -167,6 +175,7 @@
|
||||
"VDOpia": "What are zaps?",
|
||||
"VKb1MS": "Categories",
|
||||
"W7DNWx": "Stream Forwarding",
|
||||
"W7IRLs": "Your title is too short",
|
||||
"W9355R": "Unmute",
|
||||
"WVJZ0U": "Value",
|
||||
"Wp4l7+": "More Videos",
|
||||
@ -181,6 +190,7 @@
|
||||
"YagVIe": "{n}p",
|
||||
"YjhNaf": "Create Stream",
|
||||
"YwzT/0": "Clip title",
|
||||
"YyXVHf": "Clear Draft",
|
||||
"Z8ZOEY": "This method is insecure. We recommend using a {nostrlink}",
|
||||
"ZXp0z1": "Features",
|
||||
"ZaNcK4": "No goals yet",
|
||||
@ -220,18 +230,22 @@
|
||||
"hzSNj4": "Dashboard",
|
||||
"ieGrWo": "Follow",
|
||||
"ieKb+k": "What does it cost to stream?",
|
||||
"ipTKP3": "Chat Popout",
|
||||
"itPgxd": "Profile",
|
||||
"izWS4J": "Unfollow",
|
||||
"j/jueq": "Raiding {name}",
|
||||
"jDDeA0": "Your title is very long, please make sure its less than {n} chars.",
|
||||
"jJLRgo": "Publish Clip",
|
||||
"jgOqxt": "Widgets",
|
||||
"jkAQj5": "Stream Ended",
|
||||
"jr4+vD": "Markdown",
|
||||
"jvo0vs": "Save",
|
||||
"k21gTS": "e.g. about me",
|
||||
"kAEQyV": "OK",
|
||||
"kc5EOy": "Username is too long",
|
||||
"khJ51Q": "Stream Earnings",
|
||||
"kp0NPF": "Planned",
|
||||
"lBm7CY": "Upload to:",
|
||||
"lXbG97": "Most Zapped Streamers",
|
||||
"lZpRMR": "Check here if this stream contains nudity or pornographic content.",
|
||||
"ljmS5P": "Endpoint",
|
||||
@ -256,11 +270,13 @@
|
||||
"rJqhFR": "Stream Setup",
|
||||
"rWBFZA": "Sexually explicit material ahead!",
|
||||
"rgsbu9": "Current Viewers",
|
||||
"rleUgy": "Uploading..",
|
||||
"s+ORFl": "{m}d {ago}",
|
||||
"s5ksS7": "Image Link",
|
||||
"s7V+5p": "Confirm your age",
|
||||
"sInm1h": "Zap message",
|
||||
"sj6WAe": "Click the “Stream” button in the top right corner",
|
||||
"syEQFE": "Publish",
|
||||
"tG1ST3": "Incoming Zap",
|
||||
"tM6fNW": "Amazing! Continue..",
|
||||
"tNtB9I": "Name",
|
||||
@ -274,6 +290,7 @@
|
||||
"uYw2LD": "Stream",
|
||||
"uksRSi": "Latest Videos",
|
||||
"vrTOHJ": "{amount} sats",
|
||||
"w+2Vw7": "Shorts",
|
||||
"w0Xm2F": "Start typing",
|
||||
"w3btjR": "Gambling",
|
||||
"wCIL7o": "Broadcast on Nostr",
|
||||
|
@ -5,6 +5,7 @@ import { LIVE_STREAM, StreamState } from "@/const";
|
||||
import { GameInfo } from "./service/game-database";
|
||||
import { AllCategories } from "./pages/category";
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { StreamInfo } from "./element/stream/stream-info";
|
||||
|
||||
export function toAddress(e: NostrEvent): string {
|
||||
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
|
||||
@ -113,7 +114,6 @@ export interface StreamInfo {
|
||||
host?: string;
|
||||
gameId?: string;
|
||||
gameInfo?: GameInfo;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i;
|
||||
@ -144,8 +144,6 @@ export function extractStreamInfo(ev?: NostrEvent) {
|
||||
matchTag(t, "starts", v => (ret.starts = v));
|
||||
matchTag(t, "ends", v => (ret.ends = v));
|
||||
matchTag(t, "service", v => (ret.service = v));
|
||||
matchTag(t, "duration", v => (ret.duration = Number(v)));
|
||||
matchTag(t, "published_at", v => (ret.ends = v));
|
||||
}
|
||||
const { regularTags, prefixedTags } = sortStreamTags(ev?.tags ?? []);
|
||||
ret.tags = regularTags;
|
||||
@ -154,10 +152,6 @@ export function extractStreamInfo(ev?: NostrEvent) {
|
||||
ret.gameId = gameId;
|
||||
ret.gameInfo = gameInfo;
|
||||
|
||||
// video patch
|
||||
if (ev?.kind === 34_235) {
|
||||
ret.status = StreamState.VOD;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
@ -3132,6 +3132,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/uuid@npm:^9":
|
||||
version: 9.0.8
|
||||
resolution: "@types/uuid@npm:9.0.8"
|
||||
checksum: 10c0/b411b93054cb1d4361919579ef3508a1f12bf15b5fdd97337d3d351bece6c921b52b6daeef89b62340fd73fd60da407878432a1af777f40648cbe53a01723489
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/webscopeio__react-textarea-autocomplete@npm:^4.7.5":
|
||||
version: 4.7.5
|
||||
resolution: "@types/webscopeio__react-textarea-autocomplete@npm:4.7.5"
|
||||
@ -7723,6 +7730,7 @@ __metadata:
|
||||
"@types/react": "npm:^18.3.2"
|
||||
"@types/react-dom": "npm:^18.3.0"
|
||||
"@types/react-helmet": "npm:^6.1.11"
|
||||
"@types/uuid": "npm:^9"
|
||||
"@types/webscopeio__react-textarea-autocomplete": "npm:^4.7.5"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^7.9.0"
|
||||
"@typescript-eslint/parser": "npm:^7.9.0"
|
||||
@ -7764,6 +7772,7 @@ __metadata:
|
||||
tailwindcss: "npm:^3.4.3"
|
||||
typescript: "npm:^5.4.5"
|
||||
usehooks-ts: "npm:^3.1.0"
|
||||
uuid: "npm:^9.0.1"
|
||||
vite: "npm:^5.2.11"
|
||||
vite-plugin-pwa: "npm:^0.20.0"
|
||||
vite-plugin-version-mark: "npm:^0.0.13"
|
||||
|
Loading…
x
Reference in New Issue
Block a user