Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c54f834311 | |||
| 9d05005bb7 | |||
| 40c4874156 | |||
| 2de0636608 | |||
| 9e7c6f4397 | |||
| 6600ceafce | |||
| d7a497a4a2 | |||
| 4c04086d63 | |||
| 79ce42bec6 | |||
| 72c956162c | |||
| 3080b59dc8 | |||
| d054e42cc0 |
+1
-1
@@ -29,4 +29,4 @@ EXPOSE 80
|
|||||||
|
|
||||||
# Health check to verify Nginx is actively running
|
# Health check to verify Nginx is actively running
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||||
|
|||||||
+3
-3
@@ -8,7 +8,7 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
|
|
||||||
# Service worker and app shell must revalidate so PWA updates are detected
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
@@ -18,7 +18,7 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
@@ -28,7 +28,7 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
@@ -36,6 +36,10 @@ code {
|
|||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px));
|
padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
background-image: linear-gradient(rgba(15, 23, 42, 0.3), rgba(15, 23, 42, 0.5)), url('/login-bg.jpg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glassmorphism Auth Card */
|
/* Glassmorphism Auth Card */
|
||||||
|
|||||||
@@ -43,6 +43,56 @@ export default function LiveVoiceCapture({
|
|||||||
const [previewMime, setPreviewMime] = useState('audio/webm')
|
const [previewMime, setPreviewMime] = useState('audio/webm')
|
||||||
const [previewDurationSec, setPreviewDurationSec] = useState(0)
|
const [previewDurationSec, setPreviewDurationSec] = useState(0)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [logs, setLogs] = useState<string[]>([])
|
||||||
|
|
||||||
|
const log = useCallback((msg: string) => {
|
||||||
|
console.log(`[VoiceDebug] ${msg}`)
|
||||||
|
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()}: ${msg}`])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = previewAudioRef.current
|
||||||
|
if (!el) {
|
||||||
|
log('previewAudioRef is null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
|
||||||
|
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||||
|
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
|
||||||
|
el.currentTime = 1e10
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
|
||||||
|
el.currentTime = 0
|
||||||
|
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
log('currentTime reset to 0. Final duration=' + el.duration)
|
||||||
|
}
|
||||||
|
el.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
} else {
|
||||||
|
log('Duration correction skipped (duration is valid)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.readyState >= 1) {
|
||||||
|
log('readyState >= 1. Executing hack immediately...')
|
||||||
|
handleLoadedMetadata()
|
||||||
|
} else {
|
||||||
|
log('readyState = 0. Adding loadedmetadata event listener...')
|
||||||
|
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Calling el.load() to force loading of the media resource...')
|
||||||
|
el.load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
}, [previewUrl, log])
|
||||||
|
|
||||||
const stopStream = useCallback(() => {
|
const stopStream = useCallback(() => {
|
||||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
@@ -115,22 +165,46 @@ export default function LiveVoiceCapture({
|
|||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
setMicError(null)
|
setMicError(null)
|
||||||
chunksRef.current = []
|
chunksRef.current = []
|
||||||
|
log('startRecording flow triggered')
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
log('navigator.mediaDevices.getUserMedia is unavailable')
|
||||||
setMicError(t('logs.live_voice_mic_denied'))
|
setMicError(t('logs.live_voice_mic_denied'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
log('Requesting getUserMedia audio stream...')
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
streamRef.current = stream
|
streamRef.current = stream
|
||||||
|
log('Stream obtained successfully. active=' + stream.active)
|
||||||
|
stream.getTracks().forEach((track, i) => {
|
||||||
|
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
|
||||||
|
})
|
||||||
|
|
||||||
const mimeType = pickMediaRecorderMimeType()
|
const mimeType = pickMediaRecorderMimeType()
|
||||||
|
log('MIME type candidates support check:')
|
||||||
|
const MIME_CANDIDATES = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/ogg;codecs=opus'
|
||||||
|
]
|
||||||
|
MIME_CANDIDATES.forEach(mime => {
|
||||||
|
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
|
||||||
|
})
|
||||||
|
log('Selected MIME from picker: ' + mimeType)
|
||||||
|
|
||||||
const recorder = mimeType
|
const recorder = mimeType
|
||||||
? new MediaRecorder(stream, { mimeType })
|
? new MediaRecorder(stream, { mimeType })
|
||||||
: new MediaRecorder(stream)
|
: new MediaRecorder(stream)
|
||||||
mediaRecorderRef.current = recorder
|
mediaRecorderRef.current = recorder
|
||||||
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
|
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
|
||||||
|
log('MediaRecorder created. Resolved mime=' + resolvedMime)
|
||||||
|
|
||||||
recorder.ondataavailable = (ev) => {
|
recorder.ondataavailable = (ev) => {
|
||||||
if (ev.data.size > 0) chunksRef.current.push(ev.data)
|
log(`ondataavailable event: data size=${ev.data?.size} bytes`)
|
||||||
|
if (ev.data && ev.data.size > 0) {
|
||||||
|
chunksRef.current.push(ev.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recorder.onstop = () => {
|
recorder.onstop = () => {
|
||||||
@@ -138,45 +212,67 @@ export default function LiveVoiceCapture({
|
|||||||
VOICE_MEMO_MAX_DURATION_SEC,
|
VOICE_MEMO_MAX_DURATION_SEC,
|
||||||
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
|
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
|
||||||
)
|
)
|
||||||
|
log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
|
||||||
|
setTimeout(() => {
|
||||||
|
log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
|
||||||
|
const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
|
||||||
|
log(`Total raw chunks size: ${totalChunksSize} bytes`)
|
||||||
const blob = new Blob(chunksRef.current, { type: resolvedMime })
|
const blob = new Blob(chunksRef.current, { type: resolvedMime })
|
||||||
chunksRef.current = []
|
chunksRef.current = []
|
||||||
stopStream()
|
stopStream()
|
||||||
|
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
|
||||||
try {
|
try {
|
||||||
assertVoiceMemoBlobSize(blob)
|
assertVoiceMemoBlobSize(blob)
|
||||||
|
log('Blob size assertion passed. Calling finishRecording...')
|
||||||
finishRecording(blob, resolvedMime, durationSec)
|
finishRecording(blob, resolvedMime, durationSec)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
log('Blob size assertion failed (too large)')
|
||||||
setMicError(t('logs.live_voice_too_large'))
|
setMicError(t('logs.live_voice_too_large'))
|
||||||
setPhase('idle')
|
setPhase('idle')
|
||||||
}
|
}
|
||||||
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
recorder.onerror = () => {
|
recorder.onerror = (ev) => {
|
||||||
|
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
|
||||||
setMicError(t('logs.live_voice_record_failed'))
|
setMicError(t('logs.live_voice_record_failed'))
|
||||||
resetAll()
|
resetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
startedAtRef.current = Date.now()
|
startedAtRef.current = Date.now()
|
||||||
recorder.start(200)
|
log('Calling recorder.start()...')
|
||||||
|
recorder.start()
|
||||||
|
log('recorder.start() called. State=' + recorder.state)
|
||||||
setPhase('recording')
|
setPhase('recording')
|
||||||
setElapsedSec(0)
|
setElapsedSec(0)
|
||||||
timerRef.current = window.setInterval(() => {
|
timerRef.current = window.setInterval(() => {
|
||||||
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
|
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
|
||||||
setElapsedSec(sec)
|
setElapsedSec(sec)
|
||||||
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
|
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
|
||||||
|
log('Max duration reached. Stopping recording...')
|
||||||
stopRecording()
|
stopRecording()
|
||||||
}
|
}
|
||||||
}, 250)
|
}, 250)
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
|
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
|
||||||
setMicError(t('logs.live_voice_mic_denied'))
|
setMicError(t('logs.live_voice_mic_denied'))
|
||||||
stopStream()
|
stopStream()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!previewBlob || saving || busy) return
|
if (!previewBlob || saving || busy) {
|
||||||
|
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
onSave(previewBlob, previewMime, previewDurationSec)
|
log('Invoking onSave callback...')
|
||||||
|
await onSave(previewBlob, previewMime, previewDurationSec)
|
||||||
|
log('onSave callback successfully finished!')
|
||||||
|
} catch (err: any) {
|
||||||
|
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -241,7 +337,7 @@ export default function LiveVoiceCapture({
|
|||||||
|
|
||||||
{phase === 'preview' && previewUrl && (
|
{phase === 'preview' && previewUrl && (
|
||||||
<>
|
<>
|
||||||
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
<audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
||||||
{onCaptionChange && (
|
{onCaptionChange && (
|
||||||
<label className="live-voice-caption-field">
|
<label className="live-voice-caption-field">
|
||||||
<span>{t('logs.live_voice_caption_label')}</span>
|
<span>{t('logs.live_voice_caption_label')}</span>
|
||||||
@@ -278,6 +374,41 @@ export default function LiveVoiceCapture({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Debug Logs Panel */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
padding: '10px',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxHeight: '180px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#4ade80',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 'bold', marginBottom: '4px', borderBottom: '1px solid rgba(255,255,255,0.2)', paddingBottom: '2px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>Debug Console Logs:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLogs([])}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#fda4af', cursor: 'pointer', fontSize: '10px', padding: '0 4px', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<span style={{ color: '#94a3b8' }}>No logs yet. Start recording to debug.</span>
|
||||||
|
) : (
|
||||||
|
logs.map((l, i) => (
|
||||||
|
<div key={i} style={{ wordBreak: 'break-all', marginBottom: '2px' }}>{l}</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
@@ -30,6 +30,38 @@ export default function VoiceMemoPlayer({
|
|||||||
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
|
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||||
|
el.currentTime = 1e10
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
el.currentTime = 0
|
||||||
|
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
}
|
||||||
|
el.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.readyState >= 1) {
|
||||||
|
handleLoadedMetadata()
|
||||||
|
} else {
|
||||||
|
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
el.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preloaded?.audio) {
|
if (preloaded?.audio) {
|
||||||
setSrc(preloaded.audio)
|
setSrc(preloaded.audio)
|
||||||
@@ -75,7 +107,7 @@ export default function VoiceMemoPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="voice-memo-player-shell">
|
<div className="voice-memo-player-shell">
|
||||||
<audio className={playerClass} controls preload="none" src={src} />
|
<audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user