{%- import "partials/csrf_field.html" as csrf -%}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Doctate — Aufnahmen Fall {{ case_id_short }}</title>
<style>
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
header { display: flex; justify-content: space-between; align-items: center; }
header form { margin: 0; }
.breadcrumb { display: flex; gap: 0.8em; flex-wrap: wrap; }
.breadcrumb a { color: #4a90e2; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; }
.recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; }
.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; }
.recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; }
.rec-time { font-size: 0.95em; color: #666; }
.filename { font-family: monospace; font-size: 0.8em; color: #999; }
.transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
.pending { color: #888; font-style: italic; margin-top: 0.5em; }
.failed { color: #b00; font-weight: bold; margin-top: 0.5em; }

.player { display: inline-flex; align-items: center; gap: 0.5em; flex: 1; justify-content: flex-end; }
.player .play {
  position: relative;
  width: 2em; height: 2em;
  border: 1px solid #bbb; border-radius: 50%;
  background: #fff; cursor: pointer; padding: 0;
  flex-shrink: 0;
}
.player .play:hover { background: #f0f0f0; }
/* Play triangle — pseudo-element via border-trick, font-independent. */
.player .play::before {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 0; height: 0;
  border-style: solid;
  border-width: 7px 0 7px 10px;
  border-color: transparent transparent transparent #333;
  transform: translate(-35%, -50%);
}
/* Pause — two bars via ::before (reused) + ::after. */
.player .play.is-pause::before {
  width: 3px; height: 12px;
  border: none;
  background: #333;
  transform: translate(-5px, -50%);
}
.player .play.is-pause::after {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 3px; height: 12px;
  background: #333;
  transform: translate(2px, -50%);
}
.player .seek { flex: 1; max-width: 6em; cursor: pointer; }
.player .time { font-family: monospace; font-size: 0.85em; color: #555; min-width: 5.5em; text-align: right; }

.header-right { display: flex; align-items: center; gap: 0.8em; }
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
html.admin-view-off .admin-only { display: none !important; }

/* `margin-left: auto` pins the delete control to the row's right edge
   even when the player (the usual `flex: 1` spacer) is hidden during
   the confirm state — prevents the confirm row from collapsing left. */
.rec-delete-form { margin: 0 0 0 auto; display: inline-flex; align-items: center; }
.rec-delete-form .delete-btn {
  padding: 0.3em; background: transparent; border: none; color: #888;
  cursor: pointer; display: inline-flex; align-items: center;
  line-height: 0; border-radius: 4px;
  transition: color 0.15s, background 0.15s;
}
.rec-delete-form .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
/* Inline confirmation. Hidden by default; revealed via .is-confirming
   which the JS below toggles on the form. Replaces native confirm(). */
.rec-delete-form .confirm-row {
  display: none;
  align-items: center;
  gap: 0.4em;
  font-size: 0.9em;
}
.rec-delete-form .confirm-row .confirm-label { color: #b00; }
.rec-delete-form .confirm-row .confirm-btn {
  padding: 0.3em 0.7em;
  background: #e24a4a;
  color: #fff;
  border: 1px solid #c93c3c;
  border-radius: 4px;
  cursor: pointer;
  font: inherit;
  font-size: 0.85em;
}
.rec-delete-form .confirm-row .confirm-btn:hover { background: #c93c3c; }
.rec-delete-form .confirm-row .cancel-btn {
  padding: 0.2em 0.4em;
  background: transparent;
  border: none;
  color: #888;
  cursor: pointer;
  font-size: 1em;
  line-height: 1;
}
.rec-delete-form .confirm-row .cancel-btn:hover { color: #333; }
.rec-delete-form.is-confirming .delete-btn { display: none; }
.rec-delete-form.is-confirming .confirm-row { display: inline-flex; }
/* Hide the audio controls while a delete confirmation is open.
   `visibility: hidden` (not `display: none`) keeps the player's
   layout slot — and its height — reserved, so the row doesn't
   shrink. `:has()` reacts to descendant state from the parent. */
.recording-head:has(.rec-delete-form.is-confirming) .player { visibility: hidden; }
</style>
<script>
try {
  if (localStorage.getItem('admin-view') === 'off') {
    document.documentElement.classList.add('admin-view-off');
  }
} catch (_) {}
</script>
</head>
<body>
<header>
<div class="breadcrumb">
<a href="/web/cases">&larr; Fälle</a>
{% match oneliner %}
{% when Some with (t) %}
<a href="/web/cases/{{ case_id }}">&larr; {{ t }}</a>
{% when None %}
<a href="/web/cases/{{ case_id }}">&larr; Fall</a>
{% endmatch %}
</div>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
</div>
</header>
<h1>Aufnahmen</h1>
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}

{% for rec in recordings %}
<div class="recording{% if rec.failed %} failed-row{% endif %}">
<div class="recording-head">
<time class="rec-time" datetime="{{ rec.recorded_at_iso }}">{{ rec.recorded_at_iso }}</time>
<div class="player" data-src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}{% match rec.gain_db %}{% when Some with (g) %} data-gain-db="{{ g }}"{% when None %}{% endmatch %}>
<button type="button" class="play" aria-label="Abspielen"></button>
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
<span class="time">0:00 / 0:00</span>
</div>
<form method="post" action="/web/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="filename" value="{{ rec.filename }}">
<button type="submit" class="delete-btn" title="Aufnahme entfernen" aria-label="Aufnahme entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button>
<span class="confirm-row" role="group" aria-label="Löschen bestätigen">
<span class="confirm-label">Wirklich löschen?</span>
<button type="submit" class="confirm-btn">Löschen</button>
<button type="button" class="cancel-btn" aria-label="Abbrechen">✕</button>
</span>
</form>
</div>
{% if is_admin %}<div class="filename admin-only">{{ rec.filename }}</div>{% endif %}
{% if rec.failed %}
<div class="failed">Transkription fehlgeschlagen</div>
{% else %}
{% match rec.transcript %}
{% when TranscriptState::Content with { text } %}
<div class="transcript">{{ text }}</div>
{% when TranscriptState::Silent %}
<div class="pending">(stille Aufnahme)</div>
{% when TranscriptState::Pending %}
{% if transcribe_busy %}
<div class="pending">Transkription läuft…</div>
{% else %}
<div class="pending">Transkription ausstehend.</div>
{% endif %}
{% endmatch %}
{% endif %}
</div>
{% endfor %}
<script>
{% include "partials/time_format.js" %}
</script>
<script>
(() => {
  const cb = document.getElementById('admin-view-toggle');
  if (cb) {
    cb.checked = !document.documentElement.classList.contains('admin-view-off');
    cb.addEventListener('change', () => {
      const on = cb.checked;
      try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
      document.documentElement.classList.toggle('admin-view-off', !on);
    });
  }

  // Render the timestamp in the browser's local timezone using the same
  // "Heute/Gestern/DD.MM.YYYY - HH:MM" format as the case page. Server
  // renders the raw ISO into `datetime=""`; JS replaces the text.
  const formatRec = d => TimeFmt.dateLabel(d) + ' - ' + TimeFmt.timeLabel(d);
  document.querySelectorAll('.rec-time[datetime]').forEach(el => {
    const iso = el.getAttribute('datetime');
    if (!iso) return;
    const d = new Date(iso);
    if (isNaN(d.getTime())) return;
    el.textContent = formatRec(d);
  });

  // Inline two-step confirm before destructive delete: first submit
  // attempt is intercepted and toggles `.is-confirming` (which reveals
  // the inline "Wirklich löschen? [Löschen] [✕]" row); the second
  // submit (now from the explicit confirm button) goes through.
  // Without JS the form submits unconfirmed on first click — same
  // behavior as before; acceptable for an internal tool.
  const closeOtherConfirms = (except) => {
    document.querySelectorAll('.rec-delete-form.is-confirming').forEach(f => {
      if (f !== except) f.classList.remove('is-confirming');
    });
  };
  document.querySelectorAll('.rec-delete-form').forEach(form => {
    form.addEventListener('submit', e => {
      if (!form.classList.contains('is-confirming')) {
        e.preventDefault();
        closeOtherConfirms(form);
        form.classList.add('is-confirming');
      }
    });
    const cancelBtn = form.querySelector('.cancel-btn');
    if (cancelBtn) {
      cancelBtn.addEventListener('click', () => {
        form.classList.remove('is-confirming');
      });
    }
  });
  // Single document-level listener: clicking outside the active confirm
  // closes it. `closest('.rec-delete-form.is-confirming')` returns the
  // form only if the click landed inside the currently-open confirm
  // row, which is the one case we want to keep open.
  document.addEventListener('click', e => {
    const insideConfirming = e.target.closest('.rec-delete-form.is-confirming');
    closeOtherConfirms(insideConfirming);
  });

  const fmtDur = s => {
    if (!isFinite(s) || s < 0) return '0:00';
    const m = Math.floor(s / 60);
    const sec = Math.floor(s % 60);
    return `${m}:${sec.toString().padStart(2, '0')}`;
  };

  // Shared AudioContext for replay-gain. Lazy-created on the first
  // player that actually needs gain (Safari requires user-gesture
  // creation); shared across all players to avoid hitting the
  // per-page context limit. `null` if the browser does not expose
  // an AudioContext at all — falls back to the plain <audio> path.
  let sharedCtx = null;
  const ensureCtx = () => {
    if (!sharedCtx) {
      const Ctx = window.AudioContext || window.webkitAudioContext;
      if (Ctx) sharedCtx = new Ctx();
    }
    if (sharedCtx && sharedCtx.state === 'suspended') {
      // Some Safari builds keep the context suspended even when
      // created from a user gesture. Resume is a no-op when running.
      sharedCtx.resume();
    }
    return sharedCtx;
  };

  document.querySelectorAll('.player').forEach(p => {
    const src = p.dataset.src;
    const btn = p.querySelector('.play');
    const seek = p.querySelector('.seek');
    const timeEl = p.querySelector('.time');
    const initialDuration = parseFloat(p.dataset.duration);
    let audio = null;
    let seeking = false;
    // Pending seek expressed as a fraction of the full length (0..1). Used
    // whenever we can't immediately apply `audio.currentTime` (metadata not
    // yet loaded, or duration unknown at drop time). Cleared in `seeked`,
    // so render() doesn't overwrite the slider with a stale currentTime
    // reading between "set" and "applied".
    let pendingSeek = null;
    let duration = isFinite(initialDuration) ? initialDuration : 0;

    const render = () => {
      const cur = audio ? audio.currentTime : 0;
      const displayTime = pendingSeek !== null ? pendingSeek * duration : cur;
      timeEl.textContent = `${fmtDur(displayTime)} / ${fmtDur(duration)}`;
      if (!seeking && pendingSeek === null) {
        seek.value = duration > 0 ? Math.round((cur / duration) * 1000) : 0;
      }
    };
    render();

    const tryApplyPendingSeek = () => {
      if (!audio || pendingSeek === null) return;
      if (audio.readyState >= 1 && isFinite(audio.duration) && audio.duration > 0) {
        audio.currentTime = pendingSeek * audio.duration;
        // Keep pendingSeek set until `seeked` fires — the browser needs a
        // moment to commit the seek, and any timeupdate in between would
        // otherwise read the old currentTime.
      }
    };

    const ensureAudio = () => {
      if (audio) return audio;
      audio = new Audio(src);
      audio.preload = 'metadata';

      // Replay-gain: route through Web Audio with a GainNode so the
      // recording plays at the consistent target loudness regardless
      // of which client recorded it. `createMediaElementSource`
      // *redirects* the audio element's output through the graph —
      // forgetting the `connect(ctx.destination)` would silence the
      // player. Skipped entirely when gain is 0, missing, or
      // negative (older meta sidecars, silent files, analyze errors)
      // so the default <audio> path still works.
      const gainDb = parseFloat(p.dataset.gainDb);
      if (isFinite(gainDb) && gainDb > 0) {
        const ctx = ensureCtx();
        if (ctx) {
          const mediaSrc = ctx.createMediaElementSource(audio);
          const gainNode = ctx.createGain();
          gainNode.gain.value = Math.pow(10, gainDb / 20);
          mediaSrc.connect(gainNode).connect(ctx.destination);
        }
      }

      audio.addEventListener('loadedmetadata', () => {
        if (isFinite(audio.duration) && audio.duration > 0) duration = audio.duration;
        tryApplyPendingSeek();
        render();
      });
      audio.addEventListener('timeupdate', render);
      audio.addEventListener('seeked', () => {
        pendingSeek = null;
        render();
      });
      audio.addEventListener('ended', () => {
        btn.classList.remove('is-pause');
        btn.setAttribute('aria-label', 'Abspielen');
        audio.currentTime = 0;
        render();
      });
      return audio;
    };

    btn.addEventListener('click', () => {
      const a = ensureAudio();
      if (a.paused) {
        a.play();
        btn.classList.add('is-pause');
        btn.setAttribute('aria-label', 'Pause');
      } else {
        a.pause();
        btn.classList.remove('is-pause');
        btn.setAttribute('aria-label', 'Abspielen');
      }
    });

    seek.addEventListener('input', () => {
      seeking = true;
      if (duration <= 0) return;
      // Live time-label update while dragging (no audio change yet).
      const preview = (parseFloat(seek.value) / 1000) * duration;
      timeEl.textContent = `${fmtDur(preview)} / ${fmtDur(duration)}`;
    });
    seek.addEventListener('change', () => {
      seeking = false;
      const fraction = parseFloat(seek.value) / 1000;
      // Ensure audio exists even if duration is still unknown — this kicks
      // off metadata loading, so loadedmetadata will apply the pending seek.
      ensureAudio();
      pendingSeek = fraction;
      tryApplyPendingSeek();
      render();
    });
  });
})();

// Live-update bus with audio-playback protection: only events for THIS
// case trigger a reload, and if any audio is currently playing, show a
// dismissible "Updates verfügbar"-banner instead of interrupting playback.
// The `.play.is-pause` class is set by the player script above while audio
// plays, so we can probe it via DOM selector without extra wiring.
(() => {
  const MY_CASE_ID = "{{ case_id }}";
  let timer = null;
  const anyAudioPlaying = () => !!document.querySelector('.player .play.is-pause');
  const showBanner = () => {
    if (document.getElementById('update-banner')) return;
    const b = document.createElement('button');
    b.id = 'update-banner';
    b.type = 'button';
    b.textContent = '\u21bb Aktualisierungen verf\u00fcgbar \u2014 neu laden';
    b.style.cssText = 'position:fixed;bottom:1em;right:1em;z-index:9999;'
      + 'padding:0.6em 1em;background:#fff3cd;border:1px solid #d39e00;'
      + 'border-radius:4px;cursor:pointer;font-family:inherit;';
    b.addEventListener('click', () => location.reload());
    document.body.appendChild(b);
  };
  const scheduleReload = () => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      if (anyAudioPlaying()) showBanner();
      else location.reload();
    }, 300);
  };
  const es = new EventSource('/web/events');
  // Release the socket-pool slot on navigation. Without this, Chrome
  // keeps the SSE connection "draining" after unload; six rapid nav
  // cycles exhaust the 6-per-origin pool and stall further requests.
  window.addEventListener('pagehide', () => es.close());
  es.addEventListener('case', (msg) => {
    let evt;
    try { evt = JSON.parse(msg.data); } catch (_) { return; }
    if (evt.case_id === MY_CASE_ID) scheduleReload();
  });
})();
</script>
</body>
</html>
