1833 lines
65 KiB
HTML
1833 lines
65 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Audiobook Library</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700;900&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: 'Inter', sans-serif;
|
|
margin: 0;
|
|
background: #f5e9d6;
|
|
min-height: 100vh;
|
|
color: #3e2723;
|
|
}
|
|
|
|
/* Header */
|
|
.library-header {
|
|
background: linear-gradient(135deg, #4a2c2a 0%, #6b4226 100%);
|
|
color: #f5e9d6;
|
|
padding: 24px 32px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.header-container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 24px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.library-title {
|
|
font-family: 'Playfair Display', serif;
|
|
font-weight: 900;
|
|
font-size: 1.8rem;
|
|
margin: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.library-title i { color: #f0c97a; }
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.search-box {
|
|
background: rgba(255,255,255,0.1);
|
|
border: 1.5px solid rgba(255,255,255,0.25);
|
|
color: #f5e9d6;
|
|
border-radius: 24px;
|
|
padding: 8px 18px 8px 40px;
|
|
min-width: 240px;
|
|
font-size: 0.9rem;
|
|
position: relative;
|
|
}
|
|
|
|
.search-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.search-wrapper i {
|
|
position: absolute;
|
|
left: 14px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: rgba(245,233,214,0.6);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.search-box::placeholder { color: rgba(245,233,214,0.5); }
|
|
.search-box:focus {
|
|
outline: none;
|
|
border-color: #f0c97a;
|
|
background: rgba(255,255,255,0.15);
|
|
}
|
|
|
|
.btn-login-link {
|
|
background: rgba(255,255,255,0.15);
|
|
border: 1.5px solid rgba(255,255,255,0.3);
|
|
color: #f5e9d6;
|
|
padding: 8px 20px;
|
|
border-radius: 24px;
|
|
font-weight: 600;
|
|
font-size: 0.88rem;
|
|
text-decoration: none;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.btn-login-link:hover {
|
|
background: rgba(255,255,255,0.25);
|
|
color: #fff;
|
|
}
|
|
|
|
/* Bookcase container */
|
|
.bookcase-container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 40px 24px;
|
|
}
|
|
|
|
.library-intro {
|
|
text-align: center;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.library-intro h2 {
|
|
font-family: 'Playfair Display', serif;
|
|
font-size: 2.2rem;
|
|
font-weight: 700;
|
|
color: #4a2c2a;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.library-intro p {
|
|
color: #6b4226;
|
|
font-size: 1.05rem;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* Bookcase shelf */
|
|
.bookcase {
|
|
background: linear-gradient(180deg, #c8a87b 0%, #a67c52 100%);
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
box-shadow: 0 12px 40px rgba(74,44,42,0.3), inset 0 2px 4px rgba(255,255,255,0.2);
|
|
position: relative;
|
|
}
|
|
|
|
.shelf {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
gap: 24px;
|
|
padding: 20px 16px 36px;
|
|
position: relative;
|
|
border-bottom: 8px solid #6b4226;
|
|
box-shadow: 0 6px 0 #5a3520, 0 8px 12px rgba(0,0,0,0.2);
|
|
margin-bottom: 24px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.shelf:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/* Book card */
|
|
.book-card {
|
|
cursor: pointer;
|
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
transform-origin: bottom center;
|
|
position: relative;
|
|
}
|
|
|
|
.book-card:hover {
|
|
transform: translateY(-12px) scale(1.04);
|
|
}
|
|
|
|
.book-cover {
|
|
width: 100%;
|
|
aspect-ratio: 2 / 3;
|
|
border-radius: 4px 8px 8px 4px;
|
|
overflow: hidden;
|
|
box-shadow:
|
|
-2px 2px 0 rgba(0,0,0,0.1),
|
|
-4px 4px 0 rgba(0,0,0,0.08),
|
|
4px 6px 16px rgba(0,0,0,0.3);
|
|
position: relative;
|
|
background: linear-gradient(135deg, #2c3e50, #4a6278);
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-end;
|
|
padding: 16px;
|
|
color: white;
|
|
}
|
|
|
|
.book-cover::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 4px;
|
|
background: linear-gradient(90deg, rgba(0,0,0,0.3), transparent);
|
|
}
|
|
|
|
.book-cover img {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
z-index: 0;
|
|
}
|
|
|
|
.book-cover-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.85));
|
|
z-index: 1;
|
|
}
|
|
|
|
.book-cover-content {
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.book-cover-default {
|
|
background: linear-gradient(135deg, #2c3e50 0%, #4a6278 100%);
|
|
}
|
|
|
|
.book-cover-default::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 12px;
|
|
left: 12px;
|
|
bottom: 12px;
|
|
border: 2px solid rgba(255,255,255,0.2);
|
|
border-radius: 2px;
|
|
z-index: 1;
|
|
}
|
|
|
|
.book-title {
|
|
font-family: 'Playfair Display', serif;
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
line-height: 1.25;
|
|
margin-bottom: 4px;
|
|
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.book-author {
|
|
font-size: 0.75rem;
|
|
opacity: 0.85;
|
|
font-weight: 500;
|
|
font-style: italic;
|
|
}
|
|
|
|
.book-meta {
|
|
margin-top: 12px;
|
|
padding: 0 4px;
|
|
text-align: center;
|
|
}
|
|
|
|
.book-meta-title {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: #4a2c2a;
|
|
margin-bottom: 2px;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.book-meta-stats {
|
|
font-size: 0.72rem;
|
|
color: #6b4226;
|
|
opacity: 0.7;
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.book-meta-stats i { font-size: 0.7rem; }
|
|
|
|
/* Empty state */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 80px 24px;
|
|
color: #6b4226;
|
|
}
|
|
|
|
.empty-state i {
|
|
font-size: 4rem;
|
|
opacity: 0.4;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.empty-state h3 {
|
|
font-family: 'Playfair Display', serif;
|
|
font-weight: 700;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
/* Loading */
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 80px 24px;
|
|
color: #6b4226;
|
|
}
|
|
|
|
/* ============================================
|
|
Book Detail / Share Modal
|
|
============================================= */
|
|
.book-modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(30, 18, 12, 0.78);
|
|
backdrop-filter: blur(6px);
|
|
z-index: 1000;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.book-modal-overlay.visible {
|
|
display: flex;
|
|
animation: modalFadeIn 0.25s ease;
|
|
}
|
|
|
|
@keyframes modalFadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
.book-modal {
|
|
background: #fdf6ea;
|
|
border-radius: 18px;
|
|
max-width: 440px;
|
|
width: 100%;
|
|
box-shadow: 0 30px 70px rgba(0,0,0,0.45);
|
|
position: relative;
|
|
animation: modalSlideUp 0.3s cubic-bezier(0.34, 1.3, 0.64, 1);
|
|
border: 1px solid rgba(107, 66, 38, 0.15);
|
|
max-height: calc(100vh - 48px);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
@keyframes modalSlideUp {
|
|
from { opacity: 0; transform: translateY(24px) scale(0.97); }
|
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
}
|
|
|
|
.book-modal-header {
|
|
padding: 22px 28px 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.book-modal-header h3 {
|
|
font-family: 'Playfair Display', serif;
|
|
font-weight: 700;
|
|
font-size: 1.25rem;
|
|
color: #4a2c2a;
|
|
margin: 0;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
padding-right: 12px;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 1;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.book-modal-close {
|
|
background: rgba(107, 66, 38, 0.1);
|
|
border: none;
|
|
color: #6b4226;
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 50%;
|
|
font-size: 1.1rem;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.book-modal-close:hover { background: rgba(107, 66, 38, 0.22); }
|
|
|
|
.book-modal-body {
|
|
padding: 18px 28px 28px;
|
|
}
|
|
|
|
.book-modal-top {
|
|
display: flex;
|
|
gap: 18px;
|
|
margin-bottom: 22px;
|
|
}
|
|
|
|
.book-modal-cover {
|
|
width: 96px;
|
|
height: 144px;
|
|
flex-shrink: 0;
|
|
border-radius: 4px 8px 8px 4px;
|
|
overflow: hidden;
|
|
box-shadow: 4px 6px 16px rgba(0,0,0,0.25);
|
|
background: linear-gradient(135deg, #2c3e50, #4a6278);
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-end;
|
|
padding: 10px;
|
|
color: white;
|
|
}
|
|
|
|
.book-modal-cover img {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.book-modal-cover .cover-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.85));
|
|
}
|
|
|
|
.book-modal-cover-text {
|
|
position: relative;
|
|
z-index: 2;
|
|
font-family: 'Playfair Display', serif;
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
|
|
}
|
|
|
|
.book-modal-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.book-modal-info .bm-title {
|
|
font-family: 'Playfair Display', serif;
|
|
font-weight: 700;
|
|
font-size: 1.3rem;
|
|
color: #2c1810;
|
|
line-height: 1.2;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.book-modal-info .bm-meta {
|
|
font-size: 0.82rem;
|
|
color: #6b4226;
|
|
margin-bottom: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.book-modal-info .bm-meta i { opacity: 0.7; }
|
|
|
|
.book-modal-info .bm-desc {
|
|
font-size: 0.85rem;
|
|
color: #5a4636;
|
|
line-height: 1.5;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.book-modal-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 22px;
|
|
}
|
|
|
|
.btn-read {
|
|
flex: 0 0 auto;
|
|
background: linear-gradient(135deg, #c0392b, #a93226);
|
|
color: white;
|
|
border: none;
|
|
padding: 11px 26px;
|
|
border-radius: 10px;
|
|
font-weight: 700;
|
|
font-size: 0.92rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
text-decoration: none;
|
|
transition: all 0.2s;
|
|
box-shadow: 0 4px 12px rgba(192, 57, 43, 0.35);
|
|
}
|
|
|
|
.btn-read:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 16px rgba(192, 57, 43, 0.45);
|
|
color: white;
|
|
}
|
|
|
|
.btn-copy-link {
|
|
flex: 1;
|
|
background: rgba(107, 66, 38, 0.1);
|
|
color: #6b4226;
|
|
border: 1.5px solid rgba(107, 66, 38, 0.25);
|
|
padding: 11px 18px;
|
|
border-radius: 10px;
|
|
font-weight: 600;
|
|
font-size: 0.88rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-copy-link:hover { background: rgba(107, 66, 38, 0.18); }
|
|
.btn-copy-link.copied {
|
|
background: #2e7d32;
|
|
color: white;
|
|
border-color: #2e7d32;
|
|
}
|
|
|
|
.share-section-label {
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
color: #6b4226;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.6px;
|
|
margin-bottom: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.share-buttons {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.share-btn {
|
|
border: none;
|
|
color: white;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
text-decoration: none;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.share-btn:hover { transform: translateY(-2px); filter: brightness(1.08); color: white; }
|
|
.share-btn i { font-size: 0.95rem; }
|
|
|
|
.share-facebook { background: #1877f2; }
|
|
.share-twitter { background: #000000; }
|
|
.share-pinterest{ background: #e60023; }
|
|
.share-whatsapp { background: #25d366; }
|
|
.share-linkedin { background: #0a66c2; }
|
|
.share-email { background: #6b4226; }
|
|
|
|
.qr-section {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 16px;
|
|
background: rgba(107, 66, 38, 0.06);
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(107, 66, 38, 0.12);
|
|
}
|
|
|
|
.qr-code-box {
|
|
width: 110px;
|
|
height: 110px;
|
|
flex-shrink: 0;
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 7px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.qr-code-box img, .qr-code-box canvas {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
}
|
|
|
|
.qr-text {
|
|
font-size: 0.85rem;
|
|
color: #5a4636;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.qr-text strong {
|
|
color: #4a2c2a;
|
|
display: block;
|
|
margin-bottom: 2px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.header-container {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.header-actions {
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.search-box {
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.shelf {
|
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
gap: 16px;
|
|
padding: 16px 12px 28px;
|
|
}
|
|
|
|
.library-intro h2 {
|
|
font-size: 1.6rem;
|
|
}
|
|
|
|
.bookcase {
|
|
padding: 16px;
|
|
}
|
|
|
|
.book-modal-actions { flex-wrap: wrap; }
|
|
.qr-section { flex-direction: column; text-align: center; }
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.shelf {
|
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
}
|
|
|
|
.book-title { font-size: 0.85rem; }
|
|
.book-author { font-size: 0.7rem; }
|
|
}
|
|
|
|
/* ============================================
|
|
Audiobook Player Overlay (Audible-style)
|
|
============================================= */
|
|
.player-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(20, 12, 8, 0.92);
|
|
backdrop-filter: blur(10px);
|
|
z-index: 1200;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
.player-overlay.visible {
|
|
display: flex;
|
|
animation: modalFadeIn 0.25s ease;
|
|
}
|
|
.player-card {
|
|
background: #fdf6ea;
|
|
border-radius: 20px;
|
|
width: 100%;
|
|
max-width: 460px;
|
|
max-height: calc(100vh - 40px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 30px 70px rgba(0,0,0,0.5);
|
|
overflow: hidden;
|
|
animation: modalSlideUp 0.3s cubic-bezier(0.34, 1.3, 0.64, 1);
|
|
}
|
|
.player-top-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 14px 18px;
|
|
flex-shrink: 0;
|
|
}
|
|
.player-top-bar .pt-logo {
|
|
color: #c0392b;
|
|
font-size: 1.4rem;
|
|
}
|
|
.player-close {
|
|
background: rgba(107, 66, 38, 0.1);
|
|
border: none;
|
|
color: #6b4226;
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 50%;
|
|
font-size: 1.05rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.2s;
|
|
}
|
|
.player-close:hover { background: rgba(107, 66, 38, 0.22); }
|
|
|
|
.player-body {
|
|
padding: 0 24px 20px;
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.player-cover {
|
|
width: 180px;
|
|
height: 180px;
|
|
margin: 0 auto 18px;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
|
background: linear-gradient(135deg, #2c3e50, #4a6278);
|
|
position: relative;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
padding: 14px;
|
|
color: white;
|
|
flex-shrink: 0;
|
|
}
|
|
.player-cover img {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.player-cover .cover-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: linear-gradient(to bottom, transparent 45%, rgba(0,0,0,0.8));
|
|
}
|
|
.player-cover-text {
|
|
position: relative;
|
|
z-index: 2;
|
|
font-family: 'Playfair Display', serif;
|
|
font-weight: 700;
|
|
font-size: 0.95rem;
|
|
line-height: 1.2;
|
|
text-shadow: 0 2px 4px rgba(0,0,0,0.6);
|
|
}
|
|
|
|
.player-book-title {
|
|
text-align: center;
|
|
font-family: 'Playfair Display', serif;
|
|
font-weight: 700;
|
|
font-size: 1.15rem;
|
|
color: #2c1810;
|
|
margin-bottom: 2px;
|
|
}
|
|
.player-book-author {
|
|
text-align: center;
|
|
font-size: 0.82rem;
|
|
color: #6b4226;
|
|
font-style: italic;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
/* Subtitle box with word highlight */
|
|
.player-subtitle {
|
|
background: rgba(107, 66, 38, 0.07);
|
|
border: 1px solid rgba(107, 66, 38, 0.14);
|
|
border-radius: 12px;
|
|
padding: 16px 18px;
|
|
font-size: 1.05rem;
|
|
line-height: 1.7;
|
|
color: #3e2b1d;
|
|
max-height: 160px;
|
|
overflow-y: auto;
|
|
margin-bottom: 16px;
|
|
min-height: 70px;
|
|
}
|
|
.player-subtitle .pw {
|
|
cursor: pointer;
|
|
border-radius: 3px;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.player-subtitle .pw:hover { background: #f0e2cd; }
|
|
.player-subtitle .pw.current-word {
|
|
color: #c0392b;
|
|
font-weight: 700;
|
|
text-decoration: underline;
|
|
text-decoration-thickness: 2px;
|
|
text-underline-offset: 3px;
|
|
}
|
|
.player-subtitle .pw.current-sentence {
|
|
-webkit-box-decoration-break: clone;
|
|
box-decoration-break: clone;
|
|
background-color: #f3e3c8;
|
|
border-radius: 4px;
|
|
}
|
|
.player-subtitle-empty {
|
|
color: #a08a72;
|
|
font-style: italic;
|
|
text-align: center;
|
|
}
|
|
|
|
.player-section-label {
|
|
text-align: center;
|
|
font-size: 0.78rem;
|
|
color: #8a6d52;
|
|
margin-bottom: 10px;
|
|
font-weight: 500;
|
|
}
|
|
.player-section-label strong { color: #4a2c2a; }
|
|
|
|
/* Seek bar */
|
|
.player-seek {
|
|
width: 100%;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
height: 6px;
|
|
border-radius: 3px;
|
|
background: rgba(107, 66, 38, 0.2);
|
|
cursor: pointer;
|
|
margin-bottom: 6px;
|
|
}
|
|
.player-seek::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: #c0392b;
|
|
cursor: pointer;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
|
}
|
|
.player-seek::-moz-range-thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: #c0392b;
|
|
cursor: pointer;
|
|
}
|
|
.player-time-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 0.72rem;
|
|
color: #8a6d52;
|
|
margin-bottom: 14px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Controls */
|
|
.player-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 22px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.player-ctrl-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #4a2c2a;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: transform 0.15s, opacity 0.2s;
|
|
}
|
|
.player-ctrl-btn:hover { transform: scale(1.12); }
|
|
.player-ctrl-btn:active { transform: scale(0.95); }
|
|
.player-ctrl-btn:disabled { opacity: 0.3; cursor: not-allowed; transform: none; }
|
|
.player-ctrl-btn svg { width: 30px; height: 30px; fill: currentColor; }
|
|
.player-ctrl-btn .label {
|
|
font-size: 0.62rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.player-play-btn {
|
|
width: 66px;
|
|
height: 66px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, #c0392b, #a93226);
|
|
color: white;
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 6px 18px rgba(192, 57, 43, 0.4);
|
|
transition: transform 0.15s;
|
|
position: relative;
|
|
}
|
|
.player-play-btn:hover { transform: scale(1.06); }
|
|
.player-play-btn:active { transform: scale(0.96); }
|
|
.player-play-btn svg { width: 32px; height: 32px; fill: white; }
|
|
.player-play-spinner {
|
|
width: 30px; height: 30px;
|
|
border: 3px solid rgba(255,255,255,0.35);
|
|
border-top-color: white;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
display: none;
|
|
}
|
|
.player-play-btn.loading svg { display: none; }
|
|
.player-play-btn.loading .player-play-spinner { display: block; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.player-counter {
|
|
text-align: center;
|
|
font-size: 0.72rem;
|
|
color: #a08a72;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.player-cover { width: 150px; height: 150px; }
|
|
.player-controls { gap: 16px; }
|
|
}
|
|
|
|
/* Footer */
|
|
.library-footer {
|
|
text-align: center;
|
|
padding: 32px 24px;
|
|
color: #6b4226;
|
|
font-size: 0.85rem;
|
|
opacity: 0.7;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="library-header">
|
|
<div class="header-container">
|
|
<h1 class="library-title">
|
|
<i class="bi bi-book-half"></i>
|
|
Audiobook Library
|
|
</h1>
|
|
<div class="header-actions">
|
|
<div class="search-wrapper">
|
|
<i class="bi bi-search"></i>
|
|
<input type="text" class="search-box" id="searchInput"
|
|
placeholder="Search books..." oninput="filterBooks(this.value)">
|
|
</div>
|
|
<a href="/login" class="btn-login-link">
|
|
<i class="bi bi-person-circle"></i>
|
|
<span>Sign In</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="bookcase-container">
|
|
<div class="library-intro">
|
|
<h2>Discover Stories That Speak</h2>
|
|
<p>Browse our collection of interactive audiobooks</p>
|
|
</div>
|
|
|
|
<div id="bookcaseContainer">
|
|
<div class="loading-state">
|
|
<div class="spinner-border" role="status" style="color: #6b4226;"></div>
|
|
<p class="mt-3">Loading library...</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="library-footer">
|
|
<p>Powered by Audiobook Maker Pro v4.2</p>
|
|
</footer>
|
|
|
|
<!-- Book Detail / Share Modal -->
|
|
<div class="book-modal-overlay" id="bookModalOverlay" onclick="handleOverlayClick(event)">
|
|
<div class="book-modal" id="bookModal">
|
|
<div class="book-modal-header">
|
|
<h3 id="bmHeaderTitle">Book</h3>
|
|
<button class="book-modal-close" onclick="closeBookModal()" title="Close">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</div>
|
|
<div class="book-modal-body">
|
|
<div class="book-modal-top">
|
|
<div class="book-modal-cover" id="bmCover"></div>
|
|
<div class="book-modal-info">
|
|
<div class="bm-title" id="bmTitle"></div>
|
|
<div class="bm-meta" id="bmAuthor"></div>
|
|
<div class="bm-meta" id="bmExtraMeta"></div>
|
|
<div class="bm-desc" id="bmDesc"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="book-modal-actions">
|
|
<a class="btn-read" id="bmReadBtn" href="#">
|
|
<i class="bi bi-book"></i> Read
|
|
</a>
|
|
<button class="btn-read" id="bmPlayBtn" onclick="openPlayer()"
|
|
style="background:linear-gradient(135deg,#6b4226,#5a3520);box-shadow:0 4px 12px rgba(107,66,38,0.35);">
|
|
<i class="bi bi-headphones"></i> Play Audio
|
|
</button>
|
|
<button class="btn-copy-link" id="bmCopyBtn" onclick="copyBookLink()">
|
|
<i class="bi bi-link-45deg"></i> <span id="bmCopyText">Copy Link</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="share-section-label">
|
|
<i class="bi bi-share-fill"></i> Share
|
|
</div>
|
|
<div class="share-buttons" id="bmShareButtons"></div>
|
|
|
|
<div class="qr-section">
|
|
<div class="qr-code-box" id="bmQrCode"></div>
|
|
<div class="qr-text">
|
|
<strong>Scan to open</strong>
|
|
Open this audiobook on any device by scanning the QR code.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audiobook Player Overlay (Audible-style) -->
|
|
<div class="player-overlay" id="playerOverlay" onclick="handlePlayerOverlayClick(event)">
|
|
<div class="player-card">
|
|
<div class="player-top-bar">
|
|
<i class="bi bi-soundwave pt-logo"></i>
|
|
<button class="player-close" onclick="closePlayer()" title="Close">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</div>
|
|
<div class="player-body">
|
|
<div class="player-cover" id="plCover"></div>
|
|
<div class="player-book-title" id="plTitle"></div>
|
|
<div class="player-book-author" id="plAuthor"></div>
|
|
|
|
<div class="player-section-label" id="plSectionLabel"></div>
|
|
|
|
<div class="player-subtitle" id="plSubtitle">
|
|
<div class="player-subtitle-empty">Press play to begin listening.</div>
|
|
</div>
|
|
|
|
<input type="range" class="player-seek" id="plSeek" min="0" max="100" value="0" step="0.1">
|
|
<div class="player-time-row">
|
|
<span id="plCurrentTime">00:00</span>
|
|
<span id="plDuration">00:00</span>
|
|
</div>
|
|
|
|
<div class="player-controls">
|
|
<button class="player-ctrl-btn" id="plPrevBtn" onclick="playerPrev()" title="Previous">
|
|
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
|
</button>
|
|
<button class="player-play-btn" id="plPlayBtn" onclick="playerTogglePlay()">
|
|
<svg id="plPlayIcon" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
<svg id="plPauseIcon" viewBox="0 0 24 24" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
<div class="player-play-spinner"></div>
|
|
</button>
|
|
<button class="player-ctrl-btn" id="plNextBtn" onclick="playerNext()" title="Next">
|
|
<svg viewBox="0 0 24 24"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="player-counter" id="plCounter"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<script>
|
|
let allBooks = [];
|
|
let currentBook = null;
|
|
let currentBookUrl = '';
|
|
|
|
async function loadBooks() {
|
|
try {
|
|
const resp = await fetch('/api/public/books');
|
|
const data = await resp.json();
|
|
allBooks = data.books || [];
|
|
renderBookcase(allBooks);
|
|
} catch (e) {
|
|
document.getElementById('bookcaseContainer').innerHTML = `
|
|
<div class="empty-state">
|
|
<i class="bi bi-exclamation-circle"></i>
|
|
<h3>Failed to load library</h3>
|
|
<p>Please refresh the page to try again.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function renderBookcase(books) {
|
|
const container = document.getElementById('bookcaseContainer');
|
|
|
|
if (!books || books.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<i class="bi bi-book"></i>
|
|
<h3>No books yet</h3>
|
|
<p>The library is being curated. Check back soon!</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const booksPerShelf = 6;
|
|
const shelves = [];
|
|
for (let i = 0; i < books.length; i += booksPerShelf) {
|
|
shelves.push(books.slice(i, i + booksPerShelf));
|
|
}
|
|
|
|
let html = '<div class="bookcase">';
|
|
|
|
for (const shelf of shelves) {
|
|
html += '<div class="shelf">';
|
|
for (const book of shelf) {
|
|
html += renderBookCard(book);
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function renderBookCard(book) {
|
|
const thumbnailHtml = book.thumbnail_data
|
|
? `<img src="data:image/${book.thumbnail_format};base64,${book.thumbnail_data}" alt="${escapeHtml(book.name)}">
|
|
<div class="book-cover-overlay"></div>`
|
|
: '';
|
|
|
|
const coverClass = book.thumbnail_data ? '' : 'book-cover-default';
|
|
const author = book.author || 'Unknown Author';
|
|
|
|
return `
|
|
<div class="book-card" onclick="openBook(${book.id})" title="${escapeHtml(book.name)}">
|
|
<div class="book-cover ${coverClass}">
|
|
${thumbnailHtml}
|
|
<div class="book-cover-content">
|
|
<div class="book-title">${escapeHtml(book.name)}</div>
|
|
<div class="book-author">by ${escapeHtml(author)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ============================================
|
|
// Book Modal (detail + share + QR)
|
|
// ============================================
|
|
function openBook(bookId) {
|
|
const book = allBooks.find(b => b.id === bookId);
|
|
if (!book) {
|
|
// fallback: open directly
|
|
window.location.href = `/read/${bookId}`;
|
|
return;
|
|
}
|
|
|
|
currentBook = book;
|
|
currentBookUrl = `${window.location.origin}/read/${book.id}`;
|
|
|
|
const author = book.author || 'Unknown Author';
|
|
|
|
// Header + title
|
|
document.getElementById('bmHeaderTitle').textContent = book.name;
|
|
document.getElementById('bmTitle').textContent = book.name;
|
|
|
|
// Cover
|
|
const coverEl = document.getElementById('bmCover');
|
|
if (book.thumbnail_data) {
|
|
coverEl.innerHTML = `
|
|
<img src="data:image/${book.thumbnail_format};base64,${book.thumbnail_data}" alt="${escapeHtml(book.name)}">
|
|
<div class="cover-overlay"></div>
|
|
`;
|
|
} else {
|
|
coverEl.innerHTML = `<div class="book-modal-cover-text">${escapeHtml(book.name)}</div>`;
|
|
}
|
|
|
|
// Author
|
|
document.getElementById('bmAuthor').innerHTML =
|
|
`<i class="bi bi-person"></i> ${escapeHtml(author)}`;
|
|
|
|
// Extra meta (category + chapters)
|
|
const metaParts = [];
|
|
if (book.category) {
|
|
metaParts.push(`<i class="bi bi-tag"></i> ${escapeHtml(book.category)}`);
|
|
}
|
|
if (book.chapter_count) {
|
|
metaParts.push(`<i class="bi bi-collection"></i> ${book.chapter_count} sections`);
|
|
}
|
|
const extraEl = document.getElementById('bmExtraMeta');
|
|
if (metaParts.length) {
|
|
extraEl.innerHTML = metaParts.join('<span style="opacity:0.4;">•</span>');
|
|
extraEl.style.display = 'flex';
|
|
} else {
|
|
extraEl.style.display = 'none';
|
|
}
|
|
|
|
// Description
|
|
const descEl = document.getElementById('bmDesc');
|
|
if (book.description) {
|
|
descEl.textContent = book.description;
|
|
descEl.style.display = 'block';
|
|
} else {
|
|
descEl.style.display = 'none';
|
|
}
|
|
|
|
// Read button
|
|
document.getElementById('bmReadBtn').href = `/read/${book.id}`;
|
|
|
|
// Reset copy button
|
|
const copyBtn = document.getElementById('bmCopyBtn');
|
|
copyBtn.classList.remove('copied');
|
|
document.getElementById('bmCopyText').textContent = 'Copy Link';
|
|
|
|
// Share buttons
|
|
renderShareButtons(book);
|
|
|
|
// QR code
|
|
renderQRCode(currentBookUrl);
|
|
|
|
// Show
|
|
const overlay = document.getElementById('bookModalOverlay');
|
|
overlay.classList.add('visible');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function renderShareButtons(book) {
|
|
const url = encodeURIComponent(currentBookUrl);
|
|
const title = encodeURIComponent(book.name + (book.author ? ` by ${book.author}` : ''));
|
|
const desc = encodeURIComponent(book.description || 'Listen to this interactive audiobook');
|
|
const thumb = book.thumbnail_data
|
|
? '' // Pinterest needs a public image URL; base64 won't work, so skip media param
|
|
: '';
|
|
|
|
const shares = [
|
|
{
|
|
cls: 'share-facebook', icon: 'facebook', label: 'Facebook',
|
|
href: `https://www.facebook.com/sharer/sharer.php?u=${url}`
|
|
},
|
|
{
|
|
cls: 'share-twitter', icon: 'twitter-x', label: 'X',
|
|
href: `https://twitter.com/intent/tweet?url=${url}&text=${title}`
|
|
},
|
|
{
|
|
cls: 'share-pinterest', icon: 'pinterest', label: 'Pinterest',
|
|
href: `https://pinterest.com/pin/create/button/?url=${url}&description=${title}`
|
|
},
|
|
{
|
|
cls: 'share-whatsapp', icon: 'whatsapp', label: 'WhatsApp',
|
|
href: `https://api.whatsapp.com/send?text=${title}%20${url}`
|
|
},
|
|
{
|
|
cls: 'share-linkedin', icon: 'linkedin', label: 'LinkedIn',
|
|
href: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`
|
|
},
|
|
{
|
|
cls: 'share-email', icon: 'envelope-fill', label: 'Email',
|
|
href: `mailto:?subject=${title}&body=${desc}%0A%0A${url}`
|
|
}
|
|
];
|
|
|
|
const container = document.getElementById('bmShareButtons');
|
|
container.innerHTML = shares.map(s => `
|
|
<a class="share-btn ${s.cls}" href="${s.href}" target="_blank" rel="noopener"
|
|
onclick="trackShare(event, '${s.label}')">
|
|
<i class="bi bi-${s.icon}"></i> ${s.label}
|
|
</a>
|
|
`).join('');
|
|
}
|
|
|
|
function trackShare(event, platform) {
|
|
// Email uses mailto (same tab); social opens in popup window for nicer UX
|
|
if (platform !== 'Email') {
|
|
event.preventDefault();
|
|
const href = event.currentTarget.href;
|
|
window.open(href, '_blank', 'width=600,height=600,noopener');
|
|
}
|
|
}
|
|
|
|
function renderQRCode(url) {
|
|
const qrBox = document.getElementById('bmQrCode');
|
|
qrBox.innerHTML = '';
|
|
|
|
if (typeof QRCode === 'undefined') {
|
|
qrBox.innerHTML = '<span style="font-size:0.7rem;color:#999;">QR unavailable</span>';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
new QRCode(qrBox, {
|
|
text: url,
|
|
width: 96,
|
|
height: 96,
|
|
colorDark: '#2c1810',
|
|
colorLight: '#ffffff',
|
|
correctLevel: QRCode.CorrectLevel.M
|
|
});
|
|
} catch (e) {
|
|
qrBox.innerHTML = '<span style="font-size:0.7rem;color:#999;">QR error</span>';
|
|
}
|
|
}
|
|
|
|
async function copyBookLink() {
|
|
const copyBtn = document.getElementById('bmCopyBtn');
|
|
const copyText = document.getElementById('bmCopyText');
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(currentBookUrl);
|
|
} catch (e) {
|
|
// Fallback for older browsers / non-HTTPS
|
|
const ta = document.createElement('textarea');
|
|
ta.value = currentBookUrl;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { document.execCommand('copy'); } catch (err) {}
|
|
document.body.removeChild(ta);
|
|
}
|
|
|
|
copyBtn.classList.add('copied');
|
|
copyText.textContent = 'Copied!';
|
|
setTimeout(() => {
|
|
copyBtn.classList.remove('copied');
|
|
copyText.textContent = 'Copy Link';
|
|
}, 2000);
|
|
}
|
|
|
|
function closeBookModal() {
|
|
const overlay = document.getElementById('bookModalOverlay');
|
|
overlay.classList.remove('visible');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
function handleOverlayClick(event) {
|
|
if (event.target === document.getElementById('bookModalOverlay')) {
|
|
closeBookModal();
|
|
}
|
|
}
|
|
|
|
// Close on Escape
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closeBookModal();
|
|
});
|
|
|
|
function filterBooks(query) {
|
|
query = query.toLowerCase().trim();
|
|
if (!query) {
|
|
renderBookcase(allBooks);
|
|
return;
|
|
}
|
|
const filtered = allBooks.filter(b =>
|
|
b.name.toLowerCase().includes(query) ||
|
|
(b.author && b.author.toLowerCase().includes(query)) ||
|
|
(b.description && b.description.toLowerCase().includes(query))
|
|
);
|
|
renderBookcase(filtered);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text || '';
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ============================================
|
|
// Audiobook Player (Audible-style)
|
|
// ============================================
|
|
let playerState = {
|
|
book: null,
|
|
tracks: [], // playable blocks with audio
|
|
currentIndex: -1,
|
|
audio: null,
|
|
audioUrl: null,
|
|
wordSpans: [],
|
|
wordMap: [],
|
|
sentenceData: [],
|
|
animFrameId: null,
|
|
lastWordSpan: null,
|
|
lastSentenceSpans: [],
|
|
loadingPromise: null,
|
|
isSeeking: false
|
|
};
|
|
|
|
async function openPlayer() {
|
|
if (!currentBook) return;
|
|
|
|
// Show overlay immediately with loading state
|
|
const overlay = document.getElementById('playerOverlay');
|
|
overlay.classList.add('visible');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
// Populate cover/title/author
|
|
const coverEl = document.getElementById('plCover');
|
|
if (currentBook.thumbnail_data) {
|
|
coverEl.innerHTML = `
|
|
<img src="data:image/${currentBook.thumbnail_format};base64,${currentBook.thumbnail_data}" alt="">
|
|
<div class="cover-overlay"></div>
|
|
`;
|
|
} else {
|
|
coverEl.innerHTML = `<div class="player-cover-text">${escapeHtml(currentBook.name)}</div>`;
|
|
}
|
|
document.getElementById('plTitle').textContent = currentBook.name;
|
|
document.getElementById('plAuthor').textContent =
|
|
currentBook.author ? `by ${currentBook.author}` : '';
|
|
|
|
document.getElementById('plSubtitle').innerHTML =
|
|
'<div class="player-subtitle-empty">Loading audiobook…</div>';
|
|
document.getElementById('plSectionLabel').textContent = '';
|
|
document.getElementById('plCounter').textContent = '';
|
|
|
|
// Fetch full book metadata (chapters + blocks + transcription)
|
|
try {
|
|
const resp = await fetch(`/api/public/books/${currentBook.id}`);
|
|
if (!resp.ok) throw new Error('Failed to load');
|
|
const fullBook = await resp.json();
|
|
playerState.book = fullBook;
|
|
|
|
// Build flat list of playable tracks (blocks with audio)
|
|
const tracks = [];
|
|
for (const ch of fullBook.chapters) {
|
|
for (const block of ch.blocks) {
|
|
if (block.has_audio && block.block_type !== 'image') {
|
|
tracks.push({
|
|
blockId: block.id,
|
|
content: block.content || '',
|
|
transcription: block.transcription || [],
|
|
sectionTitle: ch.title,
|
|
sectionNumber: ch.chapter_number
|
|
});
|
|
}
|
|
}
|
|
}
|
|
playerState.tracks = tracks;
|
|
|
|
if (tracks.length === 0) {
|
|
document.getElementById('plSubtitle').innerHTML =
|
|
'<div class="player-subtitle-empty">No audio available for this book.</div>';
|
|
return;
|
|
}
|
|
|
|
playerState.currentIndex = -1;
|
|
loadTrack(0, { autoPlay: false });
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
document.getElementById('plSubtitle').innerHTML =
|
|
'<div class="player-subtitle-empty">Failed to load audiobook.</div>';
|
|
}
|
|
}
|
|
|
|
function loadTrack(index, opts = {}) {
|
|
if (index < 0 || index >= playerState.tracks.length) return;
|
|
if (index === playerState.currentIndex && playerState.audio) {
|
|
if (opts.autoPlay) playerState.audio.play().catch(() => {});
|
|
return;
|
|
}
|
|
|
|
// Tear down previous audio
|
|
releasePlayerAudio();
|
|
|
|
playerState.currentIndex = index;
|
|
const track = playerState.tracks[index];
|
|
|
|
// Render subtitle words
|
|
renderPlayerSubtitle(track);
|
|
buildPlayerSync(track);
|
|
|
|
// Section label + counter
|
|
document.getElementById('plSectionLabel').innerHTML =
|
|
`<strong>${escapeHtml(track.sectionTitle || 'Section')}</strong>`;
|
|
document.getElementById('plCounter').textContent =
|
|
`Track ${index + 1} of ${playerState.tracks.length}`;
|
|
|
|
// Update prev/next disabled state
|
|
document.getElementById('plPrevBtn').disabled = (index === 0);
|
|
document.getElementById('plNextBtn').disabled = (index === playerState.tracks.length - 1);
|
|
|
|
// Reset seek/time
|
|
document.getElementById('plSeek').value = 0;
|
|
document.getElementById('plCurrentTime').textContent = '00:00';
|
|
document.getElementById('plDuration').textContent = '00:00';
|
|
|
|
// Load audio
|
|
const autoPlay = opts.autoPlay !== false;
|
|
ensurePlayerAudio(track).then(() => {
|
|
if (autoPlay && playerState.currentIndex === index) {
|
|
playerState.audio.play().catch(() => {});
|
|
}
|
|
}).catch(err => {
|
|
console.error(err);
|
|
setPlayerLoading(false);
|
|
});
|
|
}
|
|
|
|
function ensurePlayerAudio(track) {
|
|
setPlayerLoading(true);
|
|
playerState.loadingPromise = (async () => {
|
|
const resp = await fetch(`/api/public/books/${playerState.book.id}/audio/${track.blockId}`);
|
|
if (!resp.ok) throw new Error('Audio fetch failed');
|
|
const data = await resp.json();
|
|
if (data.error || !data.audio_data) throw new Error(data.error || 'No audio');
|
|
|
|
const blob = base64ToBlob(data.audio_data, `audio/${data.audio_format || 'mp3'}`);
|
|
const url = URL.createObjectURL(blob);
|
|
const audio = new Audio(url);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const onReady = () => {
|
|
audio.removeEventListener('error', onErr);
|
|
playerState.audio = audio;
|
|
playerState.audioUrl = url;
|
|
wirePlayerAudio(audio);
|
|
setPlayerLoading(false);
|
|
resolve();
|
|
};
|
|
const onErr = () => {
|
|
audio.removeEventListener('canplay', onReady);
|
|
try { URL.revokeObjectURL(url); } catch (e) {}
|
|
setPlayerLoading(false);
|
|
reject(new Error('Audio load error'));
|
|
};
|
|
audio.addEventListener('canplay', onReady, { once: true });
|
|
audio.addEventListener('error', onErr, { once: true });
|
|
audio.preload = 'auto';
|
|
audio.load();
|
|
});
|
|
})();
|
|
return playerState.loadingPromise;
|
|
}
|
|
|
|
function wirePlayerAudio(audio) {
|
|
audio.addEventListener('play', () => {
|
|
updatePlayerPlayIcon(true);
|
|
startPlayerHighlight();
|
|
});
|
|
audio.addEventListener('pause', () => {
|
|
updatePlayerPlayIcon(false);
|
|
stopPlayerHighlight();
|
|
});
|
|
audio.addEventListener('ended', () => {
|
|
stopPlayerHighlight();
|
|
clearPlayerHighlights();
|
|
if (playerState.currentIndex < playerState.tracks.length - 1) {
|
|
loadTrack(playerState.currentIndex + 1, { autoPlay: true });
|
|
} else {
|
|
updatePlayerPlayIcon(false);
|
|
}
|
|
});
|
|
audio.addEventListener('loadedmetadata', () => {
|
|
document.getElementById('plDuration').textContent = formatTime(audio.duration);
|
|
});
|
|
audio.addEventListener('timeupdate', () => {
|
|
if (playerState.isSeeking) return;
|
|
const cur = audio.currentTime;
|
|
const dur = audio.duration || 0;
|
|
document.getElementById('plCurrentTime').textContent = formatTime(cur);
|
|
if (dur > 0) {
|
|
document.getElementById('plSeek').value = (cur / dur) * 100;
|
|
}
|
|
});
|
|
}
|
|
|
|
function setPlayerLoading(loading) {
|
|
const btn = document.getElementById('plPlayBtn');
|
|
btn.classList.toggle('loading', loading);
|
|
}
|
|
|
|
function updatePlayerPlayIcon(playing) {
|
|
document.getElementById('plPlayIcon').style.display = playing ? 'none' : 'block';
|
|
document.getElementById('plPauseIcon').style.display = playing ? 'block' : 'none';
|
|
}
|
|
|
|
function playerTogglePlay() {
|
|
if (!playerState.audio) {
|
|
if (playerState.tracks.length > 0) loadTrack(0, { autoPlay: true });
|
|
return;
|
|
}
|
|
if (playerState.audio.paused) {
|
|
playerState.audio.play().catch(() => {});
|
|
} else {
|
|
playerState.audio.pause();
|
|
}
|
|
}
|
|
|
|
function playerPrev() {
|
|
if (playerState.currentIndex > 0) {
|
|
loadTrack(playerState.currentIndex - 1, { autoPlay: true });
|
|
}
|
|
}
|
|
|
|
function playerNext() {
|
|
if (playerState.currentIndex < playerState.tracks.length - 1) {
|
|
loadTrack(playerState.currentIndex + 1, { autoPlay: true });
|
|
}
|
|
}
|
|
|
|
// Seek bar interaction
|
|
(function initSeek() {
|
|
const seek = document.getElementById('plSeek');
|
|
const onSeekStart = () => { playerState.isSeeking = true; };
|
|
const onSeekEnd = () => {
|
|
if (playerState.audio && playerState.audio.duration) {
|
|
playerState.audio.currentTime =
|
|
(seek.value / 100) * playerState.audio.duration;
|
|
}
|
|
playerState.isSeeking = false;
|
|
};
|
|
seek.addEventListener('input', () => {
|
|
if (playerState.audio && playerState.audio.duration) {
|
|
const t = (seek.value / 100) * playerState.audio.duration;
|
|
document.getElementById('plCurrentTime').textContent = formatTime(t);
|
|
}
|
|
});
|
|
seek.addEventListener('mousedown', onSeekStart);
|
|
seek.addEventListener('touchstart', onSeekStart, { passive: true });
|
|
seek.addEventListener('change', onSeekEnd);
|
|
seek.addEventListener('mouseup', onSeekEnd);
|
|
seek.addEventListener('touchend', onSeekEnd);
|
|
})();
|
|
|
|
// Subtitle rendering + sync
|
|
function renderPlayerSubtitle(track) {
|
|
const container = document.getElementById('plSubtitle');
|
|
container.innerHTML = '';
|
|
playerState.wordSpans = [];
|
|
|
|
const div = document.createElement('div');
|
|
// Strip markdown image refs for cleaner subtitle text
|
|
const text = track.content.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
|
div.innerHTML = (typeof marked !== 'undefined')
|
|
? marked.parse(text, { breaks: true, gfm: true })
|
|
: text;
|
|
|
|
(function processNode(node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const words = node.textContent.split(/(\s+)/);
|
|
const frag = document.createDocumentFragment();
|
|
words.forEach(part => {
|
|
if (part.trim().length > 0) {
|
|
const span = document.createElement('span');
|
|
span.className = 'pw';
|
|
span.textContent = part;
|
|
span.dataset.wordIdx = playerState.wordSpans.length;
|
|
playerState.wordSpans.push(span);
|
|
frag.appendChild(span);
|
|
} else {
|
|
frag.appendChild(document.createTextNode(part));
|
|
}
|
|
});
|
|
node.parentNode.replaceChild(frag, node);
|
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
Array.from(node.childNodes).forEach(processNode);
|
|
}
|
|
})(div);
|
|
|
|
while (div.firstChild) container.appendChild(div.firstChild);
|
|
container.scrollTop = 0;
|
|
|
|
// Click-to-seek on words
|
|
container.onclick = (e) => {
|
|
const span = e.target.closest('.pw');
|
|
if (!span) return;
|
|
const idx = parseInt(span.dataset.wordIdx, 10);
|
|
const ai = playerState.wordMap[idx];
|
|
if (ai === undefined) return;
|
|
const track = playerState.tracks[playerState.currentIndex];
|
|
const ts = track.transcription[ai];
|
|
if (!ts) return;
|
|
if (playerState.audio) {
|
|
playerState.audio.currentTime = ts.start;
|
|
playerState.audio.play().catch(() => {});
|
|
} else {
|
|
loadTrack(playerState.currentIndex, { autoPlay: true });
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildPlayerSync(track) {
|
|
const transcription = track.transcription || [];
|
|
const wordSpans = playerState.wordSpans;
|
|
playerState.wordMap = new Array(wordSpans.length).fill(undefined);
|
|
let ai = 0;
|
|
wordSpans.forEach((span, i) => {
|
|
const tw = span.textContent.toLowerCase().replace(/[^\w]/g, '');
|
|
for (let o = 0; o < 5; o++) {
|
|
if (ai + o >= transcription.length) break;
|
|
const aw = (transcription[ai + o].word || '').toLowerCase().replace(/[^\w]/g, '');
|
|
if (tw === aw) { playerState.wordMap[i] = ai + o; ai += o + 1; return; }
|
|
}
|
|
});
|
|
|
|
playerState.sentenceData = [];
|
|
let buf = [], si = 0;
|
|
wordSpans.forEach((s, i) => {
|
|
buf.push(s);
|
|
if (/[.!?]["'\u201D\u2019]?$/.test(s.textContent.trim())) {
|
|
let sT = 0, eT = 0;
|
|
for (let k = si; k <= i; k++) if (playerState.wordMap[k] !== undefined) { sT = transcription[playerState.wordMap[k]].start; break; }
|
|
for (let k = i; k >= si; k--) if (playerState.wordMap[k] !== undefined) { eT = transcription[playerState.wordMap[k]].end; break; }
|
|
if (eT > sT) playerState.sentenceData.push({ spans: [...buf], startTime: sT, endTime: eT });
|
|
buf = []; si = i + 1;
|
|
}
|
|
});
|
|
if (buf.length > 0) {
|
|
let sT = 0, eT = 0;
|
|
for (let k = si; k < wordSpans.length; k++) if (playerState.wordMap[k] !== undefined) { sT = transcription[playerState.wordMap[k]].start; break; }
|
|
for (let k = wordSpans.length - 1; k >= si; k--) if (playerState.wordMap[k] !== undefined) { eT = transcription[playerState.wordMap[k]].end; break; }
|
|
if (eT > sT) playerState.sentenceData.push({ spans: [...buf], startTime: sT, endTime: eT });
|
|
}
|
|
}
|
|
|
|
function startPlayerHighlight() {
|
|
cancelAnimationFrame(playerState.animFrameId);
|
|
playerState.animFrameId = requestAnimationFrame(playerHighlightLoop);
|
|
}
|
|
function stopPlayerHighlight() {
|
|
cancelAnimationFrame(playerState.animFrameId);
|
|
}
|
|
function playerHighlightLoop() {
|
|
const audio = playerState.audio;
|
|
if (!audio || audio.paused) return;
|
|
const t = audio.currentTime;
|
|
const track = playerState.tracks[playerState.currentIndex];
|
|
const transcription = track ? (track.transcription || []) : [];
|
|
|
|
const ai = transcription.findIndex(w => t >= w.start && t < w.end);
|
|
if (ai !== -1) {
|
|
const ti = playerState.wordMap.findIndex(i => i === ai);
|
|
if (ti !== -1) {
|
|
const sp = playerState.wordSpans[ti];
|
|
if (sp !== playerState.lastWordSpan) {
|
|
if (playerState.lastWordSpan) playerState.lastWordSpan.classList.remove('current-word');
|
|
sp.classList.add('current-word');
|
|
// keep highlighted word in view inside subtitle box
|
|
const box = document.getElementById('plSubtitle');
|
|
const spTop = sp.offsetTop, spBottom = spTop + sp.offsetHeight;
|
|
if (spTop < box.scrollTop || spBottom > box.scrollTop + box.clientHeight) {
|
|
box.scrollTo({ top: spTop - box.clientHeight / 2, behavior: 'smooth' });
|
|
}
|
|
playerState.lastWordSpan = sp;
|
|
}
|
|
}
|
|
}
|
|
|
|
const sent = playerState.sentenceData.find(s => t >= s.startTime && t <= s.endTime);
|
|
if (sent && sent.spans !== playerState.lastSentenceSpans) {
|
|
playerState.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence'));
|
|
sent.spans.forEach(s => s.classList.add('current-sentence'));
|
|
playerState.lastSentenceSpans = sent.spans;
|
|
}
|
|
|
|
playerState.animFrameId = requestAnimationFrame(playerHighlightLoop);
|
|
}
|
|
function clearPlayerHighlights() {
|
|
if (playerState.lastWordSpan) playerState.lastWordSpan.classList.remove('current-word');
|
|
playerState.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence'));
|
|
playerState.lastWordSpan = null;
|
|
playerState.lastSentenceSpans = [];
|
|
}
|
|
|
|
function releasePlayerAudio() {
|
|
stopPlayerHighlight();
|
|
clearPlayerHighlights();
|
|
if (playerState.audio) {
|
|
try { playerState.audio.pause(); } catch (e) {}
|
|
playerState.audio = null;
|
|
}
|
|
if (playerState.audioUrl) {
|
|
try { URL.revokeObjectURL(playerState.audioUrl); } catch (e) {}
|
|
playerState.audioUrl = null;
|
|
}
|
|
playerState.loadingPromise = null;
|
|
setPlayerLoading(false);
|
|
updatePlayerPlayIcon(false);
|
|
}
|
|
|
|
function closePlayer() {
|
|
releasePlayerAudio();
|
|
const overlay = document.getElementById('playerOverlay');
|
|
overlay.classList.remove('visible');
|
|
// book modal still open underneath, keep body locked
|
|
const bookModalVisible = document.getElementById('bookModalOverlay').classList.contains('visible');
|
|
if (!bookModalVisible) document.body.style.overflow = '';
|
|
}
|
|
|
|
function handlePlayerOverlayClick(event) {
|
|
if (event.target === document.getElementById('playerOverlay')) {
|
|
closePlayer();
|
|
}
|
|
}
|
|
|
|
function formatTime(sec) {
|
|
if (!sec || isNaN(sec)) return '00:00';
|
|
const m = Math.floor(sec / 60);
|
|
const s = Math.floor(sec % 60);
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
function base64ToBlob(b64, mime) {
|
|
const bin = atob(b64);
|
|
const arr = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
return new Blob([arr], { type: mime });
|
|
}
|
|
|
|
// Escape closes player first, then book modal
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
const playerVisible = document.getElementById('playerOverlay').classList.contains('visible');
|
|
if (playerVisible) { closePlayer(); }
|
|
}
|
|
});
|
|
|
|
loadBooks();
|
|
</script>
|
|
</body>
|
|
</html>
|