surface audio-ready chapters: headphones badge on chapter list, instant-play prompt on reader
- getReadyChaptersForSlug(slug, preferredVoice) in pocketbase.ts: per-slug done jobs map, cached 60s, prefers user's voice - GET /api/audio/chapters?slug=&voice= endpoint - Chapter list (/books/[slug]/chapters): amber headphones icon on ready rows, play button for instant listen, banner showing ready count - Chapter reader: audioReady + availableVoice from server load; 'Audio ready — Listen now' banner shown when audio exists and player is not yet active; sets voice preference before expanding player
This commit is contained in:
@@ -1219,6 +1219,47 @@ export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntr
|
|||||||
return entries.slice(0, limit);
|
return entries.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a map of chapter number → best available voice for a given slug.
|
||||||
|
* "Best" means: prefer `preferredVoice` if a done job exists for it,
|
||||||
|
* otherwise fall back to any done voice for that chapter.
|
||||||
|
* Result is cached per slug for 60 seconds (audio jobs complete frequently).
|
||||||
|
*/
|
||||||
|
export async function getReadyChaptersForSlug(
|
||||||
|
slug: string,
|
||||||
|
preferredVoice = ''
|
||||||
|
): Promise<Map<number, string>> {
|
||||||
|
const cacheKey = `audio:ready_chapters:${slug}`;
|
||||||
|
const cached = await cache.get<{ chapter: number; voice: string }[]>(cacheKey);
|
||||||
|
const raw = cached ?? await (async () => {
|
||||||
|
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&status="done"`);
|
||||||
|
const jobs = await listAll<AudioJob>('audio_jobs', filter, 'chapter');
|
||||||
|
const result: { chapter: number; voice: string }[] = jobs.map((j) => ({
|
||||||
|
chapter: j.chapter,
|
||||||
|
voice: j.voice ?? ''
|
||||||
|
}));
|
||||||
|
await cache.set(cacheKey, result, 60);
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Build chapter → voices map
|
||||||
|
const byChapter = new Map<number, string[]>();
|
||||||
|
for (const { chapter, voice } of raw) {
|
||||||
|
if (!byChapter.has(chapter)) byChapter.set(chapter, []);
|
||||||
|
byChapter.get(chapter)!.push(voice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve best voice per chapter
|
||||||
|
const result = new Map<number, string>();
|
||||||
|
for (const [chapter, voices] of byChapter) {
|
||||||
|
const best = preferredVoice && voices.includes(preferredVoice)
|
||||||
|
? preferredVoice
|
||||||
|
: voices[0] ?? '';
|
||||||
|
result.set(chapter, best);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TranslationJob {
|
export interface TranslationJob {
|
||||||
|
|||||||
18
ui/src/routes/api/audio/chapters/+server.ts
Normal file
18
ui/src/routes/api/audio/chapters/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getReadyChaptersForSlug } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const slug = url.searchParams.get('slug') ?? '';
|
||||||
|
if (!slug) error(400, 'slug is required');
|
||||||
|
|
||||||
|
const voice = url.searchParams.get('voice') ?? '';
|
||||||
|
const readyMap = await getReadyChaptersForSlug(slug, voice);
|
||||||
|
|
||||||
|
// Return array of { chapter, voice } pairs
|
||||||
|
const chapters = [...readyMap.entries()].map(([chapter, v]) => ({ chapter, voice: v }));
|
||||||
|
|
||||||
|
return json({ chapters }, {
|
||||||
|
headers: { 'Cache-Control': 'public, max-age=60' }
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
|
import { getBook, listChapterIdx, getProgress, getReadyChaptersForSlug } from '$lib/server/pocketbase';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
@@ -13,20 +13,26 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
|
|
||||||
if (!book) error(404, `Book "${slug}" not found`);
|
if (!book) error(404, `Book "${slug}" not found`);
|
||||||
|
|
||||||
let chapters, progress;
|
let chapters, progress, readyMap;
|
||||||
try {
|
try {
|
||||||
[chapters, progress] = await Promise.all([
|
[chapters, progress, readyMap] = await Promise.all([
|
||||||
listChapterIdx(slug),
|
listChapterIdx(slug),
|
||||||
getProgress(locals.sessionId, slug, locals.user?.id)
|
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||||
|
getReadyChaptersForSlug(slug).catch(() => new Map<number, string>())
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('chapters', 'failed to load chapters', { slug, err: String(e) });
|
log.error('chapters', 'failed to load chapters', { slug, err: String(e) });
|
||||||
throw error(500, 'Failed to load chapters');
|
throw error(500, 'Failed to load chapters');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serialize Map as plain object for SvelteKit data transfer
|
||||||
|
const readyChapters: Record<number, string> = {};
|
||||||
|
for (const [ch, voice] of readyMap) readyChapters[ch] = voice;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters },
|
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters },
|
||||||
chapters,
|
chapters,
|
||||||
lastChapter: progress?.chapter ?? null
|
lastChapter: progress?.chapter ?? null,
|
||||||
|
readyChapters
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { ChapterIdx } from '$lib/server/pocketbase';
|
import type { ChapterIdx } from '$lib/server/pocketbase';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
@@ -7,6 +9,18 @@
|
|||||||
|
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
// ── Ready-to-listen map ──────────────────────────────────────────────────
|
||||||
|
// readyChapters is a Record<number, string> (chapter → voice) from server
|
||||||
|
const readySet = $derived(new Set(Object.keys(data.readyChapters).map(Number)));
|
||||||
|
const readyCount = $derived(readySet.size);
|
||||||
|
|
||||||
|
function listenChapter(chapterNum: number) {
|
||||||
|
const voice = data.readyChapters[chapterNum];
|
||||||
|
if (voice) audioStore.voice = voice;
|
||||||
|
audioStore.autoStartChapter = chapterNum;
|
||||||
|
goto(`/books/${data.book.slug}/chapters/${chapterNum}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Search ──────────────────────────────────────────────────────────────────
|
// ── Search ──────────────────────────────────────────────────────────────────
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
|
||||||
@@ -76,6 +90,20 @@
|
|||||||
<h1 class="text-base font-semibold text-(--color-text) truncate">{data.book.title}</h1>
|
<h1 class="text-base font-semibold text-(--color-text) truncate">{data.book.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Audio ready banner ────────────────────────────────────────────────── -->
|
||||||
|
{#if readyCount > 0}
|
||||||
|
<div class="flex items-center gap-2.5 mb-4 px-3 py-2.5 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/25">
|
||||||
|
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-(--color-brand) font-medium">
|
||||||
|
{readyCount} chapter{readyCount !== 1 ? 's' : ''} ready to listen — tap
|
||||||
|
<svg class="w-3 h-3 inline-block" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
to play instantly
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
|
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
|
||||||
<div class="relative mb-4">
|
<div class="relative mb-4">
|
||||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted) pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted) pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
@@ -147,40 +175,65 @@
|
|||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
{#each visibleChapters as chapter}
|
{#each visibleChapters as chapter}
|
||||||
{@const isCurrent = data.lastChapter === chapter.number}
|
{@const isCurrent = data.lastChapter === chapter.number}
|
||||||
<a
|
{@const isReady = readySet.has(chapter.number)}
|
||||||
href="/books/{data.book.slug}/chapters/{chapter.number}"
|
<div
|
||||||
id="ch-{chapter.number}"
|
class="flex items-center gap-1 rounded transition-colors group
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
|
|
||||||
{isCurrent ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)/60'}"
|
{isCurrent ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)/60'}"
|
||||||
>
|
>
|
||||||
<!-- Number badge -->
|
<a
|
||||||
<span
|
href="/books/{data.book.slug}/chapters/{chapter.number}"
|
||||||
class="w-9 text-right text-sm font-mono flex-shrink-0
|
id="ch-{chapter.number}"
|
||||||
{isCurrent ? 'text-(--color-brand) font-semibold' : 'text-(--color-muted)'}"
|
class="flex items-center gap-3 px-3 py-2.5 flex-1 min-w-0"
|
||||||
>
|
>
|
||||||
{chapter.number}
|
<!-- Number badge -->
|
||||||
</span>
|
<span
|
||||||
|
class="w-9 text-right text-sm font-mono flex-shrink-0
|
||||||
<!-- Title -->
|
{isCurrent ? 'text-(--color-brand) font-semibold' : 'text-(--color-muted)'}"
|
||||||
<span
|
>
|
||||||
class="flex-1 min-w-0 text-sm truncate transition-colors
|
{chapter.number}
|
||||||
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
|
|
||||||
>
|
|
||||||
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Date — desktop only -->
|
|
||||||
{#if chapter.date_label}
|
|
||||||
<span class="hidden sm:block text-xs text-(--color-muted) flex-shrink-0">
|
|
||||||
{chapter.date_label}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Reading indicator -->
|
<!-- Title -->
|
||||||
{#if isCurrent}
|
<span
|
||||||
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
|
class="flex-1 min-w-0 text-sm truncate transition-colors
|
||||||
|
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
|
||||||
|
>
|
||||||
|
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Headphones icon for ready chapters -->
|
||||||
|
{#if isReady}
|
||||||
|
<svg class="w-3.5 h-3.5 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24" aria-label="Audio ready">
|
||||||
|
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Date — desktop only -->
|
||||||
|
{#if chapter.date_label}
|
||||||
|
<span class="hidden sm:block text-xs text-(--color-muted) flex-shrink-0">
|
||||||
|
{chapter.date_label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Reading indicator -->
|
||||||
|
{#if isCurrent}
|
||||||
|
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Instant-play button (only for ready chapters) -->
|
||||||
|
{#if isReady}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => listenChapter(chapter.number)}
|
||||||
|
class="mr-2 flex items-center justify-center w-7 h-7 rounded-full bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) transition-colors shrink-0"
|
||||||
|
title="Listen now"
|
||||||
|
aria-label="Listen to chapter {chapter.number} now"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
|
import { getBook, listChapterIdx, getReadyChaptersForSlug } from '$lib/server/pocketbase';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
import { backendFetch } from '$lib/server/scraper';
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
import type { Voice } from '$lib/types';
|
import type { Voice } from '$lib/types';
|
||||||
@@ -87,18 +87,22 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
lang: '',
|
lang: '',
|
||||||
translationStatus: 'unavailable' as string,
|
translationStatus: 'unavailable' as string,
|
||||||
isPro: locals.isPro,
|
isPro: locals.isPro,
|
||||||
chapterImageUrl: null as string | null
|
chapterImageUrl: null as string | null,
|
||||||
|
audioReady: false,
|
||||||
|
availableVoice: null as string | null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
|
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
|
||||||
// Fetch book metadata, chapter index, voice list, and chapter image check in parallel.
|
// Fetch book metadata, chapter index, voice list, chapter image check, and
|
||||||
|
// audio-ready map in parallel.
|
||||||
// HEAD /api/chapter-image checks existence cheaply without downloading the image.
|
// HEAD /api/chapter-image checks existence cheaply without downloading the image.
|
||||||
const [book, chapters, voicesRes, chapterImageRes] = await Promise.all([
|
const [book, chapters, voicesRes, chapterImageRes, readyMap] = await Promise.all([
|
||||||
getBook(slug),
|
getBook(slug),
|
||||||
listChapterIdx(slug),
|
listChapterIdx(slug),
|
||||||
backendFetch('/api/voices').catch(() => null),
|
backendFetch('/api/voices').catch(() => null),
|
||||||
backendFetch(`/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`, { method: 'HEAD' }).catch(() => null)
|
backendFetch(`/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`, { method: 'HEAD' }).catch(() => null),
|
||||||
|
getReadyChaptersForSlug(slug).catch(() => new Map<number, string>())
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!book) error(404, `Book "${slug}" not found`);
|
if (!book) error(404, `Book "${slug}" not found`);
|
||||||
@@ -112,6 +116,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
? `/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`
|
? `/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Audio readiness for this specific chapter
|
||||||
|
const availableVoice = readyMap.get(n) ?? null;
|
||||||
|
const audioReady = availableVoice !== null;
|
||||||
|
|
||||||
// Parse voices — fall back to empty list on error
|
// Parse voices — fall back to empty list on error
|
||||||
let voices: Voice[] = [];
|
let voices: Voice[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -146,7 +154,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
lang,
|
lang,
|
||||||
translationStatus: 'done',
|
translationStatus: 'done',
|
||||||
isPro: locals.isPro,
|
isPro: locals.isPro,
|
||||||
chapterImageUrl
|
chapterImageUrl,
|
||||||
|
audioReady,
|
||||||
|
availableVoice
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// 404 = not generated yet — fall through to original, UI can trigger generation
|
// 404 = not generated yet — fall through to original, UI can trigger generation
|
||||||
@@ -204,6 +214,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
lang: useTranslation ? lang : '',
|
lang: useTranslation ? lang : '',
|
||||||
translationStatus,
|
translationStatus,
|
||||||
isPro: locals.isPro,
|
isPro: locals.isPro,
|
||||||
chapterImageUrl
|
chapterImageUrl,
|
||||||
|
audioReady,
|
||||||
|
availableVoice
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -427,6 +427,13 @@
|
|||||||
audioExpanded = true;
|
audioExpanded = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Audio-ready instant play ─────────────────────────────────────────────────
|
||||||
|
function listenNow() {
|
||||||
|
if (data.availableVoice) audioStore.voice = data.availableVoice;
|
||||||
|
audioStore.autoStartChapter = data.chapter.number;
|
||||||
|
audioExpanded = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -600,6 +607,27 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Audio ready prompt — shown when audio exists and player is not yet active -->
|
||||||
|
{#if data.audioReady && !(audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active)}
|
||||||
|
<div class="mb-3 flex items-center gap-3 px-4 py-3 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/30">
|
||||||
|
<svg class="w-5 h-5 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-(--color-text)">Audio ready</p>
|
||||||
|
<p class="text-xs text-(--color-muted)">This chapter has been narrated — listen instantly</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={listenNow}
|
||||||
|
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
Listen now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Collapsible audio panel -->
|
<!-- Collapsible audio panel -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user