diff --git a/enjoy/src/api/client.ts b/enjoy/src/api/client.ts index a3203670..fc0196b4 100644 --- a/enjoy/src/api/client.ts +++ b/enjoy/src/api/client.ts @@ -94,7 +94,11 @@ export class Client { return this.api.get(`/api/posts/${id}`); } - createPost(params: { content: string }): Promise { + createPost(params: { + content?: string; + targetType?: string; + targetId?: string; + }): Promise { return this.api.post("/api/posts", decamelizeKeys(params)); } diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index feaadd24..35cd9d0b 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -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?" } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index 4b330cba..cf809eb7 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -329,5 +329,11 @@ "allRankings": "总排行榜", "noOneHasRecordedYet": "还没有人练习", "activities": "动态", - "noOneSharedYet": "还没有人分享" + "noOneSharedYet": "还没有人分享", + "sharedSuccessfully": "分享成功", + "sharedFailed": "分享失败", + "shareAudio": "分享音频", + "areYouSureToShareThisAudioToCommunity": "您确定要分享此音频到社区吗?", + "shareVideo": "分享视频", + "areYouSureToShareThisVideoToCommunity": "您确定要分享此视频到社区吗?" } diff --git a/enjoy/src/main/db/handlers/audios-handler.ts b/enjoy/src/main/db/handlers/audios-handler.ts index adf08c10..3b3ecd0a 100644 --- a/enjoy/src/main/db/handlers/audios-handler.ts +++ b/enjoy/src/main/db/handlers/audios-handler.ts @@ -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 }), }); } } diff --git a/enjoy/src/main/db/handlers/videos-handler.ts b/enjoy/src/main/db/handlers/videos-handler.ts index e1e69479..48fac884 100644 --- a/enjoy/src/main/db/handlers/videos-handler.ts +++ b/enjoy/src/main/db/handlers/videos-handler.ts @@ -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 }), }); } } diff --git a/enjoy/src/main/youtubedr.ts b/enjoy/src/main/youtubedr.ts index 97909595..9d239c1b 100644 --- a/enjoy/src/main/youtubedr.ts +++ b/enjoy/src/main/youtubedr.ts @@ -204,7 +204,7 @@ class Youtubedr { this.getYtVideoId(url); return true; } catch (error) { - console.error(error); + logger.warn(error); return false; } }; diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index 425131d9..82af7a4c 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -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); diff --git a/enjoy/src/renderer/components/audios/audio-detail.tsx b/enjoy/src/renderer/components/audios/audio-detail.tsx index e1a4f116..c5a9276a 100644 --- a/enjoy/src/renderer/components/audios/audio-detail.tsx +++ b/enjoy/src/renderer/components/audios/audio-detail.tsx @@ -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(null); const [transcription, setTranscription] = useState(null); const [initialized, setInitialized] = useState(false); + const [sharing, setSharing] = useState(false); // Player controls const [currentTime, setCurrentTime] = useState(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)} /> @@ -146,6 +193,23 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => { + setSharing(value)}> + + + {t("shareAudio")} + + {t("areYouSureToShareThisAudioToCommunity")} + + + + {t("cancel")} + + + + + {!initialized && (
diff --git a/enjoy/src/renderer/components/medias/media-player-controls.tsx b/enjoy/src/renderer/components/medias/media-player-controls.tsx index d8b579b0..00ff909f 100644 --- a/enjoy/src/renderer/components/medias/media-player-controls.tsx +++ b/enjoy/src/renderer/components/medias/media-player-controls.tsx @@ -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) => 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: { )} - {transcriptionDirty && ( -
-
- - -
+ + +
+
+ {transcriptionDirty && ( + <> + + + + )}
- )} +
diff --git a/enjoy/src/renderer/components/medias/media-player.tsx b/enjoy/src/renderer/components/medias/media-player.tsx index 5c38c681..788917c2 100644 --- a/enjoy/src/renderer/components/medias/media-player.tsx +++ b/enjoy/src/renderer/components/medias/media-player.tsx @@ -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} />
diff --git a/enjoy/src/renderer/components/posts/index.ts b/enjoy/src/renderer/components/posts/index.ts index 595c3ebb..653291f2 100644 --- a/enjoy/src/renderer/components/posts/index.ts +++ b/enjoy/src/renderer/components/posts/index.ts @@ -1 +1,2 @@ -export * from './posts'; +export * from "./posts"; +export * from "./post-audio-player"; diff --git a/enjoy/src/renderer/components/posts/post-audio-player.tsx b/enjoy/src/renderer/components/posts/post-audio-player.tsx new file mode 100644 index 00000000..1e05ed89 --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-audio-player.tsx @@ -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(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 ( +
+
+ + {secondsToTimestamp(duration)} + +
+ +
+ {!initialized && ( +
+ + + +
+ )} + +
+ +
+ +
+
+
+ ); +}; diff --git a/enjoy/src/renderer/components/posts/posts.tsx b/enjoy/src/renderer/components/posts/posts.tsx index 87139641..86b62740 100644 --- a/enjoy/src/renderer/components/posts/posts.tsx +++ b/enjoy/src/renderer/components/posts/posts.tsx @@ -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 ( -
+
{posts.length === 0 && (
{t("noOneSharedYet")}
)} + +
+ {posts.map((post) => ( + + ))} +
); }; + +const PostCard = (props: { post: PostType }) => { + const { post } = props; + + return ( +
+
+
+ + + + {post.user.name[0].toUpperCase()} + + +
{post.user.name}
+
+
+ {post.content &&
{post.content}
} + {post.targetType == "Medium" && } +
+ ); +}; + +const PostMedium = (props: { medium: MediumType }) => { + const { medium } = props; + if (!medium.sourceUrl) return null; + + return ( + <> +
+ {medium.mediumType == "Video" && ( + + + + + )} + + {medium.mediumType == "Audio" && ( + + )} +
+ + {medium.coverUrl && medium.mediumType == "Audio" && ( +
+ +
+ )} + + ); +}; diff --git a/enjoy/src/renderer/components/users/users-rankings.tsx b/enjoy/src/renderer/components/users/users-rankings.tsx index 422cac6c..733310a3 100644 --- a/enjoy/src/renderer/components/users/users-rankings.tsx +++ b/enjoy/src/renderer/components/users/users-rankings.tsx @@ -58,11 +58,11 @@ const RankingsCard = (props: { )} {rankings.map((user, index) => ( -
+
#{index + 1}
- + {user.name[0].toUpperCase()} diff --git a/enjoy/src/renderer/components/videos/video-detail.tsx b/enjoy/src/renderer/components/videos/video-detail.tsx index 921203f1..c9b28ada 100644 --- a/enjoy/src/renderer/components/videos/video-detail.tsx +++ b/enjoy/src/renderer/components/videos/video-detail.tsx @@ -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(null); const [transcription, setTranscription] = useState(null); const [initialized, setInitialized] = useState(false); + const [sharing, setSharing] = useState(false); // Player controls const [currentTime, setCurrentTime] = useState(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)} /> {
+ setSharing(value)}> + + + {t("shareAudio")} + + {t("areYouSureToShareThisAudioToCommunity")} + + + + {t("cancel")} + + + + + {!initialized && (
diff --git a/enjoy/src/renderer/pages/community.tsx b/enjoy/src/renderer/pages/community.tsx index 97055906..2be315d7 100644 --- a/enjoy/src/renderer/pages/community.tsx +++ b/enjoy/src/renderer/pages/community.tsx @@ -14,28 +14,30 @@ export default () => { const navigate = useNavigate(); return ( -
-
- - {t("sidebar.community")} +
+
+
+ + {t("sidebar.community")} +
+ + + + {t("activities")} + {t("rankings")} + + + + + + + + + +
- - - - {t("activities")} - {t("rankings")} - - - - - - - - - -
); }; diff --git a/enjoy/src/types/audio.d.ts b/enjoy/src/types/audio.d.ts index 426d72b7..99808529 100644 --- a/enjoy/src/types/audio.d.ts +++ b/enjoy/src/types/audio.d.ts @@ -11,6 +11,7 @@ type AudioType = { transcribing?: boolean; recordingsCount?: number; recordingsDuration?: number; + isUploaded?: boolean; uploadedAt?: Date; createdAt: Date; updatedAt: Date; diff --git a/enjoy/src/types/enjoy-app.d.ts b/enjoy/src/types/enjoy-app.d.ts index faf40f20..14588882 100644 --- a/enjoy/src/types/enjoy-app.d.ts +++ b/enjoy/src/types/enjoy-app.d.ts @@ -93,7 +93,7 @@ type EnjoyAppType = { audios: { findAll: (params: object) => Promise; findOne: (params: object) => Promise; - create: (source: string, params?: object) => Promise; + create: (uri: string, params?: object) => Promise; update: (id: string, params: object) => Promise; destroy: (id: string) => Promise; transcribe: (id: string) => Promise; @@ -102,7 +102,7 @@ type EnjoyAppType = { videos: { findAll: (params: object) => Promise; findOne: (params: object) => Promise; - create: (source: string, params?: object) => Promise; + create: (uri: string, params?: object) => Promise; update: (id: string, params: object) => Promise; destroy: (id: string) => Promise; transcribe: (id: string) => Promise; diff --git a/enjoy/src/types/medium.d.ts b/enjoy/src/types/medium.d.ts new file mode 100644 index 00000000..8c742639 --- /dev/null +++ b/enjoy/src/types/medium.d.ts @@ -0,0 +1,9 @@ +type MediumType = { + id: string; + mediumType: string; + coverUrl?: string; + sourceUrl?: string; + extname?: string; + createdAt: string; + updatedAt: string; +} diff --git a/enjoy/src/types/post.d.ts b/enjoy/src/types/post.d.ts index 82589ef3..84a90544 100644 --- a/enjoy/src/types/post.d.ts +++ b/enjoy/src/types/post.d.ts @@ -2,6 +2,8 @@ type PostType = { id: string; content?: string; user: UserType; + targetType: string; + target?: MediumType; createdAt: string; updatedAt: string; } diff --git a/enjoy/src/types/video.d.ts b/enjoy/src/types/video.d.ts index 8b44b727..7804a7bd 100644 --- a/enjoy/src/types/video.d.ts +++ b/enjoy/src/types/video.d.ts @@ -12,6 +12,7 @@ type VideoType = { transcribing: boolean; recordingsCount?: number; recordingsDuration?: number; + isUploaded?: boolean; uploadedAt?: Date; createdAt: Date; updatedAt: Date;