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:
parent
4771c021a4
commit
244aca2ad6
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,5 +32,13 @@
|
|||
},
|
||||
})
|
||||
},
|
||||
seekAudio: (time) => {
|
||||
send({
|
||||
type: "audio:seekTo",
|
||||
payload: {
|
||||
time,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ export const atStart = `
|
|||
},
|
||||
})
|
||||
},
|
||||
seekAudio: (time) => {
|
||||
send({
|
||||
type: "audio:seekTo",
|
||||
payload: {
|
||||
time,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue