- Implemented.
Safari/iPhone recording now uses MP4/AAC when WebM is unavailable. WebM remains the default when supported and FFmpeg is disabled. Added optional FFmpeg profiles: recommended M4A/AAC-LC or requested MP4/H.264 + AAC. Added MIME-aware, playsinline audio playback. Added dashboard controls under Plans & Platform → Voice Transcoding / FFmpeg. Added Apache/Nginx media MIME configuration and documentation. Core implementation: voice_transcoder.php (line 72), messages.php (line 86), and cyberchat-app.js (line 635).
This commit is contained in:
+33
-10
@@ -14,7 +14,7 @@
|
||||
loginChallenge: initialChallenge,
|
||||
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
||||
chunks: [], blob: null, duration: 0, url: '', autoSend: false, autoPlay: false,
|
||||
chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false,
|
||||
playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
|
||||
};
|
||||
let root;
|
||||
@@ -89,9 +89,11 @@
|
||||
function linkify(text) {
|
||||
return text.replace(/(https?:\/\/[^\s<>]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
}
|
||||
function audioPlayer(src = '', id = '') {
|
||||
function audioPlayer(src = '', id = '', mime = '') {
|
||||
return `<span class="cc-cyber-audio">
|
||||
<audio ${id ? `id="${id}"` : ''} preload="metadata" ${src ? `src="${esc(src)}"` : ''}></audio>
|
||||
<audio ${id ? `id="${id}"` : ''} preload="metadata" playsinline>${src
|
||||
? `<source src="${esc(src)}" ${mime ? `type="${esc(mime)}"` : ''}>`
|
||||
: ''}</audio>
|
||||
<button class="cc-audio-play" type="button" aria-label="Play audio"><span class="cc-audio-play-icon"></span></button>
|
||||
<span class="cc-audio-signal" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
||||
<input class="cc-audio-seek" type="range" min="0" max="1000" value="0" aria-label="Audio position">
|
||||
@@ -565,7 +567,7 @@
|
||||
item.dataset.id = String(id);
|
||||
item.dataset.renderKey = messageRenderKey(message);
|
||||
const content = message.message_type === 'voice' && message.voice_url
|
||||
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
|
||||
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>`
|
||||
: linkify(esc(message.message));
|
||||
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
||||
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
||||
@@ -636,15 +638,31 @@
|
||||
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported');
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mime = ['audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type));
|
||||
if (!mime) { stream.getTracks().forEach(track => track.stop()); return toast('WebM recording is not supported'); }
|
||||
const candidates = [
|
||||
'audio/webm;codecs=opus', 'audio/webm',
|
||||
'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/aac'
|
||||
];
|
||||
let recorder = null;
|
||||
let mime = '';
|
||||
for (const candidate of candidates) {
|
||||
if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue;
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, { mimeType: candidate });
|
||||
mime = candidate;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
if (!recorder) recorder = new MediaRecorder(stream);
|
||||
state.voice.stream = stream;
|
||||
state.voice.chunks = [];
|
||||
state.voice.started = Date.now();
|
||||
const recorder = new MediaRecorder(stream, { mimeType: mime });
|
||||
const recordedMime = recorder.mimeType || mime || 'audio/webm';
|
||||
recorder.addEventListener('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); });
|
||||
recorder.addEventListener('stop', () => {
|
||||
if (!recorder._discard) finishVoice(new Blob(state.voice.chunks, { type: mime }), (Date.now() - state.voice.started) / 1000);
|
||||
if (!recorder._discard) finishVoice(
|
||||
new Blob(state.voice.chunks, { type: recordedMime }),
|
||||
(Date.now() - state.voice.started) / 1000
|
||||
);
|
||||
}, { once: true });
|
||||
recorder.start(250);
|
||||
state.voice.recorder = recorder;
|
||||
@@ -680,6 +698,7 @@
|
||||
function finishVoice(blob, length) {
|
||||
if (!blob.size || length < 0.25) return toast('Voice clip was too short');
|
||||
state.voice.blob = blob;
|
||||
state.voice.mime = blob.type || 'audio/webm';
|
||||
state.voice.duration = length;
|
||||
if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice();
|
||||
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
|
||||
@@ -694,6 +713,7 @@
|
||||
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
|
||||
state.voice.url = '';
|
||||
state.voice.blob = null;
|
||||
state.voice.mime = '';
|
||||
state.voice.duration = 0;
|
||||
if ($('#voice-review')) $('#voice-review').hidden = true;
|
||||
}
|
||||
@@ -703,7 +723,10 @@
|
||||
const body = new FormData();
|
||||
body.append('action', 'send_voice');
|
||||
body.append('duration', state.voice.duration.toFixed(2));
|
||||
body.append('voice', state.voice.blob, 'voice.webm');
|
||||
const uploadName = state.voice.mime.includes('mp4') ? 'voice.m4a'
|
||||
: state.voice.mime.includes('aac') ? 'voice.aac'
|
||||
: state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm';
|
||||
body.append('voice', state.voice.blob, uploadName);
|
||||
const result = await api('messages.php', { method: 'POST', body })
|
||||
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
||||
state.voice.sending = false;
|
||||
@@ -729,7 +752,7 @@
|
||||
}
|
||||
function archiveRow(message) {
|
||||
const content = message.message_type === 'voice' && message.voice_url
|
||||
? audioPlayer(publicUrl(message.voice_url)) : esc(message.message);
|
||||
? audioPlayer(publicUrl(message.voice_url), '', message.voice_mime) : esc(message.message);
|
||||
return `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
|
||||
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user