fix: custom sound playback, unmark, editor click-to-edit, bigger fonts
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m41s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m41s
This commit is contained in:
@@ -100,8 +100,10 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
|
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
|
||||||
}, [fetchState]);
|
}, [fetchState]);
|
||||||
|
|
||||||
const handleMark = async (itemId: string) => {
|
const markItem = async (itemId: string) => {
|
||||||
if (marking) return;
|
if (marking) return;
|
||||||
|
const item = grid.find(c => c.item?.id === itemId);
|
||||||
|
if (item?.item) playSound(item.item.soundCategory, item.item.soundUrl);
|
||||||
setMarking(itemId);
|
setMarking(itemId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/game/mark", {
|
const res = await fetch("/api/game/mark", {
|
||||||
@@ -109,13 +111,24 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ campaignId: campaign.id, itemId }),
|
body: JSON.stringify({ campaignId: campaign.id, itemId }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (!res.ok && res.status === 409) {
|
||||||
const item = grid.find(c => c.item?.id === itemId);
|
// already marked by someone else, just refresh
|
||||||
if (item?.item) playSound(item.item.soundCategory, item.item.soundUrl);
|
|
||||||
await fetchState();
|
|
||||||
} else if (res.status === 409) {
|
|
||||||
await fetchState();
|
|
||||||
}
|
}
|
||||||
|
await fetchState();
|
||||||
|
} catch {}
|
||||||
|
setMarking(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unmarkItem = async (itemId: string) => {
|
||||||
|
if (marking) return;
|
||||||
|
setMarking(itemId);
|
||||||
|
try {
|
||||||
|
await fetch("/api/game/mark", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ campaignId: campaign.id, itemId }),
|
||||||
|
});
|
||||||
|
await fetchState();
|
||||||
} catch {}
|
} catch {}
|
||||||
setMarking(null);
|
setMarking(null);
|
||||||
};
|
};
|
||||||
@@ -239,7 +252,8 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
markCount={cell.markCount}
|
markCount={cell.markCount}
|
||||||
markedBy={cell.markedBy}
|
markedBy={cell.markedBy}
|
||||||
gridSize={campaign.gridSize}
|
gridSize={campaign.gridSize}
|
||||||
onMark={handleMark}
|
onMark={markItem}
|
||||||
|
onUnmark={unmarkItem}
|
||||||
disabled={marking !== null}
|
disabled={marking !== null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Item = {
|
|||||||
text: string;
|
text: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
soundCategory: string;
|
soundCategory: string;
|
||||||
|
soundUrl?: string | null;
|
||||||
gridIndex: number;
|
gridIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ type Props = {
|
|||||||
markedBy: string[];
|
markedBy: string[];
|
||||||
gridSize: number;
|
gridSize: number;
|
||||||
onMark: (itemId: string) => void;
|
onMark: (itemId: string) => void;
|
||||||
|
onUnmark?: (itemId: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isFreeSpace?: boolean;
|
isFreeSpace?: boolean;
|
||||||
};
|
};
|
||||||
@@ -36,7 +38,7 @@ function getSoundEmoji(cat: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, onMark, disabled, isFreeSpace }: Props) {
|
export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, onMark, onUnmark, disabled, isFreeSpace }: Props) {
|
||||||
const [shake, setShake] = useState(false);
|
const [shake, setShake] = useState(false);
|
||||||
const [glow, setGlow] = useState(false);
|
const [glow, setGlow] = useState(false);
|
||||||
|
|
||||||
@@ -66,14 +68,17 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => !disabled && !marked && onMark(item.id)}
|
onClick={() => {
|
||||||
disabled={disabled || marked}
|
if (disabled) return;
|
||||||
|
if (marked) { onUnmark?.(item.id); return; }
|
||||||
|
onMark(item.id);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden",
|
"relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden",
|
||||||
"select-none",
|
"select-none",
|
||||||
gridSize > 5 ? "p-1 gap-0" : "p-2 gap-1",
|
gridSize > 5 ? "p-1 gap-0" : "p-2 gap-1",
|
||||||
marked
|
marked
|
||||||
? "border-cyan-500/60 bg-cyan-900/40 cursor-default"
|
? "border-cyan-500/60 bg-cyan-900/40 hover:bg-cyan-800/40 cursor-pointer"
|
||||||
: "border-slate-700/40 bg-slate-800/40 hover:bg-slate-700/40 hover:border-cyan-600/40 hover:shadow-lg hover:shadow-cyan-900/20 cursor-pointer active:scale-95",
|
: "border-slate-700/40 bg-slate-800/40 hover:bg-slate-700/40 hover:border-cyan-600/40 hover:shadow-lg hover:shadow-cyan-900/20 cursor-pointer active:scale-95",
|
||||||
shake && "animate-shake",
|
shake && "animate-shake",
|
||||||
glow && "animate-glow-pulse",
|
glow && "animate-glow-pulse",
|
||||||
@@ -94,12 +99,15 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize,
|
|||||||
</span>
|
</span>
|
||||||
{marked && markCount > 0 && (
|
{marked && markCount > 0 && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"absolute top-0.5 right-1 font-bold text-cyan-400",
|
"absolute top-0.5 right-1 font-bold text-amber-400",
|
||||||
gridSize > 5 ? "text-[9px]" : "text-xs"
|
gridSize > 5 ? "text-[9px]" : "text-xs"
|
||||||
)}>
|
)}>
|
||||||
{markCount}
|
{markCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{marked && (
|
||||||
|
<span className="absolute top-0.5 left-1 text-[8px] text-cyan-500/60">✕</span>
|
||||||
|
)}
|
||||||
{!marked && !isFree && (
|
{!marked && !isFree && (
|
||||||
<span className="text-[9px] text-slate-600 mt-0.5">{getSoundEmoji(item.soundCategory)}</span>
|
<span className="text-[9px] text-slate-600 mt-0.5">{getSoundEmoji(item.soundCategory)}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ function EmojiPicker({ value, onChange }: { value: string; onChange: (v: string)
|
|||||||
export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editItemId, setEditItemId] = useState<string | null>(null);
|
||||||
const [editText, setEditText] = useState("");
|
const [editText, setEditText] = useState("");
|
||||||
const [editEmoji, setEditEmoji] = useState("💀");
|
const [editEmoji, setEditEmoji] = useState("💀");
|
||||||
const [editSound, setEditSound] = useState("horn");
|
const [editSound, setEditSound] = useState("horn");
|
||||||
const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null);
|
const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
const res = await fetch(`/api/campaigns/${campaign.id}/items`);
|
const res = await fetch(`/api/campaigns/${campaign.id}/items`);
|
||||||
@@ -101,6 +103,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteItem = async (itemId: string) => {
|
const deleteItem = async (itemId: string) => {
|
||||||
|
if (editItemId === itemId) resetForm();
|
||||||
await fetch(`/api/campaigns/${campaign.id}/items?itemId=${itemId}`, { method: "DELETE" });
|
await fetch(`/api/campaigns/${campaign.id}/items?itemId=${itemId}`, { method: "DELETE" });
|
||||||
await fetchItems();
|
await fetchItems();
|
||||||
};
|
};
|
||||||
@@ -124,11 +127,45 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
gridIndex: maxIdx + 1,
|
gridIndex: maxIdx + 1,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
resetForm();
|
||||||
|
await fetchItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitItem = async () => {
|
||||||
|
if (!editText) return;
|
||||||
|
if (editItemId) {
|
||||||
|
const existing = items.find(it => it.id === editItemId);
|
||||||
|
if (existing) {
|
||||||
|
await updateItem({
|
||||||
|
...existing,
|
||||||
|
text: editText,
|
||||||
|
emoji: editEmoji,
|
||||||
|
soundCategory: editSound,
|
||||||
|
soundUrl: editSoundUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
await addItem();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editItem = (item: Item) => {
|
||||||
|
setEditItemId(item.id);
|
||||||
|
setEditText(item.text);
|
||||||
|
setEditEmoji(item.emoji);
|
||||||
|
setEditSound(item.soundCategory);
|
||||||
|
setEditSoundUrl(item.soundUrl || null);
|
||||||
|
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditItemId(null);
|
||||||
setEditText("");
|
setEditText("");
|
||||||
setEditEmoji("💀");
|
setEditEmoji("💀");
|
||||||
setEditSound("horn");
|
setEditSound("horn");
|
||||||
setEditSoundUrl(null);
|
setEditSoundUrl(null);
|
||||||
await fetchItems();
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadSound = async (file: File) => {
|
const uploadSound = async (file: File) => {
|
||||||
@@ -141,6 +178,10 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
setUploading(false);
|
setUploading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playSoundOnce = (url: string) => {
|
||||||
|
try { const a = new Audio(url); a.preload = "auto"; a.volume = 0.4; a.play().catch(() => {}); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
const totalCells = campaign.gridSize * campaign.gridSize;
|
const totalCells = campaign.gridSize * campaign.gridSize;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -149,79 +190,88 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="border-cyan-800/30">
|
<div ref={formRef}>
|
||||||
<CardContent className="p-4 space-y-3">
|
<Card className={editItemId ? "border-amber-500/40" : "border-cyan-800/30"}>
|
||||||
<h3 className="text-xs font-mono text-cyan-400 uppercase">+ Add New Cell</h3>
|
<CardContent className="p-4 space-y-3">
|
||||||
<div className="flex flex-wrap gap-2 items-end">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1 min-w-[150px]">
|
<h3 className="text-xs font-mono text-cyan-400 uppercase">{editItemId ? "✏ Edit Cell" : "+ Add New Cell"}</h3>
|
||||||
<label className="text-[9px] font-mono text-slate-600 uppercase">Text</label>
|
{editItemId && (
|
||||||
<Input
|
<button onClick={resetForm} className="text-[10px] font-mono text-slate-500 hover:text-slate-300 cursor-pointer">
|
||||||
placeholder="Reactor explodes"
|
Cancel
|
||||||
value={editText}
|
</button>
|
||||||
onChange={e => setEditText(e.target.value)}
|
)}
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-wrap gap-2 items-end">
|
||||||
<label className="text-[9px] font-mono text-slate-600 uppercase">Emoji</label>
|
<div className="flex-1 min-w-[150px]">
|
||||||
<EmojiPicker value={editEmoji} onChange={setEditEmoji} />
|
<label className="text-[9px] font-mono text-slate-600 uppercase">Text</label>
|
||||||
</div>
|
<Input
|
||||||
<div className="w-32">
|
placeholder="Reactor explodes"
|
||||||
<label className="text-[9px] font-mono text-slate-600 uppercase">Sound</label>
|
value={editText}
|
||||||
<Select
|
onChange={e => setEditText(e.target.value)}
|
||||||
value={editSound}
|
className="font-mono text-sm"
|
||||||
onValueChange={v => { setEditSound(v); if (v !== "custom") setEditSoundUrl(null); }}
|
/>
|
||||||
>
|
|
||||||
<SelectTrigger className="font-mono text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SOUND_CATEGORIES.map(cat => (
|
|
||||||
<SelectItem key={cat.value} value={cat.value} className="font-mono text-xs">
|
|
||||||
{cat.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{editSound === "custom" && (
|
|
||||||
<div>
|
|
||||||
<label className="text-[9px] font-mono text-slate-600 uppercase">File</label>
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".ogg"
|
|
||||||
className="hidden"
|
|
||||||
onChange={e => { const f = e.target.files?.[0]; if (f) uploadSound(f); }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="font-mono text-[9px] h-10"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
>
|
|
||||||
{uploading ? "..." : editSoundUrl ? "✓" : "OGG"}
|
|
||||||
</Button>
|
|
||||||
{editSoundUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { const a = new Audio(editSoundUrl); a.volume = 0.4; a.play(); }}
|
|
||||||
className="text-sm cursor-pointer hover:text-cyan-300"
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
<Button size="sm" onClick={addItem} disabled={!editText} className="font-mono text-xs">
|
<label className="text-[9px] font-mono text-slate-600 uppercase">Emoji</label>
|
||||||
ADD
|
<EmojiPicker value={editEmoji} onChange={setEditEmoji} />
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<div className="w-32">
|
||||||
</CardContent>
|
<label className="text-[9px] font-mono text-slate-600 uppercase">Sound</label>
|
||||||
</Card>
|
<Select
|
||||||
|
value={editSound}
|
||||||
|
onValueChange={v => { setEditSound(v); if (v !== "custom") setEditSoundUrl(null); }}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="font-mono text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SOUND_CATEGORIES.map(cat => (
|
||||||
|
<SelectItem key={cat.value} value={cat.value} className="font-mono text-xs">
|
||||||
|
{cat.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{editSound === "custom" && (
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-mono text-slate-600 uppercase">File</label>
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".ogg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { const f = e.target.files?.[0]; if (f) uploadSound(f); }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="font-mono text-[9px] h-10"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? "..." : editSoundUrl ? "✓" : "OGG"}
|
||||||
|
</Button>
|
||||||
|
{editSoundUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => playSoundOnce(editSoundUrl)}
|
||||||
|
className="text-sm cursor-pointer hover:text-cyan-300"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={submitItem} disabled={!editText} className="font-mono text-xs">
|
||||||
|
{editItemId ? "SAVE" : "ADD"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="grid gap-1"
|
className="grid gap-1"
|
||||||
@@ -236,17 +286,26 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const isEditing = editItemId === item.id;
|
||||||
return (
|
return (
|
||||||
<Card key={item.id} className="border-slate-700/30 aspect-square group">
|
<Card key={item.id} className={"border-slate-700/30 aspect-square group" + (isEditing ? " ring-2 ring-amber-500/50" : "")}>
|
||||||
<CardContent className="p-1.5 h-full flex flex-col items-center justify-center gap-0.5 relative">
|
<CardContent className="p-1.5 h-full flex flex-col items-center justify-center gap-0.5 relative">
|
||||||
<span className="text-lg">{item.emoji}</span>
|
<span className="text-lg">{item.emoji}</span>
|
||||||
<span className="text-[8px] text-center font-mono text-slate-300 leading-tight line-clamp-2">
|
<span className="text-[10px] text-center font-mono text-slate-300 leading-tight line-clamp-3 px-0.5">
|
||||||
{item.text}
|
{item.text}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className="text-[6px] px-1 py-0 absolute top-0.5 right-0.5">
|
<Badge variant="outline" className="text-[8px] px-1 py-0 absolute top-0.5 right-0.5">
|
||||||
{item.soundUrl ? "🔊" : item.soundCategory}
|
{item.soundUrl ? "🔊" : item.soundCategory}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="absolute bottom-0.5 left-0.5 right-0.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute bottom-0.5 left-0.5 right-0.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 text-[8px] px-1 py-0 flex-1"
|
||||||
|
onClick={() => editItem(item)}
|
||||||
|
>
|
||||||
|
✏
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -256,6 +315,15 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
✕
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{item.soundUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => { e.stopPropagation(); playSoundOnce(item.soundUrl!); }}
|
||||||
|
className="absolute bottom-0.5 left-0.5 text-[10px] text-cyan-500/60 hover:text-cyan-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -301,3 +369,5 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export function playChaosRiser() {
|
|||||||
export function playSoundUrl(url: string) {
|
export function playSoundUrl(url: string) {
|
||||||
try {
|
try {
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
|
audio.preload = "auto";
|
||||||
audio.volume = 0.4;
|
audio.volume = 0.4;
|
||||||
audio.play().catch(() => {});
|
audio.play().catch(() => {});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
Reference in New Issue
Block a user