add mobile scanner

This commit is contained in:
2025-08-04 17:14:34 +02:00
parent b954b93dd6
commit a71428dc57
2 changed files with 140 additions and 29 deletions

View File

@ -2,6 +2,7 @@ import panel as pn
import base64 import base64
from io import BytesIO from io import BytesIO
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle
import qrcode
pn.extension("filedownload", "notifications", "location") 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) 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( template = pn.template.FastListTemplate(
title='Hipsterfy', title='Hipsterfy',
sidebar=[ sidebar=[
player_qr_pane, # <-- Dynamischer QR-Code ganz oben in der Sidebar
playlist_instructions, playlist_uri, primary_item, secondary_item, additional_items, card_style, playlist_instructions, playlist_uri, primary_item, secondary_item, additional_items, card_style,
cards_per_row_widget, cards_per_row_widget,
create_preview_button, download_front_html_button, download_back_html_button, print_instructions create_preview_button, download_front_html_button, download_back_html_button, print_instructions

View File

@ -1,16 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <style>
html, body { html, body {
margin: 0; padding: 0; margin: 0; padding: 0;
font-family: sans-serif; font-family: 'Segoe UI', Arial, sans-serif;
background: #181818; background: #181818;
color: #fff; color: #fff;
height: 100%; min-height: 100vh;
width: 100vw;
} }
#app { #app {
display: flex; display: flex;
@ -18,38 +19,92 @@
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
min-height: 100vh; 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 { #video {
width: 90vw; width: 100%;
max-width: 400px; max-width: 420px;
border-radius: 12px; aspect-ratio: 4/3;
margin: 1em 0; border-radius: 18px;
margin: 1em 0 1.5em 0;
background: #222; background: #222;
box-shadow: 0 4px 24px #0008;
object-fit: cover;
} }
#result { #result {
margin: 1em 0; margin: 0.5em 0 1em 0;
word-break: break-all; word-break: break-all;
text-align: center;
font-size: 1.08em;
} }
#backBtn { #backBtn {
margin-top: 1em; margin: 1.2em 0 1em 0;
padding: 0.7em 1.5em; padding: 0.9em 2em;
border: none; border: none;
border-radius: 8px; border-radius: 10px;
background: #1DB954; background: #1DB954;
color: #fff; color: #fff;
font-size: 1.1em; font-size: 1.15em;
font-weight: 600;
cursor: pointer; 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; 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: none;
border-radius: 12px; border-radius: 16px;
margin-top: 1em;
width: 90vw;
max-width: 400px;
height: 152px; /* Spotify empfiehlt 152px für volle Vorschau */
background: #222; 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> </style>
</head> </head>
@ -58,7 +113,8 @@
<h2>Spotify QR-Scanner</h2> <h2>Spotify QR-Scanner</h2>
<video id="video" autoplay playsinline></video> <video id="video" autoplay playsinline></video>
<div id="result"></div> <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 id="embed"></div>
</div> </div>
<script src="https://unpkg.com/jsqr/dist/jsQR.js"></script> <script src="https://unpkg.com/jsqr/dist/jsQR.js"></script>
@ -67,14 +123,24 @@
const resultDiv = document.getElementById('result'); const resultDiv = document.getElementById('result');
const embedDiv = document.getElementById('embed'); const embedDiv = document.getElementById('embed');
const backBtn = document.getElementById('backBtn'); const backBtn = document.getElementById('backBtn');
const previewAudio = document.getElementById('preview');
let scanning = true; let scanning = true;
let stream = null; let stream = null;
let animationId = 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) { function extractSpotifyTrackId(url) {
try { try {
// Suche nach /track/<id> oder /embed/track/<id>
const match = url.match(/(?:embed\/)?track\/([a-zA-Z0-9]+)/); const match = url.match(/(?:embed\/)?track\/([a-zA-Z0-9]+)/);
if (match) { if (match) {
return match[1]; return match[1];
@ -95,7 +161,6 @@
function getSpotifyEmbed(url) { function getSpotifyEmbed(url) {
const trackId = extractSpotifyTrackId(url); const trackId = extractSpotifyTrackId(url);
if (trackId) { if (trackId) {
// Die Vorschau ist immer das offizielle Spotify-Embed, keine App-Credentials!
return `https://open.spotify.com/embed/track/${trackId}`; return `https://open.spotify.com/embed/track/${trackId}`;
} }
return null; return null;
@ -106,12 +171,15 @@
embedDiv.innerHTML = ''; embedDiv.innerHTML = '';
resultDiv.textContent = ''; resultDiv.textContent = '';
backBtn.style.display = 'none'; backBtn.style.display = 'none';
previewAudio.style.display = 'none';
previewAudio.pause();
previewAudio.src = '';
if (!stream) { if (!stream) {
try { try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }); stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
video.srcObject = stream; video.srcObject = stream;
} catch (e) { } catch (e) {
resultDiv.textContent = "Kamera konnte nicht gestartet werden."; resultDiv.textContent = "Could not start camera.";
return; return;
} }
} }
@ -145,16 +213,31 @@
function handleResult(data) { function handleResult(data) {
stopScanner(); 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); const embedUrl = getSpotifyEmbed(data);
if (embedUrl) { 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>`; embedDiv.innerHTML = `<iframe src="${embedUrl}" allow="encrypted-media"></iframe>`;
} else { } 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 { } 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'; backBtn.style.display = 'inline-block';
} }
@ -169,6 +252,8 @@
stream.getTracks().forEach(track => track.stop()); stream.getTracks().forEach(track => track.stop());
stream = null; stream = null;
} }
previewAudio.pause();
previewAudio.src = '';
}; };
</script> </script>
</body> </body>