unreleased rework
This commit is contained in:
parent
9ef3d6573f
commit
db777a7923
9 changed files with 1413 additions and 451 deletions
66
index.html
66
index.html
|
|
@ -45,6 +45,8 @@
|
||||||
<li data-action="go-to-artist" data-type-filter="track,album">Go to artist</li>
|
<li data-action="go-to-artist" data-type-filter="track,album">Go to artist</li>
|
||||||
<li data-action="go-to-album" data-type-filter="track">Go to album</li>
|
<li data-action="go-to-album" data-type-filter="track">Go to album</li>
|
||||||
<li data-action="copy-link">Copy link</li>
|
<li data-action="copy-link">Copy link</li>
|
||||||
|
<li data-action="track-info" data-type-filter="track">Track Info</li>
|
||||||
|
<li data-action="open-original-url" data-type-filter="track">Open Original URL</li>
|
||||||
<li data-action="download">Download</li>
|
<li data-action="download">Download</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -795,6 +797,27 @@
|
||||||
<span>Recent</span>
|
<span>Recent</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/unreleased">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
<span>Unreleased</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="/settings">
|
<a href="/settings">
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -1338,6 +1361,35 @@
|
||||||
<div class="track-list" id="recent-tracks-container"></div>
|
<div class="track-list" id="recent-tracks-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="page-unreleased" class="page">
|
||||||
|
<div id="unreleased-content" style="padding: 1rem 0;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page-tracker-artist" class="page">
|
||||||
|
<header class="detail-header">
|
||||||
|
<img
|
||||||
|
id="tracker-artist-detail-image"
|
||||||
|
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||||
|
alt=""
|
||||||
|
class="detail-header-image artist"
|
||||||
|
/>
|
||||||
|
<div class="detail-header-info">
|
||||||
|
<div class="type">Unreleased Artist</div>
|
||||||
|
<h1 class="title" id="tracker-artist-detail-name"></h1>
|
||||||
|
<div class="meta" id="tracker-artist-detail-meta"></div>
|
||||||
|
<div class="detail-header-actions">
|
||||||
|
<button id="play-tracker-artist-btn" class="btn-primary">
|
||||||
|
<span>Shuffle Play</span>
|
||||||
|
</button>
|
||||||
|
<button id="download-tracker-artist-btn" class="btn-primary">
|
||||||
|
<span>Download All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="tracker-artist-projects-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="page-album" class="page">
|
<div id="page-album" class="page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<img
|
<img
|
||||||
|
|
@ -1704,11 +1756,13 @@
|
||||||
<h2 class="section-title">EPs and Singles</h2>
|
<h2 class="section-title">EPs and Singles</h2>
|
||||||
<div class="card-grid" id="artist-detail-eps"></div>
|
<div class="card-grid" id="artist-detail-eps"></div>
|
||||||
</section>
|
</section>
|
||||||
<div
|
<section class="content-section" id="artist-section-load-unreleased" style="display: none; margin: 1.5rem 0;">
|
||||||
id="artist-tracker-section"
|
<button id="load-unreleased-btn" class="btn-primary">Load Unreleased Projects</button>
|
||||||
class="content-section"
|
</section>
|
||||||
style="display: none; margin-top: 2rem"
|
<section class="content-section" id="artist-section-unreleased" style="display: none">
|
||||||
></div>
|
<h2 class="section-title">Unreleased Music</h2>
|
||||||
|
<div class="card-grid" id="artist-detail-unreleased"></div>
|
||||||
|
</section>
|
||||||
<section class="content-section" id="artist-section-similar" style="display: none">
|
<section class="content-section" id="artist-section-similar" style="display: none">
|
||||||
<h2 class="section-title">Similar Artists</h2>
|
<h2 class="section-title">Similar Artists</h2>
|
||||||
<div class="card-grid" id="artist-detail-similar"></div>
|
<div class="card-grid" id="artist-detail-similar"></div>
|
||||||
|
|
@ -2429,7 +2483,7 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-footer">
|
<div class="about-footer">
|
||||||
<p class="version">Version 2.1.0</p>
|
<p class="version">Version 2.2.0</p>
|
||||||
<p class="disclaimer">
|
<p class="disclaimer">
|
||||||
This is an independent client and is not affiliated with or endorsed by TIDAL or any
|
This is an independent client and is not affiliated with or endorsed by TIDAL or any
|
||||||
music streaming service.
|
music streaming service.
|
||||||
|
|
|
||||||
1
js/db.js
1
js/db.js
|
|
@ -231,6 +231,7 @@ export class MusicDatabase {
|
||||||
// Keep mix info
|
// Keep mix info
|
||||||
mixes: item.mixes || null,
|
mixes: item.mixes || null,
|
||||||
isTracker: item.isTracker || (item.id && String(item.id).startsWith('tracker-')),
|
isTracker: item.isTracker || (item.id && String(item.id).startsWith('tracker-')),
|
||||||
|
trackerInfo: item.trackerInfo || null,
|
||||||
audioUrl: item.remoteUrl || item.audioUrl || null,
|
audioUrl: item.remoteUrl || item.audioUrl || null,
|
||||||
remoteUrl: item.remoteUrl || null,
|
remoteUrl: item.remoteUrl || null,
|
||||||
audioQuality: item.audioQuality || null,
|
audioQuality: item.audioQuality || null,
|
||||||
|
|
|
||||||
265
js/events.js
265
js/events.js
|
|
@ -8,6 +8,7 @@ import {
|
||||||
trackDataStore,
|
trackDataStore,
|
||||||
formatTime,
|
formatTime,
|
||||||
SVG_BIN,
|
SVG_BIN,
|
||||||
|
getTrackArtists,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { lastFMStorage, waveformSettings } from './storage.js';
|
import { lastFMStorage, waveformSettings } from './storage.js';
|
||||||
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
||||||
|
|
@ -579,6 +580,112 @@ function initializeSmoothSliders(audioPlayer, player) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone function to show add to playlist modal
|
||||||
|
export async function showAddToPlaylistModal(track) {
|
||||||
|
const modal = document.getElementById('playlist-select-modal');
|
||||||
|
const list = document.getElementById('playlist-select-list');
|
||||||
|
const cancelBtn = document.getElementById('playlist-select-cancel');
|
||||||
|
const overlay = modal.querySelector('.modal-overlay');
|
||||||
|
|
||||||
|
const renderModal = async () => {
|
||||||
|
const playlists = await db.getPlaylists(true);
|
||||||
|
|
||||||
|
const trackId = track.id;
|
||||||
|
const playlistsWithTrack = new Set();
|
||||||
|
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
if (playlist.tracks && playlist.tracks.some((t) => t.id == trackId)) {
|
||||||
|
playlistsWithTrack.add(playlist.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML =
|
||||||
|
`
|
||||||
|
<div class="modal-option create-new-option" style="border-bottom: 1px solid var(--border); margin-bottom: 0.5rem;">
|
||||||
|
<span style="font-weight: 600; color: var(--primary);">+ Create New Playlist</span>
|
||||||
|
</div>
|
||||||
|
` +
|
||||||
|
playlists
|
||||||
|
.map((p) => {
|
||||||
|
const alreadyContains = playlistsWithTrack.has(p.id);
|
||||||
|
return `
|
||||||
|
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
||||||
|
<span>${p.name}</span>
|
||||||
|
${
|
||||||
|
alreadyContains
|
||||||
|
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(await renderModal())) return;
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOptionClick = async (e) => {
|
||||||
|
const removeBtn = e.target.closest('.remove-from-playlist-btn-modal');
|
||||||
|
const option = e.target.closest('.modal-option');
|
||||||
|
|
||||||
|
if (!option) return;
|
||||||
|
|
||||||
|
if (option.classList.contains('create-new-option')) {
|
||||||
|
closeModal();
|
||||||
|
const createModal = document.getElementById('playlist-modal');
|
||||||
|
document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
|
||||||
|
document.getElementById('playlist-name-input').value = '';
|
||||||
|
document.getElementById('playlist-cover-input').value = '';
|
||||||
|
createModal.dataset.editingId = '';
|
||||||
|
document.getElementById('csv-import-section').style.display = 'none';
|
||||||
|
|
||||||
|
// Pass track
|
||||||
|
createModal._pendingTracks = [track];
|
||||||
|
|
||||||
|
createModal.classList.add('active');
|
||||||
|
document.getElementById('playlist-name-input').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistId = option.dataset.id;
|
||||||
|
|
||||||
|
if (removeBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
await db.removeTrackFromPlaylist(playlistId, track.id);
|
||||||
|
const updatedPlaylist = await db.getPlaylist(playlistId);
|
||||||
|
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||||
|
showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`);
|
||||||
|
await renderModal();
|
||||||
|
} else {
|
||||||
|
if (option.classList.contains('already-contains')) return;
|
||||||
|
|
||||||
|
await db.addTrackToPlaylist(playlistId, track);
|
||||||
|
const updatedPlaylist = await db.getPlaylist(playlistId);
|
||||||
|
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||||
|
showNotification(`Added to playlist: ${option.querySelector('span').textContent}`);
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
cancelBtn.removeEventListener('click', closeModal);
|
||||||
|
overlay.removeEventListener('click', closeModal);
|
||||||
|
list.removeEventListener('click', handleOptionClick);
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', closeModal);
|
||||||
|
overlay.addEventListener('click', closeModal);
|
||||||
|
list.addEventListener('click', handleOptionClick);
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleTrackAction(
|
export async function handleTrackAction(
|
||||||
action,
|
action,
|
||||||
item,
|
item,
|
||||||
|
|
@ -942,6 +1049,143 @@ export async function handleTrackAction(
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
showNotification('Link copied to clipboard!');
|
showNotification('Link copied to clipboard!');
|
||||||
});
|
});
|
||||||
|
} else if (action === 'track-info') {
|
||||||
|
// Show detailed track info modal
|
||||||
|
const isTracker = item.isTracker;
|
||||||
|
let infoHTML = '';
|
||||||
|
|
||||||
|
if (isTracker && item.trackerInfo) {
|
||||||
|
// Detailed unreleased/tracker track info
|
||||||
|
const releaseDate = item.trackerInfo.releaseDate || item.streamStartDate;
|
||||||
|
const dateDisplay = releaseDate ? new Date(releaseDate).toLocaleDateString() : 'Unknown';
|
||||||
|
const addedDate = item.trackerInfo.addedDate ? new Date(item.trackerInfo.addedDate).toLocaleDateString() : 'Unknown';
|
||||||
|
|
||||||
|
infoHTML = `
|
||||||
|
<div style="padding: 1.5rem; max-width: 500px; max-height: 80vh; overflow-y: auto;">
|
||||||
|
<h3 style="margin-bottom: 1rem; font-size: 1.3rem; font-weight: 600;">${item.title}</h3>
|
||||||
|
<div style="color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.8;">
|
||||||
|
<div style="margin-bottom: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
|
<p style="color: var(--primary); font-weight: 500;">Unreleased Track</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; gap: 0.5rem;">
|
||||||
|
${item.artists ? `<p><strong style="color: var(--foreground);">Artist:</strong> ${Array.isArray(item.artists) ? item.artists.map(a => a.name || a).join(', ') : item.artists}</p>` : ''}
|
||||||
|
${item.trackerInfo.artist ? `<p><strong style="color: var(--foreground);">Tracked Artist:</strong> ${item.trackerInfo.artist}</p>` : ''}
|
||||||
|
${item.trackerInfo.project ? `<p><strong style="color: var(--foreground);">Project:</strong> ${item.trackerInfo.project}</p>` : ''}
|
||||||
|
${item.trackerInfo.era ? `<p><strong style="color: var(--foreground);">Era:</strong> ${item.trackerInfo.era}</p>` : ''}
|
||||||
|
${item.trackerInfo.timeline ? `<p><strong style="color: var(--foreground);">Timeline:</strong> ${item.trackerInfo.timeline}</p>` : ''}
|
||||||
|
${item.trackerInfo.category ? `<p><strong style="color: var(--foreground);">Category:</strong> ${item.trackerInfo.category}</p>` : ''}
|
||||||
|
${item.trackerInfo.trackNumber ? `<p><strong style="color: var(--foreground);">Track Number:</strong> ${item.trackerInfo.trackNumber}</p>` : ''}
|
||||||
|
<p><strong style="color: var(--foreground);">Duration:</strong> ${formatTime(item.duration)}</p>
|
||||||
|
${releaseDate !== 'Unknown' ? `<p><strong style="color: var(--foreground);">Release Date:</strong> ${dateDisplay}</p>` : ''}
|
||||||
|
${item.trackerInfo.addedDate ? `<p><strong style="color: var(--foreground);">Added to Tracker:</strong> ${addedDate}</p>` : ''}
|
||||||
|
${item.trackerInfo.leakedDate ? `<p><strong style="color: var(--foreground);">Leak Date:</strong> ${new Date(item.trackerInfo.leakedDate).toLocaleDateString()}</p>` : ''}
|
||||||
|
${item.trackerInfo.recordingDate ? `<p><strong style="color: var(--foreground);">Recording Date:</strong> ${new Date(item.trackerInfo.recordingDate).toLocaleDateString()}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${item.trackerInfo.description ? `
|
||||||
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Description</p>
|
||||||
|
<p style="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.description}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${item.trackerInfo.notes ? `
|
||||||
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Notes</p>
|
||||||
|
<p style="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.notes}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${item.trackerInfo.sourceUrl ? `
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<p style="margin-bottom: 0.5rem;"><strong style="color: var(--foreground);">Source URL:</strong></p>
|
||||||
|
<a href="${item.trackerInfo.sourceUrl}" target="_blank" style="color: var(--primary); word-break: break-all; font-size: 0.85rem; display: block; padding: 0.5rem; background: var(--accent); border-radius: 6px; text-decoration: none;">
|
||||||
|
${item.trackerInfo.sourceUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button onclick="this.closest('.modal-overlay').remove()" class="btn-primary" style="margin-top: 1.5rem; width: 100%;">Close</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Detailed normal track info
|
||||||
|
const releaseDate = item.album?.releaseDate || item.streamStartDate;
|
||||||
|
const dateDisplay = releaseDate ? new Date(releaseDate).toLocaleDateString() : 'Unknown';
|
||||||
|
const quality = item.audioQuality || 'Unknown';
|
||||||
|
const bitrate = item.bitrate ? `${item.bitrate} kbps` : '';
|
||||||
|
|
||||||
|
infoHTML = `
|
||||||
|
<div style="padding: 1.5rem; max-width: 500px; max-height: 80vh; overflow-y: auto;">
|
||||||
|
<h3 style="margin-bottom: 1rem; font-size: 1.3rem; font-weight: 600;">${item.title}</h3>
|
||||||
|
<div style="color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.8;">
|
||||||
|
<div style="display: grid; gap: 0.5rem;">
|
||||||
|
<p><strong style="color: var(--foreground);">Artist:</strong> ${getTrackArtists(item)}</p>
|
||||||
|
<p><strong style="color: var(--foreground);">Album:</strong> ${item.album?.title || 'Unknown'}</p>
|
||||||
|
${item.album?.artist?.name ? `<p><strong style="color: var(--foreground);">Album Artist:</strong> ${item.album.artist.name}</p>` : ''}
|
||||||
|
<p><strong style="color: var(--foreground);">Release Date:</strong> ${dateDisplay}</p>
|
||||||
|
<p><strong style="color: var(--foreground);">Duration:</strong> ${formatTime(item.duration)}</p>
|
||||||
|
${item.trackNumber ? `<p><strong style="color: var(--foreground);">Track Number:</strong> ${item.trackNumber}</p>` : ''}
|
||||||
|
${item.discNumber ? `<p><strong style="color: var(--foreground);">Disc Number:</strong> ${item.discNumber}</p>` : ''}
|
||||||
|
${item.version ? `<p><strong style="color: var(--foreground);">Version:</strong> ${item.version}</p>` : ''}
|
||||||
|
${item.explicit ? `<p><strong style="color: var(--foreground);">Explicit:</strong> Yes</p>` : ''}
|
||||||
|
<p><strong style="color: var(--foreground);">Quality:</strong> ${quality} ${bitrate ? `(${bitrate})` : ''}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${item.credits && item.credits.length > 0 ? `
|
||||||
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Credits</p>
|
||||||
|
<div style="font-size: 0.85rem; line-height: 1.6;">
|
||||||
|
${item.credits.map(c => `<p>${c.type}: ${c.name}</p>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${item.composers && item.composers.length > 0 ? `
|
||||||
|
<p style="margin-top: 0.5rem;"><strong style="color: var(--foreground);">Composers:</strong> ${item.composers.map(c => c.name).join(', ')}</p>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${item.lyrics?.text ? `
|
||||||
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Has Lyrics</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
||||||
|
${item.album?.id ? `<p style="font-size: 0.8rem; color: var(--muted);"><strong>Album ID:</strong> ${item.album.id}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button onclick="this.closest('.modal-overlay').remove()" class="btn-primary" style="margin-top: 1.5rem; width: 100%;">Close</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and show modal
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 10000;';
|
||||||
|
modal.innerHTML = infoHTML;
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) modal.remove();
|
||||||
|
};
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
} else if (action === 'open-original-url') {
|
||||||
|
// Open the original source URL for the track
|
||||||
|
let url = null;
|
||||||
|
|
||||||
|
if (item.isTracker && item.trackerInfo && item.trackerInfo.sourceUrl) {
|
||||||
|
url = item.trackerInfo.sourceUrl;
|
||||||
|
} else if (item.remoteUrl) {
|
||||||
|
url = item.remoteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
} else {
|
||||||
|
showNotification('No original URL available for this track.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -961,6 +1205,13 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
|
||||||
trackMixItem.style.display = hasMix ? 'block' : 'none';
|
trackMixItem.style.display = hasMix ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide "Open Original URL" only for unreleased/tracker tracks
|
||||||
|
const openOriginalUrlItem = contextMenu.querySelector('li[data-action="open-original-url"]');
|
||||||
|
if (openOriginalUrlItem) {
|
||||||
|
const isUnreleased = contextTrack.isTracker || (contextTrack.trackerInfo && contextTrack.trackerInfo.sourceUrl);
|
||||||
|
openOriginalUrlItem.style.display = isUnreleased ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Filter items based on type
|
// Filter items based on type
|
||||||
const type = contextMenu._contextType || 'track';
|
const type = contextMenu._contextType || 'track';
|
||||||
contextMenu.querySelectorAll('li[data-action]').forEach((item) => {
|
contextMenu.querySelectorAll('li[data-action]').forEach((item) => {
|
||||||
|
|
@ -1229,7 +1480,11 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
if (link) {
|
if (link) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const artistId = link.dataset.artistId;
|
const artistId = link.dataset.artistId;
|
||||||
if (artistId) {
|
const trackerSheetId = link.dataset.trackerSheetId;
|
||||||
|
if (trackerSheetId) {
|
||||||
|
// Navigate to tracker artist page
|
||||||
|
navigate(`/unreleased/${trackerSheetId}`);
|
||||||
|
} else if (artistId) {
|
||||||
navigate(`/artist/${artistId}`);
|
navigate(`/artist/${artistId}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -1237,9 +1492,15 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
|
|
||||||
// Fallback for non-link clicks (e.g. separators) or single artist legacy
|
// Fallback for non-link clicks (e.g. separators) or single artist legacy
|
||||||
const track = player.currentTrack;
|
const track = player.currentTrack;
|
||||||
if (track?.artist?.id) {
|
if (track) {
|
||||||
|
// Check if this is a tracker track
|
||||||
|
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
||||||
|
if (isTracker && track.trackerInfo?.sheetId) {
|
||||||
|
navigate(`/unreleased/${track.trackerInfo.sheetId}`);
|
||||||
|
} else if (track.artist?.id) {
|
||||||
navigate(`/artist/${track.artist.id}`);
|
navigate(`/artist/${track.artist.id}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
|
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
|
||||||
|
|
|
||||||
10
js/lastfm.js
10
js/lastfm.js
|
|
@ -180,9 +180,10 @@ export class LastFMScrobbler {
|
||||||
this.clearScrobbleTimer();
|
this.clearScrobbleTimer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const scrobbleTitle = track.cleanTitle || track.title;
|
||||||
const params = {
|
const params = {
|
||||||
artist: this._getScrobbleArtist(track),
|
artist: this._getScrobbleArtist(track),
|
||||||
track: track.title,
|
track: scrobbleTitle,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (track.album?.title) {
|
if (track.album?.title) {
|
||||||
|
|
@ -199,7 +200,7 @@ export class LastFMScrobbler {
|
||||||
|
|
||||||
await this.makeRequest('track.updateNowPlaying', params, true);
|
await this.makeRequest('track.updateNowPlaying', params, true);
|
||||||
|
|
||||||
console.log('Now playing updated:', track.title);
|
console.log('Now playing updated:', scrobbleTitle);
|
||||||
|
|
||||||
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
|
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
|
||||||
this.scheduleScrobble(this.scrobbleThreshold * 1000);
|
this.scheduleScrobble(this.scrobbleThreshold * 1000);
|
||||||
|
|
@ -228,10 +229,11 @@ export class LastFMScrobbler {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const scrobbleTitle = this.currentTrack.cleanTitle || this.currentTrack.title;
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
artist: this._getScrobbleArtist(this.currentTrack),
|
artist: this._getScrobbleArtist(this.currentTrack),
|
||||||
track: this.currentTrack.title,
|
track: scrobbleTitle,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -250,7 +252,7 @@ export class LastFMScrobbler {
|
||||||
await this.makeRequest('track.scrobble', params, true);
|
await this.makeRequest('track.scrobble', params, true);
|
||||||
|
|
||||||
this.hasScrobbled = true;
|
this.hasScrobbled = true;
|
||||||
console.log('Scrobbled:', this.currentTrack.title);
|
console.log('Scrobbled:', this.currentTrack.cleanTitle || this.currentTrack.title);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to scrobble:', error);
|
console.error('Failed to scrobble:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
js/player.js
26
js/player.js
|
|
@ -356,6 +356,32 @@ export class Player {
|
||||||
this.applyReplayGain();
|
this.applyReplayGain();
|
||||||
|
|
||||||
this.audio.src = streamUrl;
|
this.audio.src = streamUrl;
|
||||||
|
|
||||||
|
// Wait for audio to be ready before playing (prevents restart issues with blob URLs)
|
||||||
|
if (isTracker) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const onCanPlay = () => {
|
||||||
|
this.audio.removeEventListener('canplay', onCanPlay);
|
||||||
|
this.audio.removeEventListener('error', onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onError = (e) => {
|
||||||
|
this.audio.removeEventListener('canplay', onCanPlay);
|
||||||
|
this.audio.removeEventListener('error', onError);
|
||||||
|
reject(e);
|
||||||
|
};
|
||||||
|
this.audio.addEventListener('canplay', onCanPlay);
|
||||||
|
this.audio.addEventListener('error', onError);
|
||||||
|
|
||||||
|
// Timeout after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.audio.removeEventListener('canplay', onCanPlay);
|
||||||
|
this.audio.removeEventListener('error', onError);
|
||||||
|
reject(new Error('Timeout waiting for audio to load'));
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (startTime > 0) {
|
if (startTime > 0) {
|
||||||
this.audio.currentTime = startTime;
|
this.audio.currentTime = startTime;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
js/router.js
18
js/router.js
|
|
@ -52,7 +52,11 @@ export function createRouter(ui) {
|
||||||
await ui.renderMixPage(param);
|
await ui.renderMixPage(param);
|
||||||
break;
|
break;
|
||||||
case 'track':
|
case 'track':
|
||||||
|
if (param.startsWith('tracker-')) {
|
||||||
|
await ui.renderTrackerTrackPage(param);
|
||||||
|
} else {
|
||||||
await ui.renderTrackPage(param);
|
await ui.renderTrackPage(param);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'library':
|
case 'library':
|
||||||
await ui.renderLibraryPage();
|
await ui.renderLibraryPage();
|
||||||
|
|
@ -60,6 +64,20 @@ export function createRouter(ui) {
|
||||||
case 'recent':
|
case 'recent':
|
||||||
await ui.renderRecentPage();
|
await ui.renderRecentPage();
|
||||||
break;
|
break;
|
||||||
|
case 'unreleased':
|
||||||
|
if (param) {
|
||||||
|
const parts = param.split('/');
|
||||||
|
const sheetId = parts[0];
|
||||||
|
const projectName = parts[1] ? decodeURIComponent(parts[1]) : null;
|
||||||
|
if (projectName) {
|
||||||
|
await ui.renderTrackerProjectPage(sheetId, projectName);
|
||||||
|
} else {
|
||||||
|
await ui.renderTrackerArtistPage(sheetId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await ui.renderUnreleasedPage();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'home':
|
case 'home':
|
||||||
await ui.renderHomePage();
|
await ui.renderHomePage();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
1292
js/tracker.js
1292
js/tracker.js
File diff suppressed because it is too large
Load diff
139
js/ui.js
139
js/ui.js
|
|
@ -23,6 +23,7 @@ import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { Visualizer } from './visualizer.js';
|
import { Visualizer } from './visualizer.js';
|
||||||
import { navigate } from './router.js';
|
import { navigate } from './router.js';
|
||||||
|
import { renderUnreleasedPage as renderUnreleasedTrackerPage, renderTrackerArtistPage as renderTrackerArtistContent, renderTrackerProjectPage as renderTrackerProjectContent, renderTrackerTrackPage as renderTrackerTrackContent, findTrackerArtistByName, getArtistUnreleasedProjects, createProjectCardHTML } from './tracker.js';
|
||||||
|
|
||||||
export class UIRenderer {
|
export class UIRenderer {
|
||||||
constructor(api, player) {
|
constructor(api, player) {
|
||||||
|
|
@ -109,31 +110,42 @@ export class UIRenderer {
|
||||||
if (track) {
|
if (track) {
|
||||||
const isLocal = track.isLocal;
|
const isLocal = track.isLocal;
|
||||||
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
||||||
const shouldHide = isLocal || isTracker;
|
const shouldHideLikes = isLocal || isTracker;
|
||||||
|
|
||||||
if (likeBtn) {
|
if (likeBtn) {
|
||||||
if (shouldHide) {
|
if (shouldHideLikes) {
|
||||||
likeBtn.style.display = 'none';
|
likeBtn.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
likeBtn.style.display = 'flex';
|
likeBtn.style.display = 'flex';
|
||||||
this.updateLikeState(likeBtn.parentElement, 'track', track.id);
|
this.updateLikeState(likeBtn.parentElement, 'track', track.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For tracker tracks: show add playlist, hide lyrics
|
||||||
|
// For normal tracks: hide add playlist, show lyrics (unless local)
|
||||||
if (addPlaylistBtn) {
|
if (addPlaylistBtn) {
|
||||||
if (shouldHide) addPlaylistBtn.style.setProperty('display', 'none', 'important');
|
if (isTracker) {
|
||||||
else addPlaylistBtn.style.removeProperty('display');
|
addPlaylistBtn.style.removeProperty('display');
|
||||||
|
addPlaylistBtn.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
addPlaylistBtn.style.setProperty('display', 'none', 'important');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (mobileAddPlaylistBtn) {
|
if (mobileAddPlaylistBtn) {
|
||||||
if (shouldHide) mobileAddPlaylistBtn.style.setProperty('display', 'none', 'important');
|
if (isTracker) {
|
||||||
else mobileAddPlaylistBtn.style.removeProperty('display');
|
mobileAddPlaylistBtn.style.removeProperty('display');
|
||||||
|
mobileAddPlaylistBtn.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
mobileAddPlaylistBtn.style.setProperty('display', 'none', 'important');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (lyricsBtn) {
|
if (lyricsBtn) {
|
||||||
if (isLocal) lyricsBtn.style.display = 'none';
|
if (isLocal || isTracker) lyricsBtn.style.display = 'none';
|
||||||
else lyricsBtn.style.removeProperty('display');
|
else lyricsBtn.style.removeProperty('display');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fsLikeBtn) {
|
if (fsLikeBtn) {
|
||||||
if (shouldHide) {
|
if (shouldHideLikes) {
|
||||||
fsLikeBtn.style.display = 'none';
|
fsLikeBtn.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
fsLikeBtn.style.display = 'flex';
|
fsLikeBtn.style.display = 'flex';
|
||||||
|
|
@ -141,7 +153,7 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fsAddPlaylistBtn) {
|
if (fsAddPlaylistBtn) {
|
||||||
if (shouldHide) fsAddPlaylistBtn.style.display = 'none';
|
if (shouldHideLikes) fsAddPlaylistBtn.style.display = 'none';
|
||||||
else fsAddPlaylistBtn.style.display = 'flex';
|
else fsAddPlaylistBtn.style.display = 'flex';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2192,6 +2204,8 @@ export class UIRenderer {
|
||||||
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||||
if (epsContainer) epsContainer.innerHTML = this.createSkeletonCards(6, false);
|
if (epsContainer) epsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||||
if (epsSection) epsSection.style.display = 'none';
|
if (epsSection) epsSection.style.display = 'none';
|
||||||
|
const loadUnreleasedSection = document.getElementById('artist-section-load-unreleased');
|
||||||
|
if (loadUnreleasedSection) loadUnreleasedSection.style.display = 'none';
|
||||||
if (similarContainer) similarContainer.innerHTML = this.createSkeletonCards(6, true);
|
if (similarContainer) similarContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||||
if (similarSection) similarSection.style.display = 'block';
|
if (similarSection) similarSection.style.display = 'block';
|
||||||
|
|
||||||
|
|
@ -2299,6 +2313,89 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for unreleased projects
|
||||||
|
const unreleasedSection = document.getElementById('artist-section-unreleased');
|
||||||
|
const unreleasedContainer = document.getElementById('artist-detail-unreleased');
|
||||||
|
const loadUnreleasedBtn = document.getElementById('load-unreleased-btn');
|
||||||
|
const loadUnreleasedSection = document.getElementById('artist-section-load-unreleased');
|
||||||
|
if (unreleasedSection && unreleasedContainer && loadUnreleasedBtn && loadUnreleasedSection) {
|
||||||
|
// Initially hide the unreleased section
|
||||||
|
unreleasedSection.style.display = 'none';
|
||||||
|
loadUnreleasedSection.style.display = 'none';
|
||||||
|
|
||||||
|
// Check if artist has unreleased projects
|
||||||
|
const trackerArtist = findTrackerArtistByName(artist.name);
|
||||||
|
if (trackerArtist) {
|
||||||
|
// Show the load button section
|
||||||
|
loadUnreleasedSection.style.display = 'block';
|
||||||
|
|
||||||
|
// Add click handler to load and display unreleased projects
|
||||||
|
loadUnreleasedBtn.onclick = async () => {
|
||||||
|
loadUnreleasedBtn.disabled = true;
|
||||||
|
loadUnreleasedBtn.textContent = 'Loading...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unreleasedData = await getArtistUnreleasedProjects(artist.name);
|
||||||
|
if (unreleasedData && unreleasedData.eras.length > 0) {
|
||||||
|
const { artist: trackerArtistData, sheetId, eras } = unreleasedData;
|
||||||
|
|
||||||
|
unreleasedContainer.innerHTML = eras.map(e => {
|
||||||
|
let trackCount = 0;
|
||||||
|
if (e.data) {
|
||||||
|
Object.values(e.data).forEach((songs) => {
|
||||||
|
if (songs && songs.length) trackCount += songs.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createProjectCardHTML(e, trackerArtistData, sheetId, trackCount);
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
unreleasedSection.style.display = 'block';
|
||||||
|
loadUnreleasedBtn.style.display = 'none';
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
|
unreleasedContainer.querySelectorAll('.card').forEach((card) => {
|
||||||
|
const eraName = decodeURIComponent(card.dataset.trackerProjectId);
|
||||||
|
const era = eras.find(e => e.name === eraName);
|
||||||
|
if (!era) return;
|
||||||
|
|
||||||
|
card.onclick = (e) => {
|
||||||
|
if (e.target.closest('.card-play-btn')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
let eraTracks = [];
|
||||||
|
if (era.data) {
|
||||||
|
Object.values(era.data).forEach((songs) => {
|
||||||
|
if (songs && songs.length) {
|
||||||
|
songs.forEach((song, index) => {
|
||||||
|
const track = createTrackFromSong(song, era, trackerArtistData.name, eraTracks.length, sheetId);
|
||||||
|
eraTracks.push(track);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const availableTracks = eraTracks.filter(t => !t.unavailable);
|
||||||
|
if (availableTracks.length > 0) {
|
||||||
|
globalPlayer.setQueue(availableTracks, 0);
|
||||||
|
globalPlayer.playTrackFromQueue();
|
||||||
|
}
|
||||||
|
} else if (e.target.closest('.card-menu-btn')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
} else {
|
||||||
|
navigate(`/unreleased/${sheetId}/${encodeURIComponent(era.name)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loadUnreleasedBtn.textContent = 'No unreleased projects';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load unreleased projects:', error);
|
||||||
|
loadUnreleasedBtn.textContent = 'Failed to load';
|
||||||
|
loadUnreleasedBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
recentActivityManager.addArtist(artist);
|
recentActivityManager.addArtist(artist);
|
||||||
|
|
||||||
document.title = artist.name;
|
document.title = artist.name;
|
||||||
|
|
@ -2376,6 +2473,30 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renderUnreleasedPage() {
|
||||||
|
this.showPage('unreleased');
|
||||||
|
const container = document.getElementById('unreleased-content');
|
||||||
|
await renderUnreleasedTrackerPage(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderTrackerArtistPage(sheetId) {
|
||||||
|
this.showPage('tracker-artist');
|
||||||
|
const container = document.getElementById('tracker-artist-projects-container');
|
||||||
|
await renderTrackerArtistContent(sheetId, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderTrackerProjectPage(sheetId, projectName) {
|
||||||
|
this.showPage('album'); // Use album page template
|
||||||
|
const container = document.getElementById('album-detail-tracklist');
|
||||||
|
await renderTrackerProjectContent(sheetId, projectName, container, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderTrackerTrackPage(trackId) {
|
||||||
|
this.showPage('album'); // Use album page template
|
||||||
|
const container = document.getElementById('album-detail-tracklist');
|
||||||
|
await renderTrackerTrackContent(trackId, container, this);
|
||||||
|
}
|
||||||
|
|
||||||
updatePlaylistHeaderActions(playlist, isOwned, tracks, showShare = false, onSort = null) {
|
updatePlaylistHeaderActions(playlist, isOwned, tracks, showShare = false, onSort = null) {
|
||||||
const actionsDiv = document.getElementById('page-playlist').querySelector('.detail-header-actions');
|
const actionsDiv = document.getElementById('page-playlist').querySelector('.detail-header-actions');
|
||||||
|
|
||||||
|
|
|
||||||
11
js/utils.js
11
js/utils.js
|
|
@ -274,7 +274,16 @@ export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}
|
||||||
export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
|
export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
|
||||||
if (track?.artists?.length) {
|
if (track?.artists?.length) {
|
||||||
return track.artists
|
return track.artists
|
||||||
.map((artist) => `<span class="artist-link" data-artist-id="${artist.id}">${artist.name}</span>`)
|
.map((artist) => {
|
||||||
|
// Check if this is a tracker/unreleased track
|
||||||
|
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
||||||
|
if (isTracker && track.trackerInfo?.sheetId) {
|
||||||
|
// For tracker tracks, link to the tracker artist page
|
||||||
|
return `<span class="artist-link tracker-artist-link" data-tracker-sheet-id="${track.trackerInfo.sheetId}">${artist.name}</span>`;
|
||||||
|
}
|
||||||
|
// For normal tracks, use the artist ID
|
||||||
|
return `<span class="artist-link" data-artist-id="${artist.id}">${artist.name}</span>`;
|
||||||
|
})
|
||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue