Message rendering (#6868)
Co-authored-by: luowei <glpat-EjySCyNjWiLqAED-YmwM> Co-authored-by: crazywoola <427733928@qq.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
This commit is contained in:
119
web/app/components/base/audio-gallery/AudioPlayer.module.css
Normal file
119
web/app/components/base/audio-gallery/AudioPlayer.module.css
Normal file
@@ -0,0 +1,119 @@
|
||||
.audioPlayer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
min-width: 240px;
|
||||
max-width: 420px;
|
||||
max-height: 40px;
|
||||
backdrop-filter: blur(5px);
|
||||
border: 1px solid rgba(16, 24, 40, 0.08);
|
||||
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.05);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: #296DFF;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playButton:hover {
|
||||
background-color: #3367d6;
|
||||
}
|
||||
|
||||
.playButton:disabled {
|
||||
background-color: #bdbdbf;
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
flex-grow: 1;
|
||||
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
position: relative;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0.5;
|
||||
border-radius: 2px;
|
||||
flex: none;
|
||||
order: 55;
|
||||
flex-grow: 0;
|
||||
height: 100%;
|
||||
background-color: rgba(66, 133, 244, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeDisplay {
|
||||
/* position: absolute; */
|
||||
color: #296DFF;
|
||||
border-radius: 2px;
|
||||
order: 0;
|
||||
height: 100%;
|
||||
width: 50px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* .currentTime {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 5px);
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(255,255,255,.8);
|
||||
padding: 2px 4px;
|
||||
border-radius:10px;
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.08);
|
||||
} */
|
||||
|
||||
.duration {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
padding: 2px 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.source_unavailable {
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
color: #bdbdbf;
|
||||
}
|
||||
|
||||
.playButton svg path,
|
||||
.playButton svg rect{
|
||||
fill:currentColor;
|
||||
}
|
||||
320
web/app/components/base/audio-gallery/AudioPlayer.tsx
Normal file
320
web/app/components/base/audio-gallery/AudioPlayer.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import styles from './AudioPlayer.module.css'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
type AudioPlayerProps = {
|
||||
src: string
|
||||
}
|
||||
|
||||
const AudioPlayer: React.FC<AudioPlayerProps> = ({ src }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [waveformData, setWaveformData] = useState<number[]>([])
|
||||
const [bufferedTime, setBufferedTime] = useState(0)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [hasStartedPlaying, setHasStartedPlaying] = useState(false)
|
||||
const [hoverTime, setHoverTime] = useState(0)
|
||||
const [isAudioAvailable, setIsAudioAvailable] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio)
|
||||
return
|
||||
|
||||
const handleError = () => {
|
||||
setIsAudioAvailable(false)
|
||||
}
|
||||
|
||||
const setAudioData = () => {
|
||||
setDuration(audio.duration)
|
||||
}
|
||||
|
||||
const setAudioTime = () => {
|
||||
setCurrentTime(audio.currentTime)
|
||||
}
|
||||
|
||||
const handleProgress = () => {
|
||||
if (audio.buffered.length > 0)
|
||||
setBufferedTime(audio.buffered.end(audio.buffered.length - 1))
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
audio.addEventListener('loadedmetadata', setAudioData)
|
||||
audio.addEventListener('timeupdate', setAudioTime)
|
||||
audio.addEventListener('progress', handleProgress)
|
||||
audio.addEventListener('ended', handleEnded)
|
||||
audio.addEventListener('error', handleError)
|
||||
|
||||
// Preload audio metadata
|
||||
audio.load()
|
||||
|
||||
// Delayed generation of waveform data
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
const timer = setTimeout(() => generateWaveformData(src), 1000)
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('loadedmetadata', setAudioData)
|
||||
audio.removeEventListener('timeupdate', setAudioTime)
|
||||
audio.removeEventListener('progress', handleProgress)
|
||||
audio.removeEventListener('ended', handleEnded)
|
||||
audio.removeEventListener('error', handleError)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [src])
|
||||
|
||||
const generateWaveformData = async (audioSrc: string) => {
|
||||
if (!window.AudioContext && !(window as any).webkitAudioContext) {
|
||||
setIsAudioAvailable(false)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Web Audio API is not supported in this browser',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const url = new URL(src)
|
||||
const isHttp = url.protocol === 'http:' || url.protocol === 'https:'
|
||||
if (!isHttp) {
|
||||
setIsAudioAvailable(false)
|
||||
return null
|
||||
}
|
||||
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const samples = 70
|
||||
|
||||
try {
|
||||
const response = await fetch(audioSrc, { mode: 'cors' })
|
||||
if (!response || !response.ok) {
|
||||
setIsAudioAvailable(false)
|
||||
return null
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
||||
const channelData = audioBuffer.getChannelData(0)
|
||||
const blockSize = Math.floor(channelData.length / samples)
|
||||
const waveformData: number[] = []
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
let sum = 0
|
||||
for (let j = 0; j < blockSize; j++)
|
||||
sum += Math.abs(channelData[i * blockSize + j])
|
||||
|
||||
// Apply nonlinear scaling to enhance small amplitudes
|
||||
waveformData.push((sum / blockSize) * 5)
|
||||
}
|
||||
|
||||
// Normalized waveform data
|
||||
const maxAmplitude = Math.max(...waveformData)
|
||||
const normalizedWaveform = waveformData.map(amp => amp / maxAmplitude)
|
||||
|
||||
setWaveformData(normalizedWaveform)
|
||||
setIsAudioAvailable(true)
|
||||
}
|
||||
catch (error) {
|
||||
const waveform: number[] = []
|
||||
let prevValue = Math.random()
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const targetValue = Math.random()
|
||||
const interpolatedValue = prevValue + (targetValue - prevValue) * 0.3
|
||||
waveform.push(interpolatedValue)
|
||||
prevValue = interpolatedValue
|
||||
}
|
||||
|
||||
const maxAmplitude = Math.max(...waveform)
|
||||
const randomWaveform = waveform.map(amp => amp / maxAmplitude)
|
||||
|
||||
setWaveformData(randomWaveform)
|
||||
setIsAudioAvailable(true)
|
||||
}
|
||||
finally {
|
||||
await audioContext.close()
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio && isAudioAvailable) {
|
||||
if (isPlaying) {
|
||||
setHasStartedPlaying(false)
|
||||
audio.pause()
|
||||
}
|
||||
else {
|
||||
setHasStartedPlaying(true)
|
||||
audio.play().catch(error => console.error('Error playing audio:', error))
|
||||
}
|
||||
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Audio element not found',
|
||||
})
|
||||
setIsAudioAvailable(false)
|
||||
}
|
||||
}, [isAudioAvailable, isPlaying])
|
||||
|
||||
const handleCanvasInteraction = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const getClientX = (event: React.MouseEvent | React.TouchEvent): number => {
|
||||
if ('touches' in event)
|
||||
return event.touches[0].clientX
|
||||
return event.clientX
|
||||
}
|
||||
|
||||
const updateProgress = (clientX: number) => {
|
||||
const canvas = canvasRef.current
|
||||
const audio = audioRef.current
|
||||
if (!canvas || !audio)
|
||||
return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width
|
||||
const newTime = percent * duration
|
||||
|
||||
// Removes the buffer check, allowing drag to any location
|
||||
audio.currentTime = newTime
|
||||
setCurrentTime(newTime)
|
||||
|
||||
if (!isPlaying) {
|
||||
setIsPlaying(true)
|
||||
audio.play().catch((error) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: `Error playing audio: ${error}`,
|
||||
})
|
||||
setIsPlaying(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateProgress(getClientX(e))
|
||||
}, [duration, isPlaying])
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const drawWaveform = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas)
|
||||
return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx)
|
||||
return
|
||||
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const data = waveformData
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const barWidth = width / data.length
|
||||
const playedWidth = (currentTime / duration) * width
|
||||
const cornerRadius = 2
|
||||
|
||||
// Draw waveform bars
|
||||
data.forEach((value, index) => {
|
||||
let color
|
||||
|
||||
if (index * barWidth <= playedWidth)
|
||||
color = '#296DFF'
|
||||
else if ((index * barWidth / width) * duration <= hoverTime)
|
||||
color = 'rgba(21,90,239,.40)'
|
||||
else
|
||||
color = 'rgba(21,90,239,.20)'
|
||||
|
||||
const barHeight = value * height
|
||||
const rectX = index * barWidth
|
||||
const rectY = (height - barHeight) / 2
|
||||
const rectWidth = barWidth * 0.5
|
||||
const rectHeight = barHeight
|
||||
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = color
|
||||
if (ctx.roundRect) {
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius)
|
||||
ctx.fill()
|
||||
}
|
||||
else {
|
||||
ctx.fillRect(rectX, rectY, rectWidth, rectHeight)
|
||||
}
|
||||
})
|
||||
}, [currentTime, duration, hoverTime, waveformData])
|
||||
|
||||
useEffect(() => {
|
||||
drawWaveform()
|
||||
}, [drawWaveform, bufferedTime, hasStartedPlaying])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const canvas = canvasRef.current
|
||||
const audio = audioRef.current
|
||||
if (!canvas || !audio)
|
||||
return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const percent = Math.min(Math.max(0, e.clientX - rect.left), rect.width) / rect.width
|
||||
const time = percent * duration
|
||||
|
||||
// Check if the hovered position is within a buffered range before updating hoverTime
|
||||
for (let i = 0; i < audio.buffered.length; i++) {
|
||||
if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
|
||||
setHoverTime(time)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [duration])
|
||||
|
||||
return (
|
||||
<div className={styles.audioPlayer}>
|
||||
<audio ref={audioRef} src={src} preload="auto"/>
|
||||
<button className={styles.playButton} onClick={togglePlay} disabled={!isAudioAvailable}>
|
||||
{isPlaying
|
||||
? (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<rect x="7" y="6" width="3" height="12" rx="1.5" ry="1.5"/>
|
||||
<rect x="15" y="6" width="3" height="12" rx="1.5" ry="1.5"/>
|
||||
</svg>
|
||||
)
|
||||
: (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path d="M8 5v14l11-7z" fill="currentColor"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className={isAudioAvailable ? styles.audioControls : styles.audioControls_disabled} hidden={!isAudioAvailable}>
|
||||
<div className={styles.progressBarContainer}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={styles.waveform}
|
||||
onClick={handleCanvasInteraction}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleCanvasInteraction}
|
||||
/>
|
||||
{/* <div className={styles.currentTime} style={{ left: `${(currentTime / duration) * 81}%`, bottom: '29px' }}>
|
||||
{formatTime(currentTime)}
|
||||
</div> */}
|
||||
<div className={styles.timeDisplay}>
|
||||
<span className={styles.duration}>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.source_unavailable} hidden={isAudioAvailable}>{t('common.operation.audioSourceUnavailable')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioPlayer
|
||||
12
web/app/components/base/audio-gallery/index.tsx
Normal file
12
web/app/components/base/audio-gallery/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import AudioPlayer from './AudioPlayer'
|
||||
|
||||
type Props = {
|
||||
srcs: string[]
|
||||
}
|
||||
|
||||
const AudioGallery: React.FC<Props> = ({ srcs }) => {
|
||||
return (<><br/>{srcs.map((src, index) => (<AudioPlayer key={`audio_${index}`} src={src}/>))}</>)
|
||||
}
|
||||
|
||||
export default React.memo(AudioGallery)
|
||||
38
web/app/components/base/image-uploader/audio-preview.tsx
Normal file
38
web/app/components/base/image-uploader/audio-preview.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
|
||||
type AudioPreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const AudioPreview: FC<AudioPreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
return createPortal(
|
||||
<div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]' onClick={e => e.stopPropagation()}>
|
||||
<div>
|
||||
<audio controls title={title} autoPlay={false} preload="metadata">
|
||||
<source
|
||||
type="audio/mpeg"
|
||||
src={url}
|
||||
className='max-w-full max-h-full'
|
||||
/>
|
||||
</audio>
|
||||
</div>
|
||||
<div
|
||||
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='w-4 h-4 text-gray-500'/>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioPreview
|
||||
@@ -1,19 +1,43 @@
|
||||
import type { FC } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { RiCloseLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { randomString } from '@/utils'
|
||||
|
||||
type ImagePreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
const selector = useRef(`copy-tooltip-${randomString(4)}`)
|
||||
|
||||
const openInNewTab = () => {
|
||||
// Open in a new window, considering the case when the page is inside an iframe
|
||||
if (url.startsWith('http')) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
else if (url.startsWith('data:image')) {
|
||||
// Base64 image
|
||||
const win = window.open()
|
||||
win?.document.write(`<img src="${url}" alt="${title}" />`)
|
||||
}
|
||||
else {
|
||||
console.error('Unable to open image', url)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]' onClick={e => e.stopPropagation()}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt='preview image'
|
||||
alt={title}
|
||||
src={url}
|
||||
className='max-w-full max-h-full'
|
||||
/>
|
||||
@@ -23,6 +47,18 @@ const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
>
|
||||
<RiCloseLine className='w-4 h-4 text-white' />
|
||||
</div>
|
||||
<Tooltip
|
||||
selector={selector.current}
|
||||
content={(t('common.operation.openInNewTab') ?? 'Open in new tab')}
|
||||
className='z-10'
|
||||
>
|
||||
<div
|
||||
className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
|
||||
onClick={openInNewTab}
|
||||
>
|
||||
<RiExternalLinkLine className='w-4 h-4 text-white' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
|
||||
38
web/app/components/base/image-uploader/video-preview.tsx
Normal file
38
web/app/components/base/image-uploader/video-preview.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
|
||||
type VideoPreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const VideoPreview: FC<VideoPreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
return createPortal(
|
||||
<div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]' onClick={e => e.stopPropagation()}>
|
||||
<div>
|
||||
<video controls title={title} autoPlay={false} preload="metadata">
|
||||
<source
|
||||
type="video/mp4"
|
||||
src={url}
|
||||
className='max-w-full max-h-full'
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
<div
|
||||
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='w-4 h-4 text-gray-500'/>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoPreview
|
||||
@@ -4,6 +4,7 @@ import 'katex/dist/katex.min.css'
|
||||
import RemarkMath from 'remark-math'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RehypeKatex from 'rehype-katex'
|
||||
import RehypeRaw from 'rehype-raw'
|
||||
import RemarkGfm from 'remark-gfm'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
||||
@@ -15,6 +16,9 @@ import CopyBtn from '@/app/components/base/copy-btn'
|
||||
import SVGBtn from '@/app/components/base/svg'
|
||||
import Flowchart from '@/app/components/base/mermaid'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import VideoGallery from '@/app/components/base/video-gallery'
|
||||
import AudioGallery from '@/app/components/base/audio-gallery'
|
||||
|
||||
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
|
||||
const capitalizationLanguageNameMap: Record<string, string> = {
|
||||
@@ -33,6 +37,10 @@ const capitalizationLanguageNameMap: Record<string, string> = {
|
||||
markdown: 'MarkDown',
|
||||
makefile: 'MakeFile',
|
||||
echarts: 'ECharts',
|
||||
shell: 'Shell',
|
||||
powershell: 'PowerShell',
|
||||
json: 'JSON',
|
||||
latex: 'Latex',
|
||||
}
|
||||
const getCorrectCapitalizationLanguageName = (language: string) => {
|
||||
if (!language)
|
||||
@@ -65,6 +73,7 @@ export function PreCode(props: { children: any }) {
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
const useLazyLoad = (ref: RefObject<Element>): boolean => {
|
||||
const [isIntersecting, setIntersecting] = useState<boolean>(false)
|
||||
|
||||
@@ -126,12 +135,7 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
|
||||
>
|
||||
<div className='text-[13px] text-gray-500 font-normal'>{languageShowName}</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{language === 'mermaid'
|
||||
&& <SVGBtn
|
||||
isSVG={isSVG}
|
||||
setIsSVG={setIsSVG}
|
||||
/>
|
||||
}
|
||||
{language === 'mermaid' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
|
||||
<CopyBtn
|
||||
className='mr-1'
|
||||
value={String(children).replace(/\n$/, '')}
|
||||
@@ -172,36 +176,83 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
|
||||
|
||||
CodeBlock.displayName = 'CodeBlock'
|
||||
|
||||
const VideoBlock: CodeComponent = memo(({ node }) => {
|
||||
const srcs = node.children.filter(child => 'properties' in child).map(child => (child as any).properties.src)
|
||||
if (srcs.length === 0)
|
||||
return null
|
||||
return <VideoGallery key={srcs.join()} srcs={srcs} />
|
||||
})
|
||||
VideoBlock.displayName = 'VideoBlock'
|
||||
|
||||
const AudioBlock: CodeComponent = memo(({ node }) => {
|
||||
const srcs = node.children.filter(child => 'properties' in child).map(child => (child as any).properties.src)
|
||||
if (srcs.length === 0)
|
||||
return null
|
||||
return <AudioGallery key={srcs.join()} srcs={srcs} />
|
||||
})
|
||||
AudioBlock.displayName = 'AudioBlock'
|
||||
|
||||
const Paragraph = (paragraph: any) => {
|
||||
const { node }: any = paragraph
|
||||
const children_node = node.children
|
||||
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
|
||||
return (
|
||||
<>
|
||||
<ImageGallery srcs={[children_node[0].properties.src]} />
|
||||
<div>{paragraph.children.slice(1)}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <div>{paragraph.children}</div>
|
||||
}
|
||||
|
||||
const Img = ({ src }: any) => {
|
||||
return (<ImageGallery srcs={[src]} />)
|
||||
}
|
||||
|
||||
const Link = ({ node, ...props }: any) => {
|
||||
if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { onSend } = useChatContext()
|
||||
const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1])
|
||||
|
||||
return <abbr className="underline decoration-dashed !decoration-primary-700 cursor-pointer" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value}>{node.children[0]?.value}</abbr>
|
||||
}
|
||||
else {
|
||||
return <a {...props} target="_blank" className="underline decoration-dashed !decoration-primary-700 cursor-pointer">{node.children[0] ? node.children[0]?.value : 'Download'}</a>
|
||||
}
|
||||
}
|
||||
|
||||
export function Markdown(props: { content: string; className?: string }) {
|
||||
const latexContent = preprocessLaTeX(props.content)
|
||||
return (
|
||||
<div className={cn(props.className, 'markdown-body')}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[RemarkMath, { singleDollarTextMath: false }], RemarkGfm, RemarkBreaks]}
|
||||
remarkPlugins={[[RemarkGfm, RemarkMath, { singleDollarTextMath: false }], RemarkBreaks]}
|
||||
rehypePlugins={[
|
||||
RehypeKatex as any,
|
||||
RehypeKatex,
|
||||
RehypeRaw as any,
|
||||
// The Rehype plug-in is used to remove the ref attribute of an element
|
||||
() => {
|
||||
return (tree) => {
|
||||
const iterate = (node: any) => {
|
||||
if (node.type === 'element' && !node.properties?.src && node.properties?.ref && node.properties.ref.startsWith('{') && node.properties.ref.endsWith('}'))
|
||||
delete node.properties.ref
|
||||
|
||||
if (node.children)
|
||||
node.children.forEach(iterate)
|
||||
}
|
||||
tree.children.forEach(iterate)
|
||||
}
|
||||
},
|
||||
]}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
img({ src }) {
|
||||
return (
|
||||
<ImageGallery srcs={[src || '']} />
|
||||
)
|
||||
},
|
||||
p: (paragraph) => {
|
||||
const { node }: any = paragraph
|
||||
if (node.children[0].tagName === 'img') {
|
||||
const image = node.children[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
<ImageGallery srcs={[image.properties.src]} />
|
||||
<p>{paragraph.children.slice(1)}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <p>{paragraph.children}</p>
|
||||
},
|
||||
img: Img,
|
||||
video: VideoBlock,
|
||||
audio: AudioBlock,
|
||||
a: Link,
|
||||
p: Paragraph,
|
||||
}}
|
||||
linkTarget='_blank'
|
||||
>
|
||||
|
||||
188
web/app/components/base/video-gallery/VideoPlayer.module.css
Normal file
188
web/app/components/base/video-gallery/VideoPlayer.module.css
Normal file
@@ -0,0 +1,188 @@
|
||||
.videoPlayer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.controls.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.controls.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.controlsContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.leftControls, .rightControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playPauseButton, .muteButton, .fullscreenButton {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playPauseButton:hover, .muteButton:hover, .fullscreenButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.volumeControl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.volumeSlider {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.volumeLevel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
overflow: visible;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.progressBar:hover {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #ffffff;
|
||||
transition: width 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.hoverTimeIndicator {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hoverTimeIndicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
border-width: 4px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.7) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.controls.smallSize .controlsContent {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.controls.smallSize .leftControls,
|
||||
.controls.smallSize .rightControls {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls.smallSize .rightControls {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.controls.smallSize .progressBarContainer {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.controls.smallSize .playPauseButton,
|
||||
.controls.smallSize .muteButton,
|
||||
.controls.smallSize .fullscreenButton {
|
||||
padding: 2px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.controls.smallSize .playPauseButton svg,
|
||||
.controls.smallSize .muteButton svg,
|
||||
.controls.smallSize .fullscreenButton svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.controls.smallSize .muteButton {
|
||||
order: -1;
|
||||
}
|
||||
278
web/app/components/base/video-gallery/VideoPlayer.tsx
Normal file
278
web/app/components/base/video-gallery/VideoPlayer.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styles from './VideoPlayer.module.css'
|
||||
|
||||
type VideoPlayerProps = {
|
||||
src: string
|
||||
}
|
||||
|
||||
const PlayIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PauseIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 19H10V5H6V19ZM14 5V19H18V5H14Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const MuteIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 9V15H7L12 20V4L7 9H3ZM16.5 12C16.5 10.23 15.48 8.71 14 7.97V16.02C15.48 15.29 16.5 13.77 16.5 12ZM14 3.23V5.29C16.89 6.15 19 8.83 19 12C19 15.17 16.89 17.85 14 18.71V20.77C18.01 19.86 21 16.28 21 12C21 7.72 18.01 4.14 14 3.23Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UnmuteIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.34 2.93L2.93 4.34L7.29 8.7L7 9H3V15H7L12 20V13.41L16.18 17.59C15.69 17.96 15.16 18.27 14.58 18.5V20.58C15.94 20.22 17.15 19.56 18.13 18.67L19.66 20.2L21.07 18.79L4.34 2.93ZM10 15.17L7.83 13H5V11H7.83L10 8.83V15.17ZM19 12C19 12.82 18.85 13.61 18.59 14.34L20.12 15.87C20.68 14.7 21 13.39 21 12C21 7.72 18.01 4.14 14 3.23V5.29C16.89 6.15 19 8.83 19 12ZM12 4L10.12 5.88L12 7.76V4ZM16.5 12C16.5 10.23 15.48 8.71 14 7.97V10.18L16.45 12.63C16.48 12.43 16.5 12.22 16.5 12Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FullscreenIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 14H5V19H10V17H7V14ZM5 10H7V7H10V5H5V10ZM17 17H14V19H19V14H17V17ZM14 5V7H17V10H19V5H14Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({ src }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(true)
|
||||
const [hoverTime, setHoverTime] = useState<number | null>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const volumeRef = useRef<HTMLDivElement>(null)
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [isSmallSize, setIsSmallSize] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video)
|
||||
return
|
||||
|
||||
const setVideoData = () => {
|
||||
setDuration(video.duration)
|
||||
setVolume(video.volume)
|
||||
}
|
||||
|
||||
const setVideoTime = () => {
|
||||
setCurrentTime(video.currentTime)
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
video.addEventListener('loadedmetadata', setVideoData)
|
||||
video.addEventListener('timeupdate', setVideoTime)
|
||||
video.addEventListener('ended', handleEnded)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', setVideoData)
|
||||
video.removeEventListener('timeupdate', setVideoTime)
|
||||
video.removeEventListener('ended', handleEnded)
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current)
|
||||
clearTimeout(controlsTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
setIsControlsVisible(true)
|
||||
if (controlsTimeoutRef.current)
|
||||
clearTimeout(controlsTimeoutRef.current)
|
||||
|
||||
controlsTimeoutRef.current = setTimeout(() => setIsControlsVisible(false), 3000)
|
||||
}, [])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
if (isPlaying)
|
||||
video.pause()
|
||||
else video.play().catch(error => console.error('Error playing video:', error))
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
const newMutedState = !video.muted
|
||||
video.muted = newMutedState
|
||||
setIsMuted(newMutedState)
|
||||
setVolume(newMutedState ? 0 : (video.volume > 0 ? video.volume : 1))
|
||||
video.volume = newMutedState ? 0 : (video.volume > 0 ? video.volume : 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
if (document.fullscreenElement)
|
||||
document.exitFullscreen()
|
||||
else video.requestFullscreen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const updateVideoProgress = useCallback((clientX: number) => {
|
||||
const progressBar = progressRef.current
|
||||
const video = videoRef.current
|
||||
if (progressBar && video) {
|
||||
const rect = progressBar.getBoundingClientRect()
|
||||
const pos = (clientX - rect.left) / rect.width
|
||||
const newTime = pos * video.duration
|
||||
if (newTime >= 0 && newTime <= video.duration) {
|
||||
setHoverTime(newTime)
|
||||
if (isDragging)
|
||||
video.currentTime = newTime
|
||||
}
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
updateVideoProgress(e.clientX)
|
||||
}, [updateVideoProgress])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!isDragging)
|
||||
setHoverTime(null)
|
||||
}, [isDragging])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
updateVideoProgress(e.clientX)
|
||||
}, [updateVideoProgress])
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging)
|
||||
updateVideoProgress(e.clientX)
|
||||
}
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
setHoverTime(null)
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove)
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove)
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp)
|
||||
}
|
||||
}, [isDragging, updateVideoProgress])
|
||||
|
||||
const checkSize = useCallback(() => {
|
||||
if (containerRef.current)
|
||||
setIsSmallSize(containerRef.current.offsetWidth < 400)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkSize()
|
||||
window.addEventListener('resize', checkSize)
|
||||
return () => window.removeEventListener('resize', checkSize)
|
||||
}, [checkSize])
|
||||
|
||||
const handleVolumeChange = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const volumeBar = volumeRef.current
|
||||
const video = videoRef.current
|
||||
if (volumeBar && video) {
|
||||
const rect = volumeBar.getBoundingClientRect()
|
||||
const newVolume = (e.clientX - rect.left) / rect.width
|
||||
const clampedVolume = Math.max(0, Math.min(1, newVolume))
|
||||
video.volume = clampedVolume
|
||||
setVolume(clampedVolume)
|
||||
setIsMuted(clampedVolume === 0)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.videoPlayer} onMouseMove={showControls} onMouseEnter={showControls}>
|
||||
<video ref={videoRef} src={src} className={styles.video} />
|
||||
<div className={`${styles.controls} ${isControlsVisible ? styles.visible : styles.hidden} ${isSmallSize ? styles.smallSize : ''}`}>
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.progressBarContainer}>
|
||||
<div
|
||||
ref={progressRef}
|
||||
className={styles.progressBar}
|
||||
onClick={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className={styles.progress} style={{ width: `${(currentTime / duration) * 100}%` }} />
|
||||
{hoverTime !== null && (
|
||||
<div
|
||||
className={styles.hoverTimeIndicator}
|
||||
style={{ left: `${(hoverTime / duration) * 100}%` }}
|
||||
>
|
||||
{formatTime(hoverTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.controlsContent}>
|
||||
<div className={styles.leftControls}>
|
||||
<button className={styles.playPauseButton} onClick={togglePlayPause}>
|
||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
</button>
|
||||
{!isSmallSize && (<span className={styles.time}>{formatTime(currentTime)} / {formatTime(duration)}</span>)}
|
||||
</div>
|
||||
<div className={styles.rightControls}>
|
||||
<button className={styles.muteButton} onClick={toggleMute}>
|
||||
{isMuted ? <UnmuteIcon /> : <MuteIcon />}
|
||||
</button>
|
||||
{!isSmallSize && (
|
||||
<div className={styles.volumeControl}>
|
||||
<div
|
||||
ref={volumeRef}
|
||||
className={styles.volumeSlider}
|
||||
onClick={handleVolumeChange}
|
||||
onMouseDown={(e) => {
|
||||
handleVolumeChange(e)
|
||||
const handleMouseMove = (e: MouseEvent) => handleVolumeChange(e as unknown as React.MouseEvent<HTMLDivElement>)
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}}
|
||||
>
|
||||
<div className={styles.volumeLevel} style={{ width: `${volume * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button className={styles.fullscreenButton} onClick={toggleFullscreen}>
|
||||
<FullscreenIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoPlayer
|
||||
12
web/app/components/base/video-gallery/index.tsx
Normal file
12
web/app/components/base/video-gallery/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
|
||||
type Props = {
|
||||
srcs: string[]
|
||||
}
|
||||
|
||||
const VideoGallery: React.FC<Props> = ({ srcs }) => {
|
||||
return (<><br/>{srcs.map((src, index) => (<><br/><VideoPlayer key={`video_${index}`} src={src}/></>))}</>)
|
||||
}
|
||||
|
||||
export default React.memo(VideoGallery)
|
||||
@@ -16,11 +16,13 @@ import { audioToText } from '@/service/share'
|
||||
type VoiceInputTypes = {
|
||||
onConverted: (text: string) => void
|
||||
onCancel: () => void
|
||||
wordTimestamps?: string
|
||||
}
|
||||
|
||||
const VoiceInput = ({
|
||||
onCancel,
|
||||
onConverted,
|
||||
wordTimestamps,
|
||||
}: VoiceInputTypes) => {
|
||||
const { t } = useTranslation()
|
||||
const recorder = useRef(new Recorder({
|
||||
@@ -88,6 +90,7 @@ const VoiceInput = ({
|
||||
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const formData = new FormData()
|
||||
formData.append('file', mp3File)
|
||||
formData.append('word_timestamps', wordTimestamps || 'disabled')
|
||||
|
||||
let url = ''
|
||||
let isPublic = false
|
||||
@@ -112,7 +115,7 @@ const VoiceInput = ({
|
||||
onConverted('')
|
||||
onCancel()
|
||||
}
|
||||
}, [])
|
||||
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
|
||||
const handleStartRecord = async () => {
|
||||
try {
|
||||
await recorder.current.start()
|
||||
@@ -146,7 +149,7 @@ const VoiceInput = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (originDuration >= 120 && startRecord)
|
||||
if (originDuration >= 600 && startRecord)
|
||||
handleStopRecorder()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,7 +207,7 @@ const VoiceInput = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 110 ? 'text-[#F04438]' : 'text-gray-700'}`}>{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div>
|
||||
<div className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`}>{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user