Files
Hipsterfy/hipsterfy/panel_page.py
2025-08-06 16:26:26 +02:00

275 lines
13 KiB
Python

import panel as pn
import base64
from io import BytesIO
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle
import qrcode
import traceback
import subprocess
pn.extension("filedownload", "notifications", "location")
def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = None) -> pn.Template:
# create widgets
playlist_uri = pn.widgets.TextInput(name='Playlist URI', value=playlist_uri or '', placeholder='Enter Spotify Playlist URI')
primary_item = pn.widgets.Select(name='Primary Item', options=HipsterfyPlaylistItem.get_available_properties(), value="release_year")
secondary_item = pn.widgets.Select(name='Secondary Item', options=HipsterfyPlaylistItem.get_available_properties(), value="title")
additional_items = pn.widgets.MultiChoice(name='Additional Items', options=HipsterfyPlaylistItem.get_available_properties(), value=["artists", "album", "popularity"])
card_style = pn.widgets.Select(name='Card Style', options=[style.value for style in CardStyle], value=CardStyle.FULL_ALBUM_COVER.value)
cards_per_row_widget = pn.widgets.IntSlider(name="Cards per Row in print", start=1, end=8, value=4, step=1)
mode_selector = pn.widgets.RadioButtonGroup(
name="Card Generation Mode (2D for printing or 3D to produce stl files for 3D printing)",
options=["2D", "3D"],
value="2D",
button_type="primary",
button_style="outline",
sizing_mode='stretch_width'
)
# add a callback that hides the card style widget if 3D mode is selected
def toggle_card_style_visibility(event):
if mode_selector.value == "3D":
card_style.visible = False
else:
card_style.visible = True
mode_selector.param.watch(toggle_card_style_visibility, 'value')
pn.state.location.sync(playlist_uri, {"value": "playlist_uri"})
pn.state.location.sync(primary_item, {"value": "primary_item"})
pn.state.location.sync(secondary_item, {"value": "secondary_item"})
pn.state.location.sync(additional_items, {"value": "additional_items"})
pn.state.location.sync(card_style, {"value": "card_style"})
pn.state.location.sync(cards_per_row_widget, {"value": "cards_per_row"})
pn.state.location.sync(mode_selector, {"value": "mode_selector"})
create_preview_button = pn.widgets.Button(name='Parse Playlist and Generate Cards', button_type='primary', sizing_mode='stretch_width')
front_cards_column = pn.Column(sizing_mode='stretch_width')
back_cards_column = pn.Column(sizing_mode='stretch_width')
# FileDownload-Buttons (werden erst nach Preview-Generierung angezeigt)
download_front_html_button = pn.widgets.FileDownload(
label="Download Front Cards (HTML)", button_type="success", visible=False, filename="hipsterfy_front_cards.html", sizing_mode='stretch_width'
)
download_back_html_button = pn.widgets.FileDownload(
label="Download Back Cards (HTML)", button_type="success", visible=False, filename="hipsterfy_back_cards.html", sizing_mode='stretch_width'
)
playlist_instructions = pn.pane.HTML(
"""
<div>
<h3>Playlist Instructions</h3>
<p>Select any Spotify playlist and click the "Parse Playlist and Generate Cards" button to generate the cards.</p>
<p><b>Note:</b> The playlist must be public accessible. Also dynamically individualized playlists (unfortunately a lot of spotify official playlists) Will not work.</p>
</div>
""",
sizing_mode='stretch_width'
)
print_instructions = pn.pane.HTML(
"""
<div>
<h3>Print Instructions</h3>
<p>To print the cards:</p>
<ol>
<li>Click on the "Download Front Cards" and "Download Back Cards" buttons to download the HTML files.</li>
<li>Open the downloaded files in your web browser.</li>
<li>Use your browser's print function (usually Ctrl+P or Cmd+P) to print the cards.</li>
<li>Make sure to first print all front cards, then all back cards on the back side of the paper.</li>
<li>Adjust the print settings to ensure the cards fit well on the page.</li
</ol>
</div>
""",
sizing_mode='stretch_width'
)
print_instructions.visible = False # Initially hidden, will be shown after preview generation
def generate_html(cards, row_reverse=False):
html = "<html><head><title>Print Cards</title>"
html += """
<style>
body { background: white; }
.card-row { display: flex; flex-direction: row; margin-bottom: 24px; }
.card { margin: 12px; }
@media print {
.card { page-break-inside: avoid; }
body, .card, .card-row {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
background: white !important;
}
}
</style>
"""
html += "</head><body>"
cards_per_row = cards_per_row_widget.value # <-- Widget-Wert verwenden
# Karten in Reihen zu je 3 anordnen
for i in range(0, len(cards), cards_per_row):
row = cards[i:i+cards_per_row]
if row_reverse:
row = row[::-1]
html += '<div class="card-row">'
for card in row:
html += f'<div class="card">{card}</div>'
html += '</div>'
html += "</body></html>"
return html.encode() # FileDownload erwartet Bytes
def update_download_buttons():
# Front Cards
cards = [pane.object for pane in front_cards_column[1:]]
download_front_html_button.callback = lambda: BytesIO(generate_html(cards, row_reverse=False))
download_front_html_button.visible = True
# Back Cards
cards_back = [pane.object for pane in back_cards_column[1:]]
download_back_html_button.callback = lambda: BytesIO(generate_html(cards_back, row_reverse=True))
download_back_html_button.visible = True
download_front_html_button.filename = f"hipsterfy_front_cards.html"
download_back_html_button.filename = f"hipsterfy_back_cards.html"
print_instructions.visible = True # Show print instructions after generating preview
pn.state.notifications.success("Download buttons updated successfully!. Download and print them from the sidebar")
def create_preview(event):
try:
pn.state.notifications.info("Generating preview for playlist...")
# clear previous cards
front_cards_column.clear()
back_cards_column.clear()
front_cards_column.loading = True
back_cards_column.loading = True
front_cards_column.append(pn.pane.HTML("<h2>Front Cards</h2>", sizing_mode='stretch_width'))
back_cards_column.append(pn.pane.HTML("<h2>Back Cards</h2>", sizing_mode='stretch_width'))
uri = playlist_uri.value.strip()
if not uri:
pn.state.notifications.error("Please enter a valid Spotify Playlist URI.")
return
hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy)
new_back_cards = []
new_front_cards = []
if mode_selector.value == "2D":
# 2D-HTML wie gehabt
for item in hipsterfy_playlist.get_tracks_data():
front_card = item.generate_hipsterfy_front_card(include_preview=False, card_style=CardStyle(card_style.value))
back_card = item.generate_hipsterfy_back_card(
primary_property=primary_item.value,
secondary_property=secondary_item.value,
additional_properties=additional_items.value,
card_style=CardStyle(card_style.value)
)
new_front_cards.append(pn.pane.HTML(front_card, sizing_mode='stretch_width'))
new_back_cards.append(pn.pane.HTML(back_card, sizing_mode='stretch_width'))
for card in new_front_cards:
front_cards_column.append(card)
for card in new_back_cards:
back_cards_column.append(card)
update_download_buttons()
pn.state.notifications.success("2D preview generated successfully!")
else:
stl_buffers = []
for idx, item in enumerate(hipsterfy_playlist.get_tracks_data()):
stl_bytes = item.generate_3d_qr_card(
primary_property=primary_item.value,
secondary_property=secondary_item.value,
additional_properties=additional_items.value,
)
stl_filename = f"hipsterfy_card_{idx+1}.stl"
stl_buffers.append((stl_filename, stl_bytes))
front_cards_column.append(
pn.pane.Markdown(f"**3D Card {idx+1}:** `{stl_filename}` generated.", sizing_mode='stretch_width')
)
import zipfile
def zip_stls():
try:
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
for fname, stl_bytes in stl_buffers:
zf.writestr(fname, stl_bytes)
buffer.seek(0)
return buffer
except Exception as e:
print("Error creating ZIP file:", e)
traceback.print_exc()
pn.state.notifications.error(f"Error creating ZIP file: {str(e)}", duration=10000)
return None
download_front_html_button.label = "Download 3D Cards (ZIP)"
download_front_html_button.callback = zip_stls
download_front_html_button.visible = True
download_back_html_button.visible = False
download_front_html_button.filename = "hipsterfy_3d_cards.zip"
pn.state.notifications.success("3D STL files generated! Download the ZIP from the sidebar.")
print_instructions.visible = mode_selector.value == "2d"
except Exception as e:
print("Error:", e)
traceback.print_exc()
pn.state.notifications.error(f"Error generating preview: {str(e)}", duration=10000)
finally:
front_cards_column.loading = False
back_cards_column.loading = False
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
if root_url is None:
base_url = pn.state.location.href
# Basis-URL ohne evtl. Pfad/Query
from urllib.parse import urlparse
parsed = urlparse(base_url)
base = f"{parsed.scheme}://{parsed.netloc}"
else:
base = root_url
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,
pn.pane.HTML("<h3> Select card generation mode:</h3>", sizing_mode='stretch_width'),
pn.pane.HTML(
"""
<p>In 2D mode, you can print the cards directly from your browser. In 3D mode, STL files will be generated for 3d printing will be generated.</p>
""",
sizing_mode='stretch_width'
),
mode_selector,
pn.layout.Divider(),
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
],
main=[pn.Row(front_cards_column, back_cards_column, sizing_mode='stretch_width')],
accent_base_color='indigo',
header_background='indigo',
header_color='white'
)
return template