mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
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:
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 & 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">▾</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 %}
|
||||||
@@ -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. #}
|
||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user