feat: video player designs
This commit is contained in:
@ -24,6 +24,7 @@ import { TopZappers } from "@/element/top-zappers";
|
||||
import { useProfileClips } from "@/hooks/clips";
|
||||
import VideoGrid from "@/element/video-grid";
|
||||
import { ClipTile } from "@/element/stream/clip-tile";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
|
||||
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";
|
||||
|
||||
@ -32,17 +33,18 @@ export function ProfilePage() {
|
||||
const link = parseNostrLink(unwrap(params.npub));
|
||||
const { streams, zaps } = useProfile(link, true);
|
||||
const profile = useUserProfile(link.id);
|
||||
const { proxy } = useImgProxy();
|
||||
|
||||
const pastStreams = useMemo(() => {
|
||||
return streams.filter(ev => findTag(ev, "status") === StreamState.Ended);
|
||||
}, [streams]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 xl:px-4">
|
||||
<div className="flex flex-col gap-3 xl:px-4 w-full">
|
||||
<img
|
||||
className="rounded-xl object-cover h-[360px]"
|
||||
alt={profile?.name || link.id}
|
||||
src={profile?.banner ? profile?.banner : defaultBanner}
|
||||
src={profile?.banner ? proxy(profile?.banner) : defaultBanner}
|
||||
/>
|
||||
<ProfileHeader link={link} profile={profile} streams={streams} />
|
||||
<div className="grid lg:grid-cols-2 gap-4 py-2">
|
||||
@ -145,7 +147,7 @@ function ProfileStreamList({ streams }: { streams: Array<TaggedNostrEvent> }) {
|
||||
<VideoGrid>
|
||||
{streams.map(ev => (
|
||||
<div key={ev.id} className="flex flex-col gap-1">
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} style="grid" />
|
||||
<span className="text-neutral-500">
|
||||
<FormattedMessage
|
||||
defaultMessage="Streamed on {date}"
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { Textarea } from "@/element/chat/textarea";
|
||||
import { WriteMessage } from "@/element/chat/write-message";
|
||||
import { FollowButton } from "@/element/follow-button";
|
||||
import { Profile, getName } from "@/element/profile";
|
||||
@ -7,60 +6,151 @@ 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 { getHost, extractStreamInfo } from "@/utils";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
import { getHost, extractStreamInfo, 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 classNames from "classnames";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { VideoTile } from "@/element/video-tile";
|
||||
import { VIDEO_KIND } from "@/const";
|
||||
|
||||
export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const ev = useCurrentStreamFeed(link, true, evPreload);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const host = getHost(ev);
|
||||
const [widePlayer, setWidePlayer] = useState(localStorage.getItem("wide-player") === "true");
|
||||
const { title, summary, image, contentWarning, 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]);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-[80dvw] mx-auto">
|
||||
<video src={recording} controls className="w-full aspect-video" poster={image} />
|
||||
<div className="grid grid-cols-[auto_450px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="font-medium text-xl">{title}</div>
|
||||
<div className="flex justify-between">
|
||||
{/* PROFILE SECTION */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<Profile pubkey={host} />
|
||||
<FollowButton pubkey={host} />
|
||||
</div>
|
||||
{/* ACTIONS */}
|
||||
<div className="flex gap-2">
|
||||
{ev && (
|
||||
<>
|
||||
<ShareMenu ev={ev} />
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
pubkey={host}
|
||||
aTag={link.tagKey}
|
||||
targetName={getName(ev.pubkey, profile)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={classNames("lg:p-4 grow lg:grid lg:gap-2 lg:grid-cols-[auto_450px]", {
|
||||
"max-w-[60dvw] mx-auto": !widePlayer,
|
||||
})}>
|
||||
<div
|
||||
className={classNames("min-w-0 w-full max-h-[80dvh] aspect-video mx-auto bg-black", {
|
||||
"col-span-2": 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>
|
||||
</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,
|
||||
})}>
|
||||
<div className="font-medium text-xl">{title}</div>
|
||||
<div className="flex justify-between">
|
||||
{/* PROFILE SECTION */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<Profile pubkey={host} />
|
||||
<FollowButton pubkey={host} />
|
||||
</div>
|
||||
{summary && <StreamSummary text={summary} />}
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Comments" />
|
||||
</h3>
|
||||
<div>
|
||||
<WriteMessage link={link} emojiPacks={[]} kind={1} />
|
||||
{/* ACTIONS */}
|
||||
<div className="flex gap-2">
|
||||
{ev && (
|
||||
<>
|
||||
<ShareMenu ev={ev} />
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
pubkey={host}
|
||||
aTag={link.tagKey}
|
||||
targetName={getName(ev.pubkey, profile)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<VideoComments link={link} />
|
||||
</div>
|
||||
{summary && <StreamSummary text={summary} />}
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Comments" />
|
||||
</h3>
|
||||
<div>
|
||||
<WriteMessage link={link} emojiPacks={[]} kind={1} />
|
||||
</div>
|
||||
<VideoComments link={link} />
|
||||
</div>
|
||||
<div
|
||||
className={classNames("p-2 col-start-2", {
|
||||
"row-start-1 row-span-3": !widePlayer,
|
||||
"row-start-2": widePlayer,
|
||||
})}>
|
||||
<UpNext pubkey={host} exclude={[link]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpNext({ pubkey, exclude }: { pubkey: string; exclude: Array<NostrLink> }) {
|
||||
const rb = new RequestBuilder(`videos:${pubkey}`);
|
||||
rb.withFilter().kinds([VIDEO_KIND]);
|
||||
|
||||
const videos = useRequestBuilder(rb);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
videos
|
||||
.filter(a => !exclude.some(b => b.equals(NostrLink.fromEvent(a))))
|
||||
.sort((a, b) => {
|
||||
const pubA = findTag(a, "published_at");
|
||||
const pubB = findTag(b, "published_at");
|
||||
return Number(pubA) > Number(pubB) ? -1 : 1;
|
||||
})
|
||||
.slice(0, 10),
|
||||
[videos],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="More Videos" />
|
||||
</h3>
|
||||
{sorted.map(a => (
|
||||
<VideoTile ev={a} key={a.id} showStatus={false} style="list" className="h-[100px]" showAvatar={false} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import { VideoTile } from "@/element/video-tile";
|
||||
import { findTag } from "@/utils";
|
||||
import { RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function VideosPage() {
|
||||
const rb = new RequestBuilder("videos");
|
||||
@ -19,9 +20,13 @@ export function VideosPage() {
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Latest Videos" />
|
||||
</h2>
|
||||
<br />
|
||||
<VideoGrid>
|
||||
{sorted.map(a => (
|
||||
<VideoTile ev={a} key={a.id} showStatus={false} />
|
||||
<VideoTile ev={a} key={a.id} showStatus={false} style="grid" />
|
||||
))}
|
||||
</VideoGrid>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user