diff --git a/webui/static/script.js b/webui/static/script.js index 9761bbba..df6268ac 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -11595,23 +11595,153 @@ async function exportPlaylistAsM3U(playlistId) { // WING IT — Download without metadata discovery // ================================================================================== -async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null) { +function _toggleWingItDropdown(btn, urlHash) { + // Remove any existing dropdown + const existing = document.querySelector('.wing-it-dropdown.visible'); + if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; } + + const wrap = btn.closest('.wing-it-wrap'); + if (!wrap) return; + + const dropdown = document.createElement('div'); + dropdown.className = 'wing-it-dropdown'; + dropdown.innerHTML = ` + + + `; + + dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => { + item.addEventListener('click', () => { + dropdown.classList.remove('visible'); + setTimeout(() => dropdown.remove(), 150); + const action = item.dataset.action; + if (action === 'download') { + _wingItAction(urlHash, 'download'); + } else { + _wingItAction(urlHash, 'sync'); + } + }); + }); + + // Flip dropdown direction if button is in the top portion of viewport + const btnRect = btn.getBoundingClientRect(); + if (btnRect.top < 200) dropdown.classList.add('flip-down'); + + wrap.appendChild(dropdown); + requestAnimationFrame(() => dropdown.classList.add('visible')); + + // Close on outside click + setTimeout(() => { + const closeHandler = e => { + if (!dropdown.contains(e.target) && e.target !== btn) { + dropdown.classList.remove('visible'); + setTimeout(() => dropdown.remove(), 150); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 50); +} + +function _wingItAction(urlHash, action) { + if (urlHash) { + // Called from a modal — use _wingItFromModal logic + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {}; + const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || []; + const name = state.playlistName || state.name || state.playlist?.name || 'Playlist'; + const isTidal = state.is_tidal_playlist; + const isLB = state.is_listenbrainz_playlist; + const isBeatport = state.is_beatport_playlist; + const isDeezer = state.is_deezer_playlist; + const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube'; + + if (!tracks.length) { + showToast('No tracks available for Wing It', 'error'); + return; + } + + if (action === 'sync') { + // Sync inline — keep modal open + _wingItSyncFromModal(urlHash, tracks, name, isLB); + } else { + // Download — close modal, open download modal + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (modal) modal.remove(); + const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`); + if (overlay) overlay.remove(); + wingItDownload(tracks, name, source, null, true); + } + } +} + +async function _wingItSyncFromModal(urlHash, tracks, name, isLB) { + showToast('Starting Wing It sync...', 'info'); + updateYouTubeModalButtons(urlHash, 'syncing'); + + try { + const syncTracks = tracks.map((t, i) => { + let artists = t.artists || []; + if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; + return { + id: t.id || t.source_track_id || `wing_it_${i}`, + name: t.name || t.track_name || 'Unknown', + artists: artists.map(a => typeof a === 'string' ? { name: a } : a), + album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, + duration_ms: t.duration_ms || 0, + }; + }); + + const res = await fetch('/api/wing-it/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks: syncTracks, playlist_name: name }) + }); + const data = await res.json(); + + if (data.error) { + showToast(`Sync failed: ${data.error}`, 'error'); + updateYouTubeModalButtons(urlHash, 'discovered'); + return; + } + + if (isLB) { + const state = listenbrainzPlaylistStates[urlHash]; + if (state) state.syncPlaylistId = data.sync_playlist_id; + startListenBrainzSyncPolling(urlHash, data.sync_playlist_id); + } else { + startYouTubeSyncPolling(urlHash, data.sync_playlist_id); + } + } catch (e) { + showToast('Sync failed: ' + e.message, 'error'); + updateYouTubeModalButtons(urlHash, 'discovered'); + } +} + +async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null, skipConfirm = false) { if (!tracks || tracks.length === 0) { showToast('No tracks to download', 'error'); return; } - // Show choice: Download or Sync - const choice = await _showWingItChoiceDialog(tracks.length, source); - if (!choice) return; + if (!skipConfirm) { + // Show choice: Download or Sync (for LB card button which doesn't have dropdown) + const choice = await _showWingItChoiceDialog(tracks.length, source); + if (!choice) return; - if (choice === 'sync') { - await _wingItSync(tracks, playlistName, source, cardIdentifier); - return; + if (choice === 'sync') { + await _wingItSync(tracks, playlistName, source, cardIdentifier); + return; + } } - // choice === 'download' — continue with download flow - // Normalize tracks to Spotify-compatible format const formattedTracks = tracks.map(t => { // Handle various artist formats @@ -11787,7 +11917,7 @@ function _pollWingItSyncProgress(syncPlaylistId, playlistName, cardPlaylistId) { if (data.status === 'error') { showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); } else { - showToast(`Sync complete — ${matched}/${total} tracks matched to server`, 'success'); + showToast(`⚡ Wing It sync complete — "${playlistName}" created on server (${matched}/${total} tracks matched)`, 'success'); } // Update card status display to show completion @@ -30823,7 +30953,7 @@ function getModalActionButtons(urlHash, phase, state = null) { case 'discovering': // Show start discovery button for fresh playlists if (phase === 'fresh') { - const wingItBtn = ` `; + const wingItBtn = ` `; if (isListenBrainz) { return `${wingItBtn}`; @@ -30895,8 +31025,8 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } - // Wing It button — available in discovered phase for unmatched tracks - buttons += ` `; + // Wing It button — available in discovered phase + buttons += ` `; if (!buttons || buttons.trim().startsWith('`; + syncCompleteButtons += ` `; return syncCompleteButtons; @@ -52150,12 +52280,14 @@ function buildListenBrainzPlaylistsHtml(playlists, tabId) { ↓ Download + + + + `; + + dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => { + item.addEventListener('click', () => { + dropdown.classList.remove('visible'); + setTimeout(() => dropdown.remove(), 150); + const tracks = listenbrainzTracksCache[identifier]; + if (!tracks || tracks.length === 0) { + showToast('No tracks cached. Try opening the playlist first.', 'error'); + return; + } + if (item.dataset.action === 'download') { + wingItDownload(tracks, title, 'ListenBrainz', identifier, true); + } else { + _wingItSync(tracks, title, 'ListenBrainz', identifier); + } + }); + }); + + const btnRect2 = btn.getBoundingClientRect(); + if (btnRect2.top < 200) dropdown.classList.add('flip-down'); + + wrap.appendChild(dropdown); + requestAnimationFrame(() => dropdown.classList.add('visible')); + + setTimeout(() => { + const closeHandler = e => { + if (!dropdown.contains(e.target) && e.target !== btn) { + dropdown.classList.remove('visible'); + setTimeout(() => dropdown.remove(), 150); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 50); +} + async function _wingItFromLBCard(identifier, title) { + // Legacy — kept for backward compat const tracks = listenbrainzTracksCache[identifier]; if (!tracks || tracks.length === 0) { showToast('No tracks cached for this playlist. Try opening the discovery modal first.', 'error'); diff --git a/webui/static/style.css b/webui/static/style.css index e1247836..4ca43548 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5170,35 +5170,102 @@ body.helper-mode-active #dashboard-activity-feed:hover { .notif-entry-link:hover { opacity: 0.7; } /* ── Wing It Button ── */ +/* ── Wing It Button + Dropdown ── */ +.wing-it-wrap { + position: relative; + display: inline-block; +} + .wing-it-btn { - background: rgba(255, 183, 77, 0.08) !important; - border: 1px solid rgba(255, 183, 77, 0.3) !important; - color: #ffb74d !important; - font-size: 14px !important; - font-weight: 700 !important; - padding: 10px 20px !important; + background: rgba(255, 255, 255, 0.05) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + color: rgba(255, 255, 255, 0.6) !important; + font-size: 13px !important; + font-weight: 600 !important; + padding: 8px 16px !important; border-radius: 10px !important; cursor: pointer; transition: all 0.2s; } .wing-it-btn:hover { - background: rgba(255, 183, 77, 0.15) !important; - border-color: rgba(255, 183, 77, 0.5) !important; + background: rgba(255, 255, 255, 0.1) !important; + border-color: rgba(255, 255, 255, 0.2) !important; + color: #fff !important; +} + +.wing-it-dropdown { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(4px) scale(0.96); + background: rgba(18, 18, 26, 0.97); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 4px; + min-width: 180px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5); + z-index: 10001; + opacity: 0; + transform: translateY(4px) scale(0.96); + transition: opacity 0.15s, transform 0.15s; + pointer-events: none; +} +.wing-it-dropdown.visible { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + pointer-events: auto; +} +.wing-it-dropdown.flip-down { + bottom: auto; + top: calc(100% + 6px); +} + +.wing-it-dropdown-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; + border: none; + background: none; + width: 100%; + text-align: left; + color: rgba(255, 255, 255, 0.8); + font-size: 13px; + font-weight: 500; +} +.wing-it-dropdown-item:hover { + background: rgba(255, 255, 255, 0.06); +} +.wing-it-dropdown-icon { + font-size: 15px; + width: 20px; + text-align: center; + flex-shrink: 0; +} +.wing-it-dropdown-label { flex: 1; } +.wing-it-dropdown-hint { + font-size: 10px; + color: rgba(255, 255, 255, 0.25); } .wing-it-btn-sm { background: transparent; - border: 1px solid rgba(255, 183, 77, 0.25); - color: #ffb74d; + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.5); font-size: 11px; + font-weight: 600; border-radius: 6px; padding: 4px 10px; cursor: pointer; transition: all 0.2s; } .wing-it-btn-sm:hover { - background: rgba(255, 183, 77, 0.1); - border-color: rgba(255, 183, 77, 0.5); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.8); } /* Desktop-Only Optimizations */