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('
+
+ onclick="_toggleWingItDropdownLB(this, '${identifier}', '${escapeForInlineJs(title)}')"
+ title="Download or sync using raw track names â no metadata discovery">
âĄ
Wing It
+
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 = `
+
+ âŦī¸
+ Download
+ Raw names
+
+
+ đ
+ Sync to Server
+ Best-effort
+
+ `;
+
+ 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 */