{% extends "base.html" %} {% from "fragments/export_menu.html" import export_menu %} {% block title %}Voxint — transcript {{ run.id.hex[:8] }}{% endblock %} {% block body %}

← run

Transcript {{ run.id.hex[:8] }}

{{ run.media_item.source_path }}

{# Read mode (issue #65): plain-anchor toggles, no JS. Each link preserves the current text variant so switching views never loses the reading position. The reading-view entry deliberately opens timestamp-free (the shareable "reading copy"); a within-mode toggle brings timestamps back. #} {{ export_menu(run, text) }} {% if not lines %}

No transcript segments for this run.

{% elif read %} {# Server-rendered reading view: one heading + paragraph per contiguous same-speaker run. Grouping and timestamp formatting are done in Python (paragraphize_transcript / format_timespan); the template only lays out the supplied rows. Jinja autoescape keeps hostile transcript text literal. #}
{% for row in read_rows %}

{{ row.speaker }}

{% if row.timespan %}{{ row.timespan }} {% endif %}{% for tl in row.lines %}{% if not loop.first %}
{% endif %}{{ tl }}{% endfor %}

{% endfor %}
{% else %} {# Island mount point (issue #48): the transcript-player island renders the audio-synced view over this fallback once hydrated. The server-rendered loop below is fully functional with JS disabled, htmx off, or the asset route misconfigured — the same "degrades to plain HTML" guarantee the rest of the console has. Islands are leaf nodes htmx does not swap. #}
{% for ln in lines %} {% set idx = palette.get(ln.diarization_label) %} {% set uncertain = ln.confidence is not none and ln.confidence < low_confidence_threshold %}

[{{ "%.2f"|format(ln.start_seconds) }}–{{ "%.2f"|format(ln.end_seconds) }}] {% if uncertain %}uncertain{% endif %} {% if ln.diarization_label %}{{ ln.diarization_label }}{% endif %} {% if ln.speaker != ln.diarization_label %}{{ ln.speaker }}:{% endif %} {{ ln.text }}

{% endfor %}
{% endif %} {% endblock %}