1000 lines
41 KiB
HTML
1000 lines
41 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Karaoke Favorites - Web UI</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
|
<script>
|
|
// Fallback for Sortable.js if CDN fails
|
|
if (typeof Sortable === 'undefined') {
|
|
console.warn('Primary Sortable.js CDN failed, trying fallback...');
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js';
|
|
script.onload = function() {
|
|
console.log('Sortable.js loaded from fallback CDN');
|
|
};
|
|
script.onerror = function() {
|
|
console.error('Both Sortable.js CDNs failed to load');
|
|
alert('Warning: Sortable.js library failed to load. Drag and drop functionality will not work.');
|
|
};
|
|
document.head.appendChild(script);
|
|
}
|
|
</script>
|
|
<style>
|
|
.favorite-card {
|
|
border-left: 4px solid #ffc107;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.current-version {
|
|
background-color: #d4edda;
|
|
border-left: 4px solid #28a745;
|
|
}
|
|
.alternative-version {
|
|
background-color: #f8f9fa;
|
|
border-left: 4px solid #6c757d;
|
|
}
|
|
.file-type-badge {
|
|
font-size: 0.75rem;
|
|
}
|
|
.channel-badge {
|
|
font-size: 0.8rem;
|
|
}
|
|
.stats-card {
|
|
background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
|
|
color: white;
|
|
}
|
|
.file-type-card {
|
|
transition: transform 0.2s;
|
|
}
|
|
.file-type-card:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
.metric-highlight {
|
|
font-weight: bold;
|
|
color: #28a745;
|
|
}
|
|
.metric-warning {
|
|
font-weight: bold;
|
|
color: #dc3545;
|
|
}
|
|
.filter-section {
|
|
background-color: #f8f9fa;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.loading {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
}
|
|
.pagination-info {
|
|
font-size: 0.9rem;
|
|
color: #6c757d;
|
|
}
|
|
.path-text {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.85rem;
|
|
word-break: break-all;
|
|
}
|
|
|
|
/* Drag and Drop Styles */
|
|
.sortable-list {
|
|
min-height: 50px;
|
|
}
|
|
.sortable-item {
|
|
cursor: grab;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.sortable-item:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
|
}
|
|
.sortable-item:active {
|
|
cursor: grabbing;
|
|
}
|
|
.sortable-ghost {
|
|
opacity: 0.5;
|
|
background-color: #e9ecef;
|
|
}
|
|
.sortable-chosen {
|
|
background-color: #fff3cd;
|
|
}
|
|
|
|
/* Navigation */
|
|
.nav-link {
|
|
color: #6c757d;
|
|
}
|
|
.nav-link.active {
|
|
color: #ffc107;
|
|
font-weight: bold;
|
|
}
|
|
|
|
/* Video Modal */
|
|
.video-modal .modal-dialog {
|
|
max-width: 90vw;
|
|
}
|
|
.video-modal .modal-body {
|
|
padding: 0;
|
|
}
|
|
.video-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 0;
|
|
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
|
}
|
|
.video-container video {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Inline Editing Styles */
|
|
.editable-field {
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.editable-field:hover {
|
|
border-color: #007bff;
|
|
background-color: #f8f9fa;
|
|
}
|
|
.editable-field.editing {
|
|
border-color: #007bff;
|
|
background-color: #fff;
|
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
|
}
|
|
.edit-input {
|
|
width: 100%;
|
|
border: 1px solid #007bff;
|
|
border-radius: 3px;
|
|
padding: 2px 4px;
|
|
font-size: inherit;
|
|
font-family: inherit;
|
|
}
|
|
.edit-buttons {
|
|
display: flex;
|
|
gap: 5px;
|
|
margin-top: 5px;
|
|
}
|
|
.edit-btn {
|
|
padding: 2px 8px;
|
|
font-size: 0.8rem;
|
|
border-radius: 3px;
|
|
}
|
|
.property-label {
|
|
font-weight: bold;
|
|
color: #495057;
|
|
font-size: 0.9rem;
|
|
}
|
|
.property-value {
|
|
font-size: 0.9rem;
|
|
}
|
|
.boolean-toggle {
|
|
cursor: pointer;
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
font-size: 0.8rem;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.boolean-toggle.true {
|
|
background-color: #28a745;
|
|
color: white;
|
|
}
|
|
.boolean-toggle.false {
|
|
background-color: #6c757d;
|
|
color: white;
|
|
}
|
|
.boolean-toggle:hover {
|
|
opacity: 0.8;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Navigation -->
|
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
|
<div class="container-fluid">
|
|
<a class="navbar-brand" href="/">
|
|
<i class="fas fa-music"></i> Karaoke Manager
|
|
</a>
|
|
<div class="navbar-nav">
|
|
<a class="nav-link" href="/">
|
|
<i class="fas fa-copy"></i> Duplicates
|
|
</a>
|
|
<a class="nav-link active" href="/favorites">
|
|
<i class="fas fa-heart"></i> Favorites
|
|
</a>
|
|
<a class="nav-link" href="/history">
|
|
<i class="fas fa-history"></i> History
|
|
</a>
|
|
<a class="nav-link" href="/remaining-songs">
|
|
<i class="fas fa-list"></i> Remaining Songs
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container-fluid mt-3">
|
|
<!-- Header -->
|
|
<div class="row mb-3">
|
|
<div class="col">
|
|
<h1><i class="fas fa-heart text-warning"></i> Favorites Management</h1>
|
|
<p class="text-muted">Review and manage your favorite songs with alternative versions</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistics Cards -->
|
|
<div class="row mb-3" id="statsRow">
|
|
<div class="col-md-3">
|
|
<div class="card stats-card">
|
|
<div class="card-body text-center">
|
|
<h5 class="card-title">Total Favorites</h5>
|
|
<h3 id="totalFavorites">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stats-card">
|
|
<div class="card-body text-center">
|
|
<h5 class="card-title">With Alternatives</h5>
|
|
<h3 id="withAlternatives">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stats-card">
|
|
<div class="card-body text-center">
|
|
<h5 class="card-title">MP4 Versions</h5>
|
|
<h3 id="mp4Versions">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stats-card">
|
|
<div class="card-body text-center">
|
|
<h5 class="card-title">MP3 Versions</h5>
|
|
<h3 id="mp3Versions">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filter-section">
|
|
<div class="row">
|
|
<div class="col-md-3">
|
|
<label for="artistFilter" class="form-label">Artist Filter</label>
|
|
<input type="text" class="form-control" id="artistFilter" placeholder="Filter by artist...">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label for="titleFilter" class="form-label">Title Filter</label>
|
|
<input type="text" class="form-control" id="titleFilter" placeholder="Filter by title...">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="fileTypeFilter" class="form-label">File Type</label>
|
|
<select class="form-select" id="fileTypeFilter">
|
|
<option value="">All Types</option>
|
|
<option value="MP4">MP4</option>
|
|
<option value="MP3">MP3</option>
|
|
<option value="MP3-only">MP3 Only (No MP4 Alternative)</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="channelFilter" class="form-label">Channel</label>
|
|
<input type="text" class="form-control" id="channelFilter" placeholder="Filter by channel...">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="minAlternatives" class="form-label">Min Alternatives</label>
|
|
<input type="number" class="form-control" id="minAlternatives" value="0" min="0">
|
|
</div>
|
|
</div>
|
|
<div class="row mt-2">
|
|
<div class="col">
|
|
<button class="btn btn-primary" onclick="applyFilters()">
|
|
<i class="fas fa-filter"></i> Apply Filters
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="clearFilters()">
|
|
<i class="fas fa-times"></i> Clear Filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div id="loading" class="loading">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
<p class="mt-2">Loading favorites data...</p>
|
|
</div>
|
|
|
|
<!-- Favorites List -->
|
|
<div id="favoritesList" style="display: none;">
|
|
<!-- Favorites will be loaded here -->
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="row mt-3">
|
|
<div class="col-md-6">
|
|
<div class="pagination-info" id="paginationInfo">
|
|
Showing 0 of 0 favorites
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<nav aria-label="Favorites pagination">
|
|
<ul class="pagination justify-content-end" id="pagination">
|
|
<!-- Pagination will be generated here -->
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Video Modal -->
|
|
<div class="modal fade video-modal" id="videoModal" tabindex="-1">
|
|
<div class="modal-dialog modal-xl">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="videoModalTitle">Video Preview</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="video-container">
|
|
<video id="videoPlayer" controls>
|
|
Your browser does not support the video tag.
|
|
</video>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bootstrap JS -->
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
|
|
<script>
|
|
let allFavorites = [];
|
|
let filteredFavorites = [];
|
|
let currentPage = 1;
|
|
const perPage = 20;
|
|
let pathChanges = {};
|
|
let editingField = null;
|
|
|
|
// Inline editing functions
|
|
function startEdit(index, property) {
|
|
// Cancel any existing edit
|
|
if (editingField) {
|
|
cancelEdit();
|
|
}
|
|
|
|
const field = event.target;
|
|
const currentValue = field.textContent;
|
|
const isNumber = property === 'count';
|
|
|
|
// Create input field
|
|
const input = document.createElement('input');
|
|
input.type = isNumber ? 'number' : 'text';
|
|
input.className = 'edit-input';
|
|
input.value = currentValue;
|
|
|
|
// Create buttons
|
|
const buttonContainer = document.createElement('div');
|
|
buttonContainer.className = 'edit-buttons';
|
|
buttonContainer.innerHTML = `
|
|
<button class="btn btn-sm btn-success edit-btn" onclick="saveEdit(${index}, '${property}')">
|
|
<i class="fas fa-check"></i> Save
|
|
</button>
|
|
<button class="btn btn-sm btn-secondary edit-btn" onclick="cancelEdit()">
|
|
<i class="fas fa-times"></i> Cancel
|
|
</button>
|
|
`;
|
|
|
|
// Replace field content
|
|
field.innerHTML = '';
|
|
field.appendChild(input);
|
|
field.appendChild(buttonContainer);
|
|
field.classList.add('editing');
|
|
|
|
// Focus input
|
|
input.focus();
|
|
input.select();
|
|
|
|
// Store reference
|
|
editingField = {
|
|
field: field,
|
|
input: input,
|
|
originalValue: currentValue,
|
|
index: index,
|
|
property: property
|
|
};
|
|
|
|
// Handle Enter and Escape keys
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') {
|
|
saveEdit(index, property);
|
|
} else if (e.key === 'Escape') {
|
|
cancelEdit();
|
|
}
|
|
});
|
|
}
|
|
|
|
function saveEdit(index, property) {
|
|
if (!editingField) return;
|
|
|
|
const newValue = editingField.input.value;
|
|
const originalValue = editingField.originalValue;
|
|
|
|
if (newValue === originalValue) {
|
|
cancelEdit();
|
|
return;
|
|
}
|
|
|
|
// Update via API
|
|
updateFavoriteProperty(index, property, newValue);
|
|
}
|
|
|
|
function cancelEdit() {
|
|
if (!editingField) return;
|
|
|
|
const field = editingField.field;
|
|
const originalValue = editingField.originalValue;
|
|
|
|
field.innerHTML = originalValue;
|
|
field.classList.remove('editing');
|
|
|
|
editingField = null;
|
|
}
|
|
|
|
async function updateFavoriteProperty(index, property, value) {
|
|
try {
|
|
const response = await fetch('/api/update-favorite-property', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ index, property, value })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
// Update local data
|
|
const favorite = allFavorites.find(f => f.index === index);
|
|
if (favorite) {
|
|
favorite[property] = value;
|
|
}
|
|
|
|
// Update display
|
|
if (editingField) {
|
|
editingField.field.innerHTML = escapeHtml(value);
|
|
editingField.field.classList.remove('editing');
|
|
editingField = null;
|
|
}
|
|
|
|
showSuccess(`Updated ${property} successfully`);
|
|
} else {
|
|
throw new Error(data.error || 'Failed to update property');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating property:', error);
|
|
showError('Failed to update property: ' + error.message);
|
|
cancelEdit();
|
|
}
|
|
}
|
|
|
|
function toggleBoolean(index, property) {
|
|
const favorite = allFavorites.find(f => f.index === index);
|
|
if (!favorite) return;
|
|
|
|
const newValue = !favorite[property];
|
|
updateFavoriteProperty(index, property, newValue);
|
|
}
|
|
|
|
async function deleteFavorite(index) {
|
|
const favorite = allFavorites.find(f => f.index === index);
|
|
if (!favorite) return;
|
|
|
|
const songName = `${favorite.artist} - ${favorite.title}`;
|
|
if (!confirm(`Are you sure you want to delete "${songName}" from favorites?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/delete-favorite', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ index })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
// Remove from local arrays
|
|
allFavorites = allFavorites.filter(f => f.index !== index);
|
|
filteredFavorites = filteredFavorites.filter(f => f.index !== index);
|
|
|
|
// Update display
|
|
updateStats();
|
|
displayFavorites();
|
|
|
|
showSuccess(data.message || 'Favorite deleted successfully');
|
|
} else {
|
|
throw new Error(data.error || 'Failed to delete favorite');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting favorite:', error);
|
|
showError('Failed to delete favorite: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
function getFileType(path) {
|
|
const lower = path.toLowerCase();
|
|
if (lower.endsWith('.mp4')) return 'MP4';
|
|
if (lower.endsWith('.mp3')) return 'MP3';
|
|
if (lower.endsWith('.cdg')) return 'MP3'; // Treat CDG as MP3 since they're paired
|
|
return 'Unknown';
|
|
}
|
|
|
|
function extractChannel(path) {
|
|
const lower = path.toLowerCase();
|
|
const parts = path.split('\\');
|
|
|
|
// Look for specific known channels first
|
|
const knownChannels = ['Sing King Karaoke', 'KaraFun Karaoke', 'Stingray Karaoke'];
|
|
for (const channel of knownChannels) {
|
|
if (lower.includes(channel.toLowerCase())) {
|
|
return channel;
|
|
}
|
|
}
|
|
|
|
// Look for MP4 folder structure: MP4/ChannelName/song.mp4
|
|
for (let i = 0; i < parts.length; i++) {
|
|
if (parts[i].toLowerCase() === 'mp4' && i < parts.length - 1) {
|
|
// If MP4 is found, return the next folder (the actual channel)
|
|
if (i + 1 < parts.length) {
|
|
const nextPart = parts[i + 1];
|
|
// Skip if the next part is the filename (no extension means it's a folder)
|
|
if (nextPart.indexOf('.') === -1) {
|
|
return nextPart;
|
|
} else {
|
|
return 'MP4 Root'; // File is directly in MP4 folder
|
|
}
|
|
} else {
|
|
return 'MP4 Root';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look for any folder that contains 'karaoke' (fallback)
|
|
for (const part of parts) {
|
|
if (part.toLowerCase().includes('karaoke')) {
|
|
return part;
|
|
}
|
|
}
|
|
|
|
// If no specific channel found, return the folder containing the file
|
|
if (parts.length >= 2) {
|
|
const parentFolder = parts[parts.length - 2]; // Second to last part (folder containing the file)
|
|
// If parent folder is MP4, then file is in root
|
|
if (parentFolder.toLowerCase() === 'mp4') {
|
|
return 'MP4 Root';
|
|
}
|
|
return parentFolder;
|
|
}
|
|
|
|
return 'Unknown';
|
|
}
|
|
|
|
// Load favorites data
|
|
async function loadFavorites() {
|
|
try {
|
|
const response = await fetch('/api/favorites');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
allFavorites = data.favorites;
|
|
filteredFavorites = [...allFavorites];
|
|
updateStats();
|
|
displayFavorites();
|
|
hideLoading();
|
|
} else {
|
|
throw new Error(data.error || 'Failed to load favorites');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading favorites:', error);
|
|
hideLoading();
|
|
showError('Failed to load favorites: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Update statistics
|
|
function updateStats() {
|
|
const totalFavorites = allFavorites.length;
|
|
const withAlternatives = allFavorites.filter(f => f.matching_songs.length > 1).length;
|
|
const mp4Versions = allFavorites.reduce((count, f) =>
|
|
count + f.matching_songs.filter(s => s.file_type === 'MP4').length, 0);
|
|
const mp3Versions = allFavorites.reduce((count, f) =>
|
|
count + f.matching_songs.filter(s => s.file_type === 'MP3').length, 0);
|
|
|
|
document.getElementById('totalFavorites').textContent = totalFavorites;
|
|
document.getElementById('withAlternatives').textContent = withAlternatives;
|
|
document.getElementById('mp4Versions').textContent = mp4Versions;
|
|
document.getElementById('mp3Versions').textContent = mp3Versions;
|
|
}
|
|
|
|
// Display favorites
|
|
function displayFavorites() {
|
|
const startIndex = (currentPage - 1) * perPage;
|
|
const endIndex = startIndex + perPage;
|
|
const pageFavorites = filteredFavorites.slice(startIndex, endIndex);
|
|
|
|
const container = document.getElementById('favoritesList');
|
|
container.innerHTML = '';
|
|
|
|
pageFavorites.forEach(favorite => {
|
|
const card = createFavoriteCard(favorite);
|
|
container.appendChild(card);
|
|
});
|
|
|
|
updatePagination();
|
|
initializeSortable();
|
|
}
|
|
|
|
// Create favorite card
|
|
function createFavoriteCard(favorite) {
|
|
const card = document.createElement('div');
|
|
card.className = 'card favorite-card';
|
|
card.innerHTML = `
|
|
<div class="card-header">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-6">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-heart text-warning"></i>
|
|
<span class="editable-field" onclick="startEdit(${favorite.index}, 'artist')">${escapeHtml(favorite.artist)}</span> -
|
|
<span class="editable-field" onclick="startEdit(${favorite.index}, 'title')">${escapeHtml(favorite.title)}</span>
|
|
</h5>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<button class="btn btn-sm btn-danger me-2" onclick="deleteFavorite(${favorite.index})" title="Delete this favorite">
|
|
<i class="fas fa-trash"></i> Delete
|
|
</button>
|
|
<span class="badge bg-primary">${favorite.matching_songs.length} versions</span>
|
|
<span class="boolean-toggle ${favorite.favorite ? 'true' : 'false'}" onclick="toggleBoolean(${favorite.index}, 'favorite')">
|
|
${favorite.favorite ? 'Favorite' : 'Not Favorite'}
|
|
</span>
|
|
<span class="boolean-toggle ${favorite.disabled ? 'true' : 'false'}" onclick="toggleBoolean(${favorite.index}, 'disabled')">
|
|
${favorite.disabled ? 'Disabled' : 'Enabled'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h6>Properties:</h6>
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="mb-2">
|
|
<div class="property-label">Path:</div>
|
|
<div class="editable-field path-text" onclick="startEdit(${favorite.index}, 'path')">${escapeHtml(favorite.path)}</div>
|
|
</div>
|
|
${favorite.original_path ? `
|
|
<div class="mb-2">
|
|
<div class="property-label">Original Path:</div>
|
|
<div class="editable-field path-text" onclick="startEdit(${favorite.index}, 'original_path')">${escapeHtml(favorite.original_path)}</div>
|
|
</div>
|
|
` : ''}
|
|
${favorite.key ? `
|
|
<div class="mb-2">
|
|
<div class="property-label">Key:</div>
|
|
<div class="editable-field" onclick="startEdit(${favorite.index}, 'key')">${escapeHtml(favorite.key)}</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<div class="col-md-6">
|
|
${favorite.count ? `
|
|
<div class="mb-2">
|
|
<div class="property-label">Count:</div>
|
|
<div class="editable-field" onclick="startEdit(${favorite.index}, 'count')">${favorite.count}</div>
|
|
</div>
|
|
` : ''}
|
|
${favorite.genre ? `
|
|
<div class="mb-2">
|
|
<div class="property-label">Genre:</div>
|
|
<div class="editable-field" onclick="startEdit(${favorite.index}, 'genre')">${escapeHtml(favorite.genre)}</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<h6>Available Versions (drag to reorder):</h6>
|
|
<div class="sortable-list" data-favorite-index="${favorite.index}">
|
|
${favorite.matching_songs.map((song, songIndex) => `
|
|
<div class="card sortable-item ${song.is_current ? 'current-version' : 'alternative-version'} mb-2"
|
|
data-song-index="${songIndex}">
|
|
<div class="card-body py-2">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-1">
|
|
<i class="fas fa-grip-vertical text-muted"></i>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<span class="badge bg-${song.file_type === 'MP4' ? 'danger' : 'success'} file-type-badge">
|
|
${song.file_type}
|
|
</span>
|
|
${song.channel ? `<span class="badge bg-info channel-badge ms-1">${song.channel}</span>` : ''}
|
|
</div>
|
|
<div class="col-md-7">
|
|
<div class="path-text">${escapeHtml(song.path)}</div>
|
|
</div>
|
|
<div class="col-md-2 text-end">
|
|
${song.is_current ? '<span class="badge bg-success">Current</span>' : ''}
|
|
${song.file_type === 'MP4' ?
|
|
`<button class="btn btn-sm btn-outline-primary" onclick="playVideo('${song.path.replace(/\\/g, '\\\\')}')">
|
|
<i class="fas fa-play"></i> Play
|
|
</button>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return card;
|
|
}
|
|
|
|
// Initialize sortable for drag and drop
|
|
function initializeSortable() {
|
|
document.querySelectorAll('.sortable-list').forEach(list => {
|
|
new Sortable(list, {
|
|
animation: 150,
|
|
ghostClass: 'sortable-ghost',
|
|
chosenClass: 'sortable-chosen',
|
|
onEnd: function(evt) {
|
|
const favoriteIndex = parseInt(evt.target.dataset.favoriteIndex);
|
|
const newOrder = Array.from(evt.target.children).map((item, index) => ({
|
|
songIndex: parseInt(item.dataset.songIndex),
|
|
newIndex: index
|
|
}));
|
|
|
|
// Update the favorite's path to the first song in the list
|
|
const firstSong = newOrder[0];
|
|
const favorite = allFavorites.find(f => f.index === favoriteIndex);
|
|
if (favorite && favorite.matching_songs[firstSong.songIndex]) {
|
|
const newPath = favorite.matching_songs[firstSong.songIndex].path;
|
|
updateFavoritePath(favoriteIndex, newPath);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Update favorite path
|
|
async function updateFavoritePath(index, newPath) {
|
|
try {
|
|
const response = await fetch('/api/update-favorite-path', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ index, path: newPath })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
// Update local data
|
|
const favorite = allFavorites.find(f => f.index === index);
|
|
if (favorite) {
|
|
favorite.current_path = newPath;
|
|
// Update is_current flags
|
|
favorite.matching_songs.forEach(song => {
|
|
song.is_current = song.path === newPath;
|
|
});
|
|
}
|
|
showSuccess('Favorite path updated successfully');
|
|
} else {
|
|
throw new Error(data.error || 'Failed to update path');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating favorite path:', error);
|
|
showError('Failed to update favorite path: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Apply filters
|
|
function applyFilters() {
|
|
const artistFilter = document.getElementById('artistFilter').value.toLowerCase();
|
|
const titleFilter = document.getElementById('titleFilter').value.toLowerCase();
|
|
const fileTypeFilter = document.getElementById('fileTypeFilter').value.toLowerCase();
|
|
const channelFilter = document.getElementById('channelFilter').value.toLowerCase();
|
|
const minAlternatives = parseInt(document.getElementById('minAlternatives').value) || 0;
|
|
|
|
filteredFavorites = allFavorites.filter(favorite => {
|
|
// Artist filter
|
|
if (artistFilter && !favorite.artist.toLowerCase().includes(artistFilter)) {
|
|
return false;
|
|
}
|
|
|
|
// Title filter
|
|
if (titleFilter && !favorite.title.toLowerCase().includes(titleFilter)) {
|
|
return false;
|
|
}
|
|
|
|
// Min alternatives filter
|
|
if (favorite.matching_songs.length < minAlternatives) {
|
|
return false;
|
|
}
|
|
|
|
// File type and channel filters
|
|
if (fileTypeFilter || channelFilter) {
|
|
let hasMatchingSong = false;
|
|
|
|
// Special handling for MP3-only filter
|
|
if (fileTypeFilter === 'mp3-only') {
|
|
// Check if current favorite is MP3 and has no MP4 alternatives
|
|
const currentFileType = getFileType(favorite.current_path);
|
|
const hasMp4Alternative = favorite.matching_songs.some(song =>
|
|
song.file_type.toUpperCase() === 'MP4'
|
|
);
|
|
|
|
if (currentFileType.toUpperCase() === 'MP3' && !hasMp4Alternative) {
|
|
// Apply channel filter if specified
|
|
if (channelFilter) {
|
|
const currentChannel = extractChannel(favorite.current_path);
|
|
hasMatchingSong = currentChannel.toLowerCase().includes(channelFilter);
|
|
} else {
|
|
hasMatchingSong = true;
|
|
}
|
|
}
|
|
} else if (fileTypeFilter === 'mp3') {
|
|
// Special handling for MP3 filter - show songs where primary is MP3 and has alternatives
|
|
const currentFileType = getFileType(favorite.current_path);
|
|
|
|
// Only show if current favorite is MP3 and has alternatives
|
|
if (currentFileType.toUpperCase() === 'MP3' && favorite.matching_songs.length > 0) {
|
|
// Apply channel filter if specified
|
|
if (channelFilter) {
|
|
const currentChannel = extractChannel(favorite.current_path);
|
|
hasMatchingSong = currentChannel.toLowerCase().includes(channelFilter);
|
|
} else {
|
|
hasMatchingSong = true;
|
|
}
|
|
}
|
|
} else {
|
|
// Regular file type and channel filtering
|
|
hasMatchingSong = favorite.matching_songs.some(song => {
|
|
const matchesFileType = !fileTypeFilter || song.file_type.toLowerCase().includes(fileTypeFilter);
|
|
const matchesChannel = !channelFilter || (song.channel && song.channel.toLowerCase().includes(channelFilter));
|
|
return matchesFileType && matchesChannel;
|
|
});
|
|
}
|
|
|
|
if (!hasMatchingSong) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
currentPage = 1;
|
|
displayFavorites();
|
|
}
|
|
|
|
// Clear filters
|
|
function clearFilters() {
|
|
document.getElementById('artistFilter').value = '';
|
|
document.getElementById('titleFilter').value = '';
|
|
document.getElementById('fileTypeFilter').value = '';
|
|
document.getElementById('channelFilter').value = '';
|
|
document.getElementById('minAlternatives').value = '0';
|
|
|
|
filteredFavorites = [...allFavorites];
|
|
currentPage = 1;
|
|
displayFavorites();
|
|
}
|
|
|
|
// Update pagination
|
|
function updatePagination() {
|
|
const totalPages = Math.ceil(filteredFavorites.length / perPage);
|
|
const startItem = (currentPage - 1) * perPage + 1;
|
|
const endItem = Math.min(currentPage * perPage, filteredFavorites.length);
|
|
|
|
document.getElementById('paginationInfo').textContent =
|
|
`Showing ${startItem}-${endItem} of ${filteredFavorites.length} favorites`;
|
|
|
|
const pagination = document.getElementById('pagination');
|
|
pagination.innerHTML = '';
|
|
|
|
// Previous button
|
|
const prevLi = document.createElement('li');
|
|
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
|
|
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1})">Previous</a>`;
|
|
pagination.appendChild(prevLi);
|
|
|
|
// Page numbers
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
|
|
const li = document.createElement('li');
|
|
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
|
|
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i})">${i}</a>`;
|
|
pagination.appendChild(li);
|
|
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
|
const li = document.createElement('li');
|
|
li.className = 'page-item disabled';
|
|
li.innerHTML = '<span class="page-link">...</span>';
|
|
pagination.appendChild(li);
|
|
}
|
|
}
|
|
|
|
// Next button
|
|
const nextLi = document.createElement('li');
|
|
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
|
|
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1})">Next</a>`;
|
|
pagination.appendChild(nextLi);
|
|
}
|
|
|
|
// Change page
|
|
function changePage(page) {
|
|
const totalPages = Math.ceil(filteredFavorites.length / perPage);
|
|
if (page >= 1 && page <= totalPages) {
|
|
currentPage = page;
|
|
displayFavorites();
|
|
}
|
|
}
|
|
|
|
// Play video
|
|
function playVideo(path) {
|
|
const videoPlayer = document.getElementById('videoPlayer');
|
|
const modal = new bootstrap.Modal(document.getElementById('videoModal'));
|
|
|
|
videoPlayer.src = `/api/video/${encodeURIComponent(path)}`;
|
|
document.getElementById('videoModalTitle').textContent = `Video Preview: ${path.split('\\').pop()}`;
|
|
|
|
modal.show();
|
|
}
|
|
|
|
// Utility functions
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showLoading() {
|
|
document.getElementById('loading').style.display = 'block';
|
|
document.getElementById('favoritesList').style.display = 'none';
|
|
}
|
|
|
|
function hideLoading() {
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('favoritesList').style.display = 'block';
|
|
}
|
|
|
|
function showError(message) {
|
|
alert('Error: ' + message);
|
|
}
|
|
|
|
function showSuccess(message) {
|
|
// You could implement a toast notification here
|
|
console.log('Success: ' + message);
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
showLoading();
|
|
loadFavorites();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |