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
This commit is contained in:
Whitewater 2025-08-01 23:43:30 +08:00 committed by GitHub
parent 4771c021a4
commit 244aca2ad6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 187 additions and 45 deletions

View File

@ -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<CircleProgressProps> = ({
</svg>
)
}
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
}
}

View File

@ -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

View File

@ -32,5 +32,13 @@
},
})
},
seekAudio: (time) => {
send({
type: "audio:seekTo",
payload: {
time,
},
})
},
}
})()

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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}
/>
</View>

View File

@ -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)
}

View File

@ -30,6 +30,14 @@ export const atStart = `
},
})
},
seekAudio: (time) => {
send({
type: "audio:seekTo",
payload: {
time,
},
})
}
}
})()
`

View File

@ -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<WebView | null>(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],
)}
/>
)
}

View File

@ -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")

View File

@ -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 {

View File

@ -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 (
<button className="text-accent underline" onClick={handleTimestampClick} type="button">
{props.children}
</button>
)
}
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>

View File

@ -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