add mobile scanner
This commit is contained in:
		@ -2,6 +2,7 @@ import panel as pn
 | 
			
		||||
import base64
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle
 | 
			
		||||
import qrcode
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
pn.extension("filedownload", "notifications", "location")
 | 
			
		||||
@ -167,9 +168,34 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
 | 
			
		||||
 | 
			
		||||
    create_preview_button.on_click(create_preview)
 | 
			
		||||
 | 
			
		||||
    # Dynamischer QR-Code für die Player-App
 | 
			
		||||
    def generate_qr_code_base64(url):
 | 
			
		||||
        qr = qrcode.QRCode(box_size=4, border=2)
 | 
			
		||||
        qr.add_data(url)
 | 
			
		||||
        qr.make(fit=True)
 | 
			
		||||
        img = qr.make_image(fill_color="black", back_color="white")
 | 
			
		||||
        buffered = BytesIO()
 | 
			
		||||
        img.save(buffered, format="PNG")
 | 
			
		||||
        img_str = base64.b64encode(buffered.getvalue()).decode()
 | 
			
		||||
        return f'<div style="text-align:center;"><b>Mobile QR-Scanner:</b><br><img src="data:image/png;base64,{img_str}" alt="QR Code" style="margin-top:8px;max-width:200px;"/><br><a href="{url}" target="_blank" style="color:#1DB954">{url}</a></div>'
 | 
			
		||||
 | 
			
		||||
    # Dynamisch: QR-Code aktualisiert sich, wenn die Seite unter einer anderen URL läuft
 | 
			
		||||
    def player_qr_html():
 | 
			
		||||
        # pn.state.location.href ist erst nach dem ersten Request gesetzt
 | 
			
		||||
        base_url = pn.state.location.href or main_url
 | 
			
		||||
        # Basis-URL ohne evtl. Pfad/Query
 | 
			
		||||
        from urllib.parse import urlparse
 | 
			
		||||
        parsed = urlparse(base_url)
 | 
			
		||||
        base = f"{parsed.scheme}://{parsed.netloc}"
 | 
			
		||||
        url = base + "/qr/scan.html"
 | 
			
		||||
        return generate_qr_code_base64(url)
 | 
			
		||||
 | 
			
		||||
    player_qr_pane = pn.bind(lambda: pn.pane.HTML(player_qr_html(), sizing_mode='stretch_width'))
 | 
			
		||||
 | 
			
		||||
    template = pn.template.FastListTemplate(
 | 
			
		||||
        title='Hipsterfy',
 | 
			
		||||
        sidebar=[
 | 
			
		||||
            player_qr_pane,  # <-- Dynamischer QR-Code ganz oben in der Sidebar
 | 
			
		||||
            playlist_instructions, playlist_uri, primary_item, secondary_item, additional_items, card_style,
 | 
			
		||||
            cards_per_row_widget,  
 | 
			
		||||
            create_preview_button, download_front_html_button, download_back_html_button, print_instructions
 | 
			
		||||
 | 
			
		||||
@ -1,16 +1,17 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="de">
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <title>Spotify QR-Scanner</title>
 | 
			
		||||
  <title>Hipsterfy QR-Scanner</title>
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
  <style>
 | 
			
		||||
    html, body {
 | 
			
		||||
      margin: 0; padding: 0;
 | 
			
		||||
      font-family: sans-serif;
 | 
			
		||||
      font-family: 'Segoe UI', Arial, sans-serif;
 | 
			
		||||
      background: #181818;
 | 
			
		||||
      color: #fff;
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      min-height: 100vh;
 | 
			
		||||
      width: 100vw;
 | 
			
		||||
    }
 | 
			
		||||
    #app {
 | 
			
		||||
      display: flex;
 | 
			
		||||
@ -18,38 +19,92 @@
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      justify-content: flex-start;
 | 
			
		||||
      min-height: 100vh;
 | 
			
		||||
      padding: 1em;
 | 
			
		||||
      padding: 1.5em 0.5em 2em 0.5em;
 | 
			
		||||
      box-sizing: border-box;
 | 
			
		||||
      width: 100vw;
 | 
			
		||||
      max-width: 100vw;
 | 
			
		||||
    }
 | 
			
		||||
    h2 {
 | 
			
		||||
      margin-bottom: 0.5em;
 | 
			
		||||
      font-size: 2em;
 | 
			
		||||
      letter-spacing: 1px;
 | 
			
		||||
      text-align: center;
 | 
			
		||||
    }
 | 
			
		||||
    #video {
 | 
			
		||||
      width: 90vw;
 | 
			
		||||
      max-width: 400px;
 | 
			
		||||
      border-radius: 12px;
 | 
			
		||||
      margin: 1em 0;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      max-width: 420px;
 | 
			
		||||
      aspect-ratio: 4/3;
 | 
			
		||||
      border-radius: 18px;
 | 
			
		||||
      margin: 1em 0 1.5em 0;
 | 
			
		||||
      background: #222;
 | 
			
		||||
      box-shadow: 0 4px 24px #0008;
 | 
			
		||||
      object-fit: cover;
 | 
			
		||||
    }
 | 
			
		||||
    #result {
 | 
			
		||||
      margin: 1em 0;
 | 
			
		||||
      margin: 0.5em 0 1em 0;
 | 
			
		||||
      word-break: break-all;
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      font-size: 1.08em;
 | 
			
		||||
    }
 | 
			
		||||
    #backBtn {
 | 
			
		||||
      margin-top: 1em;
 | 
			
		||||
      padding: 0.7em 1.5em;
 | 
			
		||||
      margin: 1.2em 0 1em 0;
 | 
			
		||||
      padding: 0.9em 2em;
 | 
			
		||||
      border: none;
 | 
			
		||||
      border-radius: 8px;
 | 
			
		||||
      border-radius: 10px;
 | 
			
		||||
      background: #1DB954;
 | 
			
		||||
      color: #fff;
 | 
			
		||||
      font-size: 1.1em;
 | 
			
		||||
      font-size: 1.15em;
 | 
			
		||||
      font-weight: 600;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      box-shadow: 0 2px 8px #0004;
 | 
			
		||||
      display: none;
 | 
			
		||||
      transition: background 0.2s;
 | 
			
		||||
    }
 | 
			
		||||
    #backBtn:hover {
 | 
			
		||||
      background: #17a74a;
 | 
			
		||||
    }
 | 
			
		||||
    #preview {
 | 
			
		||||
      margin-top: 1em;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      max-width: 420px;
 | 
			
		||||
      outline: none;
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
    iframe {
 | 
			
		||||
    #embed {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      max-width: 420px;
 | 
			
		||||
      margin: 1em 0 0 0;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      min-height: 170px;
 | 
			
		||||
    }
 | 
			
		||||
    #embed iframe {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      min-width: 260px;
 | 
			
		||||
      max-width: 420px;
 | 
			
		||||
      height: 170px;
 | 
			
		||||
      border: none;
 | 
			
		||||
      border-radius: 12px;
 | 
			
		||||
      margin-top: 1em;
 | 
			
		||||
      width: 90vw;
 | 
			
		||||
      max-width: 400px;
 | 
			
		||||
      height: 152px; /* Spotify empfiehlt 152px für volle Vorschau */
 | 
			
		||||
      border-radius: 16px;
 | 
			
		||||
      background: #222;
 | 
			
		||||
      box-shadow: 0 2px 12px #0005;
 | 
			
		||||
      transition: box-shadow 0.2s;
 | 
			
		||||
    }
 | 
			
		||||
    a {
 | 
			
		||||
      color: #1DB954;
 | 
			
		||||
      word-break: break-all;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 600px) {
 | 
			
		||||
      #app {
 | 
			
		||||
        padding: 0.5em 0.1em 1em 0.1em;
 | 
			
		||||
      }
 | 
			
		||||
      #video, #embed, #preview {
 | 
			
		||||
        max-width: 98vw;
 | 
			
		||||
      }
 | 
			
		||||
      #embed iframe {
 | 
			
		||||
        min-width: 180px;
 | 
			
		||||
        max-width: 98vw;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  </style>
 | 
			
		||||
</head>
 | 
			
		||||
@ -58,7 +113,8 @@
 | 
			
		||||
    <h2>Spotify QR-Scanner</h2>
 | 
			
		||||
    <video id="video" autoplay playsinline></video>
 | 
			
		||||
    <div id="result"></div>
 | 
			
		||||
    <button id="backBtn">Zurück zum Scanner</button>
 | 
			
		||||
    <button id="backBtn">Back to scanner</button>
 | 
			
		||||
    <audio id="preview" controls autoplay></audio>
 | 
			
		||||
    <div id="embed"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <script src="https://unpkg.com/jsqr/dist/jsQR.js"></script>
 | 
			
		||||
@ -67,14 +123,24 @@
 | 
			
		||||
    const resultDiv = document.getElementById('result');
 | 
			
		||||
    const embedDiv = document.getElementById('embed');
 | 
			
		||||
    const backBtn = document.getElementById('backBtn');
 | 
			
		||||
    const previewAudio = document.getElementById('preview');
 | 
			
		||||
    let scanning = true;
 | 
			
		||||
    let stream = null;
 | 
			
		||||
    let animationId = null;
 | 
			
		||||
 | 
			
		||||
    // Extrahiere die Track-ID aus beliebigen Spotify-Links
 | 
			
		||||
    function extractPreviewUrl(url) {
 | 
			
		||||
      try {
 | 
			
		||||
        if (url.endsWith('.mp3')) return url;
 | 
			
		||||
        const u = new URL(url);
 | 
			
		||||
        if (u.searchParams.has('preview_url')) {
 | 
			
		||||
          return u.searchParams.get('preview_url');
 | 
			
		||||
        }
 | 
			
		||||
      } catch {}
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function extractSpotifyTrackId(url) {
 | 
			
		||||
      try {
 | 
			
		||||
        // Suche nach /track/<id> oder /embed/track/<id>
 | 
			
		||||
        const match = url.match(/(?:embed\/)?track\/([a-zA-Z0-9]+)/);
 | 
			
		||||
        if (match) {
 | 
			
		||||
          return match[1];
 | 
			
		||||
@ -95,7 +161,6 @@
 | 
			
		||||
    function getSpotifyEmbed(url) {
 | 
			
		||||
      const trackId = extractSpotifyTrackId(url);
 | 
			
		||||
      if (trackId) {
 | 
			
		||||
        // Die Vorschau ist immer das offizielle Spotify-Embed, keine App-Credentials!
 | 
			
		||||
        return `https://open.spotify.com/embed/track/${trackId}`;
 | 
			
		||||
      }
 | 
			
		||||
      return null;
 | 
			
		||||
@ -106,12 +171,15 @@
 | 
			
		||||
      embedDiv.innerHTML = '';
 | 
			
		||||
      resultDiv.textContent = '';
 | 
			
		||||
      backBtn.style.display = 'none';
 | 
			
		||||
      previewAudio.style.display = 'none';
 | 
			
		||||
      previewAudio.pause();
 | 
			
		||||
      previewAudio.src = '';
 | 
			
		||||
      if (!stream) {
 | 
			
		||||
        try {
 | 
			
		||||
          stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
 | 
			
		||||
          video.srcObject = stream;
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          resultDiv.textContent = "Kamera konnte nicht gestartet werden.";
 | 
			
		||||
          resultDiv.textContent = "Could not start camera.";
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
@ -145,16 +213,31 @@
 | 
			
		||||
 | 
			
		||||
    function handleResult(data) {
 | 
			
		||||
      stopScanner();
 | 
			
		||||
      if (isSpotifyUrl(data)) {
 | 
			
		||||
      const previewUrl = extractPreviewUrl(data);
 | 
			
		||||
      if (previewUrl) {
 | 
			
		||||
        resultDiv.innerHTML = `<span>Spotify preview found and playing.</span>`;
 | 
			
		||||
        previewAudio.src = previewUrl;
 | 
			
		||||
        previewAudio.style.display = 'block';
 | 
			
		||||
        previewAudio.play();
 | 
			
		||||
        embedDiv.innerHTML = '';
 | 
			
		||||
      } else if (isSpotifyUrl(data)) {
 | 
			
		||||
        const embedUrl = getSpotifyEmbed(data);
 | 
			
		||||
        if (embedUrl) {
 | 
			
		||||
          resultDiv.innerHTML = `<span>Spotify-Link erkannt:</span><br><a href="${data}" target="_blank" style="color:#1DB954">${data}</a>`;
 | 
			
		||||
          resultDiv.innerHTML = `<span>Spotify link found and loaded below.</span>`;
 | 
			
		||||
          embedDiv.innerHTML = `<iframe src="${embedUrl}" allow="encrypted-media"></iframe>`;
 | 
			
		||||
        } else {
 | 
			
		||||
          resultDiv.textContent = "Spotify-Link erkannt, aber kein unterstützter Typ.";
 | 
			
		||||
          resultDiv.textContent = "Spotify link found, but not a supported type.";
 | 
			
		||||
          embedDiv.innerHTML = '';
 | 
			
		||||
        }
 | 
			
		||||
        previewAudio.style.display = 'none';
 | 
			
		||||
        previewAudio.pause();
 | 
			
		||||
        previewAudio.src = '';
 | 
			
		||||
      } else {
 | 
			
		||||
        resultDiv.textContent = "Kein gültiger Spotify-Link erkannt.";
 | 
			
		||||
        resultDiv.textContent = "No valid Spotify link found.";
 | 
			
		||||
        embedDiv.innerHTML = '';
 | 
			
		||||
        previewAudio.style.display = 'none';
 | 
			
		||||
        previewAudio.pause();
 | 
			
		||||
        previewAudio.src = '';
 | 
			
		||||
      }
 | 
			
		||||
      backBtn.style.display = 'inline-block';
 | 
			
		||||
    }
 | 
			
		||||
@ -169,6 +252,8 @@
 | 
			
		||||
        stream.getTracks().forEach(track => track.stop());
 | 
			
		||||
        stream = null;
 | 
			
		||||
      }
 | 
			
		||||
      previewAudio.pause();
 | 
			
		||||
      previewAudio.src = '';
 | 
			
		||||
    };
 | 
			
		||||
  </script>
 | 
			
		||||
</body>
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user