From 244aca2ad63d566755c3dfdfee5f496a69b740bf Mon Sep 17 00:00:00 2001 From: Whitewater Date: Fri, 1 Aug 2025 23:43:30 +0800 Subject: [PATCH] feat: implement audio seeking functionality (#4295) * refactor: move timeStringToSeconds to utility function * feat: add seekAudio functionality to bridge and implement timestamp handling in MarkdownLink * feat: implement audio seeking functionality in EntryContentWebView * feat: implement audio seeking functionality for iOS * chore: clean code * fix: audio seeking functionality for iOS --- .../modules/renderer/components/TimeStamp.tsx | 16 +--- .../ios/Modules/SharedWebView/FOWebView.swift | 8 ++ .../SharedWebView/Injected/at_start.js | 8 ++ .../SharedWebView+BridgeData.swift | 10 +++ .../SharedWebView/SharedWebViewModule.swift | 11 +++ .../Modules/SharedWebView/WebViewState.swift | 9 +++ .../native/webview/EntryContentWebView.tsx | 35 ++++++++- .../src/components/native/webview/index.ts | 9 +++ .../components/native/webview/injected-js.ts | 8 ++ .../native/webview/native-webview.android.tsx | 76 ++++++++++++------- .../native/webview/native-webview.tsx | 1 + apps/mobile/web-app/html-renderer/global.d.ts | 1 + .../html-renderer/src/components/link.tsx | 19 +++++ packages/internal/utils/src/utils.ts | 21 +++++ 14 files changed, 187 insertions(+), 45 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/renderer/components/TimeStamp.tsx b/apps/desktop/layer/renderer/src/modules/renderer/components/TimeStamp.tsx index cc5ed1ce2..29f9abccd 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/components/TimeStamp.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/components/TimeStamp.tsx @@ -1,6 +1,6 @@ import { useEntry } from "@follow/store/entry/hooks" import { nextFrame } from "@follow/utils/dom" -import { formatTimeToSeconds } from "@follow/utils/utils" +import { formatTimeToSeconds, timeStringToSeconds } from "@follow/utils/utils" import { use } from "react" import { AudioPlayer } from "~/atoms/player" @@ -101,17 +101,3 @@ const CircleProgress: React.FC = ({ ) } - -function timeStringToSeconds(time: string): number | null { - const timeParts = time.split(":").map(Number) - - if (timeParts.length === 2) { - const [minutes, seconds] = timeParts - return minutes! * 60 + seconds! - } else if (timeParts.length === 3) { - const [hours, minutes, seconds] = timeParts - return hours! * 3600 + minutes! * 60 + seconds! - } else { - return null - } -} diff --git a/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift b/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift index 93d4e3a4b..d02c221a8 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift @@ -203,6 +203,14 @@ extension FOWebView: WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate self.state.previewImages(urls: urls, index: index) } } + case "audio:seekTo": + let data = try? JSONDecoder().decode( + AudioSeekPayload.self, from: decode + ) + guard let data = data else { return } + DispatchQueue.main.async { + self.state.seekAudio(time: data.payload.time) + } default: break diff --git a/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js b/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js index 869e00170..895a672b0 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js +++ b/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js @@ -32,5 +32,13 @@ }, }) }, + seekAudio: (time) => { + send({ + type: "audio:seekTo", + payload: { + time, + }, + }) + }, } })() diff --git a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView+BridgeData.swift b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView+BridgeData.swift index 4cf8e5a62..0dcc8b4c0 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView+BridgeData.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView+BridgeData.swift @@ -5,6 +5,7 @@ // Created by Innei on 2025/2/7. // import Foundation +import UIKit private protocol BasePayload { var type: String { get } @@ -28,3 +29,12 @@ struct PreviewImagePayload: Codable, BasePayload { var type: String var payload: PreviewImagePayloadProps } + +struct AudioSeekPayloadProps: Codable { + let time: Double +} + +struct AudioSeekPayload: Codable, BasePayload { + var type: String + var payload: AudioSeekPayloadProps +} \ No newline at end of file diff --git a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift index 9dd2d246b..d755a3ac7 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift @@ -10,6 +10,7 @@ import WebKit let onContentHeightChanged = "onContentHeightChanged" let onImagePreview = "onImagePreview" +let onSeekAudio = "onSeekAudio" public class SharedWebViewModule: Module { private var pendingJavaScripts: [String] = [] @@ -36,6 +37,7 @@ public class SharedWebViewModule: Module { View(WebViewView.self) { Events("onContentHeightChange") + Events("onSeekAudio") Prop("url") { (_: UIView, urlString: String) in DispatchQueue.main.async { @@ -46,6 +48,7 @@ public class SharedWebViewModule: Module { Events(onContentHeightChanged) Events(onImagePreview) + Events(onSeekAudio) OnStartObserving { // Monitor content height changes @@ -64,6 +67,14 @@ public class SharedWebViewModule: Module { self?.sendEvent(onImagePreview, ["imageUrls": event.imageUrls, "index": event.index]) } .store(in: &self.cancellables) + + WebViewManager.state.$audioSeekEvent + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] event in + self?.sendEvent(onSeekAudio, ["time": event.time]) + } + .store(in: &self.cancellables) } OnStopObserving { diff --git a/apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift b/apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift index 5488af7ee..dae437bc0 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift @@ -13,11 +13,20 @@ struct ImagePreviewEvent { let index: Int } +struct AudioSeekEvent { + let time: Double +} + class WebViewState: ObservableObject { @Published var contentHeight: CGFloat = UIWindow().bounds.height @Published var imagePreviewEvent: ImagePreviewEvent? + @Published var audioSeekEvent: AudioSeekEvent? func previewImages(urls: [String], index: Int) { imagePreviewEvent = ImagePreviewEvent(imageUrls: urls, index: index) } + + func seekAudio(time: Double) { + audioSeekEvent = AudioSeekEvent(time: time) + } } diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index c9b81553b..fef5b728b 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -3,16 +3,19 @@ import { EventBus } from "@follow/utils/event-bus" import { Portal } from "@gorhom/portal" import { useAtom } from "jotai" import * as React from "react" -import { useEffect } from "react" +import { useCallback, useEffect } from "react" import { TouchableOpacity, View } from "react-native" import { runOnJS, runOnUI } from "react-native-reanimated" +import TrackPlayer from "react-native-track-player" import { BugCuteReIcon } from "@/src/icons/bug_cute_re" +import { player } from "@/src/lib/player" import { useLightboxControls } from "../../ui/lightbox/lightboxState" import { PlatformActivityIndicator } from "../../ui/loading/PlatformActivityIndicator" import { sharedWebViewHeightAtom } from "./atom" import { useWebViewEntry, useWebViewMode } from "./hooks" +import type { AudioSeekEvent } from "./index" import { prepareEntryRenderWebView } from "./index" import { NativeWebView } from "./native-webview" import { WebViewManager } from "./webview-manager" @@ -34,6 +37,35 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { const { entryInWebview, isLoading } = useWebViewEntry(props) const { handleModeSwitch, mode } = useWebViewMode() + const handleSeekAudio = useCallback( + async (e: AudioSeekEvent) => { + const activeTrack = await TrackPlayer.getActiveTrack() + const entryAudio = entryInWebview?.attachments?.find((attachment) => + attachment.mime_type?.startsWith("audio/"), + ) + if (!entryAudio) { + console.warn("Failed to seek audio! No audio attachment found") + return + } + if (activeTrack?.url !== entryAudio.url) { + await player.play({ + url: entryAudio.url || "", + title: entryInWebview?.title || "Unknown Title", + artist: entryInWebview?.author || "Unknown Artist", + }) + } + await player.seekTo(e.time) + }, + [entryInWebview?.attachments, entryInWebview?.author, entryInWebview?.title], + ) + + // Handle audio seek events + useEffect(() => { + return EventBus.subscribe("SEEK_AUDIO", (event) => { + handleSeekAudio(event) + }) + }, [handleSeekAudio]) + // Handle image preview events useEffect(() => { return EventBus.subscribe("PREVIEW_IMAGE", (event) => { @@ -82,6 +114,7 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { onContentHeightChange={(e) => { setContentHeight(e.nativeEvent.height) }} + onSeekAudio={handleSeekAudio} /> diff --git a/apps/mobile/src/components/native/webview/index.ts b/apps/mobile/src/components/native/webview/index.ts index 86d6b52c6..a182acad6 100644 --- a/apps/mobile/src/components/native/webview/index.ts +++ b/apps/mobile/src/components/native/webview/index.ts @@ -9,15 +9,21 @@ export interface ImagePreviewEvent { index: number } +export interface AudioSeekEvent { + time: number +} + declare module "@follow/utils/event-bus" { export interface CustomEvent { PREVIEW_IMAGE: ImagePreviewEvent + SEEK_AUDIO: AudioSeekEvent } } declare class ISharedWebViewModule extends NativeModule<{ onContentHeightChanged: ({ height }: { height: number }) => void onImagePreview: (event: ImagePreviewEvent) => void + onSeekAudio?: (e: { time: number }) => void }> { load(url: string): void evaluateJavaScript(js: string): void @@ -51,6 +57,9 @@ export const prepareEntryRenderWebView = () => { SharedWebViewModule.addListener("onImagePreview", (event: ImagePreviewEvent) => { EventBus.dispatch("PREVIEW_IMAGE", event) }) + SharedWebViewModule.addListener("onSeekAudio", (event: AudioSeekEvent) => { + EventBus.dispatch("SEEK_AUDIO", event) + }) } catch (error) { console.error("Failed to prepare entry render WebView:", error) } diff --git a/apps/mobile/src/components/native/webview/injected-js.ts b/apps/mobile/src/components/native/webview/injected-js.ts index 4d2c7439d..86abd2e18 100644 --- a/apps/mobile/src/components/native/webview/injected-js.ts +++ b/apps/mobile/src/components/native/webview/injected-js.ts @@ -30,6 +30,14 @@ export const atStart = ` }, }) }, + seekAudio: (time) => { + send({ + type: "audio:seekTo", + payload: { + time, + }, + }) + } } })() ` diff --git a/apps/mobile/src/components/native/webview/native-webview.android.tsx b/apps/mobile/src/components/native/webview/native-webview.android.tsx index 4786af169..716410444 100644 --- a/apps/mobile/src/components/native/webview/native-webview.android.tsx +++ b/apps/mobile/src/components/native/webview/native-webview.android.tsx @@ -1,3 +1,4 @@ +import { useTypeScriptHappyCallback } from "@follow/hooks" import { jotaiStore } from "@follow/utils" import { atom } from "jotai" import type * as React from "react" @@ -36,9 +37,10 @@ const onLoadEnd = () => { export const NativeWebView: React.ComponentType< ViewProps & { onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void + onSeekAudio?: (e: { time: number }) => void url?: string } -> = ({ onContentHeightChange }) => { +> = ({ onContentHeightChange, onSeekAudio }) => { const webViewRef = useRef(null) const { onNavigationStateChange } = useWebViewNavigation({ webViewRef }) const { openLightbox } = useLightboxControls() @@ -70,34 +72,50 @@ export const NativeWebView: React.ComponentType< }} onNavigationStateChange={onNavigationStateChange} onLoadEnd={onLoadEnd} - onMessage={(e) => { - const message = e.nativeEvent.data - const parsed = JSON.parse(message) - if (parsed.type === "setContentHeight") { - onContentHeightChange?.({ - nativeEvent: { height: parsed.payload }, - }) - return - } else if (parsed.type === "previewImage") { - const { imageUrls, index } = parsed.payload - runOnUI(() => { - "worklet" - // const rect = measureHandle(aviHandle) - runOnJS(openLightbox)({ - images: (imageUrls as string[]).map((url: string) => ({ - uri: url, - dimensions: null, - thumbUri: url, - thumbDimensions: null, - thumbRect: null, - type: "image", - })), - index, - }) - })() - return - } - }} + onMessage={useTypeScriptHappyCallback( + (e) => { + const message = e.nativeEvent.data + const parsed = JSON.parse(message) + switch (parsed.type) { + case "setContentHeight": { + onContentHeightChange?.({ + nativeEvent: { height: parsed.payload }, + }) + return + } + case "previewImage": { + const { imageUrls, index } = parsed.payload + runOnUI(() => { + "worklet" + // const rect = measureHandle(aviHandle) + runOnJS(openLightbox)({ + images: (imageUrls as string[]).map((url: string) => ({ + uri: url, + dimensions: null, + thumbUri: url, + thumbDimensions: null, + thumbRect: null, + type: "image", + })), + index, + }) + })() + return + } + case "audio:seekTo": { + const { time } = parsed.payload + if (typeof time !== "number") { + console.warn("Failed to seek audio! Invalid time", time) + return + } + onSeekAudio?.({ time }) + break + } + // No default + } + }, + [onContentHeightChange, onSeekAudio, openLightbox], + )} /> ) } diff --git a/apps/mobile/src/components/native/webview/native-webview.tsx b/apps/mobile/src/components/native/webview/native-webview.tsx index 5c5486988..03178d40d 100644 --- a/apps/mobile/src/components/native/webview/native-webview.tsx +++ b/apps/mobile/src/components/native/webview/native-webview.tsx @@ -5,6 +5,7 @@ import type { ViewProps } from "react-native" export const NativeWebView: React.ComponentType< ViewProps & { onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void + onSeekAudio?: (e: { time: number }) => void url?: string } > = requireNativeView("FOSharedWebView") diff --git a/apps/mobile/web-app/html-renderer/global.d.ts b/apps/mobile/web-app/html-renderer/global.d.ts index fffa69fb1..01f011f1c 100644 --- a/apps/mobile/web-app/html-renderer/global.d.ts +++ b/apps/mobile/web-app/html-renderer/global.d.ts @@ -6,6 +6,7 @@ interface Bridge { measure: () => void setContentHeight: (height: number) => void previewImage: (data: { imageUrls: string[]; index: number }) => void + seekAudio: (time: number) => void } declare global { diff --git a/apps/mobile/web-app/html-renderer/src/components/link.tsx b/apps/mobile/web-app/html-renderer/src/components/link.tsx index 1c9d71d3d..2b12d7ecc 100644 --- a/apps/mobile/web-app/html-renderer/src/components/link.tsx +++ b/apps/mobile/web-app/html-renderer/src/components/link.tsx @@ -4,6 +4,8 @@ import { TooltipPortal, TooltipTrigger, } from "@follow/components/ui/tooltip/index.jsx" +import { timeStringToSeconds } from "@follow/utils/utils" +import { useCallback } from "react" export interface LinkProps { href: string @@ -16,6 +18,23 @@ export const MarkdownLink = (props: LinkProps) => { // TODO should populate the href with the populatedFullHref const populatedFullHref = props.href + + const childrenText = typeof props.children === "string" ? props.children : null + const time = childrenText ? timeStringToSeconds(childrenText) : null + + const handleTimestampClick = useCallback(() => { + if (time === null) return + bridge.seekAudio(time) + }, [time]) + + if (time !== null) { + return ( + + ) + } + return ( diff --git a/packages/internal/utils/src/utils.ts b/packages/internal/utils/src/utils.ts index 6a396f2df..ba79722ad 100644 --- a/packages/internal/utils/src/utils.ts +++ b/packages/internal/utils/src/utils.ts @@ -395,6 +395,27 @@ export const formatTimeToSeconds = (time?: string | number) => { } } +/** + * @example + * ```ts + * timeStringToSeconds("1:30") // 90 + * timeStringToSeconds("1:30:00") // 5400 + * ``` + */ +export function timeStringToSeconds(time: string): number | null { + const timeParts = time.split(":").map(Number) + + if (timeParts.length === 2) { + const [minutes, seconds] = timeParts + return minutes! * 60 + seconds! + } else if (timeParts.length === 3) { + const [hours, minutes, seconds] = timeParts + return hours! * 3600 + minutes! * 60 + seconds! + } else { + return null + } +} + export const formatEstimatedMins = (estimatedMins: number) => { const minutesInHour = 60 const minutesInDay = minutesInHour * 24