a38e2f96c0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
8.0 KiB
TypeScript
245 lines
8.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import {
|
|
clearIncomingRingtone,
|
|
getIncomingRingtone,
|
|
MAX_RINGTONE_BYTES,
|
|
saveIncomingRingtone,
|
|
type StoredRingtone,
|
|
} from '../lib/ringtoneStorage';
|
|
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
|
|
|
|
interface Props {
|
|
/** Disable interactions while a parent action is in flight. */
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const BYTES_PER_MB = 1024 * 1024;
|
|
|
|
// UI for the custom incoming-call ringtone. Single file slot. Upload
|
|
// validates size + mime and surfaces errors inline. Preview button plays
|
|
// the stored blob through a local <audio> element without touching the
|
|
// shared ringtone singleton so we don't interfere with a live call.
|
|
export function RingtoneSettings({ disabled = false }: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const previewRef = useRef<HTMLAudioElement | null>(null);
|
|
const previewUrlRef = useRef<string | null>(null);
|
|
const [current, setCurrent] = useState<StoredRingtone | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [playing, setPlaying] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const cur = await getIncomingRingtone();
|
|
setCurrent(cur);
|
|
} catch (err: unknown) {
|
|
console.error('getIncomingRingtone failed', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
useEffect(() => {
|
|
// Revoke any preview blob URL when the component unmounts so long-lived
|
|
// pages don't leak memory.
|
|
return () => {
|
|
stopPreview();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
function stopPreview(): void {
|
|
const el = previewRef.current;
|
|
if (el) {
|
|
try {
|
|
el.pause();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
el.src = '';
|
|
}
|
|
previewRef.current = null;
|
|
if (previewUrlRef.current) {
|
|
URL.revokeObjectURL(previewUrlRef.current);
|
|
previewUrlRef.current = null;
|
|
}
|
|
setPlaying(false);
|
|
}
|
|
|
|
async function handleFile(file: File): Promise<void> {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
await saveIncomingRingtone(file);
|
|
await refresh();
|
|
} catch (err: unknown) {
|
|
const code = err instanceof Error ? err.message : 'upload_failed';
|
|
if (code === 'ringtone_too_large') {
|
|
setError(
|
|
t('app:settings.ringtone_error_too_large', {
|
|
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
|
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
|
|
}),
|
|
);
|
|
} else if (code === 'ringtone_not_audio') {
|
|
setError(
|
|
t('app:settings.ringtone_error_not_audio', {
|
|
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
|
}),
|
|
);
|
|
} else {
|
|
setError(
|
|
t('app:settings.ringtone_error_generic', {
|
|
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
|
|
}),
|
|
);
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
}
|
|
}
|
|
|
|
async function handleReset(): Promise<void> {
|
|
setError(null);
|
|
setBusy(true);
|
|
stopPreview();
|
|
try {
|
|
await clearIncomingRingtone();
|
|
await refresh();
|
|
} catch (err: unknown) {
|
|
console.error('clearIncomingRingtone failed', err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function handlePreview(): void {
|
|
if (!current) return;
|
|
if (playing) {
|
|
stopPreview();
|
|
return;
|
|
}
|
|
const url = URL.createObjectURL(current.blob);
|
|
const el = new Audio(url);
|
|
el.loop = false;
|
|
el.volume = 0.85;
|
|
el.onended = () => stopPreview();
|
|
el.onerror = () => {
|
|
setError(
|
|
t('app:settings.ringtone_error_play', {
|
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
|
}),
|
|
);
|
|
stopPreview();
|
|
};
|
|
el.play().catch(() => {
|
|
setError(
|
|
t('app:settings.ringtone_error_play', {
|
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
|
}),
|
|
);
|
|
stopPreview();
|
|
});
|
|
previewRef.current = el;
|
|
previewUrlRef.current = url;
|
|
setPlaying(true);
|
|
}
|
|
|
|
const hasCustom = current !== null;
|
|
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
|
|
const interactionsDisabled = disabled || busy;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
|
<PhoneIcon className="h-4 w-4 text-fg-muted" />
|
|
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
|
|
</div>
|
|
<div className="mt-1 text-xs text-fg-muted">
|
|
{hasCustom && current
|
|
? t('app:settings.ringtone_custom_active', {
|
|
defaultValue: '{{name}} · {{size}} MB',
|
|
name: current.filename,
|
|
size: sizeMb,
|
|
})
|
|
: t('app:settings.ringtone_default_active', {
|
|
defaultValue: 'Standard-Klingelton (Doppelton)',
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{hasCustom && (
|
|
<button
|
|
type="button"
|
|
onClick={handlePreview}
|
|
disabled={interactionsDisabled}
|
|
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{playing
|
|
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
|
|
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="audio/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) void handleFile(f);
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => inputRef.current?.click()}
|
|
disabled={interactionsDisabled}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
|
<span>
|
|
{hasCustom
|
|
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
|
|
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
|
|
</span>
|
|
</button>
|
|
{hasCustom && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleReset()}
|
|
disabled={interactionsDisabled}
|
|
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
|
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<p className="text-[11px] text-fg-muted">
|
|
{t('app:settings.ringtone_hint', {
|
|
defaultValue:
|
|
'MP3, WAV, OGG oder M4A bis 8 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
|
|
})}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|