- 通话状态机(Ch8):canStartCall 补齐通话中重复发起的防护,callShouldStart 补齐放弃/失败路径的清理 - 分页截断(Ch5-6):新增 fetchAllPages 辅助函数,修复通讯录/黑名单/好友申请/群成员/管理员/禁言等 11 处列表只读首页导致真实成员被遗漏的问题;群角色判断改用不受分页影响的 getGroup().my_role - 消息与媒体(Ch3-4/Ch7/Ch9):录音超时污染时长、手动重试丢失 head 导致消息被错误分类、批量删除 未取消上传中附件、语音播放器补齐同时只能播一条、workplace-collaboration-view 冗余重试请求 - 独立密码/青少年模式(Ch10-11):设置密码后按调用来源判断是否应自动开启青少年模式,避免关闭流程 中途"忘记密码"被误当成开启;注销账号确认文案补充群主后果说明 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
298 lines
9.5 KiB
React
298 lines
9.5 KiB
React
// Audio recorder widget.
|
||
|
||
import React from 'react';
|
||
import { defineMessages, injectIntl } from 'react-intl';
|
||
|
||
import { secondsToTime } from '../lib/strformat';
|
||
import { base64ToIntArray } from '../lib/blob-helpers';
|
||
|
||
// Make canvas bigger than the element size to reduce blurring.
|
||
const CANVAS_UPSCALING = 2.0;
|
||
// Thickness of a visualization bar.
|
||
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
|
||
// Spacing between two visualization bars.
|
||
const SPACING = 2 * CANVAS_UPSCALING;
|
||
// Color of histogram bars.
|
||
const BAR_COLOR = '#888A';
|
||
const BAR_COLOR_DARK = '#666C';
|
||
const THUMB_COLOR = '#1781d7'; // average between dark and light accent colors.
|
||
// Minimum number of amplitude bars to draw.
|
||
const MIN_PREVIEW_LENGTH = 16;
|
||
|
||
// 7.5 "列表中同时只能有一条语音播放":favorites/history 列表和聊天气泡里的每条语音各自是一个独立
|
||
// AudioPlayer 实例,彼此不知道对方的存在。Module-scoped(不是某个列表父组件的 state)所以跨 tab、
|
||
// 跨收藏/聊天记录也生效,不只是同一个列表内部。
|
||
let activeAudioPlayer = null;
|
||
|
||
const messages = defineMessages({
|
||
icon_title_play: {
|
||
id: 'icon_title_play',
|
||
defaultMessage: 'Play recording',
|
||
description: 'Icon tool tip for starting audio playback'
|
||
}
|
||
});
|
||
|
||
class AudioPlayer extends React.PureComponent {
|
||
constructor(props) {
|
||
super(props);
|
||
|
||
let preview = base64ToIntArray(this.props.preview);
|
||
if (!Array.isArray(preview) || preview.length < MIN_PREVIEW_LENGTH) {
|
||
preview = null;
|
||
}
|
||
|
||
this.state = {
|
||
canPlay: false,
|
||
playing: false,
|
||
currentTime: '0:00',
|
||
duration: this.props.duration > 0 ? secondsToTime(this.props.duration / 1000) : '-:--',
|
||
longMin: this.props.duration >= 600000,
|
||
preview: preview
|
||
};
|
||
|
||
this.initAudio = this.initAudio.bind(this);
|
||
this.initCanvas = this.initCanvas.bind(this);
|
||
this.resampleBars = this.resampleBars.bind(this);
|
||
this.visualize = this.visualize.bind(this);
|
||
|
||
this.handlePlay = this.handlePlay.bind(this);
|
||
this.handleSeek = this.handleSeek.bind(this);
|
||
this.handleError = this.handleError.bind(this);
|
||
|
||
this.audioPlayer = null;
|
||
|
||
this.viewBuffer = [];
|
||
this.canvasRef = React.createRef();
|
||
}
|
||
|
||
componentDidMount() {
|
||
if (this.props.src) {
|
||
this.initAudio();
|
||
}
|
||
|
||
this.initCanvas();
|
||
}
|
||
|
||
componentWillUnmount() {
|
||
if (activeAudioPlayer === this) {
|
||
activeAudioPlayer = null;
|
||
}
|
||
if (this.audioPlayer) {
|
||
this.audioPlayer.onloadedmetadata = null;
|
||
this.audioPlayer.ontimeupdate = null;
|
||
this.audioPlayer.onended = null;
|
||
this.audioPlayer.pause();
|
||
this.audioPlayer = null;
|
||
}
|
||
}
|
||
|
||
// Called on the previously-playing instance by whichever other instance is about to start.
|
||
pauseForOtherPlayer() {
|
||
if (this.audioPlayer) {
|
||
this.audioPlayer.pause();
|
||
}
|
||
this.setState({playing: false});
|
||
}
|
||
|
||
componentDidUpdate(prevProps) {
|
||
if (this.props.src != prevProps.src) {
|
||
this.initAudio();
|
||
}
|
||
|
||
if (this.props.preview != prevProps.preview) {
|
||
let preview = base64ToIntArray(this.props.preview);
|
||
if (!Array.isArray(preview) || preview.length < MIN_PREVIEW_LENGTH) {
|
||
preview = null;
|
||
}
|
||
this.setState({preview: preview}, this.initCanvas);
|
||
}
|
||
}
|
||
|
||
initAudio() {
|
||
this.audioPlayer = new Audio(this.props.src);
|
||
this.audioPlayer.onloadedmetadata = _ => this.setState({canPlay: true});
|
||
this.audioPlayer.ontimeupdate = _ => this.setState({
|
||
currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)
|
||
});
|
||
this.audioPlayer.onended = _ => {
|
||
this.audioPlayer.currentTime = 0;
|
||
this.setState({playing: false, currentTime: secondsToTime(0, this.state.longMin)});
|
||
if (activeAudioPlayer === this) {
|
||
activeAudioPlayer = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
initCanvas() {
|
||
// Force canvas aspect ratio to match one of the element + upscale canvas to reduce blurring.
|
||
this.canvasRef.current.width = this.canvasRef.current.offsetWidth * CANVAS_UPSCALING;
|
||
this.canvasRef.current.height = this.canvasRef.current.offsetHeight * CANVAS_UPSCALING;
|
||
|
||
this.canvasContext = this.canvasRef.current.getContext('2d');
|
||
this.canvasContext.lineCap = 'round';
|
||
|
||
this.viewBuffer = this.resampleBars(this.state.preview);
|
||
this.visualize();
|
||
}
|
||
|
||
// Draw amplitude of sound.
|
||
visualize() {
|
||
if (!this.canvasRef.current) {
|
||
return;
|
||
}
|
||
|
||
const width = this.effectiveWidth;
|
||
const height = this.canvasRef.current.height;
|
||
|
||
this.canvasContext.lineWidth = LINE_WIDTH;
|
||
|
||
const drawFrame = _ => {
|
||
if (!this.canvasRef.current || !this.audioPlayer) {
|
||
// The component is unmounted.
|
||
return;
|
||
}
|
||
|
||
this.canvasContext.clearRect(0, 0, this.canvasRef.current.width, height);
|
||
|
||
if (this.viewBuffer) {
|
||
if (this.state.playing) {
|
||
window.requestAnimationFrame(drawFrame);
|
||
}
|
||
|
||
// Current playback position.
|
||
const thumbAt = this.props.duration ?
|
||
Math.max(0, Math.min(this.audioPlayer.currentTime * 1000 / this.props.duration, 1)) * (width - LINE_WIDTH * 2) : -1;
|
||
|
||
// Draw amplitude bars.
|
||
this.canvasContext.beginPath();
|
||
this.canvasContext.strokeStyle = BAR_COLOR_DARK;
|
||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||
let x = 1 + i * (LINE_WIDTH + SPACING) + LINE_WIDTH * 0.5;
|
||
let y = Math.max(this.viewBuffer[i] * height * 0.9, 1);
|
||
|
||
const color = x < thumbAt ? BAR_COLOR_DARK : BAR_COLOR;
|
||
if (this.canvasContext.strokeStyle != color) {
|
||
this.canvasContext.stroke();
|
||
this.canvasContext.beginPath();
|
||
this.canvasContext.strokeStyle = color;
|
||
}
|
||
|
||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||
this.canvasContext.lineTo(x, (height + y) * 0.5);
|
||
}
|
||
// Actually draw the bars on canvas.
|
||
this.canvasContext.stroke();
|
||
|
||
// Draw thumb.
|
||
if (this.props.duration) {
|
||
this.canvasContext.beginPath();
|
||
this.canvasContext.arc(thumbAt + LINE_WIDTH * 2, height * 0.5, LINE_WIDTH * 2, 0, 2 * Math.PI);
|
||
this.canvasContext.fillStyle = THUMB_COLOR;
|
||
this.canvasContext.fill();
|
||
}
|
||
}
|
||
}
|
||
|
||
drawFrame();
|
||
}
|
||
|
||
// Quick and dirty downsampling of the original preview bars into a smaller (or equal) number of bars we can display here.
|
||
resampleBars(original) {
|
||
const dstCount = ((this.canvasRef.current.width - SPACING) / (LINE_WIDTH + SPACING)) | 0;
|
||
// Remove extra padding on the right due to fractional bar which is not drawn.
|
||
this.effectiveWidth = dstCount * (LINE_WIDTH + SPACING) + SPACING;
|
||
|
||
if (!Array.isArray(original) || original.length == 0) {
|
||
return Array.apply(null, Array(dstCount)).map(_ => 0.01);
|
||
}
|
||
|
||
const factor = original.length / dstCount;
|
||
let amps = [];
|
||
let maxAmp = -1;
|
||
for (let i=0; i<dstCount; i++) {
|
||
let lo = (i * factor) | 0; // low bound;
|
||
let hi = ((i + 1) * factor) | 0; // high bound;
|
||
if (hi == lo) {
|
||
amps[i] = original[lo];
|
||
} else {
|
||
let amp = 0.0;
|
||
for (let j=lo; j<hi; j++) {
|
||
amp += original[j]
|
||
}
|
||
amps[i] = Math.max(0, amp / (hi - lo));
|
||
}
|
||
maxAmp = Math.max(amps[i], maxAmp);
|
||
}
|
||
|
||
if (maxAmp > 0) {
|
||
return amps.map(a => a / maxAmp);
|
||
}
|
||
return Array.apply(null, Array(dstCount)).map(_ => 0.01);
|
||
}
|
||
|
||
handlePlay(e) {
|
||
e.preventDefault();
|
||
if (!this.state.canPlay) {
|
||
return;
|
||
}
|
||
|
||
if (this.state.playing) {
|
||
this.audioPlayer.pause();
|
||
this.setState({playing: false});
|
||
} else if (this.audioPlayer.readyState >= 2) {
|
||
this.audioPlayer.play();
|
||
this.setState({playing: true}, this.visualize);
|
||
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
|
||
// 有没有被标记已读(原生 Tinode recv/read),跟"这条语音有没有被真的点开听过"是两件事。
|
||
// 可选 prop,不传不影响任何现有调用点(收藏页/聊天记录检索也在用这个组件,不需要这个信号)。
|
||
if (this.props.onPlay) {
|
||
this.props.onPlay();
|
||
}
|
||
}
|
||
}
|
||
|
||
handleError(err) {
|
||
console.error(err);
|
||
}
|
||
|
||
handleSeek(e) {
|
||
e.preventDefault();
|
||
if (e.target && this.props.duration) {
|
||
const rect = e.target.getBoundingClientRect();
|
||
const offset = (e.clientX - rect.left) / this.effectiveWidth * CANVAS_UPSCALING;
|
||
this.audioPlayer.currentTime = this.props.duration * offset / 1000;
|
||
this.setState({currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)});
|
||
if (!this.state.playing) {
|
||
this.visualize();
|
||
}
|
||
}
|
||
}
|
||
|
||
render() {
|
||
const playClass = 'material-icons' +
|
||
(this.props.short ? '' : ' large') +
|
||
(this.state.canPlay ? '' : ' disabled');
|
||
const play = (<a href="#" onClick={this.handlePlay} title={this.props.intl.formatMessage(messages.icon_title_play)}>
|
||
<i className={playClass}>{this.state.playing ? 'pause_circle' :
|
||
(this.state.canPlay ? 'play_circle' : 'not_interested')}</i>
|
||
</a>);
|
||
return (<div className="audio-player">{this.props.short ?
|
||
<>
|
||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
|
||
{play}
|
||
</>
|
||
:
|
||
<>
|
||
{play}
|
||
<div>
|
||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
|
||
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
|
||
</div>
|
||
</>
|
||
}
|
||
</div>);
|
||
}
|
||
}
|
||
|
||
export default injectIntl(AudioPlayer);
|