1458 lines
66 KiB
HTML
1458 lines
66 KiB
HTML
<!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‑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">←</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">→</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 & 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 & 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 & 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 & 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 & 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">↻</button>
|
||
</div>
|
||
<div class="playlist-carousel" id="spotifyCarousel">
|
||
<button class="carousel-btn carousel-btn--prev" id="spotifyPrev" disabled>‹</button>
|
||
<div class="carousel-viewport">
|
||
<div class="carousel-track" id="spotifyTrack"></div>
|
||
</div>
|
||
<button class="carousel-btn carousel-btn--next" id="spotifyNext" disabled>›</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">↻</button>
|
||
</div>
|
||
<div class="playlist-carousel" id="tidalCarousel">
|
||
<button class="carousel-btn carousel-btn--prev" id="tidalPrev" disabled>‹</button>
|
||
<div class="carousel-viewport">
|
||
<div class="carousel-track" id="tidalTrack"></div>
|
||
</div>
|
||
<button class="carousel-btn carousel-btn--next" id="tidalNext" disabled>›</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">↻</button>
|
||
</div>
|
||
<div class="playlist-carousel" id="appleCarousel">
|
||
<button class="carousel-btn carousel-btn--prev" id="applePrev" disabled>‹</button>
|
||
<div class="carousel-viewport">
|
||
<div class="carousel-track" id="appleTrack"></div>
|
||
</div>
|
||
<button class="carousel-btn carousel-btn--next" id="appleNext" disabled>›</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 & 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. 1–2 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">🎵</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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>
|