cwchang's picture
feat: 新增片段播放功能
88094ac
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whisper 語音轉文字</title>
<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=Space+Grotesk:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
/* 深邃墨藍色系 */
--ink-deep: #0a0f1a;
--ink-dark: #121829;
--ink-medium: #1a2238;
--ink-light: #252f4a;
/* 琥珀金點綴 */
--amber: #f59e0b;
--amber-glow: #fbbf24;
--amber-soft: rgba(245, 158, 11, 0.15);
/* 青色漣漪 */
--ripple: #06b6d4;
--ripple-soft: rgba(6, 182, 212, 0.12);
/* 紫色錄音 */
--record: #a855f7;
--record-soft: rgba(168, 85, 247, 0.15);
/* 文字色 */
--text-bright: #f8fafc;
--text-soft: #94a3b8;
--text-muted: #64748b;
/* 玻璃效果 */
--glass: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-hover: rgba(255, 255, 255, 0.06);
/* 狀態色 */
--success: #10b981;
--error: #ef4444;
/* 圓角 */
--radius-sm: 12px;
--radius-md: 20px;
--radius-lg: 28px;
--radius-xl: 40px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans TC', 'Space Grotesk', sans-serif;
background: var(--ink-deep);
min-height: 100vh;
color: var(--text-bright);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* 動態背景 */
.bg-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.wave-layer {
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background:
radial-gradient(ellipse 80% 50% at 20% 40%, var(--ripple-soft) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 80% 60%, var(--amber-soft) 0%, transparent 50%);
animation: drift 25s ease-in-out infinite;
}
.wave-layer:nth-child(2) {
animation-delay: -8s;
animation-duration: 30s;
opacity: 0.7;
}
.wave-layer:nth-child(3) {
animation-delay: -16s;
animation-duration: 35s;
opacity: 0.5;
}
@keyframes drift {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(2%, 3%) rotate(1deg); }
50% { transform: translate(-1%, 2%) rotate(-0.5deg); }
75% { transform: translate(-3%, -1%) rotate(0.5deg); }
}
.grid-texture {
position: absolute;
width: 100%;
height: 100%;
background-image:
linear-gradient(var(--glass-border) 1px, transparent 1px),
linear-gradient(90deg, var(--glass-border) 1px, transparent 1px);
background-size: 60px 60px;
opacity: 0.3;
mask-image: radial-gradient(ellipse at center, black 0%, transparent 70%);
}
.page {
position: relative;
z-index: 1;
max-width: 1100px;
margin: 0 auto;
padding: 80px 32px;
}
/* 標題區 */
.header {
text-align: center;
margin-bottom: 64px;
}
.header-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--amber-soft);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 100px;
font-size: 13px;
font-weight: 500;
color: var(--amber-glow);
margin-bottom: 24px;
letter-spacing: 0.02em;
}
.header-badge svg {
width: 14px;
height: 14px;
}
.header h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: clamp(42px, 8vw, 72px);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
margin-bottom: 20px;
background: linear-gradient(135deg, var(--text-bright) 0%, var(--text-soft) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
font-size: 18px;
color: var(--text-muted);
font-weight: 400;
max-width: 440px;
margin: 0 auto;
}
/* 輸入模式切換 */
.input-mode-tabs {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
}
.mode-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 100px;
color: var(--text-soft);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.mode-tab:hover {
background: var(--glass-hover);
color: var(--text-bright);
}
.mode-tab.active {
background: var(--ripple-soft);
border-color: var(--ripple);
color: var(--ripple);
}
.mode-tab.active.record-tab {
background: var(--record-soft);
border-color: var(--record);
color: var(--record);
}
.mode-tab svg {
width: 18px;
height: 18px;
}
/* 主要容器 */
.main-container {
display: grid;
grid-template-columns: 1fr 340px;
gap: 24px;
}
/* 玻璃卡片 */
.glass-card {
background: var(--glass);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 32px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-card:hover {
background: var(--glass-hover);
border-color: rgba(255, 255, 255, 0.12);
transform: translateY(-4px);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.3),
0 0 60px var(--ripple-soft);
}
/* 輸入區域容器 */
.input-zone {
display: flex;
flex-direction: column;
}
.input-panel {
display: none;
}
.input-panel.active {
display: flex;
flex-direction: column;
flex: 1;
}
/* 上傳區域 */
.upload-area {
flex: 1;
min-height: 280px;
border: 2px dashed var(--glass-border);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.upload-area::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at center, var(--ripple-soft) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.4s ease;
}
.upload-area:hover::before {
opacity: 1;
}
.upload-area:hover {
border-color: var(--ripple);
border-style: solid;
}
.upload-area.dragover {
border-color: var(--amber);
background: var(--amber-soft);
}
.upload-area.has-file {
border-style: solid;
border-color: var(--success);
}
.upload-area.has-file::before {
background: radial-gradient(circle at center, rgba(16, 185, 129, 0.1) 0%, transparent 70%);
opacity: 1;
}
/* 聲波圖示 */
.wave-icon {
position: relative;
width: 80px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-bottom: 24px;
}
.wave-bar {
width: 4px;
height: 20px;
background: var(--text-muted);
border-radius: 4px;
transition: all 0.3s ease;
}
.upload-area:hover .wave-bar {
background: var(--ripple);
animation: wave-pulse 1.2s ease-in-out infinite;
}
.wave-bar:nth-child(1) { height: 24px; animation-delay: 0s; }
.wave-bar:nth-child(2) { height: 40px; animation-delay: 0.1s; }
.wave-bar:nth-child(3) { height: 32px; animation-delay: 0.2s; }
.wave-bar:nth-child(4) { height: 48px; animation-delay: 0.3s; }
.wave-bar:nth-child(5) { height: 28px; animation-delay: 0.4s; }
.wave-bar:nth-child(6) { height: 36px; animation-delay: 0.5s; }
.wave-bar:nth-child(7) { height: 20px; animation-delay: 0.6s; }
@keyframes wave-pulse {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.5); }
}
.upload-area.has-file .wave-bar {
background: var(--success);
animation: wave-success 0.6s ease forwards;
}
@keyframes wave-success {
0% { transform: scaleY(1); }
50% { transform: scaleY(1.3); }
100% { transform: scaleY(1); }
}
.upload-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
position: relative;
z-index: 1;
}
.upload-subtitle {
font-size: 14px;
color: var(--text-muted);
position: relative;
z-index: 1;
}
#fileInput {
display: none;
}
/* 檔案資訊 */
.file-info {
display: none;
margin-top: 20px;
padding: 16px 20px;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: var(--radius-sm);
}
.file-info.active {
display: flex;
align-items: center;
gap: 12px;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.file-icon {
width: 20px;
height: 20px;
color: var(--success);
}
.file-name {
font-size: 14px;
font-weight: 500;
color: var(--text-bright);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-remove {
width: 20px;
height: 20px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
padding: 2px;
border-radius: 4px;
}
.file-remove:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
/* 錄音區域 */
.record-area {
flex: 1;
min-height: 280px;
border: 2px solid var(--glass-border);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.record-area::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at center, var(--record-soft) 0%, transparent 70%);
opacity: 0.5;
}
/* 錄音按鈕 */
.record-btn {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, var(--record) 0%, #7c3aed 100%);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 24px;
z-index: 1;
}
.record-btn::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
background: linear-gradient(135deg, var(--record) 0%, #7c3aed 100%);
opacity: 0.3;
transition: all 0.3s ease;
}
.record-btn:hover::before {
inset: -8px;
opacity: 0.4;
}
.record-btn:hover {
transform: scale(1.05);
box-shadow: 0 0 40px rgba(168, 85, 247, 0.4);
}
.record-btn.recording {
animation: pulse-record 1.5s ease-in-out infinite;
}
.record-btn.recording::before {
animation: pulse-ring 1.5s ease-in-out infinite;
}
@keyframes pulse-record {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes pulse-ring {
0%, 100% { inset: -4px; opacity: 0.3; }
50% { inset: -16px; opacity: 0.1; }
}
.record-btn svg {
width: 48px;
height: 48px;
color: white;
position: relative;
z-index: 1;
}
.record-btn.recording svg {
display: none;
}
.record-btn.recording::after {
content: '';
width: 32px;
height: 32px;
background: white;
border-radius: 6px;
}
.record-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
position: relative;
z-index: 1;
}
.record-subtitle {
font-size: 14px;
color: var(--text-muted);
position: relative;
z-index: 1;
}
.record-timer {
display: none;
font-family: 'Space Grotesk', monospace;
font-size: 32px;
font-weight: 600;
color: var(--record);
margin-top: 16px;
position: relative;
z-index: 1;
}
.record-timer.active {
display: block;
animation: fadeIn 0.3s ease;
}
/* 錄音可視化 */
.record-visualizer {
display: none;
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
height: 60px;
gap: 3px;
align-items: center;
}
.record-visualizer.active {
display: flex;
}
.viz-bar {
width: 4px;
height: 20px;
background: var(--record);
border-radius: 4px;
transition: height 0.1s ease;
}
/* 設定面板 */
.settings-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.panel-header {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 16px;
border-bottom: 1px solid var(--glass-border);
margin-bottom: 4px;
}
.panel-icon {
width: 20px;
height: 20px;
color: var(--amber);
}
.panel-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.form-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-bright);
}
select {
appearance: none;
background: var(--ink-medium);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 14px 44px 14px 16px;
font-size: 15px;
font-family: inherit;
color: var(--text-bright);
cursor: pointer;
transition: all 0.3s ease;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
}
select:focus {
outline: none;
border-color: var(--ripple);
box-shadow: 0 0 0 3px var(--ripple-soft);
}
select:hover {
border-color: var(--text-muted);
}
select option {
background: var(--ink-dark);
color: var(--text-bright);
}
/* 動作區 */
.action-section {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 32px;
}
.action-hint {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-muted);
font-size: 14px;
}
.hint-icon {
width: 18px;
height: 18px;
}
.btn-primary {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 16px 32px;
background: linear-gradient(135deg, var(--amber) 0%, #d97706 100%);
color: var(--ink-deep);
border: none;
border-radius: var(--radius-xl);
font-family: 'Space Grotesk', sans-serif;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.btn-primary::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--amber-glow) 0%, var(--amber) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.btn-primary:hover:not(:disabled)::before {
opacity: 1;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow:
0 10px 30px rgba(245, 158, 11, 0.3),
0 0 40px rgba(245, 158, 11, 0.2);
}
.btn-primary:disabled {
background: var(--ink-light);
color: var(--text-muted);
cursor: not-allowed;
}
.btn-icon {
width: 18px;
height: 18px;
position: relative;
z-index: 1;
}
.btn-text {
position: relative;
z-index: 1;
}
/* 載入狀態 */
.loading-section {
display: none;
grid-column: 1 / -1;
padding: 60px 32px;
text-align: center;
}
.loading-section.active {
display: block;
animation: fadeIn 0.4s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.loading-waves {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-bottom: 24px;
}
.loading-bar {
width: 6px;
height: 40px;
background: linear-gradient(to top, var(--ripple), var(--amber));
border-radius: 6px;
animation: loading-wave 1s ease-in-out infinite;
}
.loading-bar:nth-child(1) { animation-delay: 0s; }
.loading-bar:nth-child(2) { animation-delay: 0.1s; }
.loading-bar:nth-child(3) { animation-delay: 0.2s; }
.loading-bar:nth-child(4) { animation-delay: 0.3s; }
.loading-bar:nth-child(5) { animation-delay: 0.4s; }
.loading-bar:nth-child(6) { animation-delay: 0.5s; }
.loading-bar:nth-child(7) { animation-delay: 0.6s; }
@keyframes loading-wave {
0%, 100% { transform: scaleY(0.4); opacity: 0.5; }
50% { transform: scaleY(1); opacity: 1; }
}
.loading-text {
font-size: 16px;
color: var(--text-soft);
}
.loading-subtext {
font-size: 13px;
color: var(--text-muted);
margin-top: 8px;
}
/* 錯誤狀態 */
.error-section {
display: none;
grid-column: 1 / -1;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
padding: 20px 24px;
border-radius: var(--radius-md);
}
.error-section.active {
display: flex;
align-items: center;
gap: 14px;
animation: shake 0.4s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-6px); }
40%, 80% { transform: translateX(6px); }
}
.error-icon {
width: 22px;
height: 22px;
color: var(--error);
flex-shrink: 0;
}
.error-text {
font-size: 14px;
color: var(--error);
}
/* 結果區域 */
.result-section {
display: none;
margin-top: 48px;
}
.result-section.active {
display: block;
animation: slideUp 0.5s ease;
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.result-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, var(--text-bright) 0%, var(--ripple) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.result-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.result-meta {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.meta-tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 100px;
font-size: 13px;
color: var(--text-soft);
}
.meta-tag svg {
width: 14px;
height: 14px;
color: var(--amber);
}
/* 下載按鈕 */
.btn-download {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 100px;
color: var(--text-soft);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
font-family: inherit;
}
.btn-download:hover {
background: var(--ripple-soft);
border-color: var(--ripple);
color: var(--ripple);
}
.btn-download svg {
width: 16px;
height: 16px;
}
.result-grid {
display: grid;
grid-template-columns: 1fr 360px;
gap: 24px;
}
/* 轉錄內容卡片 */
.transcript-card .panel-header {
margin-bottom: 20px;
}
.transcript-content {
font-size: 17px;
line-height: 1.9;
color: var(--text-bright);
white-space: pre-wrap;
}
/* 時間軸卡片 */
.timeline-card {
max-height: 500px;
overflow-y: auto;
}
.timeline-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.timeline-item {
padding: 16px;
background: var(--ink-medium);
border-radius: var(--radius-sm);
border-left: 3px solid transparent;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.timeline-item:hover {
background: var(--ink-light);
border-left-color: var(--ripple);
transform: translateX(4px);
}
.timeline-item.playing {
background: var(--ripple-soft);
border-left-color: var(--amber);
}
.timeline-item.playing .timeline-time {
color: var(--amber);
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.timeline-time {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'Space Grotesk', monospace;
font-size: 12px;
font-weight: 600;
color: var(--ripple);
}
.timeline-time svg {
width: 12px;
height: 12px;
}
.play-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--ripple-soft);
border: 1px solid var(--ripple);
border-radius: 50%;
color: var(--ripple);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.play-btn:hover {
background: var(--ripple);
color: var(--ink-deep);
transform: scale(1.1);
}
.play-btn svg {
width: 14px;
height: 14px;
}
.timeline-item.playing .play-btn {
background: var(--amber);
border-color: var(--amber);
color: var(--ink-deep);
}
.timeline-text {
font-size: 14px;
color: var(--text-soft);
line-height: 1.6;
}
/* 音訊播放器 */
.audio-player-section {
display: none;
margin-bottom: 24px;
padding: 20px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
}
.audio-player-section.active {
display: block;
animation: slideUp 0.3s ease;
}
.audio-player-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.audio-player-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
color: var(--text-soft);
}
.audio-player-title svg {
width: 18px;
height: 18px;
color: var(--amber);
}
.audio-controls {
display: flex;
align-items: center;
gap: 16px;
}
.audio-main-btn {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--amber) 0%, #d97706 100%);
border: none;
border-radius: 50%;
color: var(--ink-deep);
cursor: pointer;
transition: all 0.2s ease;
}
.audio-main-btn:hover {
transform: scale(1.05);
box-shadow: 0 0 20px rgba(245, 158, 11, 0.4);
}
.audio-main-btn svg {
width: 24px;
height: 24px;
}
.audio-progress {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.audio-time {
font-family: 'Space Grotesk', monospace;
font-size: 13px;
color: var(--text-muted);
min-width: 45px;
}
.audio-time.current {
color: var(--amber);
}
.progress-bar {
flex: 1;
height: 6px;
background: var(--ink-light);
border-radius: 3px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--ripple), var(--amber));
border-radius: 3px;
transition: width 0.1s linear;
}
.progress-bar:hover .progress-fill {
background: var(--amber);
}
/* 滾動條 */
.timeline-card::-webkit-scrollbar {
width: 6px;
}
.timeline-card::-webkit-scrollbar-track {
background: transparent;
}
.timeline-card::-webkit-scrollbar-thumb {
background: var(--ink-light);
border-radius: 3px;
}
.timeline-card::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* 響應式 */
@media (max-width: 900px) {
.main-container {
grid-template-columns: 1fr;
}
.result-grid {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 36px;
}
.timeline-card {
max-height: none;
}
}
@media (max-width: 600px) {
.page {
padding: 48px 20px;
}
.header {
margin-bottom: 40px;
}
.glass-card {
padding: 24px;
}
.action-section {
flex-direction: column;
gap: 20px;
padding: 24px;
}
.action-hint {
text-align: center;
}
.btn-primary {
width: 100%;
justify-content: center;
}
.result-header {
flex-direction: column;
align-items: flex-start;
}
.input-mode-tabs {
flex-direction: column;
}
.mode-tab {
justify-content: center;
}
}
</style>
</head>
<body>
<!-- 動態背景 -->
<div class="bg-canvas">
<div class="wave-layer"></div>
<div class="wave-layer"></div>
<div class="wave-layer"></div>
<div class="grid-texture"></div>
</div>
<div class="page">
<header class="header">
<div class="header-badge">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m5 12 7-7 7 7"/>
<path d="M12 19V5"/>
</svg>
Powered by Whisper
</div>
<h1>語音轉文字</h1>
<p>上傳音訊檔案或直接錄音,AI 為您精準轉錄並生成字幕</p>
</header>
<!-- 輸入模式切換 -->
<div class="input-mode-tabs">
<button class="mode-tab active" data-mode="upload">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
上傳檔案
</button>
<button class="mode-tab record-tab" data-mode="record">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="22"/>
</svg>
即時錄音
</button>
</div>
<div class="main-container">
<!-- 輸入區域 -->
<div class="glass-card input-zone">
<!-- 上傳面板 -->
<div class="input-panel active" id="uploadPanel">
<div class="upload-area" id="uploadArea">
<div class="wave-icon">
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
</div>
<p class="upload-title" id="uploadTitle">拖放或點擊上傳音訊</p>
<p class="upload-subtitle">支援 MP3、WAV、OGG、FLAC、M4A、WebM</p>
<input type="file" id="fileInput" accept="audio/*">
</div>
<div class="file-info" id="fileInfo">
<svg class="file-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
</svg>
<span class="file-name" id="fileName"></span>
<svg class="file-remove" id="fileRemove" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</div>
</div>
<!-- 錄音面板 -->
<div class="input-panel" id="recordPanel">
<div class="record-area">
<button class="record-btn" id="recordBtn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="22"/>
</svg>
</button>
<p class="record-title" id="recordTitle">點擊開始錄音</p>
<p class="record-subtitle" id="recordSubtitle">錄製完成後將自動進行轉錄</p>
<div class="record-timer" id="recordTimer">00:00</div>
<div class="record-visualizer" id="recordVisualizer">
<!-- 動態生成的可視化條 -->
</div>
</div>
</div>
</div>
<!-- 設定面板 -->
<div class="glass-card settings-panel">
<div class="panel-header">
<svg class="panel-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="panel-title">轉錄設定</span>
</div>
<div class="form-group">
<label for="language">音訊語言</label>
<select id="language">
<option value="">自動偵測</option>
<option value="zh">中文</option>
<option value="en">English</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
</select>
</div>
<div class="form-group">
<label for="beamSize">辨識精度</label>
<select id="beamSize">
<option value="1">快速模式</option>
<option value="3">標準模式</option>
<option value="5" selected>平衡模式</option>
<option value="8">精確模式</option>
<option value="10">最高精度</option>
</select>
</div>
<div class="form-group">
<label for="toTraditional">中文字體</label>
<select id="toTraditional">
<option value="true" selected>轉換繁體</option>
<option value="false">保持原樣</option>
</select>
</div>
</div>
<!-- 動作區 -->
<div class="glass-card action-section">
<div class="action-hint">
<svg class="hint-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4"/>
<path d="M12 8h.01"/>
</svg>
<span id="actionInfoText">選擇音訊檔案或開始錄音</span>
</div>
<button class="btn-primary" id="transcribeBtn" disabled>
<svg class="btn-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span class="btn-text">開始轉錄</span>
</button>
</div>
<!-- 載入狀態 -->
<div class="glass-card loading-section" id="loading">
<div class="loading-waves">
<div class="loading-bar"></div>
<div class="loading-bar"></div>
<div class="loading-bar"></div>
<div class="loading-bar"></div>
<div class="loading-bar"></div>
<div class="loading-bar"></div>
<div class="loading-bar"></div>
</div>
<p class="loading-text">正在分析音訊內容</p>
<p class="loading-subtext">這可能需要一些時間,取決於音訊長度</p>
</div>
<!-- 錯誤狀態 -->
<div class="error-section" id="error">
<svg class="error-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span class="error-text" id="errorText"></span>
</div>
</div>
<!-- 結果區域 -->
<section class="result-section" id="result">
<div class="result-header">
<div>
<h2 class="result-title">轉錄結果</h2>
<div class="result-meta" style="margin-top: 12px;">
<div class="meta-tag">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span id="detectedLanguage">-</span>
</div>
<div class="meta-tag">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span id="duration">-</span>
</div>
</div>
</div>
<div class="result-actions">
<button class="btn-download" id="downloadSRT">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
下載 SRT
</button>
<button class="btn-download" id="downloadVTT">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
下載 VTT
</button>
<button class="btn-download" id="downloadTXT">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
下載 TXT
</button>
</div>
</div>
<!-- 音訊播放器 -->
<div class="audio-player-section" id="audioPlayerSection">
<div class="audio-player-header">
<div class="audio-player-title">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
</svg>
<span>音訊播放器</span>
<span id="playingSegmentInfo" style="color: var(--amber); margin-left: 8px;"></span>
</div>
</div>
<div class="audio-controls">
<button class="audio-main-btn" id="audioPlayBtn">
<svg id="audioPlayIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<svg id="audioPauseIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display: none;">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>
</button>
<div class="audio-progress">
<span class="audio-time current" id="audioCurrentTime">0:00</span>
<div class="progress-bar" id="audioProgressBar">
<div class="progress-fill" id="audioProgressFill" style="width: 0%;"></div>
</div>
<span class="audio-time" id="audioTotalTime">0:00</span>
</div>
</div>
<audio id="audioPlayer" style="display: none;"></audio>
</div>
<div class="result-grid">
<div class="glass-card transcript-card">
<div class="panel-header">
<svg class="panel-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span class="panel-title">完整內容</span>
</div>
<div class="transcript-content" id="fullText"></div>
</div>
<div class="glass-card timeline-card">
<div class="panel-header">
<svg class="panel-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span class="panel-title">時間軸</span>
</div>
<div class="timeline-list" id="segments"></div>
</div>
</div>
</section>
</div>
<script>
// DOM 元素
const uploadArea = document.getElementById('uploadArea');
const uploadTitle = document.getElementById('uploadTitle');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const fileName = document.getElementById('fileName');
const fileRemove = document.getElementById('fileRemove');
const transcribeBtn = document.getElementById('transcribeBtn');
const actionInfoText = document.getElementById('actionInfoText');
const loading = document.getElementById('loading');
const result = document.getElementById('result');
const error = document.getElementById('error');
const errorText = document.getElementById('errorText');
// 錄音相關元素
const recordBtn = document.getElementById('recordBtn');
const recordTitle = document.getElementById('recordTitle');
const recordSubtitle = document.getElementById('recordSubtitle');
const recordTimer = document.getElementById('recordTimer');
const recordVisualizer = document.getElementById('recordVisualizer');
// 模式切換
const modeTabs = document.querySelectorAll('.mode-tab');
const uploadPanel = document.getElementById('uploadPanel');
const recordPanel = document.getElementById('recordPanel');
// 狀態變數
let selectedFile = null;
let currentMode = 'upload';
let transcriptData = null;
let audioUrl = null;
// 音訊播放器相關
const audioPlayer = document.getElementById('audioPlayer');
const audioPlayerSection = document.getElementById('audioPlayerSection');
const audioPlayBtn = document.getElementById('audioPlayBtn');
const audioPlayIcon = document.getElementById('audioPlayIcon');
const audioPauseIcon = document.getElementById('audioPauseIcon');
const audioCurrentTime = document.getElementById('audioCurrentTime');
const audioTotalTime = document.getElementById('audioTotalTime');
const audioProgressBar = document.getElementById('audioProgressBar');
const audioProgressFill = document.getElementById('audioProgressFill');
const playingSegmentInfo = document.getElementById('playingSegmentInfo');
let currentSegmentEnd = null;
let currentPlayingItem = null;
// 錄音相關變數
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
let recordingStartTime = null;
let timerInterval = null;
let audioContext = null;
let analyser = null;
let dataArray = null;
// 初始化可視化條
function initVisualizer() {
recordVisualizer.innerHTML = '';
for (let i = 0; i < 32; i++) {
const bar = document.createElement('div');
bar.className = 'viz-bar';
recordVisualizer.appendChild(bar);
}
}
initVisualizer();
// 模式切換
modeTabs.forEach(tab => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode;
if (mode === currentMode) return;
currentMode = mode;
modeTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
if (mode === 'upload') {
uploadPanel.classList.add('active');
recordPanel.classList.remove('active');
updateActionHint();
} else {
uploadPanel.classList.remove('active');
recordPanel.classList.add('active');
actionInfoText.textContent = '點擊錄音按鈕開始';
transcribeBtn.disabled = true;
}
result.classList.remove('active');
error.classList.remove('active');
});
});
// ============ 上傳功能 ============
uploadArea.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
handleFile(e.target.files[0]);
});
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
handleFile(e.dataTransfer.files[0]);
});
fileRemove.addEventListener('click', (e) => {
e.stopPropagation();
clearFile();
});
function handleFile(file) {
if (!file) return;
selectedFile = file;
fileName.textContent = file.name;
fileInfo.classList.add('active');
uploadArea.classList.add('has-file');
uploadTitle.textContent = '檔案已準備就緒';
// 創建音訊 URL 供播放器使用
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
audioUrl = URL.createObjectURL(file);
transcribeBtn.disabled = false;
actionInfoText.textContent = '準備就緒,點擊按鈕開始轉錄';
result.classList.remove('active');
error.classList.remove('active');
audioPlayerSection.classList.remove('active');
}
function clearFile() {
selectedFile = null;
fileInput.value = '';
fileName.textContent = '';
fileInfo.classList.remove('active');
uploadArea.classList.remove('has-file');
uploadTitle.textContent = '拖放或點擊上傳音訊';
transcribeBtn.disabled = true;
updateActionHint();
}
function updateActionHint() {
if (currentMode === 'upload') {
actionInfoText.textContent = selectedFile ? '準備就緒,點擊按鈕開始轉錄' : '選擇音訊檔案後即可開始轉錄';
transcribeBtn.disabled = !selectedFile;
}
}
// ============ 錄音功能 ============
recordBtn.addEventListener('click', toggleRecording);
async function toggleRecording() {
if (isRecording) {
stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 設定音訊可視化
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
analyser.fftSize = 64;
dataArray = new Uint8Array(analyser.frequencyBinCount);
// 設定 MediaRecorder
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
audioChunks = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunks.push(e.data);
}
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
selectedFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
// 創建音訊 URL 供播放器使用
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
audioUrl = URL.createObjectURL(audioBlob);
// 清理
stream.getTracks().forEach(track => track.stop());
if (audioContext) {
audioContext.close();
audioContext = null;
}
// 自動開始轉錄
await transcribe();
};
mediaRecorder.start();
isRecording = true;
recordingStartTime = Date.now();
// 更新 UI
recordBtn.classList.add('recording');
recordTitle.textContent = '錄音中...';
recordSubtitle.textContent = '再次點擊停止錄音';
recordTimer.classList.add('active');
recordVisualizer.classList.add('active');
// 啟動計時器
timerInterval = setInterval(updateTimer, 100);
// 啟動可視化
visualize();
} catch (err) {
showError('無法存取麥克風:' + err.message);
}
}
function stopRecording() {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
isRecording = false;
// 停止計時器
clearInterval(timerInterval);
// 更新 UI
recordBtn.classList.remove('recording');
recordTitle.textContent = '處理中...';
recordSubtitle.textContent = '正在準備轉錄';
recordVisualizer.classList.remove('active');
}
}
function updateTimer() {
const elapsed = Date.now() - recordingStartTime;
const seconds = Math.floor(elapsed / 1000);
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
recordTimer.textContent = `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function visualize() {
if (!isRecording || !analyser) return;
analyser.getByteFrequencyData(dataArray);
const bars = recordVisualizer.querySelectorAll('.viz-bar');
bars.forEach((bar, i) => {
const value = dataArray[i] || 0;
const height = Math.max(4, (value / 255) * 50);
bar.style.height = `${height}px`;
});
requestAnimationFrame(visualize);
}
// ============ 轉錄功能 ============
transcribeBtn.addEventListener('click', transcribe);
async function transcribe() {
if (!selectedFile) return;
const formData = new FormData();
formData.append('audio', selectedFile);
formData.append('language', document.getElementById('language').value);
formData.append('beam_size', document.getElementById('beamSize').value);
formData.append('to_traditional', document.getElementById('toTraditional').value);
transcribeBtn.disabled = true;
loading.classList.add('active');
result.classList.remove('active');
error.classList.remove('active');
try {
const response = await fetch('/api/transcribe', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
transcriptData = data;
displayResult(data);
} else {
showError(data.error || '轉錄失敗');
}
} catch (err) {
showError('網路錯誤:' + err.message);
} finally {
loading.classList.remove('active');
transcribeBtn.disabled = false;
// 重置錄音 UI
if (currentMode === 'record') {
recordTitle.textContent = '點擊開始錄音';
recordSubtitle.textContent = '錄製完成後將自動進行轉錄';
recordTimer.classList.remove('active');
recordTimer.textContent = '00:00';
}
}
}
function displayResult(data) {
const langMap = {
'zh': '中文',
'en': 'English',
'ja': '日本語',
'ko': '한국어',
'es': 'Español',
'fr': 'Français',
'de': 'Deutsch'
};
document.getElementById('detectedLanguage').textContent = langMap[data.language] || data.language.toUpperCase();
document.getElementById('duration').textContent = formatDuration(data.duration);
document.getElementById('fullText').textContent = data.full_text;
const segmentsDiv = document.getElementById('segments');
segmentsDiv.innerHTML = '';
data.segments.forEach((segment, index) => {
const segmentDiv = document.createElement('div');
segmentDiv.className = 'timeline-item';
segmentDiv.dataset.start = segment.start;
segmentDiv.dataset.end = segment.end;
segmentDiv.dataset.index = index;
segmentDiv.innerHTML = `
<div class="timeline-header">
<div class="timeline-time">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
${formatTime(segment.start)}${formatTime(segment.end)}
</div>
<button class="play-btn" title="播放此片段">
<svg class="play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<svg class="pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display: none;">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>
</button>
</div>
<div class="timeline-text">${segment.text}</div>
`;
// 點擊片段播放
segmentDiv.addEventListener('click', (e) => {
if (!e.target.closest('.play-btn')) {
playSegment(segment.start, segment.end, segmentDiv, index);
}
});
// 點擊播放按鈕
const playBtn = segmentDiv.querySelector('.play-btn');
playBtn.addEventListener('click', (e) => {
e.stopPropagation();
playSegment(segment.start, segment.end, segmentDiv, index);
});
segmentsDiv.appendChild(segmentDiv);
});
// 設置音訊播放器
if (audioUrl) {
audioPlayer.src = audioUrl;
audioPlayerSection.classList.add('active');
audioTotalTime.textContent = formatTime(data.duration);
}
result.classList.add('active');
actionInfoText.textContent = '轉錄完成!點擊時間軸片段可播放對應音訊';
}
// ============ 音訊播放功能 ============
function playSegment(start, end, itemElement, index) {
// 如果點擊的是正在播放的片段,則暫停
if (currentPlayingItem === itemElement && !audioPlayer.paused) {
audioPlayer.pause();
return;
}
// 移除之前的 playing 狀態
document.querySelectorAll('.timeline-item.playing').forEach(item => {
item.classList.remove('playing');
item.querySelector('.play-icon').style.display = '';
item.querySelector('.pause-icon').style.display = 'none';
});
// 設置當前片段
currentSegmentEnd = end;
currentPlayingItem = itemElement;
// 更新 UI
itemElement.classList.add('playing');
itemElement.querySelector('.play-icon').style.display = 'none';
itemElement.querySelector('.pause-icon').style.display = '';
playingSegmentInfo.textContent = `片段 ${index + 1}`;
// 播放
audioPlayer.currentTime = start;
audioPlayer.play();
updatePlayPauseButton(true);
}
// 音訊播放器事件
audioPlayer.addEventListener('timeupdate', () => {
// 更新進度條
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
audioProgressFill.style.width = `${progress}%`;
audioCurrentTime.textContent = formatTime(audioPlayer.currentTime);
// 如果到達片段結束時間,暫停播放
if (currentSegmentEnd !== null && audioPlayer.currentTime >= currentSegmentEnd) {
audioPlayer.pause();
currentSegmentEnd = null;
}
});
audioPlayer.addEventListener('play', () => {
updatePlayPauseButton(true);
});
audioPlayer.addEventListener('pause', () => {
updatePlayPauseButton(false);
// 更新片段按鈕狀態
if (currentPlayingItem) {
currentPlayingItem.querySelector('.play-icon').style.display = '';
currentPlayingItem.querySelector('.pause-icon').style.display = 'none';
}
});
audioPlayer.addEventListener('ended', () => {
updatePlayPauseButton(false);
resetPlayingState();
});
audioPlayer.addEventListener('loadedmetadata', () => {
audioTotalTime.textContent = formatTime(audioPlayer.duration);
});
// 主播放按鈕
audioPlayBtn.addEventListener('click', () => {
if (audioPlayer.paused) {
currentSegmentEnd = null; // 不限制結束時間
playingSegmentInfo.textContent = '完整播放';
resetPlayingState();
audioPlayer.play();
} else {
audioPlayer.pause();
}
});
// 進度條點擊
audioProgressBar.addEventListener('click', (e) => {
const rect = audioProgressBar.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
audioPlayer.currentTime = percent * audioPlayer.duration;
currentSegmentEnd = null; // 手動跳轉後不限制結束時間
playingSegmentInfo.textContent = '';
resetPlayingState();
});
function updatePlayPauseButton(isPlaying) {
audioPlayIcon.style.display = isPlaying ? 'none' : '';
audioPauseIcon.style.display = isPlaying ? '' : 'none';
}
function resetPlayingState() {
document.querySelectorAll('.timeline-item.playing').forEach(item => {
item.classList.remove('playing');
item.querySelector('.play-icon').style.display = '';
item.querySelector('.pause-icon').style.display = 'none';
});
currentPlayingItem = null;
}
// ============ 字幕下載功能 ============
document.getElementById('downloadSRT').addEventListener('click', () => downloadSubtitle('srt'));
document.getElementById('downloadVTT').addEventListener('click', () => downloadSubtitle('vtt'));
document.getElementById('downloadTXT').addEventListener('click', () => downloadSubtitle('txt'));
function downloadSubtitle(format) {
if (!transcriptData) return;
let content = '';
let filename = `transcript.${format}`;
let mimeType = 'text/plain';
if (format === 'srt') {
content = generateSRT(transcriptData.segments);
mimeType = 'text/srt';
} else if (format === 'vtt') {
content = generateVTT(transcriptData.segments);
mimeType = 'text/vtt';
} else {
content = transcriptData.full_text;
}
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function generateSRT(segments) {
return segments.map((seg, i) => {
const startTime = formatSRTTime(seg.start);
const endTime = formatSRTTime(seg.end);
return `${i + 1}\n${startTime} --> ${endTime}\n${seg.text}\n`;
}).join('\n');
}
function generateVTT(segments) {
let vtt = 'WEBVTT\n\n';
vtt += segments.map((seg, i) => {
const startTime = formatVTTTime(seg.start);
const endTime = formatVTTTime(seg.end);
return `${i + 1}\n${startTime} --> ${endTime}\n${seg.text}\n`;
}).join('\n');
return vtt;
}
function formatSRTTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.round((seconds % 1) * 1000);
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(ms, 3)}`;
}
function formatVTTTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.round((seconds % 1) * 1000);
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)}.${pad(ms, 3)}`;
}
function pad(num, size) {
return num.toString().padStart(size, '0');
}
// ============ 工具函數 ============
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatDuration(seconds) {
if (seconds < 60) {
return `${Math.round(seconds)} 秒`;
}
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${mins}${secs} 秒`;
}
function showError(message) {
errorText.textContent = message;
error.classList.add('active');
}
</script>
</body>
</html>