share audio/video & display post

This commit is contained in:
an-lee
2024-01-12 00:54:53 +08:00
parent e510ed9337
commit f9b1c14b4c
21 changed files with 441 additions and 67 deletions

View File

@@ -94,7 +94,11 @@ export class Client {
return this.api.get(`/api/posts/${id}`);
}
createPost(params: { content: string }): Promise<PostType> {
createPost(params: {
content?: string;
targetType?: string;
targetId?: string;
}): Promise<PostType> {
return this.api.post("/api/posts", decamelizeKeys(params));
}

View File

@@ -329,5 +329,11 @@
"allRankings": "All time rankings",
"noOneHasRecordedYet": "No one has recorded yet",
"activities": "Activities",
"noOneSharedYet": "No one shared yet"
"noOneSharedYet": "No one shared yet",
"sharedSuccessfully": "Shared successfully",
"shareFailed": "Share failed",
"shareAudio": "Share audio",
"areYouSureToShareThisAudioToCommunity": "Are you sure to share this audio to community?",
"shareVideo": "Share video",
"areYouSureToShareThisVideoToCommunity": "Are you sure to share this video to community?"
}

View File

@@ -329,5 +329,11 @@
"allRankings": "总排行榜",
"noOneHasRecordedYet": "还没有人练习",
"activities": "动态",
"noOneSharedYet": "还没有人分享"
"noOneSharedYet": "还没有人分享",
"sharedSuccessfully": "分享成功",
"sharedFailed": "分享失败",
"shareAudio": "分享音频",
"areYouSureToShareThisAudioToCommunity": "您确定要分享此音频到社区吗?",
"shareVideo": "分享视频",
"areYouSureToShareThisVideoToCommunity": "您确定要分享此视频到社区吗?"
}

View File

@@ -90,27 +90,29 @@ class AudiosHandler {
private async create(
event: IpcMainEvent,
source: string,
uri: string,
params: {
name?: string;
coverUrl?: string;
} = {}
) {
let file = source;
if (source.startsWith("http")) {
let file = uri;
let source;
if (uri.startsWith("http")) {
try {
if (youtubedr.validateYtURL(source)) {
file = await youtubedr.autoDownload(source);
if (youtubedr.validateYtURL(uri)) {
file = await youtubedr.autoDownload(uri);
} else {
file = await downloader.download(source, {
file = await downloader.download(uri, {
webContents: event.sender,
});
}
if (!file) throw new Error("Failed to download file");
source = uri;
} catch (err) {
return event.sender.send("on-notification", {
type: "error",
message: t("models.audio.failedToDownloadFile", { file: source }),
message: t("models.audio.failedToDownloadFile", { file: uri }),
});
}
}

View File

@@ -90,27 +90,29 @@ class VideosHandler {
private async create(
event: IpcMainEvent,
source: string,
uri: string,
params: {
name?: string;
coverUrl?: string;
} = {}
) {
let file = source;
if (source.startsWith("http")) {
let file = uri;
let source;
if (uri.startsWith("http")) {
try {
if (youtubedr.validateYtURL(source)) {
file = await youtubedr.autoDownload(source);
if (youtubedr.validateYtURL(uri)) {
file = await youtubedr.autoDownload(uri);
} else {
file = await downloader.download(source, {
file = await downloader.download(uri, {
webContents: event.sender,
});
}
if (!file) throw new Error("Failed to download file");
source = uri;
} catch (err) {
return event.sender.send("on-notification", {
type: "error",
message: t("models.video.failedToDownloadFile", { file: source }),
message: t("models.video.failedToDownloadFile", { file: uri }),
});
}
}

View File

@@ -204,7 +204,7 @@ class Youtubedr {
this.getYtVideoId(url);
return true;
} catch (error) {
console.error(error);
logger.warn(error);
return false;
}
};

View File

@@ -175,8 +175,8 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
findOne: (params: object) => {
return ipcRenderer.invoke("audios-find-one", params);
},
create: (source: string, params?: object) => {
return ipcRenderer.invoke("audios-create", source, params);
create: (uri: string, params?: object) => {
return ipcRenderer.invoke("audios-create", uri, params);
},
update: (id: string, params: object) => {
return ipcRenderer.invoke("audios-update", id, params);
@@ -201,8 +201,8 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
findOne: (params: object) => {
return ipcRenderer.invoke("videos-find-one", params);
},
create: (source: string, params?: object) => {
return ipcRenderer.invoke("videos-create", source, params);
create: (uri: string, params?: object) => {
return ipcRenderer.invoke("videos-create", uri, params);
},
update: (id: string, params: object) => {
return ipcRenderer.invoke("videos-update", id, params);

View File

@@ -11,16 +11,30 @@ import {
MediaTranscription,
} from "@renderer/components";
import { LoaderIcon } from "lucide-react";
import { ScrollArea } from "@renderer/components/ui";
import {
AlertDialog,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogContent,
AlertDialogFooter,
AlertDialogCancel,
Button,
ScrollArea,
useToast,
} from "@renderer/components/ui";
import { t } from "i18next";
export const AudioDetail = (props: { id?: string; md5?: string }) => {
const { id, md5 } = props;
const { toast } = useToast();
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const [audio, setAudio] = useState<AudioType | null>(null);
const [transcription, setTranscription] = useState<TranscriptionType>(null);
const [initialized, setInitialized] = useState<boolean>(false);
const [sharing, setSharing] = useState<boolean>(false);
// Player controls
const [currentTime, setCurrentTime] = useState<number>(0);
@@ -43,6 +57,38 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
}
};
const handleShare = async () => {
if (!audio.source && !audio.isUploaded) {
try {
await EnjoyApp.audios.upload(audio.id);
} catch (err) {
toast({
title: t("shareFailed"),
description: err.message,
});
return;
}
}
webApi
.createPost({
targetType: "Audio",
targetId: audio.id,
})
.then(() => {
toast({
title: t("shared"),
description: t("sharedSuccessfully"),
});
})
.catch((err) => {
toast({
title: t("shareFailed"),
description: err.message,
});
});
setSharing(false);
};
useEffect(() => {
const where = id ? { id } : { md5 };
EnjoyApp.audios.findOne(where).then((audio) => {
@@ -110,6 +156,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
setPlaybackRate={setPlaybackRate}
displayInlineCaption={displayInlineCaption}
setDisplayInlineCaption={setDisplayInlineCaption}
onShare={() => setSharing(true)}
/>
<ScrollArea className={`flex-1 relative bg-muted`}>
@@ -146,6 +193,23 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
</div>
</div>
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("shareAudio")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToShareThisAudioToCommunity")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<Button variant="default" onClick={handleShare}>
{t("share")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{!initialized && (
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />

View File

@@ -16,6 +16,7 @@ import {
MinimizeIcon,
GalleryHorizontalIcon,
SpellCheckIcon,
Share2Icon,
} from "lucide-react";
import { t } from "i18next";
import { type WaveSurferOptions } from "wavesurfer.js";
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
const PLAYBACK_RATE_OPTIONS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75];
const MIN_ZOOM_RATIO = 0.25;
const MAX_ZOOM_RATIO = 5.0;
const ZOOM_RATIO_STEP = 0.25;
export const MediaPlayerControls = (props: {
isPlaying: boolean;
@@ -47,6 +47,7 @@ export const MediaPlayerControls = (props: {
setWavesurferOptions?: (options: Partial<WaveSurferOptions>) => void;
displayInlineCaption?: boolean;
setDisplayInlineCaption?: (display: boolean) => void;
onShare?: () => void;
}) => {
const {
isPlaying,
@@ -67,6 +68,7 @@ export const MediaPlayerControls = (props: {
setWavesurferOptions,
displayInlineCaption,
setDisplayInlineCaption,
onShare,
} = props;
return (
@@ -244,20 +246,32 @@ export const MediaPlayerControls = (props: {
</Button>
)}
{transcriptionDirty && (
<div className="absolute right-4">
<div className="flex items-center space-x-4">
<Button
variant="secondary"
className=""
onClick={resetTranscription}
>
{t("reset")}
</Button>
<Button onClick={saveTranscription}>{t("save")}</Button>
</div>
<Button
variant="ghost"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("share")}
className="relative aspect-square p-0 h-10"
onClick={onShare}
>
<Share2Icon className="w-6 h-6" />
</Button>
<div className="absolute right-4">
<div className="flex items-center space-x-4">
{transcriptionDirty && (
<>
<Button
variant="secondary"
className=""
onClick={resetTranscription}
>
{t("reset")}
</Button>
<Button onClick={saveTranscription}>{t("save")}</Button>
</>
)}
</div>
)}
</div>
<Tooltip id="media-player-controls-tooltip" />
</div>

View File

@@ -60,6 +60,7 @@ export const MediaPlayer = (props: {
setPlaybackRate: (value: number) => void;
displayInlineCaption?: boolean;
setDisplayInlineCaption?: (value: boolean) => void;
onShare?: () => void;
}) => {
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const {
@@ -88,6 +89,7 @@ export const MediaPlayer = (props: {
setPlaybackRate,
displayInlineCaption,
setDisplayInlineCaption,
onShare,
} = props;
if (!mediaUrl) return;
@@ -536,6 +538,7 @@ export const MediaPlayer = (props: {
setWavesurferOptions={(options) => wavesurfer?.setOptions(options)}
displayInlineCaption={displayInlineCaption}
setDisplayInlineCaption={setDisplayInlineCaption}
onShare={onShare}
/>
</div>

View File

@@ -1 +1,2 @@
export * from './posts';
export * from "./posts";
export * from "./post-audio-player";

View File

@@ -0,0 +1,121 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { PitchContour } from "@renderer/components";
import WaveSurfer from "wavesurfer.js";
import { Button, Skeleton } from "@renderer/components/ui";
import { PlayIcon, PauseIcon } from "lucide-react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
import { secondsToTimestamp } from "@renderer/lib/utils";
export const PostAudioPlayer = (props: { src: string; height?: number }) => {
const { src, height = 80 } = props;
const [initialized, setInitialized] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [wavesurfer, setWavesurfer] = useState(null);
const containerRef = useRef();
const [ref, entry] = useIntersectionObserver({
threshold: 1,
});
const [duration, setDuration] = useState<number>(0);
const onPlayClick = useCallback(() => {
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
}, [wavesurfer]);
useEffect(() => {
// use the intersection observer to only create the wavesurfer instance
// when the player is visible
if (!entry?.isIntersecting) return;
if (!src) return;
if (wavesurfer) return;
const ws = WaveSurfer.create({
container: containerRef.current,
url: src,
height,
barWidth: 1,
cursorWidth: 0,
autoCenter: true,
autoScroll: true,
dragToSeek: true,
hideScrollbar: true,
minPxPerSec: 100,
waveColor: "#ddd",
progressColor: "rgba(0, 0, 0, 0.25)",
normalize: true,
});
setWavesurfer(ws);
}, [src, entry]);
useEffect(() => {
if (!wavesurfer) return;
const subscriptions = [
wavesurfer.on("play", () => {
setIsPlaying(true);
}),
wavesurfer.on("pause", () => {
setIsPlaying(false);
}),
wavesurfer.on("decode", () => {
setDuration(wavesurfer.getDuration());
const peaks = wavesurfer.getDecodedData().getChannelData(0);
const sampleRate = wavesurfer.options.sampleRate;
wavesurfer.renderer.getWrapper().appendChild(
PitchContour({
peaks,
sampleRate,
height,
})
);
setInitialized(true);
}),
];
return () => {
subscriptions.forEach((unsub) => unsub());
wavesurfer?.destroy();
};
}, [wavesurfer]);
return (
<div className="w-full">
<div className="flex justify-end">
<span className="text-xs text-muted-foreground mb-1">
{secondsToTimestamp(duration)}
</span>
</div>
<div
ref={ref}
className="bg-white rounded-lg grid grid-cols-9 items-center relative h-[80px]"
>
{!initialized && (
<div className="col-span-9 flex flex-col justify-around h-[80px]">
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
</div>
)}
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
<Button
onClick={onPlayClick}
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
>
{isPlaying ? (
<PauseIcon className="w-6 h-6 text-white" />
) : (
<PlayIcon className="w-6 h-6 text-white" />
)}
</Button>
</div>
<div
className={`col-span-8 ${initialized ? "" : "hidden"}`}
ref={containerRef}
></div>
</div>
</div>
);
};

View File

@@ -1,6 +1,13 @@
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { PostAudioPlayer } from "@renderer/components";
import { Avatar, AvatarImage, AvatarFallback } from "@renderer/components/ui";
import { t } from "i18next";
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
export const Posts = () => {
const { webApi } = useContext(AppSettingsProviderContext);
@@ -9,6 +16,7 @@ export const Posts = () => {
const fetchPosts = async () => {
webApi.posts().then(
(res) => {
console.log(res);
setPosts(res.posts);
},
(err) => {
@@ -22,10 +30,74 @@ export const Posts = () => {
}, []);
return (
<div className="">
<div className="max-w-screen-sm mx-auto">
{posts.length === 0 && (
<div className="text-center text-gray-500">{t("noOneSharedYet")}</div>
)}
<div className="space-y-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
};
const PostCard = (props: { post: PostType }) => {
const { post } = props;
return (
<div className="rounded p-4 bg-white">
<div className="flex items-center mb-4 justify-between">
<div className="flex items-center space-x-2">
<Avatar>
<AvatarImage src={post.user.avatarUrl} />
<AvatarFallback className="text-xl">
{post.user.name[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="">{post.user.name}</div>
</div>
</div>
{post.content && <div className="mb-4">{post.content}</div>}
{post.targetType == "Medium" && <PostMedium medium={post.target} />}
</div>
);
};
const PostMedium = (props: { medium: MediumType }) => {
const { medium } = props;
if (!medium.sourceUrl) return null;
return (
<>
<div className="mb-2">
{medium.mediumType == "Video" && (
<MediaPlayer
poster={medium.coverUrl}
src={{
type: `${medium.mediumType.toLowerCase()}/${
medium.extname.replace(".", "") || "mp4"
}`,
src: medium.sourceUrl,
}}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
)}
{medium.mediumType == "Audio" && (
<PostAudioPlayer src={medium.sourceUrl} />
)}
</div>
{medium.coverUrl && medium.mediumType == "Audio" && (
<div className="">
<img src={medium.coverUrl} className="w-full rounded" />
</div>
)}
</>
);
};

View File

@@ -58,11 +58,11 @@ const RankingsCard = (props: {
)}
{rankings.map((user, index) => (
<div key={user.id} className="flex items-center space-x-4 px-4 py-2">
<div key={user.id} className="flex items-center space-x-4 p-2">
<div className="font-mono text-sm">#{index + 1}</div>
<div className="flex items-center space-x-2">
<Avatar>
<Avatar className="w-8 h-8">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="text-xl">
{user.name[0].toUpperCase()}

View File

@@ -11,16 +11,30 @@ import {
MediaTranscription,
} from "@renderer/components";
import { LoaderIcon } from "lucide-react";
import { ScrollArea } from "@renderer/components/ui";
import {
AlertDialog,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogContent,
AlertDialogFooter,
AlertDialogCancel,
Button,
ScrollArea,
useToast,
} from "@renderer/components/ui";
import { t } from "i18next";
export const VideoDetail = (props: { id?: string; md5?: string }) => {
const { id, md5 } = props;
const { toast } = useToast();
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const [video, setVideo] = useState<VideoType | null>(null);
const [transcription, setTranscription] = useState<TranscriptionType>(null);
const [initialized, setInitialized] = useState<boolean>(false);
const [sharing, setSharing] = useState<boolean>(false);
// Player controls
const [currentTime, setCurrentTime] = useState<number>(0);
@@ -45,6 +59,38 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
}
};
const handleShare = async () => {
if (!video.source && !video.isUploaded) {
try {
await EnjoyApp.videos.upload(video.id);
} catch (err) {
toast({
title: t("shareFailed"),
description: err.message,
});
return;
}
}
webApi
.createPost({
targetType: "Video",
targetId: video.id,
})
.then(() => {
toast({
description: t("sharedSuccessfully"),
});
})
.catch((err) => {
toast({
title: t("shareFailed"),
description: err.message,
});
});
setSharing(false);
};
useEffect(() => {
const where = id ? { id } : { md5 };
EnjoyApp.videos.findOne(where).then((video) => {
@@ -113,6 +159,7 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
setPlaybackRate={setPlaybackRate}
displayInlineCaption={displayInlineCaption}
setDisplayInlineCaption={setDisplayInlineCaption}
onShare={() => setSharing(true)}
/>
<ScrollArea
@@ -153,6 +200,23 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
</div>
</div>
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("shareAudio")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToShareThisAudioToCommunity")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<Button variant="default" onClick={handleShare}>
{t("share")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{!initialized && (
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />

View File

@@ -14,28 +14,30 @@ export default () => {
const navigate = useNavigate();
return (
<div className="h-full max-w-5xl mx-auto px-4 py-6">
<div className="flex space-x-1 items-center mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<span>{t("sidebar.community")}</span>
<div className="bg-muted h-full px-4 lg:px-8 py-6">
<div className="max-w-screen-md mx-auto">
<div className="flex space-x-1 items-center mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<span>{t("sidebar.community")}</span>
</div>
<Tabs defaultValue="activities">
<TabsList className="mb-6">
<TabsTrigger value="activities">{t("activities")}</TabsTrigger>
<TabsTrigger value="rankings">{t("rankings")}</TabsTrigger>
</TabsList>
<TabsContent value="activities">
<Posts />
</TabsContent>
<TabsContent value="rankings">
<UsersRankings />
</TabsContent>
</Tabs>
</div>
<Tabs defaultValue="activities">
<TabsList className="mb-6">
<TabsTrigger value="activities">{t("activities")}</TabsTrigger>
<TabsTrigger value="rankings">{t("rankings")}</TabsTrigger>
</TabsList>
<TabsContent value="activities">
<Posts />
</TabsContent>
<TabsContent value="rankings">
<UsersRankings />
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -11,6 +11,7 @@ type AudioType = {
transcribing?: boolean;
recordingsCount?: number;
recordingsDuration?: number;
isUploaded?: boolean;
uploadedAt?: Date;
createdAt: Date;
updatedAt: Date;

View File

@@ -93,7 +93,7 @@ type EnjoyAppType = {
audios: {
findAll: (params: object) => Promise<AudioType[]>;
findOne: (params: object) => Promise<AudioType>;
create: (source: string, params?: object) => Promise<AudioType>;
create: (uri: string, params?: object) => Promise<AudioType>;
update: (id: string, params: object) => Promise<AudioType | undefined>;
destroy: (id: string) => Promise<undefined>;
transcribe: (id: string) => Promise<void>;
@@ -102,7 +102,7 @@ type EnjoyAppType = {
videos: {
findAll: (params: object) => Promise<VideoType[]>;
findOne: (params: object) => Promise<VideoType>;
create: (source: string, params?: object) => Promise<VideoType>;
create: (uri: string, params?: object) => Promise<VideoType>;
update: (id: string, params: object) => Promise<VideoType | undefined>;
destroy: (id: string) => Promise<undefined>;
transcribe: (id: string) => Promise<void>;

9
enjoy/src/types/medium.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
type MediumType = {
id: string;
mediumType: string;
coverUrl?: string;
sourceUrl?: string;
extname?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -2,6 +2,8 @@ type PostType = {
id: string;
content?: string;
user: UserType;
targetType: string;
target?: MediumType;
createdAt: string;
updatedAt: string;
}

View File

@@ -12,6 +12,7 @@ type VideoType = {
transcribing: boolean;
recordingsCount?: number;
recordingsDuration?: number;
isUploaded?: boolean;
uploadedAt?: Date;
createdAt: Date;
updatedAt: Date;