Files
libredeck-web/bin/main/templates/index.html
2026-05-03 21:51:45 +02:00

1458 lines
66 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LibreDeck Spielkarten aus deinen Playlists</title>
<meta name="description" content="Erstelle druckfertige Spielkarten aus deinen Deezer-Playlists. Mit QR-Code auf der Vorderseite und Künstler, Titel & Jahr auf der Rückseite.">
<link rel="icon" type="image/png" th:href="@{/images/favicon.png}">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<!-- ── Hero ── -->
<section class="hero">
<div class="hero-inner">
<div class="hero-text">
<p class="hero-eyebrow">Kostenlos · Kein Account nötig</p>
<h1>Spielkarten aus deinen<br><span class="accent">Lieblings&#8209;Playlists</span></h1>
<p class="hero-sub">
Füge einen Playlist-Link ein und lade ein druckfertiges PDF herunter.
Jede Karte hat vorne einen QR-Code zum Abspielen und hinten
Künstler, Titel und Erscheinungsjahr.
</p>
<a href="#generator" class="btn btn-lg">Jetzt Karten erstellen</a>
</div>
<div class="hero-brand" aria-hidden="true">
<img th:src="@{/images/logo.png}" alt="LibreDeck" class="hero-brand-logo">
</div>
</div>
</section>
<!-- ── Service Topbar ── -->
<div class="service-topbar">
<div class="topbar-inner section-inner">
<button class="topbar-nav" id="btnTopbarPrev">&#8592;</button>
<div class="topbar-service">
<img class="topbar-icon" id="topbarIcon" src="https://open.spotify.com/favicon.ico" alt="">
<span class="topbar-service-name" id="topbarServiceName">Spotify</span>
</div>
<button class="topbar-nav" id="btnTopbarNext">&#8594;</button>
</div>
</div>
<!-- ── How it works ── -->
<section class="how-section">
<div class="section-inner">
<h2 class="section-title">So funktioniert es</h2>
<div class="steps-viewport">
<!-- Spotify steps -->
<div class="steps how-steps" id="howSpotify">
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/><polyline points="16 11 18 13 22 9"/>
</svg>
</div>
<h3>Mit Spotify verbinden</h3>
<p>Klicke auf „Mit Spotify verbinden" und melde dich einmalig pro Sitzung an.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
</div>
<h3>Playlist auswählen</h3>
<p>Deine Playlists erscheinen direkt zur Auswahl einfach anklicken und PDF wird erstellt.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>
</svg>
</div>
<h3>Ausdrucken &amp; spielen</h3>
<p>PDF doppelseitig drucken, Karten ausschneiden fertig.</p>
</div>
</div>
<!-- Tidal steps -->
<div class="steps how-steps" id="howTidal" style="visibility:hidden;transform:translateX(100%)">
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/><polyline points="16 11 18 13 22 9"/>
</svg>
</div>
<h3>Mit Tidal verbinden</h3>
<p>Klicke auf „Mit Tidal verbinden" und melde dich einmalig pro Sitzung an.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
</div>
<h3>Playlist auswählen</h3>
<p>Deine Tidal-Playlists erscheinen zur Auswahl einfach anklicken.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>
</svg>
</div>
<h3>Ausdrucken &amp; spielen</h3>
<p>PDF doppelseitig drucken, Karten ausschneiden fertig.</p>
</div>
</div>
<!-- Apple Music steps -->
<div class="steps how-steps" id="howApple" style="visibility:hidden;transform:translateX(100%)">
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/><polyline points="16 11 18 13 22 9"/>
</svg>
</div>
<h3>Mit Apple Music verbinden</h3>
<p>Klicke auf „Mit Apple Music verbinden" und melde dich mit deiner Apple ID an.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
</div>
<h3>Playlist auswählen</h3>
<p>Deine Apple Music-Playlists erscheinen zur Auswahl oder gib direkt einen Playlist-Link ein.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>
</svg>
</div>
<h3>Ausdrucken &amp; spielen</h3>
<p>PDF doppelseitig drucken, Karten ausschneiden fertig.</p>
</div>
</div>
<!-- YouTube steps -->
<div class="steps how-steps" id="howYoutube" style="visibility:hidden;transform:translateX(100%)">
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.95-1.96C18.88 4 12 4 12 4s-6.88 0-8.59.46A2.78 2.78 0 0 0 1.46 6.42 29 29 0 0 0 1 12a29 29 0 0 0 .46 5.58 2.78 2.78 0 0 0 1.95 1.96C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 0 0 1.96-1.96A29 29 0 0 0 23 12a29 29 0 0 0-.46-5.58z"/><polygon points="9.75 15.02 15.5 12 9.75 8.98 9.75 15.02"/>
</svg>
</div>
<h3>Playlist öffnen</h3>
<p>Öffne eine YouTube-Playlist im Browser und kopiere den Link aus der Adresszeile.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
</div>
<h3>PDF generieren</h3>
<p>Link hier einfügen und auf „PDF erstellen" klicken.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>
</svg>
</div>
<h3>Ausdrucken &amp; spielen</h3>
<p>PDF doppelseitig drucken, Karten ausschneiden fertig.</p>
</div>
</div>
<!-- Deezer steps -->
<div class="steps how-steps" id="howDeezer" style="visibility:hidden;transform:translateX(100%)">
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
</div>
<h3>Playlist öffnen</h3>
<p>Öffne eine Deezer-Playlist in der App oder im Browser und kopiere den Link.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
</div>
<h3>PDF generieren</h3>
<p>Link hier einfügen und auf „PDF erstellen" klicken. Das PDF wird direkt heruntergeladen.</p>
</div>
<div class="step-arrow" aria-hidden="true"></div>
<div class="step">
<div class="step-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>
</svg>
</div>
<h3>Ausdrucken &amp; spielen</h3>
<p>PDF doppelseitig drucken, Karten ausschneiden fertig.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ── Card detail ── -->
<section class="detail-section">
<div class="section-inner detail-inner">
<div class="detail-text">
<h2>Was drauf steht</h2>
<ul class="detail-list">
<li>
<span class="detail-icon front-icon">V</span>
<div>
<strong>Vorderseite</strong>
<p>QR-Code, der direkt zum Track auf Deezer führt einfach scannen und Musik läuft.</p>
</div>
</li>
<li>
<span class="detail-icon back-icon">R</span>
<div>
<strong>Rückseite</strong>
<p>Erscheinungsjahr, Künstlername und Titel alles was du zum Raten und Einordnen brauchst.</p>
</div>
</li>
<li>
<span class="detail-icon print-icon"></span>
<div>
<strong>Druckfertig</strong>
<p>35 Karten à 40 × 40 mm auf einer A4-Seite</p>
</div>
</li>
</ul>
</div>
<div class="detail-preview" aria-hidden="true">
<div class="preview-stack">
<div class="card-mockup card-back small">
<div class="mock-year">2007</div>
<div class="mock-divider"></div>
<div class="mock-artist">Amy Winehouse</div>
<div class="mock-title">Rehab</div>
<div class="mock-brand">libredeck</div>
</div>
<div class="card-mockup card-back small">
<div class="mock-year">2013</div>
<div class="mock-divider"></div>
<div class="mock-artist">Daft Punk</div>
<div class="mock-title">Get Lucky</div>
<div class="mock-brand">libredeck</div>
</div>
<div class="card-mockup card-back small">
<div class="mock-year">1982</div>
<div class="mock-divider"></div>
<div class="mock-artist">Michael Jackson</div>
<div class="mock-title">Billie Jean</div>
<div class="mock-brand">libredeck</div>
</div>
<div class="card-mockup card-back small">
<div class="mock-year">1994</div>
<div class="mock-divider"></div>
<div class="mock-artist">Nirvana</div>
<div class="mock-title">Come as You Are</div>
<div class="mock-brand">libredeck</div>
</div>
<div class="card-mockup card-back small">
<div class="mock-year">1977</div>
<div class="mock-divider"></div>
<div class="mock-artist">Bee Gees</div>
<div class="mock-title">Stayin' Alive</div>
<div class="mock-brand">libredeck</div>
</div>
<div class="card-mockup card-back small">
<div class="mock-year">2019</div>
<div class="mock-divider"></div>
<div class="mock-artist">The Weeknd</div>
<div class="mock-title">Blinding Lights</div>
<div class="mock-brand">libredeck</div>
</div>
</div>
</div>
</div>
</section>
<!-- ── Generator ── -->
<section class="generator-section" id="generator">
<div class="section-inner">
<h2 class="section-title">PDF erstellen</h2>
<p class="section-sub">Wähle deinen Streaming-Dienst und starte direkt.</p>
<div class="generator-card">
<div class="gen-viewport">
<!-- Spotify Panel -->
<div class="service-panel" id="panelSpotify">
<div id="spotifyConnectArea">
<p class="service-hint">Verbinde dich einmalig mit Spotify dann kannst du deine Playlists direkt auswählen.</p>
<a id="spotifyAuthLink" href="/spotify/auth" class="btn btn-lg spotify-btn">Mit Spotify verbinden</a>
</div>
<div id="spotifyPickerSection" style="display:none">
<div class="picker-header">
<span class="picker-label" style="margin:0">Deine Playlists</span>
<button class="btn-refresh" onclick="loadSpotifyPlaylists()" title="Aktualisieren">&#8635;</button>
</div>
<div class="playlist-carousel" id="spotifyCarousel">
<button class="carousel-btn carousel-btn--prev" id="spotifyPrev" disabled>&#8249;</button>
<div class="carousel-viewport">
<div class="carousel-track" id="spotifyTrack"></div>
</div>
<button class="carousel-btn carousel-btn--next" id="spotifyNext" disabled>&#8250;</button>
</div>
<div class="spotify-url-row" style="margin-top:.75rem">
<input type="url" id="spotifyUrlInput" class="url-input"
placeholder="https://open.spotify.com/playlist/…" autocomplete="off"/>
<button class="btn btn-lg" onclick="submitSpotifyUrl()">Laden</button>
</div>
</div>
</div>
<!-- Tidal Panel -->
<div class="service-panel" id="panelTidal" style="visibility:hidden;transform:translateX(100%)">
<div id="tidalConnectArea">
<p class="service-hint">Verbinde dich einmalig mit Tidal dann kannst du deine Playlists direkt auswählen.</p>
<a id="tidalAuthLink" href="/tidal/auth" class="btn btn-lg tidal-btn">Mit Tidal verbinden</a>
</div>
<div id="tidalPickerSection" style="display:none">
<div class="picker-header">
<span class="picker-label" style="margin:0">Deine Playlists</span>
<button class="btn-refresh" onclick="loadTidalPlaylists()" title="Aktualisieren">&#8635;</button>
</div>
<div class="playlist-carousel" id="tidalCarousel">
<button class="carousel-btn carousel-btn--prev" id="tidalPrev" disabled>&#8249;</button>
<div class="carousel-viewport">
<div class="carousel-track" id="tidalTrack"></div>
</div>
<button class="carousel-btn carousel-btn--next" id="tidalNext" disabled>&#8250;</button>
</div>
<div class="spotify-url-row" style="margin-top:.75rem">
<input type="url" id="tidalUrlInput" class="url-input"
placeholder="https://tidal.com/browse/playlist/…" autocomplete="off"/>
<button class="btn btn-lg" onclick="submitTidalUrl()">Laden</button>
</div>
</div>
</div>
<!-- Apple Music Panel -->
<div class="service-panel" id="panelApple" style="visibility:hidden;transform:translateX(100%)">
<div id="appleNotConfigured" style="display:none">
<p class="service-hint">Apple Music ist auf diesem Server nicht konfiguriert.</p>
</div>
<div id="appleConnectArea" style="display:none">
<p class="service-hint">Verbinde dich einmalig mit Apple Music dann kannst du deine Playlists direkt auswählen.</p>
<button id="btnAppleConnect" class="btn btn-lg" onclick="connectAppleMusic()">Mit Apple Music verbinden</button>
</div>
<div id="applePickerSection" style="display:none">
<div class="picker-header">
<span class="picker-label" style="margin:0">Deine Playlists</span>
<button class="btn-refresh" onclick="loadApplePlaylists()" title="Aktualisieren">&#8635;</button>
</div>
<div class="playlist-carousel" id="appleCarousel">
<button class="carousel-btn carousel-btn--prev" id="applePrev" disabled>&#8249;</button>
<div class="carousel-viewport">
<div class="carousel-track" id="appleTrack"></div>
</div>
<button class="carousel-btn carousel-btn--next" id="appleNext" disabled>&#8250;</button>
</div>
<div class="spotify-url-row" style="margin-top:.75rem">
<input type="url" id="appleUrlInput" class="url-input"
placeholder="https://music.apple.com/us/playlist/…" autocomplete="off"/>
<button class="btn btn-lg" onclick="submitAppleUrl()">Laden</button>
</div>
<div style="max-width:600px;margin:.5rem auto 0;text-align:right">
<button class="btn-cancel" style="font-size:.8rem" onclick="disconnectAppleMusic()">Verbindung trennen</button>
</div>
</div>
</div>
<!-- YouTube Panel -->
<div class="service-panel" id="panelYoutube" style="visibility:hidden;transform:translateX(100%)">
<div class="input-row">
<input type="url" id="youtubeUrlInput" class="url-input"
placeholder="https://www.youtube.com/playlist?list=…" autocomplete="off"/>
<button class="btn btn-lg" onclick="submitYoutubeUrl()">Daten ermitteln</button>
</div>
<p class="youtube-notice">
Das Erscheinungsjahr wird über MusicBrainz ermittelt bei langen Playlists kann das einige Minuten dauern.
Playlists mit mehr als 500 Einträgen werden nicht unterstützt.
</p>
<details class="hint-details">
<summary>Wo finde ich den Playlist-Link?</summary>
<ol class="hint-steps">
<li>Playlist auf YouTube im Browser öffnen</li>
<li>Link aus der Adresszeile kopieren</li>
<li>Link oben einfügen</li>
</ol>
<p class="hint-example">Beispiel: <code>https://www.youtube.com/playlist?list=PLxxxxxx</code></p>
</details>
</div>
<!-- Deezer Panel -->
<div class="service-panel" id="panelDeezer" style="visibility:hidden;transform:translateX(100%)">
<div class="input-row">
<input type="url" id="playlistUrl" class="url-input"
placeholder="https://www.deezer.com/playlist/…" autocomplete="off"/>
<button id="btnGenerate" class="btn btn-lg">Daten ermitteln</button>
</div>
<details class="hint-details">
<summary>Wo finde ich den Playlist-Link?</summary>
<ol class="hint-steps">
<li>Playlist in der Deezer-App bzw. im Browser öffnen</li>
<li>Auf <strong>„…"</strong><strong>„Link teilen / kopieren"</strong> tippen</li>
<li>Link oben einfügen</li>
</ol>
<p class="hint-example">Deezer: <code>https://www.deezer.com/playlist/1234567890</code></p>
</details>
</div>
</div>
<div id="errorMsg" class="error-msg" style="display:none"></div>
<div th:if="${error}" class="error-msg" th:text="${error}"></div>
</div>
<!-- ── Review section (shown after year lookup, always) ── -->
<div id="reviewSection" class="review-section" style="display:none">
<h3 class="review-heading">Titel &amp; Erscheinungsjahre prüfen</h3>
<p class="review-sub">
Gefundene Jahre sind bereits eingetragen. Passe sie bei Bedarf an oder
leere das Feld dann wird die Karte nicht erstellt.
</p>
<div class="review-actions" id="reviewActionButtons">
<button class="btn-outline" onclick="document.getElementById('bulkCheckDialog').style.display='flex'">Alle prüfen</button>
<button id="btnPlay" class="btn btn-lg btn-play">Direkt spielen</button>
<button id="btnConfirm" class="btn btn-lg">PDF erstellen</button>
</div>
<div id="bulkProgress" class="bulk-progress" style="display:none">
<div class="progress-bar-bg" style="flex:1">
<div class="progress-bar-fill" id="bulkProgressFill"></div>
</div>
<span id="bulkProgressLabel" class="bulk-progress-label" style="font-size:.85rem;color:var(--text-muted);white-space:nowrap">0 von 0 geprüft</span>
<button class="btn-cancel" onclick="cancelBulkCheck()">Abbrechen</button>
</div>
<div id="reviewTable" class="review-table"></div>
</div>
</div>
</section>
<!-- ── Footer ── -->
<footer class="site-footer">
<div class="section-inner footer-inner">
<img th:src="@{/images/logo.png}" alt="LibreDeck" class="footer-logo-img">
<p>Keine Datenspeicherung · Kein Account erforderlich</p>
</div>
</footer>
<!-- ── Bulk-check warning dialog ── -->
<div id="bulkCheckDialog" style="display:none" role="dialog" aria-modal="true" aria-labelledby="bulkCheckTitle">
<div class="dialog-backdrop" onclick="document.getElementById('bulkCheckDialog').style.display='none'"></div>
<div class="dialog-box">
<h3 class="dialog-title" id="bulkCheckTitle">Alle Titel prüfen</h3>
<p class="dialog-body">
MusicBrainz wird für jeden Titel einzeln abgefragt das dauert ca. 12 Sekunden pro Titel.
Bei großen Playlists kann das mehrere Minuten in Anspruch nehmen.<br><br>
Abweichende Jahre werden automatisch übernommen. Du kannst den Vorgang jederzeit abbrechen
bereits geprüfte Titel bleiben dann aktualisiert.
</p>
<div class="dialog-actions" style="gap:.75rem">
<button class="btn-cancel" onclick="document.getElementById('bulkCheckDialog').style.display='none'">Abbrechen</button>
<button class="btn btn-lg" onclick="startBulkCheck()">Fortfahren</button>
</div>
</div>
</div>
<!-- ── Deezer year-accuracy notice ── -->
<div id="deezerNotice" style="display:none" role="dialog" aria-modal="true" aria-labelledby="deezerNoticeTitle">
<div class="dialog-backdrop"></div>
<div class="dialog-box">
<h3 class="dialog-title" id="deezerNoticeTitle">Hinweis zu Deezer-Jahreszahlen</h3>
<p class="dialog-body">
Die von Deezer gelieferten Erscheinungsjahre können gelegentlich ungenau sein
besonders bei remasterten oder neu veröffentlichten Versionen eines Titels.<br><br>
Verwende den <strong>Prüfen</strong>-Button neben jedem Titel, um das Jahr
über MusicBrainz zu verifizieren und bei Bedarf zu korrigieren.
</p>
<div class="dialog-actions">
<button class="btn btn-lg" onclick="closeDeezerNotice()">Verstanden</button>
</div>
</div>
</div>
<!-- ── Loading overlay ── -->
<div id="loading-overlay" style="display:none" aria-live="polite">
<div class="spinner"></div>
<p id="overlayStatus">Tracks werden geladen…</p>
<div id="progressWrap" style="display:none">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressFill"></div>
</div>
<p class="loading-sub" id="progressLabel"></p>
</div>
<button id="btnCancel" class="btn-cancel" onclick="cancelJob()">Abbrechen</button>
</div>
<script>
// ── Unified service state ──
const SERVICES = ['Spotify', 'Tidal', 'Deezer', 'Apple Music', 'YouTube'];
const GEN_IDS = ['panelSpotify', 'panelTidal', 'panelDeezer', 'panelApple', 'panelYoutube'];
const HOW_IDS = ['howSpotify', 'howTidal', 'howDeezer', 'howApple', 'howYoutube'];
const SVC_ICONS = ['https://open.spotify.com/favicon.ico', 'https://tidal.com/favicon.ico', 'https://www.deezer.com/favicon.ico', 'https://www.apple.com/favicon.ico', 'https://www.youtube.com/favicon.ico'];
let currentService = 0;
function updateTopbar() {
document.getElementById('topbarServiceName').textContent = SERVICES[currentService];
document.getElementById('topbarIcon').src = SVC_ICONS[currentService];
}
function animatePanels(outId, inId, dir) {
const outEl = document.getElementById(outId);
const inEl = document.getElementById(inId);
inEl.style.transition = 'none';
inEl.style.transform = `translateX(${dir > 0 ? '100%' : '-100%'})`;
inEl.style.visibility = 'visible';
requestAnimationFrame(() => requestAnimationFrame(() => {
outEl.style.transform = `translateX(${dir > 0 ? '-100%' : '100%'})`;
inEl.style.transition = '';
inEl.style.transform = 'translateX(0)';
}));
setTimeout(() => {
outEl.style.visibility = 'hidden';
outEl.style.transition = 'none';
}, 360);
}
function switchService(dir) {
const next = (currentService + dir + SERVICES.length) % SERVICES.length;
animatePanels(HOW_IDS[currentService], HOW_IDS[next], dir);
animatePanels(GEN_IDS[currentService], GEN_IDS[next], dir);
currentService = next;
updateTopbar();
hideError();
}
function setService(idx) {
if (idx === currentService) return;
const dir = idx > currentService ? 1 : -1;
animatePanels(HOW_IDS[currentService], HOW_IDS[idx], dir);
animatePanels(GEN_IDS[currentService], GEN_IDS[idx], dir);
currentService = idx;
updateTopbar();
hideError();
}
document.getElementById('btnTopbarPrev').addEventListener('click', () => switchService(-1));
document.getElementById('btnTopbarNext').addEventListener('click', () => switchService(1));
// ── Global touch swipe (service switch) ──
let _tx = 0, _ty = 0, _tm = false, _tCarousel = false;
document.addEventListener('touchstart', e => {
_tx = e.touches[0].clientX;
_ty = e.touches[0].clientY;
_tm = false;
_tCarousel = !!e.target.closest('.carousel-viewport');
}, { passive: true });
document.addEventListener('touchmove', e => {
if (Math.abs(e.touches[0].clientX - _tx) > 8 ||
Math.abs(e.touches[0].clientY - _ty) > 8) _tm = true;
}, { passive: true });
document.addEventListener('touchend', e => {
if (!_tm || _tCarousel) return;
_tm = false;
const dx = e.changedTouches[0].clientX - _tx;
const dy = e.changedTouches[0].clientY - _ty;
if (Math.abs(dx) >= 60 && Math.abs(dx) > Math.abs(dy) * 1.5)
switchService(dx < 0 ? 1 : -1);
}, { passive: true });
// ── Carousel engine ──────────────────────────────────────────────────────
// Card widths are set purely via CSS container queries (cqw units),
// so no JS layout calculation is needed here.
const _carousels = {};
function setupCarousel(id) {
const viewport = document.querySelector('#' + id + 'Carousel .carousel-viewport');
const track = document.getElementById(id + 'Track');
const prevBtn = document.getElementById(id + 'Prev');
const nextBtn = document.getElementById(id + 'Next');
if (!viewport || !track) return null;
let offset = 0;
function updateBtns() {
const maxOff = Math.max(0, track.scrollWidth - viewport.offsetWidth);
prevBtn.disabled = offset <= 0;
nextBtn.disabled = offset >= maxOff - 1;
}
function shift(dir) {
const vw = viewport.offsetWidth;
const maxOff = Math.max(0, track.scrollWidth - vw);
offset = Math.max(0, Math.min(offset + dir * vw, maxOff));
track.style.transform = `translateX(-${offset}px)`;
updateBtns();
}
prevBtn.addEventListener('click', () => shift(-1));
nextBtn.addEventListener('click', () => shift(1));
// Carousel-local swipe (prevents global service-switch from firing)
let cx = 0, cy = 0;
viewport.addEventListener('touchstart', e => {
cx = e.touches[0].clientX; cy = e.touches[0].clientY;
}, { passive: true });
viewport.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - cx;
const dy = e.changedTouches[0].clientY - cy;
if (Math.abs(dx) >= 40 && Math.abs(dx) > Math.abs(dy) * 1.2) shift(dx < 0 ? 1 : -1);
}, { passive: true });
const api = {
load(items, onSelect) {
offset = 0;
track.innerHTML = '';
track.style.transform = '';
if (!items.length) {
track.innerHTML = '<span style="color:var(--text-muted);font-size:.85rem">Keine Playlists gefunden.</span>';
prevBtn.disabled = nextBtn.disabled = true;
return;
}
items.forEach(pl => {
const card = document.createElement('div');
card.className = 'playlist-card';
card.innerHTML = (pl.coverUrl
? '<img src="' + esc(pl.coverUrl) + '" alt="" loading="lazy">'
: '<div class="playlist-card-nocover">&#127925;</div>') +
'<span class="playlist-card-name">' + esc(pl.title) + '</span>';
card.addEventListener('click', () => onSelect(pl));
track.appendChild(card);
});
// Let the browser apply container-query widths, then check scroll range
requestAnimationFrame(updateBtns);
},
error(msg) {
track.innerHTML = '<span style="color:var(--text-muted);font-size:.85rem">' + esc(msg) + '</span>';
prevBtn.disabled = nextBtn.disabled = true;
}
};
_carousels[id] = api;
return api;
}
// Init carousels (DOM is ready at script-parse time since script is at end of body)
setupCarousel('spotify');
setupCarousel('tidal');
setupCarousel('apple');
// ── Apple Music ──────────────────────────────────────────────────────────
let appleDeveloperToken = null;
async function initAppleMusic() {
try {
const cfgRes = await fetch('/apple/configured');
const cfg = await cfgRes.json();
if (!cfg.configured) {
document.getElementById('appleNotConfigured').style.display = '';
return;
}
const tokenRes = await fetch('/apple/developer-token');
if (!tokenRes.ok) { document.getElementById('appleNotConfigured').style.display = ''; return; }
const tokenData = await tokenRes.json();
appleDeveloperToken = tokenData.token;
const connRes = await fetch('/apple/connected');
const connData = await connRes.json();
if (connData.connected) {
showApplePicker();
} else {
document.getElementById('appleConnectArea').style.display = '';
}
} catch (_) {
document.getElementById('appleConnectArea').style.display = '';
}
}
async function connectAppleMusic() {
if (!appleDeveloperToken) return;
// Ensure MusicKit JS is loaded
if (typeof MusicKit === 'undefined') {
const script = document.createElement('script');
script.src = 'https://js-cdn.music.apple.com/musickit/v3/musickit.js';
script.crossOrigin = 'anonymous';
document.head.appendChild(script);
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
});
}
try {
const music = await MusicKit.configure({
developerToken: appleDeveloperToken,
app: { name: 'LibreDeck', build: '1.0' }
});
const userToken = await music.authorize();
if (!userToken) { showError('Apple Music-Anmeldung abgebrochen.'); return; }
await fetch('/apple/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ musicUserToken: userToken })
});
showApplePicker();
} catch (e) {
showError('Apple Music-Anmeldung fehlgeschlagen: ' + (e.message || e));
}
}
async function disconnectAppleMusic() {
await fetch('/apple/disconnect', { method: 'POST' });
document.getElementById('applePickerSection').style.display = 'none';
document.getElementById('appleConnectArea').style.display = '';
}
async function showApplePicker() {
document.getElementById('appleConnectArea').style.display = 'none';
const ok = await loadApplePlaylists();
if (!ok) document.getElementById('appleConnectArea').style.display = '';
}
async function loadApplePlaylists() {
const section = document.getElementById('applePickerSection');
const savedY = window.scrollY;
section.style.display = 'block';
window.scrollTo({ top: savedY, behavior: 'instant' });
_carousels.apple?.error('Lade Playlists\u2026');
try {
const res = await fetch('/apple/playlists');
if (!res.ok) {
_carousels.apple?.error('Fehler beim Laden (' + res.status + ').');
return false;
}
const lists = await res.json();
_carousels.apple?.load(lists, pl =>
triggerGenerate('https://music.apple.com/library/playlists/' + pl.id));
return true;
} catch (e) {
_carousels.apple?.error('Fehler beim Laden.');
return false;
}
}
function submitAppleUrl() {
const url = document.getElementById('appleUrlInput').value.trim();
if (!url) { document.getElementById('appleUrlInput').focus(); return; }
triggerGenerate(url);
}
document.getElementById('appleUrlInput')?.addEventListener('keydown', e => {
if (e.key === 'Enter') submitAppleUrl();
});
function submitYoutubeUrl() {
const url = document.getElementById('youtubeUrlInput').value.trim();
if (!url) { document.getElementById('youtubeUrlInput').focus(); return; }
triggerGenerate(url);
}
document.getElementById('youtubeUrlInput')?.addEventListener('keydown', e => {
if (e.key === 'Enter') submitYoutubeUrl();
});
// ── Connection checks on page load ──
(async function () {
const params = new URLSearchParams(window.location.search);
const spotifyUrl = params.get('spotifyUrl');
const spotifyErr = params.get('spotifyError');
const tidalUrl = params.get('tidalUrl');
const tidalErr = params.get('tidalError');
if (spotifyErr) {
history.replaceState({}, '', window.location.pathname);
showError('Spotify-Anmeldung fehlgeschlagen: ' + spotifyErr);
return;
}
if (tidalErr) {
history.replaceState({}, '', window.location.pathname);
showError('Tidal-Anmeldung fehlgeschlagen: ' + tidalErr);
return;
}
// Check Spotify
try {
const res = await fetch('/spotify/connected');
const data = await res.json();
if (data.connected) showSpotifyPicker();
} catch (_) { /* ignore */ }
// Check Tidal
try {
const res = await fetch('/tidal/connected');
const data = await res.json();
if (data.connected) showTidalPicker();
} catch (_) { /* ignore */ }
// Init Apple Music (checks configured + connected)
initAppleMusic().catch(() => {});
if (spotifyUrl) {
history.replaceState({}, '', window.location.pathname);
await triggerGenerate(spotifyUrl);
} else if (tidalUrl) {
history.replaceState({}, '', window.location.pathname);
setService(1); // switch to Tidal
await triggerGenerate(tidalUrl);
}
})();
async function showSpotifyPicker() {
document.getElementById('spotifyConnectArea').style.display = 'none';
const ok = await loadSpotifyPlaylists();
if (!ok) document.getElementById('spotifyConnectArea').style.display = '';
}
async function loadSpotifyPlaylists() {
const section = document.getElementById('spotifyPickerSection');
const savedY = window.scrollY;
section.style.display = 'block';
window.scrollTo({ top: savedY, behavior: 'instant' });
_carousels.spotify?.error('Lade Playlists\u2026');
try {
const res = await fetch('/spotify/playlists');
if (res.status === 401) {
section.style.display = 'none';
document.getElementById('spotifyConnectArea').style.display = '';
return false;
}
if (!res.ok) {
_carousels.spotify?.error('Fehler beim Laden (' + res.status + ').');
return false;
}
const lists = await res.json();
_carousels.spotify?.load(lists, pl =>
triggerGenerate('https://open.spotify.com/playlist/' + pl.id));
return true;
} catch (e) {
_carousels.spotify?.error('Fehler beim Laden.');
return false;
}
}
function submitSpotifyUrl() {
const url = document.getElementById('spotifyUrlInput').value.trim();
if (!url) { document.getElementById('spotifyUrlInput').focus(); return; }
triggerGenerate(url);
}
document.getElementById('spotifyUrlInput')?.addEventListener('keydown', e => {
if (e.key === 'Enter') submitSpotifyUrl();
});
// ── Tidal picker ──
async function showTidalPicker() {
document.getElementById('tidalConnectArea').style.display = 'none';
const ok = await loadTidalPlaylists();
if (!ok) document.getElementById('tidalConnectArea').style.display = '';
}
async function loadTidalPlaylists() {
const section = document.getElementById('tidalPickerSection');
const savedY = window.scrollY;
section.style.display = 'block';
window.scrollTo({ top: savedY, behavior: 'instant' });
_carousels.tidal?.error('Lade Playlists\u2026');
try {
const res = await fetch('/tidal/playlists');
if (!res.ok) {
_carousels.tidal?.error('Fehler beim Laden (' + res.status + ').');
return false;
}
const lists = await res.json();
_carousels.tidal?.load(lists, pl =>
triggerGenerate('https://tidal.com/browse/playlist/' + pl.id));
return true;
} catch (e) {
_carousels.tidal?.error('Fehler beim Laden.');
return false;
}
}
function submitTidalUrl() {
const url = document.getElementById('tidalUrlInput').value.trim();
if (!url) { document.getElementById('tidalUrlInput').focus(); return; }
triggerGenerate(url);
}
document.getElementById('tidalUrlInput')?.addEventListener('keydown', e => {
if (e.key === 'Enter') submitTidalUrl();
});
// ── smooth scroll ──
document.querySelectorAll('a[href="#generator"]').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
document.getElementById('generator').scrollIntoView({ behavior: 'smooth' });
});
});
let activeJobId = null;
let jobCancelled = false;
// ── step 1: lookup ──
document.getElementById('btnGenerate').addEventListener('click', async () => {
const url = document.getElementById('playlistUrl').value.trim();
if (!url) { document.getElementById('playlistUrl').focus(); return; }
await triggerGenerate(url);
});
async function triggerGenerate(url) {
hideReview();
hideError();
jobCancelled = false;
showOverlay();
try {
const res = await fetch('/generate/lookup?playlistUrl=' + encodeURIComponent(url));
if (!res.ok) throw new Error('Server nicht erreichbar.');
const data = await res.json();
if (data.spotifyAuthRequired) {
hideOverlay();
setService(0);
return;
}
if (data.tidalAuthRequired) {
hideOverlay();
setService(1);
return;
}
if (data.appleMusicAuthRequired) {
hideOverlay();
setService(3); // switch to Apple Music panel
return;
}
const { jobId } = data;
activeJobId = jobId;
const status = await pollUntilReviewOrDone(jobId);
if (status.cancelled) return;
if (status.phase === 'REVIEW_READY') {
hideOverlay();
if (currentService === 2) {
showDeezerNotice(() => showReview(status.tracks));
} else {
showReview(status.tracks);
}
} else {
triggerDownload(jobId);
}
} catch (err) {
hideOverlay();
showError(err.message || 'Ein unbekannter Fehler ist aufgetreten.');
}
}
// ── step 2: confirm (after review) ──
document.getElementById('btnConfirm').addEventListener('click', async () => {
if (!activeJobId) return;
const rows = document.querySelectorAll('.review-row');
const years = {};
const artists = {};
const titles = {};
rows.forEach(row => {
const id = row.dataset.id;
years[id] = (row.querySelector('.year-input')?.value ?? '').trim();
artists[id] = (row.querySelector('.review-artist-input')?.value ?? '').trim();
titles[id] = (row.querySelector('.review-title-input')?.value ?? '').trim();
});
hideReview();
hideError();
jobCancelled = false;
showOverlay();
try {
const res = await fetch('/generate/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId: activeJobId, years, artists, titles })
});
if (!res.ok) throw new Error('Server nicht erreichbar.');
const status = await pollUntilDone(activeJobId);
if (status.cancelled) return;
if (!status.ready) throw new Error(status.error || 'Unbekannter Fehler.');
triggerDownload(activeJobId);
} catch (err) {
hideOverlay();
showError(err.message || 'Ein unbekannter Fehler ist aufgetreten.');
}
});
// ── polling helpers ──
async function pollUntilReviewOrDone(jobId) {
while (true) {
await sleep(700);
if (jobCancelled) return { cancelled: true };
const res = await fetch('/generate/status?jobId=' + encodeURIComponent(jobId));
const status = await res.json();
if (status.error) throw new Error(status.error);
if (status.phase === 'FETCHING') {
setStatus('Tracks werden geladen…'); hideProgress();
} else if (status.phase === 'LOOKUP') {
setStatus('Erscheinungsjahr wird ermittelt…');
showProgress(status.done, status.total, 'Tracks geprüft');
} else if (status.phase === 'GENERATING') {
setStatus('Karten werden erstellt…');
showProgress(status.done, status.total, 'Karten erstellt');
}
if (status.phase === 'REVIEW_READY' || status.ready) return status;
}
}
async function pollUntilDone(jobId) {
while (true) {
await sleep(500);
if (jobCancelled) return { cancelled: true };
const res = await fetch('/generate/status?jobId=' + encodeURIComponent(jobId));
const status = await res.json();
if (status.phase === 'GENERATING') {
setStatus('Karten werden erstellt…');
showProgress(status.done, status.total, 'Karten erstellt');
}
if (status.ready || status.error) return status;
}
}
async function cancelJob() {
jobCancelled = true;
if (activeJobId) {
fetch('/generate/cancel?jobId=' + encodeURIComponent(activeJobId), { method: 'POST' });
activeJobId = null;
}
hideOverlay();
hideReview();
}
function triggerDownload(jobId) {
setStatus('Download startet…');
window.location.href = '/generate/download?jobId=' + encodeURIComponent(jobId);
setTimeout(hideOverlay, 1500);
}
// ── review section ──
function showDeezerNotice(onClose) {
const el = document.getElementById('deezerNotice');
el.style.display = 'flex';
el._onClose = onClose;
}
function closeDeezerNotice() {
const el = document.getElementById('deezerNotice');
el.style.display = 'none';
if (typeof el._onClose === 'function') el._onClose();
}
function showReview(tracks) {
const section = document.getElementById('reviewSection');
const table = document.getElementById('reviewTable');
table.innerHTML = '';
const isYouTube = currentService === 4;
tracks.forEach(t => {
const missing = !t.year;
const row = document.createElement('div');
row.className = 'review-row' + (missing ? ' review-row--missing' : '');
row.dataset.id = t.id;
row.dataset.streamingUrl = t.streamingUrl ?? '';
const swapBtn = isYouTube
? '<button class="btn-swap" title="Künstler ↔ Titel tauschen" onclick="swapArtistTitle(this)">⇄</button>'
: '';
row.innerHTML =
'<input type="text" class="review-artist-input url-input" value="' + esc(t.artist) + '" placeholder="Künstler">' +
swapBtn +
'<input type="text" class="review-title-input url-input" value="' + esc(t.title) + '" placeholder="Titel">' +
'<input type="text" class="year-input url-input' + (missing ? ' year-input--missing' : '') + '"' +
' maxlength="4" placeholder="Jahr" pattern="[0-9]{4}" inputmode="numeric"' +
' value="' + esc(t.year) + '">' +
'<div class="check-cell">' +
' <button class="btn-check" onclick="checkYear(this)">Prüfen</button>' +
' <span class="year-suggestion" style="display:none">' +
' <span class="suggestion-year"></span>' +
' <button class="suggestion-accept" title="Übernehmen">✓</button>' +
' <button class="suggestion-reject" title="Behalten">✗</button>' +
' </span>' +
'</div>';
table.appendChild(row);
});
section.style.display = 'block';
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function swapArtistTitle(btn) {
const row = btn.closest('.review-row');
const aInput = row.querySelector('.review-artist-input');
const tInput = row.querySelector('.review-title-input');
const tmp = aInput.value;
aInput.value = tInput.value;
tInput.value = tmp;
}
async function checkYear(btn) {
const row = btn.closest('.review-row');
const artist = row.querySelector('.review-artist-input').value.trim();
const title = row.querySelector('.review-title-input').value.trim();
const input = row.querySelector('.year-input');
const suggestion = row.querySelector('.year-suggestion');
const sugYear = row.querySelector('.suggestion-year');
btn.disabled = true;
btn.textContent = '…';
suggestion.style.display = 'none';
try {
const res = await fetch('/generate/check-year?artist=' + encodeURIComponent(artist)
+ '&title=' + encodeURIComponent(title));
const data = await res.json();
const mbYear = data.year;
if (!mbYear) {
btn.textContent = 'Nicht gefunden';
setTimeout(() => { btn.textContent = 'Prüfen'; btn.disabled = false; }, 2000);
return;
}
if (mbYear === input.value.trim()) {
row.classList.remove('review-row--missing');
input.classList.remove('year-input--missing');
btn.textContent = '✓ Bestätigt';
setTimeout(() => { btn.textContent = 'Prüfen'; btn.disabled = false; }, 2000);
return;
}
// different year show inline suggestion
sugYear.textContent = mbYear;
suggestion.style.display = 'flex';
const accept = row.querySelector('.suggestion-accept');
const reject = row.querySelector('.suggestion-reject');
// replace listeners to avoid duplicates
const freshAccept = accept.cloneNode(true);
const freshReject = reject.cloneNode(true);
accept.replaceWith(freshAccept);
reject.replaceWith(freshReject);
freshAccept.addEventListener('click', () => {
input.value = mbYear;
input.classList.remove('year-input--missing');
row.classList.remove('review-row--missing');
suggestion.style.display = 'none';
btn.textContent = 'Prüfen';
btn.disabled = false;
});
freshReject.addEventListener('click', () => {
suggestion.style.display = 'none';
btn.textContent = 'Prüfen';
btn.disabled = false;
});
} catch (e) {
btn.textContent = 'Fehler';
setTimeout(() => { btn.textContent = 'Prüfen'; btn.disabled = false; }, 2000);
}
}
let bulkCheckCancelled = false;
function startBulkCheck() {
document.getElementById('bulkCheckDialog').style.display = 'none';
bulkCheckCancelled = false;
const rows = Array.from(document.querySelectorAll('.review-row'));
document.getElementById('reviewActionButtons').style.display = 'none';
const progress = document.getElementById('bulkProgress');
progress.style.display = 'flex';
updateBulkProgress(0, rows.length);
(async () => {
let done = 0;
for (const row of rows) {
if (bulkCheckCancelled) break;
const artist = row.querySelector('.review-artist-input').value.trim();
const title = row.querySelector('.review-title-input').value.trim();
const input = row.querySelector('.year-input');
try {
const res = await fetch('/generate/check-year?artist=' + encodeURIComponent(artist)
+ '&title=' + encodeURIComponent(title));
const data = await res.json();
const current = input.value.trim();
const isOlder = data.year && (!current || data.year < current);
if (isOlder) {
input.value = data.year;
input.classList.remove('year-input--missing');
row.classList.add('review-row--updated');
}
} catch (e) { /* skip */ }
updateBulkProgress(++done, rows.length);
}
progress.style.display = 'none';
document.getElementById('reviewActionButtons').style.display = 'flex';
})();
}
function cancelBulkCheck() {
bulkCheckCancelled = true;
}
function updateBulkProgress(done, total) {
const fill = document.getElementById('bulkProgressFill');
const label = document.getElementById('bulkProgressLabel');
const pct = total > 0 ? Math.round(done / total * 100) : 0;
fill.style.width = pct + '%';
label.textContent = done + ' von ' + total + ' geprüft';
}
function hideReview() {
document.getElementById('reviewSection').style.display = 'none';
}
// ── UI helpers ──
function showProgress(done, total, label) {
const wrap = document.getElementById('progressWrap');
const fill = document.getElementById('progressFill');
const lbl = document.getElementById('progressLabel');
wrap.style.display = 'block';
const pct = total > 0 ? Math.round(done / total * 100) : 0;
fill.style.width = pct + '%';
lbl.textContent = done + ' von ' + total + ' ' + label;
}
function hideProgress() {
document.getElementById('progressWrap').style.display = 'none';
}
function setStatus(msg) {
document.getElementById('overlayStatus').textContent = msg;
}
function showOverlay() {
setStatus('Tracks werden geladen…');
hideProgress();
document.getElementById('loading-overlay').style.display = 'flex';
}
function hideOverlay() {
document.getElementById('loading-overlay').style.display = 'none';
}
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = 'block';
document.getElementById('generator').scrollIntoView({ behavior: 'smooth' });
}
function hideError() {
document.getElementById('errorMsg').style.display = 'none';
}
function esc(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// ── Play dialog ──────────────────────────────────────────────────────────
let _playDeck = []; // remaining cards this round
let _playFlipped = false;
let _currentTrack = null;
document.getElementById('btnPlay').addEventListener('click', () => {
const tracks = collectConfirmedTracks();
if (!tracks.length) return;
_playDeck = shuffle([...tracks]);
openPlayDialog();
});
function collectConfirmedTracks() {
const rows = document.querySelectorAll('.review-row');
const out = [];
rows.forEach(row => {
const year = (row.querySelector('.year-input')?.value ?? '').trim();
if (!year) return;
out.push({
artist: (row.querySelector('.review-artist-input')?.value ?? '').trim(),
title: (row.querySelector('.review-title-input')?.value ?? '').trim(),
year,
streamingUrl: row.dataset.streamingUrl ?? ''
});
});
return out;
}
function openPlayDialog() {
document.getElementById('playDialog').style.display = 'flex';
drawNextCard();
}
function closePlayDialog() {
document.getElementById('playDialog').style.display = 'none';
_playDeck = [];
_currentTrack = null;
}
function drawNextCard() {
if (!_playDeck.length) {
showPlayEmpty();
return;
}
_currentTrack = _playDeck.pop();
_playFlipped = false;
showPlayFront(_currentTrack);
}
function showPlayFront(track) {
const card = document.getElementById('playCard');
card.classList.remove('flipped');
document.getElementById('btnFlip').style.display = '';
document.getElementById('btnNext').style.display = 'none';
// Generate QR code after flip resets
setTimeout(() => {
const qrEl = document.getElementById('playQr');
qrEl.innerHTML = '';
if (track.streamingUrl) {
const img = document.createElement('img');
img.src = '/generate/qr?url=' + encodeURIComponent(track.streamingUrl);
img.alt = 'QR';
qrEl.appendChild(img);
} else {
qrEl.textContent = '(kein Link)';
}
document.getElementById('playRemaining').textContent =
_playDeck.length + 1 + ' / ' + (document.querySelectorAll('.review-row').length) + ' Karten';
}, 50);
document.getElementById('playEmpty').style.display = 'none';
document.getElementById('playCardWrap').style.display = 'block';
}
function flipCard() {
if (_playFlipped) return;
_playFlipped = true;
document.getElementById('playCard').classList.add('flipped');
document.getElementById('btnFlip').style.display = 'none';
document.getElementById('btnNext').style.display = '';
// Fill back face
document.getElementById('playBackArtist').textContent = _currentTrack.artist;
document.getElementById('playBackTitle').textContent = _currentTrack.title;
document.getElementById('playBackYear').textContent = _currentTrack.year;
}
function showPlayEmpty() {
document.getElementById('playCardWrap').style.display = 'none';
document.getElementById('playEmpty').style.display = 'block';
}
function restartRound() {
const tracks = collectConfirmedTracks();
_playDeck = shuffle([...tracks]);
drawNextCard();
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
</script>
<!-- ── Play dialog ── -->
<div id="playDialog" style="display:none;position:fixed;inset:0;z-index:500;
background:rgba(8,13,14,.92);backdrop-filter:blur(10px);
align-items:center;justify-content:center;padding:1rem;">
<div class="play-dialog-inner">
<button class="play-close" onclick="closePlayDialog()"></button>
<p class="play-remaining" id="playRemaining"></p>
<!-- card flip wrapper -->
<div id="playCardWrap">
<div id="playCard" class="play-card">
<!-- front: QR code -->
<div class="play-face play-front">
<div id="playQr" class="play-qr"></div>
</div>
<!-- back: solution -->
<div class="play-face play-back">
<p class="play-back-year" id="playBackYear"></p>
<hr class="play-back-divider">
<p class="play-back-artist" id="playBackArtist"></p>
<p class="play-back-title" id="playBackTitle"></p>
<span class="play-brand">LibreDeck</span>
</div>
</div>
<div class="play-actions">
<button class="btn btn-lg" id="btnFlip" onclick="flipCard()">Auflösen</button>
<button class="btn btn-lg" id="btnNext" onclick="drawNextCard()" style="display:none">Nächste Karte</button>
</div>
</div>
<!-- round finished -->
<div id="playEmpty" style="display:none;text-align:center">
<p style="color:var(--text-muted);font-size:1.05rem;margin-bottom:1.5rem">
Alle Karten dieser Runde gespielt!
</p>
<button class="btn btn-lg" onclick="restartRound()">Neue Runde</button>
</div>
</div>
</div>
</body>
</html>