From 4254da6c693e6bdb0226986edec35a8cf2ba1b66 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 10 Sep 2025 15:41:37 +0800 Subject: [PATCH] feat: audio player for article layout --- .../components/layouts/ArticleLayout.tsx | 3 + .../components/layouts/shared/AudioPlayer.tsx | 217 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/AudioPlayer.tsx diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx index 217c2f8c7..3de2405bf 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx @@ -29,6 +29,7 @@ import { EntryAttachments } from "../EntryAttachments" import { EntryTitle } from "../EntryTitle" import { SupportCreator } from "../SupportCreator" import { MediaTranscript, TranscriptToggle, useTranscription } from "./shared" +import { ArticleAudioPlayer } from "./shared/AudioPlayer" interface ArticleLayoutProps { entryId: string @@ -83,6 +84,8 @@ export const ArticleLayout: React.FC = ({
+ + {/* Content Type Toggle */} { + if (!seconds || seconds === Infinity) return "0:00" + const duration = dayjs.duration(seconds, "seconds") + const hours = Math.floor(duration.asHours()) + const minutes = duration.minutes() + const secs = duration.seconds() + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` + } + return `${minutes}:${secs.toString().padStart(2, "0")}` +} + +export const ArticleAudioPlayer: React.FC = ({ entryId, className }) => { + const entry = useEntry(entryId, (state) => ({ + attachments: state.attachments, + feedId: state.feedId, + })) + + // Find the first audio attachment + const audioAttachment = useMemo(() => { + return entry?.attachments?.find( + (attachment) => attachment.mime_type?.startsWith("audio/") && attachment.url, + ) + }, [entry?.attachments]) + + const currentPlayingEntryId = useAudioPlayerAtomSelector((v) => v.entryId) + const status = useAudioPlayerAtomSelector((v) => v.status) + const currentTime = useAudioPlayerAtomSelector((v) => v.currentTime) + const duration = useAudioPlayerAtomSelector((v) => v.duration) + + const isCurrentAudio = currentPlayingEntryId === entryId + const isPlaying = isCurrentAudio && status === "playing" + const isLoading = isCurrentAudio && status === "loading" + + const handlePlayAudio = useCallback(() => { + if (!audioAttachment) return + + if (isCurrentAudio) { + AudioPlayer.togglePlayAndPause() + } else { + AudioPlayer.mount({ + entryId, + src: audioAttachment.url, + type: "audio", + currentTime: 0, + }) + } + }, [audioAttachment, entryId, isCurrentAudio]) + + const handleDownload = useCallback(() => { + if (!audioAttachment?.url) return + window.open(audioAttachment.url, "_blank") + }, [audioAttachment?.url]) + + const handleBack = useCallback(() => { + if (!isCurrentAudio) return + AudioPlayer.back(10) + }, [isCurrentAudio]) + + const handleForward = useCallback(() => { + if (!isCurrentAudio) return + AudioPlayer.forward(10) + }, [isCurrentAudio]) + + // Only show progress for current audio, otherwise reset to 0 + const displayCurrentTime = isCurrentAudio ? currentTime || 0 : 0 + const displayDuration = isCurrentAudio ? duration || 0 : 0 + const displayHasValidDuration = + isCurrentAudio && duration && duration > 0 && duration !== Infinity + + const handleProgressClick = useCallback( + (event: React.MouseEvent) => { + if (!isCurrentAudio || !displayHasValidDuration) return + + const rect = event.currentTarget.getBoundingClientRect() + const clickX = event.clientX - rect.left + const progressPercent = clickX / rect.width + const newTime = progressPercent * displayDuration + + AudioPlayer.seek(newTime) + }, + [isCurrentAudio, displayHasValidDuration, displayDuration], + ) + + const currentTimeDisplay = formatDuration(displayCurrentTime) + const durationDisplay = formatDuration(displayDuration) + + // Don't render if no audio attachment + if (!audioAttachment) { + return null + } + + const progressPercent = displayHasValidDuration ? (displayCurrentTime / displayDuration) * 100 : 0 + + return ( + + + {/* Control buttons and progress bar */} +
+ {/* Control buttons */} +
+ {/* Skip Back 10s */} + + + {/* Play/Pause Button */} + + + {/* Skip Forward 10s */} + +
+ + {/* Progress Bar Container */} +
+
+
+ {/* Hover indicator */} +
+
+
+
+
+ + {/* Time Display and Download */} +
+
+ {currentTimeDisplay} + / + {durationDisplay} +
+ + {/* Download Button */} + +
+
+ + + ) +}