feat: Add auto-prefix option for chapter titles and enhance reader functionality

- Introduced `auto_prefix_chapter_titles` setting in Job and PendingJob classes to control prefixing of chapter titles with "Chapter".
- Updated job detail and settings templates to display and configure the new option.
- Enhanced reader.js to manage playback controls, including chapter navigation and playback speed adjustments.
- Implemented a new prepare_chapters.html template for chapter selection and configuration during job preparation.
- Added tests for chapter title formatting and heading equivalence to ensure correct behavior of the new feature.
This commit is contained in:
JB
2025-10-14 06:24:15 -07:00
parent bccfd9f5c5
commit 7ca030d67d
11 changed files with 960 additions and 44 deletions
+97 -14
View File
@@ -66,6 +66,67 @@ def _coerce_truthy(value: Any, default: bool = True) -> bool:
return bool(value) return bool(value)
_HEADING_SANITIZE_RE = re.compile(r"[^a-z0-9]+")
_HEADING_NUMBER_PREFIX_RE = re.compile(r"^\s*(?P<number>(?:\d+|[ivxlcdm]+))(?P<suffix>(?:[\s.:;-].*)?)$", re.IGNORECASE)
def _simplify_heading_text(text: str) -> str:
raw = str(text or "").strip().lower()
if not raw:
return ""
simplified = _HEADING_SANITIZE_RE.sub("", raw)
if simplified.startswith("chapter"):
simplified = simplified[7:]
return simplified
def _headings_equivalent(left: str, right: str) -> bool:
simple_left = _simplify_heading_text(left)
simple_right = _simplify_heading_text(right)
return bool(simple_left and simple_left == simple_right)
def _format_spoken_chapter_title(title: str, index: int, apply_prefix: bool) -> str:
base = str(title or "").strip()
if not base:
return f"Chapter {index}" if apply_prefix else ""
if not apply_prefix:
return base
lowered = base.lower()
if lowered.startswith("chapter") and (len(lowered) == 7 or not lowered[7].isalpha()):
return base
match = _HEADING_NUMBER_PREFIX_RE.match(base)
if match:
number = match.group("number") or ""
suffix = match.group("suffix") or ""
return f"Chapter {number}{suffix}"
return base
def _strip_duplicate_heading_line(text: str, heading: str) -> tuple[str, bool]:
source_text = str(text or "")
if not source_text:
return source_text, False
normalized_heading = _simplify_heading_text(heading)
if not normalized_heading:
return source_text, False
lines = source_text.splitlines()
new_lines: List[str] = []
removed = False
for line in lines:
stripped = line.strip()
if not removed and stripped:
if _headings_equivalent(stripped, heading):
removed = True
continue
new_lines.append(line)
if not removed:
return source_text, False
while new_lines and not new_lines[0].strip():
new_lines.pop(0)
return "\n".join(new_lines), True
def _spec_to_voice_ids(spec: Any) -> Set[str]: def _spec_to_voice_ids(spec: Any) -> Set[str]:
text = str(spec or "").strip() text = str(spec or "").strip()
if not text: if not text:
@@ -909,6 +970,7 @@ def run_conversion_job(job: Job) -> None:
idx: items for idx, items in chunk_groups.items() if 0 <= idx < total_chapters idx: items for idx, items in chunk_groups.items() if 0 <= idx < total_chapters
} }
job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}")
auto_prefix_titles = getattr(job, "auto_prefix_chapter_titles", True)
def emit_text( def emit_text(
text: str, text: str,
@@ -992,7 +1054,11 @@ def run_conversion_job(job: Job) -> None:
for idx, chapter in enumerate(extraction.chapters, start=1): for idx, chapter in enumerate(extraction.chapters, start=1):
canceller() canceller()
job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}") raw_title = str(getattr(chapter, "title", "") or "").strip()
spoken_title = _format_spoken_chapter_title(raw_title, idx, auto_prefix_titles)
heading_text = spoken_title or raw_title
chapter_display_title = heading_text or f"Chapter {idx}"
job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter_display_title}")
chapter_start_time = current_time chapter_start_time = current_time
chapter_override = ( chapter_override = (
@@ -1016,7 +1082,7 @@ def run_conversion_job(job: Job) -> None:
if chapter_dir is not None: if chapter_dir is not None:
chapter_audio_path = _build_output_path( chapter_audio_path = _build_output_path(
chapter_dir, chapter_dir,
f"{Path(job.original_filename).stem}_{_slugify(chapter.title, idx)}", f"{Path(job.original_filename).stem}_{_slugify(chapter_display_title, idx)}",
job.separate_chapters_format, job.separate_chapters_format,
) )
chapter_sink = _open_audio_sink( chapter_sink = _open_audio_sink(
@@ -1026,17 +1092,18 @@ def run_conversion_job(job: Job) -> None:
fmt=job.separate_chapters_format, fmt=job.separate_chapters_format,
) )
speak_heading = bool(chapter.title.strip()) speak_heading = bool(heading_text)
if speak_heading: first_line = ""
stripped_title = chapter.title.strip() if chapter.text:
if stripped_title:
first_line = next((line.strip() for line in chapter.text.splitlines() if line.strip()), "") first_line = next((line.strip() for line in chapter.text.splitlines() if line.strip()), "")
if first_line and first_line.casefold() == stripped_title.casefold(): remove_heading_from_body = False
speak_heading = False if speak_heading and first_line:
if _headings_equivalent(first_line, heading_text) or (raw_title and _headings_equivalent(first_line, raw_title)):
remove_heading_from_body = True
if speak_heading: if speak_heading:
heading_segments = emit_text( heading_segments = emit_text(
chapter.title, heading_text,
voice_choice=voice_choice, voice_choice=voice_choice,
chapter_sink=chapter_sink, chapter_sink=chapter_sink,
preview_prefix=f"Chapter {idx} title", preview_prefix=f"Chapter {idx} title",
@@ -1052,6 +1119,7 @@ def run_conversion_job(job: Job) -> None:
chunks_for_chapter = chunk_groups.get(idx - 1, []) if chunk_groups else [] chunks_for_chapter = chunk_groups.get(idx - 1, []) if chunk_groups else []
body_segments = 0 body_segments = 0
pending_heading_strip = remove_heading_from_body
if chunks_for_chapter: if chunks_for_chapter:
job.add_log( job.add_log(
f"Emitting {len(chunks_for_chapter)} {job.chunk_level} chunks for chapter {idx}.", f"Emitting {len(chunks_for_chapter)} {job.chunk_level} chunks for chapter {idx}.",
@@ -1066,6 +1134,15 @@ def run_conversion_job(job: Job) -> None:
if not chunk_text: if not chunk_text:
continue continue
if pending_heading_strip and heading_text:
chunk_text, removed_heading = _strip_duplicate_heading_line(chunk_text, heading_text)
if removed_heading:
pending_heading_strip = False
chunk_entry = dict(chunk_entry)
chunk_entry["normalized_text"] = chunk_text
if not chunk_text.strip():
continue
chunk_voice_spec = _chunk_voice_spec( chunk_voice_spec = _chunk_voice_spec(
job, job,
chunk_entry, chunk_entry,
@@ -1116,8 +1193,13 @@ def run_conversion_job(job: Job) -> None:
if body_segments == 0: if body_segments == 0:
chapter_body_start = current_time chapter_body_start = current_time
chapter_text = chapter.text
if pending_heading_strip and heading_text:
chapter_text, removed_heading = _strip_duplicate_heading_line(chapter_text, heading_text)
if removed_heading:
pending_heading_strip = False
emitted = emit_text( emitted = emit_text(
chapter.text, chapter_text,
voice_choice=voice_choice, voice_choice=voice_choice,
chapter_sink=chapter_sink, chapter_sink=chapter_sink,
) )
@@ -1169,15 +1251,16 @@ def run_conversion_job(job: Job) -> None:
) )
chapter_end_time = current_time chapter_end_time = current_time
chapter_markers.append( marker = {
{
"index": idx, "index": idx,
"title": chapter.title, "title": chapter_display_title,
"start": chapter_start_time, "start": chapter_start_time,
"end": chapter_end_time, "end": chapter_end_time,
"voice": chapter_voice_spec, "voice": chapter_voice_spec,
} }
) if raw_title and raw_title != chapter_display_title:
marker["original_title"] = raw_title
chapter_markers.append(marker)
if not audio_path and chapter_paths: if not audio_path and chapter_paths:
job.result.audio_path = chapter_paths[0] job.result.audio_path = chapter_paths[0]
+110
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import io import io
import json import json
import math
import mimetypes import mimetypes
import os import os
import posixpath import posixpath
@@ -196,6 +197,51 @@ def _decode_text(payload: bytes) -> str:
return payload.decode("utf-8", "ignore") return payload.decode("utf-8", "ignore")
def _coerce_positive_time(value: Any) -> Optional[float]:
try:
numeric = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(numeric) or numeric < 0:
return None
return numeric
def _load_job_metadata(job: Job) -> Dict[str, Any]:
result = getattr(job, "result", None)
artifacts = getattr(result, "artifacts", None)
if not isinstance(artifacts, Mapping):
return {}
metadata_ref = artifacts.get("metadata")
if isinstance(metadata_ref, Path):
metadata_path = metadata_ref
elif isinstance(metadata_ref, str):
metadata_path = Path(metadata_ref)
else:
return {}
if not metadata_path.exists():
return {}
try:
return json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return {}
def _resolve_book_title(job: Job, *metadata_sources: Mapping[str, Any]) -> str:
for source in metadata_sources:
if not isinstance(source, Mapping):
continue
for key in ("title", "book_title", "name", "album", "album_title"):
value = source.get(key)
if isinstance(value, str):
candidate = value.strip()
if candidate:
return candidate
filename = job.original_filename or ""
stem = Path(filename).stem if filename else ""
return stem or filename
class _NavMapParser(HTMLParser): class _NavMapParser(HTMLParser):
def __init__(self, base_dir: str) -> None: def __init__(self, base_dir: str) -> None:
super().__init__() super().__init__()
@@ -1815,6 +1861,7 @@ BOOLEAN_SETTINGS = {
"save_as_project", "save_as_project",
"generate_epub3", "generate_epub3",
"enable_entity_recognition", "enable_entity_recognition",
"auto_prefix_chapter_titles",
} }
FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"} FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"}
@@ -1843,6 +1890,7 @@ def _settings_defaults() -> Dict[str, Any]:
"chunk_level": "paragraph", "chunk_level": "paragraph",
"enable_entity_recognition": True, "enable_entity_recognition": True,
"generate_epub3": False, "generate_epub3": False,
"auto_prefix_chapter_titles": True,
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD, "speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
"speaker_pronunciation_sentence": "This is {{name}} speaking.", "speaker_pronunciation_sentence": "This is {{name}} speaking.",
"speaker_random_languages": [], "speaker_random_languages": [],
@@ -2946,6 +2994,7 @@ def enqueue_job() -> ResponseReturnValue:
silence_between_chapters = settings["silence_between_chapters"] silence_between_chapters = settings["silence_between_chapters"]
chapter_intro_delay = settings["chapter_intro_delay"] chapter_intro_delay = settings["chapter_intro_delay"]
max_subtitle_words = settings["max_subtitle_words"] max_subtitle_words = settings["max_subtitle_words"]
auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"]
chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower() chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
raw_chunk_level = (request.form.get("chunk_level") or chunk_level_default).strip().lower() raw_chunk_level = (request.form.get("chunk_level") or chunk_level_default).strip().lower()
@@ -3011,6 +3060,7 @@ def enqueue_job() -> ResponseReturnValue:
cover_image_path=cover_path, cover_image_path=cover_path,
cover_image_mime=cover_mime, cover_image_mime=cover_mime,
chapter_intro_delay=chapter_intro_delay, chapter_intro_delay=chapter_intro_delay,
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
chunk_level=chunk_level_value, chunk_level=chunk_level_value,
speaker_mode=speaker_mode_value, speaker_mode=speaker_mode_value,
generate_epub3=generate_epub3, generate_epub3=generate_epub3,
@@ -3290,6 +3340,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
cover_image_path=pending.cover_image_path, cover_image_path=pending.cover_image_path,
cover_image_mime=pending.cover_image_mime, cover_image_mime=pending.cover_image_mime,
chapter_intro_delay=pending.chapter_intro_delay, chapter_intro_delay=pending.chapter_intro_delay,
auto_prefix_chapter_titles=getattr(pending, "auto_prefix_chapter_titles", True),
chunk_level=pending.chunk_level, chunk_level=pending.chunk_level,
chunks=processed_chunks, chunks=processed_chunks,
speakers=roster, speakers=roster,
@@ -3581,6 +3632,63 @@ def job_reader(job_id: str) -> ResponseReturnValue:
asset_base = url_for("web.job_reader_asset", job_id=job.id, asset_path="").rstrip("/") + "/" asset_base = url_for("web.job_reader_asset", job_id=job.id, asset_path="").rstrip("/") + "/"
audio_url = url_for("web.job_audio_stream", job_id=job.id) if audio_path else "" audio_url = url_for("web.job_audio_stream", job_id=job.id) if audio_path else ""
epub_url = url_for("web.job_epub", job_id=job.id) epub_url = url_for("web.job_epub", job_id=job.id)
metadata_payload = _load_job_metadata(job)
metadata_section_raw = metadata_payload.get("metadata") if isinstance(metadata_payload, Mapping) else {}
metadata_section = metadata_section_raw if isinstance(metadata_section_raw, Mapping) else {}
job_metadata = job.metadata_tags if isinstance(job.metadata_tags, Mapping) else {}
display_title = _resolve_book_title(job, metadata_section, job_metadata)
timing_map: Dict[int, Dict[str, Any]] = {}
chapter_entries = metadata_payload.get("chapters") if isinstance(metadata_payload, Mapping) else []
for entry in chapter_entries or []:
if not isinstance(entry, Mapping):
continue
index_raw = entry.get("index")
index_value: Optional[int]
if isinstance(index_raw, (int, float)) and not isinstance(index_raw, bool):
index_value = int(index_raw) - 1
elif isinstance(index_raw, str):
stripped = index_raw.strip()
if not stripped:
continue
try:
index_value = int(stripped) - 1
except ValueError:
continue
else:
continue
if index_value < 0:
continue
start_value = _coerce_positive_time(entry.get("start"))
end_value = _coerce_positive_time(entry.get("end"))
title_value: Optional[str] = None
for key in ("title", "display_title", "spoken_title", "original_title"):
value = entry.get(key)
if isinstance(value, str) and value.strip():
title_value = value.strip()
break
timing_map[index_value] = {
"start": start_value,
"end": end_value,
"title": title_value,
}
chapter_timings: List[Dict[str, Any]] = []
for idx, chapter in enumerate(chapters):
marker = timing_map.get(idx)
if marker and marker.get("title") and isinstance(chapter, dict):
chapter_title = marker["title"]
if isinstance(chapter_title, str) and chapter_title.strip():
chapter["title"] = chapter_title
chapter_timings.append(
{
"index": idx,
"start": marker.get("start") if marker else None,
"end": marker.get("end") if marker else None,
"title": marker.get("title") if marker else None,
}
)
return render_template( return render_template(
"reader_embed.html", "reader_embed.html",
job=job, job=job,
@@ -3589,6 +3697,8 @@ def job_reader(job_id: str) -> ResponseReturnValue:
chapters=chapters, chapters=chapters,
chapter_url=chapter_url, chapter_url=chapter_url,
asset_base=asset_base, asset_base=asset_base,
chapter_timings=chapter_timings,
display_title=display_title,
) )
+8
View File
@@ -105,6 +105,7 @@ class Job:
metadata_tags: Dict[str, str] = field(default_factory=dict) metadata_tags: Dict[str, str] = field(default_factory=dict)
max_subtitle_words: int = 50 max_subtitle_words: int = 50
chapter_intro_delay: float = 0.5 chapter_intro_delay: float = 0.5
auto_prefix_chapter_titles: bool = True
status: JobStatus = JobStatus.PENDING status: JobStatus = JobStatus.PENDING
started_at: Optional[float] = None started_at: Optional[float] = None
finished_at: Optional[float] = None finished_at: Optional[float] = None
@@ -171,6 +172,7 @@ class Job:
"voice_profile": self.voice_profile, "voice_profile": self.voice_profile,
"max_subtitle_words": self.max_subtitle_words, "max_subtitle_words": self.max_subtitle_words,
"chapter_intro_delay": self.chapter_intro_delay, "chapter_intro_delay": self.chapter_intro_delay,
"auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True),
}, },
"metadata_tags": dict(self.metadata_tags), "metadata_tags": dict(self.metadata_tags),
"chapters": [ "chapters": [
@@ -233,6 +235,7 @@ class PendingJob:
cover_image_path: Optional[Path] = None cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None cover_image_mime: Optional[str] = None
chapter_intro_delay: float = 0.5 chapter_intro_delay: float = 0.5
auto_prefix_chapter_titles: bool = True
chunk_level: str = "paragraph" chunk_level: str = "paragraph"
chunks: List[Dict[str, Any]] = field(default_factory=list) chunks: List[Dict[str, Any]] = field(default_factory=list)
speakers: Dict[str, Any] = field(default_factory=dict) speakers: Dict[str, Any] = field(default_factory=dict)
@@ -312,6 +315,7 @@ class ConversionService:
cover_image_path: Optional[Path] = None, cover_image_path: Optional[Path] = None,
cover_image_mime: Optional[str] = None, cover_image_mime: Optional[str] = None,
chapter_intro_delay: float = 0.5, chapter_intro_delay: float = 0.5,
auto_prefix_chapter_titles: bool = True,
chunk_level: str = "paragraph", chunk_level: str = "paragraph",
chunks: Optional[Iterable[Any]] = None, chunks: Optional[Iterable[Any]] = None,
speakers: Optional[Mapping[str, Any]] = None, speakers: Optional[Mapping[str, Any]] = None,
@@ -358,6 +362,7 @@ class ConversionService:
cover_image_path=cover_image_path, cover_image_path=cover_image_path,
cover_image_mime=cover_image_mime, cover_image_mime=cover_image_mime,
chapter_intro_delay=chapter_intro_delay, chapter_intro_delay=chapter_intro_delay,
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
chunk_level=chunk_level, chunk_level=chunk_level,
chunks=normalized_chunks, chunks=normalized_chunks,
speakers=dict(speakers or {}), speakers=dict(speakers or {}),
@@ -512,6 +517,7 @@ class ConversionService:
cover_image_path=job.cover_image_path, cover_image_path=job.cover_image_path,
cover_image_mime=job.cover_image_mime, cover_image_mime=job.cover_image_mime,
chapter_intro_delay=job.chapter_intro_delay, chapter_intro_delay=job.chapter_intro_delay,
auto_prefix_chapter_titles=job.auto_prefix_chapter_titles,
chunk_level=job.chunk_level, chunk_level=job.chunk_level,
chunks=job.chunks, chunks=job.chunks,
speakers=job.speakers, speakers=job.speakers,
@@ -737,6 +743,7 @@ class ConversionService:
"cover_image_path": str(job.cover_image_path) if job.cover_image_path else None, "cover_image_path": str(job.cover_image_path) if job.cover_image_path else None,
"cover_image_mime": job.cover_image_mime, "cover_image_mime": job.cover_image_mime,
"chapter_intro_delay": job.chapter_intro_delay, "chapter_intro_delay": job.chapter_intro_delay,
"auto_prefix_chapter_titles": job.auto_prefix_chapter_titles,
"chunk_level": job.chunk_level, "chunk_level": job.chunk_level,
"chunks": [dict(entry) for entry in job.chunks], "chunks": [dict(entry) for entry in job.chunks],
"speakers": dict(job.speakers), "speakers": dict(job.speakers),
@@ -828,6 +835,7 @@ class ConversionService:
metadata_tags=payload.get("metadata_tags", {}), metadata_tags=payload.get("metadata_tags", {}),
max_subtitle_words=int(payload.get("max_subtitle_words", 50)), max_subtitle_words=int(payload.get("max_subtitle_words", 50)),
chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)), chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)),
auto_prefix_chapter_titles=bool(payload.get("auto_prefix_chapter_titles", True)),
) )
job.status = JobStatus(payload.get("status", job.status.value)) job.status = JobStatus(payload.get("status", job.status.value))
job.started_at = payload.get("started_at") job.started_at = payload.get("started_at")
+10
View File
@@ -41,7 +41,17 @@ const closeReaderModal = () => {
readerModal.removeAttribute("data-open"); readerModal.removeAttribute("data-open");
document.body.classList.remove("modal-open"); document.body.classList.remove("modal-open");
if (readerFrame) { if (readerFrame) {
const frameWindow = readerFrame.contentWindow;
if (frameWindow) {
try {
frameWindow.postMessage({ type: "abogen:reader:pause", currentTime: 0 }, window.location.origin);
} catch (error) {
// Ignore cross-origin messaging errors.
}
}
window.setTimeout(() => {
readerFrame.src = "about:blank"; readerFrame.src = "about:blank";
}, 75);
} }
if (readerHint && defaultReaderHint) { if (readerHint && defaultReaderHint) {
readerHint.textContent = defaultReaderHint; readerHint.textContent = defaultReaderHint;
+1
View File
@@ -24,6 +24,7 @@
<li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li> <li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li>
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li> <li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
<li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li> <li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li>
<li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li>
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li> <li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
<li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li> <li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li>
<li><strong>Chunk granularity:</strong> {{ job.chunk_level|replace('_', ' ')|title }}</li> <li><strong>Chunk granularity:</strong> {{ job.chunk_level|replace('_', ' ')|title }}</li>
+4 -2
View File
@@ -92,13 +92,15 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if reader_source %} {% if reader_source %}
{% set metadata = job.metadata_tags if job.metadata_tags is mapping else {} %}
{% set job_display_title = metadata.get('title') or metadata.get('book_title') or metadata.get('name') or job.original_filename %}
<button type="button" <button type="button"
class="icon-button" class="icon-button"
data-role="open-reader" data-role="open-reader"
data-reader-url="{{ url_for('web.job_reader', job_id=job.id) }}" data-reader-url="{{ url_for('web.job_reader', job_id=job.id) }}"
data-book-title="{{ job.original_filename }}" data-book-title="{{ job_display_title }}"
title="Open reader" title="Open reader"
aria-label="Open EPUB reader for {{ job.original_filename }}"> aria-label="Open EPUB reader for {{ job_display_title }}">
📖 📖
</button> </button>
{% endif %} {% endif %}
+187
View File
@@ -0,0 +1,187 @@
{% extends "base.html" %}
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
{% set total_steps = 3 if is_multi_speaker else 2 %}
{% block content %}
<section class="wizard-page wizard-page--modal" data-step="chapters">
<div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
<div class="modal__overlay" aria-hidden="true"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-chapters-title">
<header class="modal__header wizard-card__header">
<div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow">
<button type="button"
class="step-indicator__item is-complete"
data-role="open-upload-modal">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</button>
<a class="step-indicator__item is-active"
aria-current="step"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Entities</span>
</a>
{% endif %}
</nav>
<h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2>
<p class="hint">Choose which chapters to convert. We'll analyse entities automatically when you continue.</p>
</div>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div>
</header>
<form method="post"
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
<input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
{% endif %}
</div>
<div class="modal__body wizard-card__body">
{% if error %}
<div class="alert alert--error">{{ error }}</div>
{% endif %}
{% if notice %}
<div class="alert alert--info">{{ notice }}</div>
{% endif %}
<section class="form-section">
<div class="form-section__title-row">
<h3 class="form-section__title">Detected chapters</h3>
<p class="hint">Toggle chapters on or off, rename them, and override the voice per chapter if needed.</p>
</div>
<div class="chapter-grid">
{% for chapter in pending.chapters %}
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
<article class="chapter-card"
data-role="chapter-row"
data-disabled="{{ 'false' if is_enabled else 'true' }}"
data-expanded="false">
<header class="chapter-card__summary" data-role="chapter-summary">
<label class="chapter-card__checkbox">
<input type="checkbox"
name="chapter-{{ loop.index0 }}-enabled"
data-role="chapter-enabled"
{% if is_enabled %}checked{% endif %}>
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
</label>
<button type="button"
class="chapter-card__toggle"
data-role="chapter-toggle"
aria-expanded="false"
aria-label="Toggle chapter details">
<span class="chapter-card__toggle-icon" aria-hidden="true">&#9662;</span>
</button>
</header>
<div class="chapter-card__details"
data-role="chapter-details">
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-title">Title</label>
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
</div>
<div class="chapter-card__preview">
<details>
<summary>Preview full text</summary>
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
</details>
</div>
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
<optgroup label="Voices">
{% for voice in options.voices %}
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %}
</optgroup>
{% if options.voice_profile_options %}
<optgroup label="Profiles">
{% for profile in options.voice_profile_options %}
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
</select>
<input type="text"
name="chapter-{{ loop.index0 }}-formula"
class="chapter-card__formula"
data-role="formula-input"
placeholder="af_nova*0.4+am_liam*0.6"
value="{{ chapter.voice_formula or '' }}"
{% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
</div>
</div>
</article>
{% endfor %}
</div>
</section>
</div>
<footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions">
<button type="button" class="button button--ghost" data-role="wizard-previous">Previous</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
<div class="wizard-card__footer-actions">
{% if is_multi_speaker %}
<button type="submit"
class="button"
data-role="submit-speaker-analysis"
data-step-toggle="analysis"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post">
Continue to entities
</button>
{% endif %}
<button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% endif %}>
Queue conversion
</button>
</div>
</footer>
</form>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
</div>
</div>
</section>
{% with pending=pending, readonly=True, active_step='chapters' %}
{% include "partials/upload_modal.html" %}
{% endwith %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{# This template is intentionally left empty.
Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`.
The file is kept as a placeholder to avoid breaking documentation references. #}
+488 -10
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ job.original_filename }} · Reader</title> <title>{{ display_title or job.original_filename }} · Reader</title>
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;
@@ -144,6 +144,116 @@
width: 100%; width: 100%;
} }
.reader-player {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reader-player__controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.reader-player__controls button {
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
color: var(--text);
border-radius: 12px;
padding: 0.4rem 0.8rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
}
.reader-player__controls button:hover,
.reader-player__controls button:focus-visible {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.4);
outline: none;
}
.reader-player__controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.reader-player__secondary {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: rgba(226, 232, 240, 0.75);
}
.reader-player__secondary label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.reader-player__secondary select {
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
color: var(--text);
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
}
.reader-player__secondary select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.reader-chapter-links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.25rem 0;
max-height: 12.5rem;
overflow-y: auto;
padding: 0.25rem 0;
}
.reader-chapter-links button {
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.7);
color: var(--text);
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
}
.reader-chapter-links button:hover,
.reader-chapter-links button:focus-visible {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.4);
outline: none;
}
.reader-chapter-links button[aria-current="true"] {
background: rgba(56, 189, 248, 0.35);
border-color: rgba(56, 189, 248, 0.6);
color: var(--text);
}
.reader-chapter-links button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.reader-chapter-links__empty {
font-size: 0.85rem;
color: rgba(226, 232, 240, 0.6);
}
.reader-status { .reader-status {
font-size: 0.85rem; font-size: 0.85rem;
color: rgba(226, 232, 240, 0.75); color: rgba(226, 232, 240, 0.75);
@@ -164,7 +274,7 @@
<div class="reader-toolbar"> <div class="reader-toolbar">
<button type="button" data-action="prev">Previous</button> <button type="button" data-action="prev">Previous</button>
<select data-role="reader-chapter" aria-label="Select chapter"></select> <select data-role="reader-chapter" aria-label="Select chapter"></select>
<div class="reader-toolbar__title" data-role="reader-title">{{ job.original_filename }}</div> <div class="reader-toolbar__title" data-role="reader-title">{{ display_title or job.original_filename }}</div>
<button type="button" data-action="next">Next</button> <button type="button" data-action="next">Next</button>
</div> </div>
<div id="reader-container"> <div id="reader-container">
@@ -172,12 +282,34 @@
</div> </div>
<div class="reader-footer"> <div class="reader-footer">
{% if audio_url %} {% if audio_url %}
<div class="reader-player" data-role="reader-player">
<audio controls preload="metadata" src="{{ audio_url }}"></audio> <audio controls preload="metadata" src="{{ audio_url }}"></audio>
<div class="reader-player__controls">
<button type="button" data-action="player-prev" aria-label="Go to previous chapter">Prev chapter</button>
<button type="button" data-action="player-rewind" aria-label="Rewind 15 seconds">-15s</button>
<button type="button" data-action="player-toggle" aria-label="Play or pause audio">Play</button>
<button type="button" data-action="player-forward" aria-label="Skip forward 15 seconds">+15s</button>
<button type="button" data-action="player-next" aria-label="Go to next chapter">Next chapter</button>
</div>
<div class="reader-player__secondary">
<label for="reader-playback-rate">Speed</label>
<select id="reader-playback-rate" data-role="playback-rate">
<option value="0.75">0.75x</option>
<option value="0.9">0.90x</option>
<option value="1" selected>1.00x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.50x</option>
<option value="1.75">1.75x</option>
<option value="2">2.00x</option>
</select>
</div>
</div>
{% endif %} {% endif %}
<div class="reader-chapter-links" data-role="chapter-links" aria-label="Chapters"></div>
<div class="reader-status" data-role="reader-status" aria-live="polite">Loading EPUB…</div> <div class="reader-status" data-role="reader-status" aria-live="polite">Loading EPUB…</div>
</div> </div>
</div> </div>
<script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': job.original_filename}|tojson }}</script> <script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': display_title or job.original_filename, 'chapterTimings': chapter_timings}|tojson }}</script>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js"></script>
<script type="module"> <script type="module">
@@ -187,6 +319,13 @@
const chapterSelect = document.querySelector('[data-role="reader-chapter"]'); const chapterSelect = document.querySelector('[data-role="reader-chapter"]');
const titleEl = document.querySelector('[data-role="reader-title"]'); const titleEl = document.querySelector('[data-role="reader-title"]');
const audioEl = document.querySelector('audio'); const audioEl = document.querySelector('audio');
const playerPrevBtn = document.querySelector('[data-action="player-prev"]');
const playerNextBtn = document.querySelector('[data-action="player-next"]');
const playerRewindBtn = document.querySelector('[data-action="player-rewind"]');
const playerForwardBtn = document.querySelector('[data-action="player-forward"]');
const playerToggleBtn = document.querySelector('[data-action="player-toggle"]');
const playbackRateSelect = document.querySelector('[data-role="playback-rate"]');
const chapterLinksEl = document.querySelector('[data-role="chapter-links"]');
const bodyDataset = document.body.dataset; const bodyDataset = document.body.dataset;
const assetBase = bodyDataset.assetBase || ''; const assetBase = bodyDataset.assetBase || '';
const epubUrl = bodyDataset.epubUrl || ''; const epubUrl = bodyDataset.epubUrl || '';
@@ -204,6 +343,7 @@
} }
const ACTIVE_CLASS = 'chunk--active'; const ACTIVE_CLASS = 'chunk--active';
const PLAYER_SKIP_SECONDS = 15;
const syncCache = new Map(); const syncCache = new Map();
const setStatus = (message) => { const setStatus = (message) => {
@@ -212,6 +352,36 @@
} }
}; };
const navigateToChapter = (index, options = {}) => {
if (!chapters.length) {
return Promise.resolve(null);
}
const normalizedIndex = Math.min(Math.max(index, 0), chapters.length - 1);
const preservePlayback = options.preservePlayback !== false;
const shouldScroll = options.scroll !== false;
const forcePlay = Boolean(options.forcePlay);
const wasPlaying = audioEl ? !audioEl.paused : false;
const targetShouldPlay = forcePlay || (preservePlayback && wasPlaying);
return displayChapter(normalizedIndex, { scroll: shouldScroll }).then((startTime) => {
if (!audioEl) {
return startTime;
}
const timelineEntry = getChapterTiming(normalizedIndex);
const fallbackStart = timelineEntry && Number.isFinite(timelineEntry.start) ? timelineEntry.start : null;
const resolvedStart = Number.isFinite(startTime) ? startTime : fallbackStart;
if (resolvedStart !== null && Number.isFinite(resolvedStart)) {
audioEl.currentTime = resolvedStart;
}
if (targetShouldPlay) {
audioEl.play().catch(() => {});
}
return resolvedStart;
}).catch((error) => {
console.warn('Failed to navigate to chapter', error);
return null;
});
};
const removeLeadingSlash = (value) => { const removeLeadingSlash = (value) => {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return value; return value;
@@ -359,6 +529,22 @@
return null; return null;
}; };
const coerceTime = (value) => {
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
const parsed = Number.parseFloat(trimmed);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
}
}
return null;
};
const fetchEpubAsset = async (url) => { const fetchEpubAsset = async (url) => {
if (!url) { if (!url) {
throw new Error('Missing EPUB URL'); throw new Error('Missing EPUB URL');
@@ -460,10 +646,24 @@
} }
const labelSource = entry.title ?? entry.label ?? entry.name ?? entry.text ?? ''; const labelSource = entry.title ?? entry.label ?? entry.name ?? entry.text ?? '';
const title = typeof labelSource === 'string' && labelSource.trim() ? labelSource.trim() : `Chapter ${normalized.length + 1}`; const title = typeof labelSource === 'string' && labelSource.trim() ? labelSource.trim() : `Chapter ${normalized.length + 1}`;
let sourceIndex = null;
const rawIndex = entry.index ?? entry.order ?? entry.position;
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) {
sourceIndex = Math.max(0, Math.floor(rawIndex));
} else if (typeof rawIndex === 'string') {
const parsed = Number.parseInt(rawIndex.trim(), 10);
if (Number.isInteger(parsed) && parsed >= 0) {
sourceIndex = parsed;
}
}
if (!Number.isInteger(sourceIndex)) {
sourceIndex = normalized.length;
}
normalized.push({ normalized.push({
href, href,
normalizedHref: normalizeHref(href), normalizedHref: normalizeHref(href),
title, title,
sourceIndex,
}); });
}); });
return normalized; return normalized;
@@ -496,20 +696,70 @@
const baseTitle = typeof payload.title === 'string' && payload.title ? payload.title : 'Book'; const baseTitle = typeof payload.title === 'string' && payload.title ? payload.title : 'Book';
document.title = `${baseTitle} · Reader`; document.title = `${baseTitle} · Reader`;
const rawTimingEntries = Array.isArray(payload.chapterTimings) ? payload.chapterTimings : [];
const timingBySourceIndex = new Map();
rawTimingEntries.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return;
}
const rawIndex = entry.index;
let sourceIndex = null;
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) {
sourceIndex = Math.max(0, Math.floor(rawIndex));
} else if (typeof rawIndex === 'string') {
const parsed = Number.parseInt(rawIndex.trim(), 10);
if (Number.isInteger(parsed) && parsed >= 0) {
sourceIndex = parsed;
}
}
if (sourceIndex === null) {
return;
}
const timingEntry = {
start: coerceTime(entry.start),
end: coerceTime(entry.end),
title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : null,
};
timingBySourceIndex.set(sourceIndex, timingEntry);
});
let chapters = dedupeChapters(normalizeChapterList(Array.isArray(payload.chapters) ? payload.chapters : [])); let chapters = dedupeChapters(normalizeChapterList(Array.isArray(payload.chapters) ? payload.chapters : []));
chapters = chapters.map((chapter, idx) => { chapters = chapters.map((chapter, idx) => {
const cleanedHref = removeLeadingSlash(chapter.href); const cleanedHref = removeLeadingSlash(chapter.href);
const normalized = chapter.normalizedHref ? removeLeadingSlash(chapter.normalizedHref) : normalizeHref(cleanedHref); const normalized = chapter.normalizedHref ? removeLeadingSlash(chapter.normalizedHref) : normalizeHref(cleanedHref);
const sourceIndex = Number.isInteger(chapter.sourceIndex) ? Number(chapter.sourceIndex) : idx;
return { return {
...chapter, ...chapter,
href: cleanedHref, href: cleanedHref,
normalizedHref: normalized, normalizedHref: normalized,
title: chapter.title || `Chapter ${idx + 1}`, title: chapter.title || `Chapter ${idx + 1}`,
spineHref: chapter.spineHref ? removeLeadingSlash(chapter.spineHref) : null, spineHref: chapter.spineHref ? removeLeadingSlash(chapter.spineHref) : null,
sourceIndex,
}; };
}); });
chapters = dedupeChapters(chapters); chapters = dedupeChapters(chapters);
console.info('[reader] Chapters payload normalized', chapters.map((chapter) => chapter.href)); console.info('[reader] Chapters payload normalized', chapters.map((chapter) => chapter.href));
let chapterTimeline = [];
const rebuildChapterTimings = () => {
chapterTimeline = chapters.map((chapter, idx) => {
const sourceIndex = Number.isInteger(chapter.sourceIndex) ? Number(chapter.sourceIndex) : idx;
const timing = timingBySourceIndex.get(sourceIndex) || null;
return {
sourceIndex,
start: timing ? coerceTime(timing.start) : null,
end: timing ? coerceTime(timing.end) : null,
title: timing && typeof timing.title === 'string' ? timing.title : null,
};
});
};
rebuildChapterTimings();
const getChapterTiming = (index) => {
if (index < 0 || index >= chapterTimeline.length) {
return null;
}
return chapterTimeline[index] || null;
};
let book = null; let book = null;
let rendition = null; let rendition = null;
let currentSync = []; let currentSync = [];
@@ -519,6 +769,98 @@
let suppressRelocate = false; let suppressRelocate = false;
let scheduledSync = 0; let scheduledSync = 0;
let chapterLinkButtons = [];
const updateChapterLinkState = () => {
if (!chapterLinkButtons.length) {
return;
}
chapterLinkButtons.forEach((button, idx) => {
if (!(button instanceof HTMLButtonElement)) {
return;
}
if (idx === currentChapterIndex) {
button.setAttribute('aria-current', 'true');
} else {
button.removeAttribute('aria-current');
}
});
};
const updatePlaybackControls = () => {
const hasChapters = chapters.length > 0;
const canPrev = hasChapters && currentChapterIndex > 0;
const canNext = hasChapters && currentChapterIndex < chapters.length - 1;
if (playerPrevBtn) {
playerPrevBtn.disabled = !canPrev;
}
if (playerNextBtn) {
playerNextBtn.disabled = !canNext;
}
if (playerRewindBtn) {
playerRewindBtn.disabled = !audioEl;
}
if (playerForwardBtn) {
playerForwardBtn.disabled = !audioEl;
}
if (playerToggleBtn) {
if (!audioEl) {
playerToggleBtn.disabled = true;
playerToggleBtn.textContent = 'Play';
} else {
playerToggleBtn.disabled = false;
playerToggleBtn.textContent = audioEl.paused ? 'Play' : 'Pause';
}
}
if (playbackRateSelect) {
playbackRateSelect.disabled = !audioEl;
if (audioEl) {
const currentRate = Math.max(0.5, Math.min(3, audioEl.playbackRate || 1));
const formattedRate = currentRate.toFixed(2);
let matchFound = false;
Array.from(playbackRateSelect.options).forEach((option) => {
const optionRate = Number.parseFloat(option.value);
if (Number.isFinite(optionRate) && Math.abs(optionRate - currentRate) < 0.01) {
matchFound = true;
}
});
if (!matchFound) {
const customOption = new Option(`${formattedRate}x`, currentRate.toString(), true, true);
playbackRateSelect.append(customOption);
}
playbackRateSelect.value = currentRate.toString();
} else {
playbackRateSelect.value = '1';
}
}
};
const buildChapterLinks = () => {
if (!chapterLinksEl) {
return;
}
chapterLinksEl.innerHTML = '';
chapterLinkButtons = [];
if (!chapters.length) {
const placeholder = document.createElement('span');
placeholder.className = 'reader-chapter-links__empty';
placeholder.textContent = 'No chapters available.';
chapterLinksEl.append(placeholder);
return;
}
chapters.forEach((chapter, idx) => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.chapterIndex = String(idx);
const label = chapter.title || `Chapter ${idx + 1}`;
button.textContent = label;
button.title = label;
chapterLinksEl.append(button);
chapterLinkButtons.push(button);
});
updateChapterLinkState();
};
const updateStatus = () => { const updateStatus = () => {
if (!statusEl) { if (!statusEl) {
return; return;
@@ -572,6 +914,8 @@
const chapter = chapters[currentChapterIndex]; const chapter = chapters[currentChapterIndex];
titleEl.textContent = chapter?.title ? `${chapter.title} · ${baseTitle}` : baseTitle; titleEl.textContent = chapter?.title ? `${chapter.title} · ${baseTitle}` : baseTitle;
} }
updatePlaybackControls();
updateChapterLinkState();
}; };
const highlightChunk = (chunkId, { scroll = false } = {}) => { const highlightChunk = (chunkId, { scroll = false } = {}) => {
@@ -777,6 +1121,7 @@
option.textContent = 'No chapters'; option.textContent = 'No chapters';
chapterSelect.append(option); chapterSelect.append(option);
chapterSelect.disabled = true; chapterSelect.disabled = true;
buildChapterLinks();
return; return;
} }
chapterSelect.disabled = false; chapterSelect.disabled = false;
@@ -787,6 +1132,7 @@
chapterSelect.append(option); chapterSelect.append(option);
}); });
chapterSelect.value = String(currentChapterIndex); chapterSelect.value = String(currentChapterIndex);
buildChapterLinks();
}; };
const registerRenditionHooks = () => { const registerRenditionHooks = () => {
@@ -854,6 +1200,7 @@
} }
navigationLock = true; navigationLock = true;
let failed = false; let failed = false;
let resolvedStart = null;
setStatus('Loading chapter…'); setStatus('Loading chapter…');
highlightChunk(null); highlightChunk(null);
activeOverlay = null; activeOverlay = null;
@@ -887,12 +1234,39 @@
updateControls(); updateControls();
const syncTarget = syncHref || target.normalizedHref || removeLeadingSlash(target.href); const syncTarget = syncHref || target.normalizedHref || removeLeadingSlash(target.href);
await loadChapterSync(syncTarget); await loadChapterSync(syncTarget);
const syncStartCandidate = currentSync.length ? currentSync[0].begin : null;
if (Number.isFinite(syncStartCandidate)) {
const timelineEntry = getChapterTiming(normalizedIndex);
if (timelineEntry) {
timelineEntry.start = syncStartCandidate;
}
const sourceIndex = chapters[normalizedIndex] && Number.isInteger(chapters[normalizedIndex].sourceIndex)
? Number(chapters[normalizedIndex].sourceIndex)
: null;
if (sourceIndex !== null) {
const existingTiming = timingBySourceIndex.get(sourceIndex) || {};
existingTiming.start = syncStartCandidate;
if (chapters[normalizedIndex] && typeof chapters[normalizedIndex].title === 'string') {
existingTiming.title = existingTiming.title || chapters[normalizedIndex].title;
}
timingBySourceIndex.set(sourceIndex, existingTiming);
}
}
const fallbackTiming = getChapterTiming(normalizedIndex);
resolvedStart = Number.isFinite(syncStartCandidate)
? syncStartCandidate
: fallbackTiming && Number.isFinite(fallbackTiming.start)
? fallbackTiming.start
: null;
const shouldScroll = options.scroll ?? Boolean(audioEl && !audioEl.paused); const shouldScroll = options.scroll ?? Boolean(audioEl && !audioEl.paused);
if (fragment) { if (fragment) {
highlightChunk(fragment, { scroll: true }); highlightChunk(fragment, { scroll: true });
const overlayMatch = currentSync.find((entry) => entry.fragment === fragment); const overlayMatch = currentSync.find((entry) => entry.fragment === fragment);
if (overlayMatch) { if (overlayMatch) {
activeOverlay = overlayMatch; activeOverlay = overlayMatch;
if (Number.isFinite(overlayMatch.begin)) {
resolvedStart = overlayMatch.begin;
}
} }
} else if (audioEl) { } else if (audioEl) {
syncToTime(audioEl.currentTime || 0, { force: true, scroll: shouldScroll }); syncToTime(audioEl.currentTime || 0, { force: true, scroll: shouldScroll });
@@ -910,6 +1284,7 @@
updateStatus(); updateStatus();
} }
} }
return failed ? null : resolvedStart;
}; };
const scheduleSync = () => { const scheduleSync = () => {
@@ -930,16 +1305,24 @@
audioEl.addEventListener('seeked', () => { audioEl.addEventListener('seeked', () => {
syncToTime(audioEl.currentTime || 0, { force: true, scroll: true }); syncToTime(audioEl.currentTime || 0, { force: true, scroll: true });
}); });
audioEl.addEventListener('loadedmetadata', updateStatus); audioEl.addEventListener('loadedmetadata', () => {
updateStatus();
updatePlaybackControls();
});
audioEl.addEventListener('play', () => { audioEl.addEventListener('play', () => {
syncToTime(audioEl.currentTime || 0, { force: true, scroll: true }); syncToTime(audioEl.currentTime || 0, { force: true, scroll: true });
updateStatus(); updateStatus();
updatePlaybackControls();
});
audioEl.addEventListener('pause', () => {
updateStatus();
updatePlaybackControls();
}); });
audioEl.addEventListener('pause', updateStatus);
audioEl.addEventListener('ended', () => { audioEl.addEventListener('ended', () => {
activeOverlay = null; activeOverlay = null;
highlightChunk(null); highlightChunk(null);
updateStatus(); updateStatus();
updatePlaybackControls();
}); });
} }
@@ -1104,6 +1487,7 @@
} }
chapters = dedupeChapters(chapters); chapters = dedupeChapters(chapters);
rebuildChapterTimings();
if (typeof book.canonical === 'function') { if (typeof book.canonical === 'function') {
chapters = chapters.map((chapter) => { chapters = chapters.map((chapter) => {
@@ -1121,6 +1505,7 @@
}; };
}); });
chapters = dedupeChapters(chapters); chapters = dedupeChapters(chapters);
rebuildChapterTimings();
} }
chapters = chapters.map((chapter) => ({ chapters = chapters.map((chapter) => ({
@@ -1189,32 +1574,125 @@
} }
if (event.key === 'ArrowLeft' && currentChapterIndex > 0) { if (event.key === 'ArrowLeft' && currentChapterIndex > 0) {
event.preventDefault(); event.preventDefault();
displayChapter(currentChapterIndex - 1, { scroll: true }); navigateToChapter(currentChapterIndex - 1, { preservePlayback: true });
} else if (event.key === 'ArrowRight' && currentChapterIndex < chapters.length - 1) { } else if (event.key === 'ArrowRight' && currentChapterIndex < chapters.length - 1) {
event.preventDefault(); event.preventDefault();
displayChapter(currentChapterIndex + 1, { scroll: true }); navigateToChapter(currentChapterIndex + 1, { preservePlayback: true });
} }
}); });
prevBtn?.addEventListener('click', () => { prevBtn?.addEventListener('click', () => {
if (currentChapterIndex > 0) { if (currentChapterIndex > 0) {
displayChapter(currentChapterIndex - 1, { scroll: true }); navigateToChapter(currentChapterIndex - 1, { preservePlayback: true });
} }
}); });
nextBtn?.addEventListener('click', () => { nextBtn?.addEventListener('click', () => {
if (currentChapterIndex < chapters.length - 1) { if (currentChapterIndex < chapters.length - 1) {
displayChapter(currentChapterIndex + 1, { scroll: true }); navigateToChapter(currentChapterIndex + 1, { preservePlayback: true });
} }
}); });
chapterSelect?.addEventListener('change', (event) => { chapterSelect?.addEventListener('change', (event) => {
const value = Number.parseInt(event.target.value, 10); const value = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(value)) { if (!Number.isNaN(value)) {
displayChapter(value, { scroll: true }); navigateToChapter(value, { preservePlayback: true });
} }
}); });
window.addEventListener('message', (event) => {
if (!audioEl) {
return;
}
if (event.origin && event.origin !== window.location.origin) {
return;
}
const data = event.data;
if (!data || typeof data !== 'object') {
return;
}
if (data.type === 'abogen:reader:pause') {
audioEl.pause();
if (Number.isFinite(data.currentTime) && data.currentTime >= 0) {
audioEl.currentTime = data.currentTime;
}
updateStatus();
updatePlaybackControls();
}
});
playerPrevBtn?.addEventListener('click', () => {
if (currentChapterIndex > 0) {
navigateToChapter(currentChapterIndex - 1, { preservePlayback: true });
}
});
playerNextBtn?.addEventListener('click', () => {
if (currentChapterIndex < chapters.length - 1) {
navigateToChapter(currentChapterIndex + 1, { preservePlayback: true });
}
});
playerRewindBtn?.addEventListener('click', () => {
if (!audioEl) {
return;
}
const current = Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
audioEl.currentTime = Math.max(0, current - PLAYER_SKIP_SECONDS);
syncToTime(audioEl.currentTime || 0, { force: true, scroll: false });
updateStatus();
});
playerForwardBtn?.addEventListener('click', () => {
if (!audioEl || !Number.isFinite(audioEl.currentTime)) {
return;
}
const duration = Number.isFinite(audioEl.duration) ? audioEl.duration : undefined;
const target = audioEl.currentTime + PLAYER_SKIP_SECONDS;
audioEl.currentTime = duration ? Math.min(duration, target) : target;
syncToTime(audioEl.currentTime || 0, { force: true, scroll: false });
updateStatus();
});
playerToggleBtn?.addEventListener('click', () => {
if (!audioEl) {
return;
}
if (audioEl.paused) {
audioEl.play().catch(() => {});
} else {
audioEl.pause();
}
});
playbackRateSelect?.addEventListener('change', (event) => {
if (!audioEl) {
return;
}
const value = Number.parseFloat(event.target.value);
if (Number.isFinite(value) && value > 0) {
const clamped = Math.max(0.5, Math.min(3, value));
audioEl.playbackRate = clamped;
}
updatePlaybackControls();
});
chapterLinksEl?.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest('button[data-chapter-index]') : null;
if (!(target instanceof HTMLButtonElement)) {
return;
}
event.preventDefault();
const idx = Number.parseInt(target.dataset.chapterIndex || '', 10);
if (Number.isNaN(idx)) {
return;
}
const shouldResume = audioEl ? !audioEl.paused : false;
navigateToChapter(idx, { preservePlayback: true, forcePlay: shouldResume });
});
updatePlaybackControls();
init().catch((error) => { init().catch((error) => {
console.error('Failed to initialize reader', error); console.error('Failed to initialize reader', error);
setStatus('Reader failed to initialize.'); setStatus('Reader failed to initialize.');
+7
View File
@@ -110,6 +110,13 @@
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}"> <input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}">
<p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p> <p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p>
</div> </div>
<div class="field field--stack">
<label class="toggle-pill">
<input type="checkbox" name="auto_prefix_chapter_titles" value="true" {% if settings.auto_prefix_chapter_titles %}checked{% endif %}>
<span>Add "Chapter" before numeric chapter titles</span>
</label>
<p class="hint">Ensures the spoken chapter heading starts with "Chapter" when source titles begin with only a number or numeral.</p>
</div>
</fieldset> </fieldset>
<fieldset class="settings__section"> <fieldset class="settings__section">
+27
View File
@@ -0,0 +1,27 @@
from abogen.web.conversion_runner import (
_format_spoken_chapter_title,
_headings_equivalent,
_strip_duplicate_heading_line,
)
def test_format_spoken_chapter_title_adds_prefix() -> None:
assert _format_spoken_chapter_title("1: A Tale", 1, True) == "Chapter 1: A Tale"
def test_format_spoken_chapter_title_respects_existing_prefix() -> None:
assert _format_spoken_chapter_title("Chapter 2: Story", 2, True) == "Chapter 2: Story"
def test_format_spoken_chapter_title_handles_empty_title() -> None:
assert _format_spoken_chapter_title("", 4, True) == "Chapter 4"
def test_headings_equivalent_ignores_case_and_prefix() -> None:
assert _headings_equivalent("1: The House", "Chapter 1: The House")
def test_strip_duplicate_heading_line_removes_first_match() -> None:
text, removed = _strip_duplicate_heading_line("Chapter 3: Intro\nBody text", "Chapter 3: Intro")
assert removed is True
assert text.strip() == "Body text"