Zpěvník
?
QR Kód s aktuální adresou
Zpěvník
Přidat píseň
Export
Import
Připojení pro účastníky
Zpěvník
Čekání na kytaristu...
Načítání...
Nová píseň
Název
Interpret
Text a akordy (akordy do [závorek])
Naskenuj pro připojení
Upravit obsah stránky
{% extends "templates/base.html" %} {% set game_name = "Zpěvník" %} {% block content %} <style> .chord { font-weight: bold; color: #dc3545; } .song-item-clickable { cursor: pointer; } .song-item-clickable:hover { background-color: #f8f9fa; } #lyrics-container { font-family: monospace; font-size: clamp(0.7rem, 2.5vw, 1.1rem); line-height: 1.4; } .song-line-group { margin-bottom: 0.8em; } .chord-line { white-space: pre; overflow-x: auto; height: 1.2em; } .lyric-line { white-space: pre-wrap; word-break: break-word; } #controls-container.controls-disabled { opacity: 0.5; pointer-events: none; } </style> <div id="guitarist-view" class="container mt-4"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3"> <h1><i class="fas fa-guitar"></i> Zpěvník</h1> <div class="d-flex gap-2"> <button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#song-modal"><i class="fas fa-plus"></i> Přidat píseň</button> <button class="btn btn-outline-primary" id="export-btn"><i class="fas fa-download"></i> Export</button> <button class="btn btn-outline-secondary" id="import-btn"><i class="fas fa-upload"></i> Import</button> <input type="file" id="import-file-input" accept=".json" style="display: none;"> </div> </div> <div class="card mb-4"> <div class="card-body text-center"> <h5 class="card-title">Připojení pro účastníky</h5> <div id="qrcode-container" class="d-flex justify-content-center"><div id="qrcode"></div></div> </div> </div> <div class="input-group mb-3"> <span class="input-group-text"><i class="fas fa-search"></i></span> <input type="text" id="search-input" class="form-control" placeholder="Hledat píseň..."> </div> <div id="song-list" class="list-group"></div> </div> <div id="song-view" class="container mt-4" style="display: none;"> <div id="waiting-screen" class="text-center"> <h1><i class="fas fa-music"></i> Zpěvník</h1> <p class="lead">Čekání na kytaristu...</p> <div class="spinner-border text-primary" role="status"><span class="visually-hidden">Načítání...</span></div> </div> <div id="song-display" style="display: none;"> <div class="d-flex justify-content-between align-items-start flex-wrap mb-3 gap-3"> <div> <h1 id="song-title" class="mb-0"></h1> <p id="song-artist" class="lead mb-0"></p> </div> <div class="d-flex align-items-center gap-2 flex-wrap justify-content-end"> <button class="btn btn-light" id="autoscroll-toggle-btn" title="Přepnout automatické rolování"> <i class="fas fa-scroll"></i> </button> <button class="btn btn-light" id="qr-modal-trigger" data-bs-toggle="modal" data-bs-target="#qr-modal" title="Připojit další účastníky"> <i class="fas fa-qrcode"></i> </button> <div id="controls-container" class="d-flex align-items-center gap-2"></div> </div> </div> <div id="lyrics-container" class="border p-3" style="height: 70vh; overflow-y: auto;"></div> </div> </div> <div class="modal fade" id="song-modal" tabindex="-1"><div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="song-modal-title">Nová píseň</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><form id="song-form"><input type="hidden" id="editing-song-id"><div class="mb-3"><label for="song-title-input" class="form-label">Název</label><input type="text" class="form-control" id="song-title-input"></div><div class="mb-3"><label for="song-artist-input" class="form-label">Interpret</label><input type="text" class="form-control" id="song-artist-input"></div><div class="mb-3"><label for="song-lyrics-input" class="form-label">Text a akordy (akordy do [závorek])</label><textarea class="form-control" id="song-lyrics-input" rows="10"></textarea></div></form></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Zrušit</button><button type="button" class="btn btn-primary" id="save-song-btn">Uložit</button></div></div></div></div> <div class="modal fade" id="qr-modal" tabindex="-1"><div class="modal-dialog modal-sm modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">Naskenuj pro připojení</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body d-flex justify-content-center" id="modal-qrcode-container"></div></div></div></div> <script> const forrestHubLib = ForrestHubLib.getInstance(true); const SONGS_ARRAY = 'songbook_songs'; const CURRENT_SONG_STATE_VAR = 'songbook_current_song_state'; const BASE_SCROLL_DURATION_MS = 180000; let isGuitarist = false, currentlyDisplayedSongId = null, currentAnimationState = null; let isManualScrolling = false, manualScrollTimeout = null, animationFrameId = null; let previewState = null; let isAutoscrollEnabled = true; let lastUserActionTime = 0; document.addEventListener('DOMContentLoaded', async () => { const params = new URLSearchParams(window.location.search); isGuitarist = (params.get('view') || 'guitarist') === 'guitarist'; setupUI(); setupManualScrollListeners(); setInterval(async () => { if (Date.now() - lastUserActionTime < 2000) { return; } try { const serverState = await forrestHubLib.dbVarGetKey(CURRENT_SONG_STATE_VAR); if (JSON.stringify(serverState) !== JSON.stringify(currentAnimationState)) { handleStateChange(serverState); } } catch (e) { if (currentAnimationState !== null) { handleStateChange(null); } } }, 1000); }); function setupUI() { if (isGuitarist) { document.getElementById('guitarist-view').style.display = 'block'; const participantUrl = window.location.origin + window.location.pathname + '?view=participant'; new QRCode(document.getElementById("qrcode"), participantUrl); new QRCode(document.getElementById("modal-qrcode-container"), participantUrl); loadAndRenderSongs(); document.getElementById('search-input').addEventListener('keyup', loadAndRenderSongs); document.getElementById('save-song-btn').addEventListener('click', saveSong); document.getElementById('export-btn').addEventListener('click', exportSongsToJson); document.getElementById('import-btn').addEventListener('click', () => document.getElementById('import-file-input').click()); document.getElementById('import-file-input').addEventListener('change', importSongsFromJson); document.getElementById('song-modal').addEventListener('hidden.bs.modal', () => { document.getElementById('song-modal-title').innerText = 'Nová píseň'; document.getElementById('song-form').reset(); document.getElementById('editing-song-id').value = ''; }); } else { initParticipantView(); } loadAutoscrollSetting(); document.getElementById('autoscroll-toggle-btn').addEventListener('click', toggleAutoscroll); } function loadAutoscrollSetting() { const savedSetting = localStorage.getItem('songbookAutoscroll'); isAutoscrollEnabled = savedSetting === null ? true : savedSetting === 'true'; updateAutoscrollButton(); } function toggleAutoscroll() { isAutoscrollEnabled = !isAutoscrollEnabled; localStorage.setItem('songbookAutoscroll', isAutoscrollEnabled); updateAutoscrollButton(); } function updateAutoscrollButton() { const btn = document.getElementById('autoscroll-toggle-btn'); if (isAutoscrollEnabled) { btn.classList.remove('btn-dark', 'text-white'); btn.classList.add('btn-light'); btn.title = "Automatické rolování je ZAPNUTO"; } else { btn.classList.remove('btn-light'); btn.classList.add('btn-dark', 'text-white'); btn.title = "Automatické rolování je VYPNUTO"; } } const notesSharp = ['A', 'A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#']; const notesFlat = ['A', 'Bb', 'B', 'C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab']; function transposeChord(chord, amount) { const rootMatch = chord.match(/^([A-G][#b]?)/); if (!rootMatch) return chord; const root = rootMatch[0]; const quality = chord.substring(root.length); const notes = root.includes('b') ? notesFlat : notesSharp; let index = notes.indexOf(root); if (index === -1) index = (notes === notesSharp ? notesFlat : notesSharp).indexOf(root); if (index === -1) return chord; const newIndex = (index + amount % 12 + 12) % 12; return notes[newIndex] + quality; } function transposeLyrics(lyrics, amount) { if (!lyrics || amount === 0) return lyrics; return lyrics.replace(/\[([^\]]+)\]/g, (match, chord) => `[${transposeChord(chord, amount)}]`); } async function loadAndRenderSongs() { const songs = await forrestHubLib.dbArrayFetchAllRecords(SONGS_ARRAY); const songListEl = document.getElementById('song-list'); const filterText = document.getElementById('search-input').value.toLowerCase(); songListEl.innerHTML = !Object.keys(songs).length ? '<p class="text-center text-muted">Zatím tu nejsou žádné písně.</p>' : ''; Object.entries(songs).filter(([, song]) => song.title.toLowerCase().includes(filterText) || song.artist.toLowerCase().includes(filterText)).sort(([, a], [, b]) => a.title.localeCompare(b.title)).forEach(([id, song]) => { const songEl = document.createElement('div'); songEl.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center'; songEl.innerHTML = `<div class="song-item-clickable flex-grow-1" onclick="showSongPreview('${id}')"> <h5 class="mb-1">${song.title}</h5> <p class="mb-1 text-muted">${song.artist}</p> </div> <div> <button class="btn btn-sm btn-outline-secondary me-2" onclick="openEditModal(event, '${id}')" title="Upravit"><i class="fas fa-pencil-alt"></i></button> <button class="btn btn-sm btn-danger" onclick="deleteSong(event, '${id}')" title="Smazat"><i class="fas fa-trash"></i></button> </div>`; songListEl.appendChild(songEl); }); } async function openEditModal(event, songId) { event.stopPropagation(); const songs = await forrestHubLib.dbArrayFetchAllRecords(SONGS_ARRAY); const song = songs[songId]; if (!song) return; document.getElementById('song-modal-title').innerText = 'Upravit píseň'; document.getElementById('editing-song-id').value = songId; document.getElementById('song-title-input').value = song.title; document.getElementById('song-artist-input').value = song.artist; document.getElementById('song-lyrics-input').value = song.lyrics; new bootstrap.Modal(document.getElementById('song-modal')).show(); } async function saveSong() { const editingId = document.getElementById('editing-song-id').value; const songData = { title: document.getElementById('song-title-input').value.trim(), artist: document.getElementById('song-artist-input').value.trim(), lyrics: document.getElementById('song-lyrics-input').value.trim() }; if (!songData.title || !songData.lyrics) { forrestHubLib.uiShowAlert('danger', 'Název a text jsou povinné!'); return; } if (editingId) { await forrestHubLib.dbArrayUpdateRecord(SONGS_ARRAY, editingId, songData); } else { await forrestHubLib.dbArrayAddRecord(SONGS_ARRAY, songData); } bootstrap.Modal.getInstance(document.getElementById('song-modal')).hide(); forrestHubLib.uiShowAlert('success', 'Píseň uložena.'); loadAndRenderSongs(); } async function deleteSong(event, songId) { event.stopPropagation(); if (confirm('Opravdu smazat píseň?')) { await forrestHubLib.dbArrayRemoveRecord(SONGS_ARRAY, songId); forrestHubLib.uiShowAlert('info', 'Píseň smazána.'); loadAndRenderSongs(); } } async function exportSongsToJson() { const songsFromDb = await forrestHubLib.dbArrayFetchAllRecords(SONGS_ARRAY); const songsToExport = Object.values(songsFromDb).map(({ title, artist, lyrics }) => ({ title, artist, lyrics })); const blob = new Blob([JSON.stringify(songsToExport, null, 2)], { type: "application/json" }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'zpevnik.json'; a.click(); URL.revokeObjectURL(a.href); } function importSongsFromJson(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (e) => { try { const songs = JSON.parse(e.target.result); if (!Array.isArray(songs)) throw new Error("JSON musí být pole písní."); const replace = confirm("Chcete nahradit stávající písně? (Stiskněte 'Zrušit' pro přidání k existujícím)."); if (replace) await forrestHubLib.dbArrayClearRecords(SONGS_ARRAY); for (const song of songs) { if (song.title && song.lyrics) await forrestHubLib.dbArrayAddRecord(SONGS_ARRAY, song); } forrestHubLib.uiShowAlert('success', 'Písně byly úspěšně naimportovány.'); loadAndRenderSongs(); } catch (error) { forrestHubLib.uiShowAlert('danger', `Chyba při importu: ${error.message}`); } finally { event.target.value = null; } }; reader.readAsText(file); } function lockControls() { document.getElementById('controls-container').classList.add('controls-disabled'); } function unlockControls() { document.getElementById('controls-container').classList.remove('controls-disabled'); } async function performStateChange(stateModifier) { lockControls(); try { const currentState = JSON.parse(JSON.stringify(currentAnimationState)); const newState = stateModifier(currentState); if (newState === undefined) { unlockControls(); return; } lastUserActionTime = Date.now(); if (newState === null) { await forrestHubLib.dbVarDeleteKey(CURRENT_SONG_STATE_VAR); } else { await forrestHubLib.dbVarSetKey(CURRENT_SONG_STATE_VAR, newState); } handleStateChange(newState); } catch (e) { console.error("Chyba při změně stavu:", e); } finally { unlockControls(); } } function showSongPreview(songId) { performStateChange(() => ({ id: songId, status: 'preview', transposition: 0, speedMultiplier: 0.8 })); } function endSong() { performStateChange(() => null); } function playForAll() { performStateChange(state => { if (!state || state.status !== 'preview') return undefined; state.status = 'playing'; state.startTime = Date.now(); return state; }); } function togglePause() { performStateChange(state => { if (!state || (state.status !== 'playing' && state.status !== 'paused')) return undefined; if (state.status === 'playing') { // Pozastavení state.status = 'paused'; state.pausedScrollTop = document.getElementById('lyrics-container').scrollTop; } else { // Obnovení state.status = 'playing'; const container = document.getElementById('lyrics-container'); const totalScrollDistance = container.scrollHeight - container.clientHeight; if (totalScrollDistance > 0 && state.pausedScrollTop > 0) { const actualDurationMs = BASE_SCROLL_DURATION_MS / state.speedMultiplier; const scrollProgress = state.pausedScrollTop / totalScrollDistance; const elapsedTime = scrollProgress * actualDurationMs; state.startTime = Date.now() - elapsedTime; } else { state.startTime = Date.now(); } delete state.pausedScrollTop; } return state; }); } function adjustLiveSpeed(delta) { performStateChange(state => { if (!state) return undefined; const oldSpeed = state.speedMultiplier; state.speedMultiplier = Math.max(0.1, Math.round((oldSpeed + delta) * 10) / 10); if (state.status === 'playing') { const elapsedTime = Date.now() - state.startTime; state.startTime = Date.now() - (elapsedTime * oldSpeed) / state.speedMultiplier; } return state; }); } function adjustPreviewState(delta, type) { performStateChange(state => { if (!state || state.status !== 'preview') return undefined; if (type === 'speed') { state.speedMultiplier = Math.max(0.1, Math.round((state.speedMultiplier + delta) * 10) / 10); } else { state.transposition = (state.transposition || 0) + delta; } return state; }); } function toggleTestPlayback() { if (!previewState) return; previewState.isTesting = !previewState.isTesting; if (previewState.isTesting) { const container = document.getElementById('lyrics-container'); const totalScrollDistance = container.scrollHeight - container.clientHeight; const actualDurationMs = BASE_SCROLL_DURATION_MS / previewState.speedMultiplier; const scrollProgress = container.scrollTop / totalScrollDistance; previewState.startTime = Date.now() - (scrollProgress * actualDurationMs); startAutoscroll(true); } else { stopAutoscroll(true); } updateControls('preview', previewState); } async function handleStateChange(songState) { const previousStatus = currentAnimationState?.status; const previousSongId = currentAnimationState?.id; const previousTransposition = currentAnimationState?.transposition; currentAnimationState = songState; if (songState && songState.id) { const songChanged = songState.id !== previousSongId; const transpositionChanged = songState.transposition !== previousTransposition; if (songChanged || transpositionChanged) { await updateSongDisplay(songState, songChanged); } switch (songState.status) { case 'preview': stopAutoscroll(); isGuitarist ? updateControls('preview', songState) : updateControls('participant_preview'); if(isGuitarist) previewState = {...previewState, ...songState}; break; case 'playing': isGuitarist ? updateControls('live', songState) : updateControls(null); if (previousStatus !== 'playing') startAutoscroll(); break; case 'paused': stopAutoscroll(); if(songState.pausedScrollTop) { document.getElementById('lyrics-container').scrollTop = songState.pausedScrollTop; }; isGuitarist ? updateControls('live', songState) : updateControls(null); break; } } else if (currentlyDisplayedSongId) { currentlyDisplayedSongId = null; previewState = null; stopAutoscroll(); showListView(); } } function initParticipantView() { document.getElementById('song-view').style.display = 'block'; } async function updateSongDisplay(songState, songChanged) { const allSongs = await forrestHubLib.dbArrayFetchAllRecords(SONGS_ARRAY); const song = allSongs[songState.id]; if (!song) return; if (songChanged) { currentlyDisplayedSongId = songState.id; document.getElementById('guitarist-view').style.display = 'none'; document.getElementById('song-view').style.display = 'block'; document.getElementById('song-display').style.display = 'block'; document.getElementById('waiting-screen').style.display = 'none'; document.getElementById('song-title').innerText = song.title; document.getElementById('song-artist').innerText = song.artist; } const transposedLyrics = transposeLyrics(song.lyrics, songState.transposition || 0); document.getElementById('lyrics-container').innerHTML = renderLyricsWithChords(transposedLyrics); if (isGuitarist) { previewState = { ...songState, originalLyrics: song.lyrics, isTesting: false }; } } function showListView() { if (isGuitarist) { document.getElementById('song-view').style.display = 'none'; document.getElementById('guitarist-view').style.display = 'block'; } else { document.getElementById('song-display').style.display = 'none'; document.getElementById('waiting-screen').style.display = 'block'; } } function updateControls(mode, state = {}) { const container = document.getElementById('controls-container'); unlockControls(); const speedCtrl = `<div class="d-flex align-items-center border rounded p-1"><button class="btn btn-sm" onclick="adjustLiveSpeed(-0.1)"><i class="fas fa-minus"></i></button><span class="mx-2 small">Rychlost: <strong id="speed-display">${Math.round((state.speedMultiplier || 1) * 100)}%</strong></span><button class="btn btn-sm" onclick="adjustLiveSpeed(0.1)"><i class="fas fa-plus"></i></button></div>`; const transposeCtrl = `<div class="d-flex align-items-center border rounded p-1"><button class="btn btn-sm" onclick="adjustPreviewState(-1, 'transpose')"><i class="fas fa-minus"></i></button><span class="mx-2 small">Tónina: <strong id="transpose-display">${(state.transposition || 0) > 0 ? '+' : ''}${state.transposition || 0}</strong></span><button class="btn btn-sm" onclick="adjustPreviewState(1, 'transpose')"><i class="fas fa-plus"></i></button></div>`; const isTesting = previewState?.isTesting; const isPaused = state.status === 'paused'; let html = ''; switch (mode) { case 'preview': html = `${transposeCtrl} ${speedCtrl} <button class="btn ${isTesting ? 'btn-danger' : 'btn-info'}" id="test-btn" onclick="toggleTestPlayback()">${isTesting ? 'Zastavit test' : 'Test'}</button> <button class="btn btn-primary" onclick="playForAll()"><i class="fas fa-play"></i> Spustit</button> <button class="btn btn-secondary" onclick="endSong()">Zpět</button>`; break; case 'live': html = `${speedCtrl} <button class="btn btn-warning" onclick="togglePause()">${isPaused ? '<i class="fas fa-play"></i> Pokračovat' : '<i class="fas fa-pause"></i> Pozastavit'}</button> <button class="btn btn-danger" onclick="endSong()"><i class="fas fa-stop"></i> Ukončit</button>`; break; case 'participant_preview': html = `<span class="text-muted">Kytarista připravuje píseň...</span>`; break; default: html = ''; } container.innerHTML = html; } function renderLyricsWithChords(text) { if (!text) return ''; const lines = text.split('\n'); const chordRegex = /\[([^\]]+)\]/g; return lines.map(line => { let chordLine = ''; let lyricLine = ''; let lastIndex = 0; let hasChords = false; let match; chordRegex.lastIndex = 0; while ((match = chordRegex.exec(line)) !== null) { hasChords = true; const textSegment = line.substring(lastIndex, match.index); lyricLine += textSegment; chordLine += ' '.repeat(textSegment.length); const chordContent = match[1]; chordLine += `<strong class="chord">${chordContent}</strong>`; lastIndex = match.index + match[0].length; } lyricLine += line.substring(lastIndex); if(hasChords){ return `<div class="song-line-group"><div class="chord-line">${chordLine}</div><div class="lyric-line">${lyricLine || ' '}</div></div>`; } else { return `<div class="song-line-group"><div class="lyric-line">${line || ' '}</div></div>`; } }).join(''); } function startAutoscroll(isTest = false) { if (!isAutoscrollEnabled && !isTest) return; stopAutoscroll(); animateScroll(isTest); } function stopAutoscroll(isTestOnly = false) { if (animationFrameId) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } if (isTestOnly && previewState) { previewState.isTesting = false; } } function animateScroll(isTest = false) { const state = isTest ? previewState : currentAnimationState; if (isManualScrolling || !state || (isTest && !state.isTesting) || (!isTest && state.status !== 'playing') || (!isAutoscrollEnabled && !isTest)) { animationFrameId = null; return; } const container = document.getElementById('lyrics-container'); const { startTime, speedMultiplier } = state; const totalScrollDistance = container.scrollHeight - container.clientHeight; if (totalScrollDistance <= 0) return; const actualDurationMs = BASE_SCROLL_DURATION_MS / speedMultiplier; const elapsedMs = Date.now() - startTime; const progress = elapsedMs / actualDurationMs; const newScrollTop = progress * totalScrollDistance; if (newScrollTop >= totalScrollDistance) { container.scrollTop = totalScrollDistance; stopAutoscroll(isTest); return; } container.scrollTop = newScrollTop; animationFrameId = requestAnimationFrame(() => animateScroll(isTest)); } function setupManualScrollListeners() { const container = document.getElementById('lyrics-container'); const startManualScroll = (e) => { const isVerticalScroll = e.type === 'wheel' ? e.deltaY !== 0 : true; if (isVerticalScroll) { isManualScrolling = true; clearTimeout(manualScrollTimeout); } }; const endManualScroll = () => { clearTimeout(manualScrollTimeout); manualScrollTimeout = setTimeout(async () => { if (!isManualScrolling) return; isManualScrolling = false; if (!isAutoscrollEnabled) return; try { const state = previewState?.isTesting ? previewState : await forrestHubLib.dbVarGetKey(CURRENT_SONG_STATE_VAR); if (!state || state.status !== 'playing') return; const totalScrollDistance = container.scrollHeight - container.clientHeight; if (totalScrollDistance <= 0) return; const actualDurationMs = BASE_SCROLL_DURATION_MS / state.speedMultiplier; const scrollProgress = container.scrollTop / totalScrollDistance; const elapsedTime = scrollProgress * actualDurationMs; const newStartTime = Date.now() - elapsedTime; if (previewState?.isTesting) { previewState.startTime = newStartTime; } else if(isGuitarist) { await performStateChange(() => ({ ...state, startTime: newStartTime })); } } catch (e) { console.error(e); } }, 1000); }; container.addEventListener('wheel', startManualScroll, { passive: true }); container.addEventListener('touchstart', startManualScroll, { passive: true }); container.addEventListener('scroll', endManualScroll); } </script> {% endblock %}