diff --git a/podcasts/migrations/0002_podcastfeed_auto_queue.py b/podcasts/migrations/0002_podcastfeed_auto_queue.py new file mode 100644 index 0000000..cd2fa7d --- /dev/null +++ b/podcasts/migrations/0002_podcastfeed_auto_queue.py @@ -0,0 +1,12 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('podcasts', '0001_initial')] + operations = [ + migrations.AddField( + model_name='podcastfeed', + name='auto_queue', + field=models.BooleanField(default=False), + ), + ] diff --git a/podcasts/migrations/0003_episodeprogress_dismissed.py b/podcasts/migrations/0003_episodeprogress_dismissed.py new file mode 100644 index 0000000..c80f9ad --- /dev/null +++ b/podcasts/migrations/0003_episodeprogress_dismissed.py @@ -0,0 +1,12 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('podcasts', '0002_podcastfeed_auto_queue')] + operations = [ + migrations.AddField( + model_name='episodeprogress', + name='dismissed', + field=models.BooleanField(default=False), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 74b72dc..e12a3af 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -12,6 +12,7 @@ class PodcastFeed(models.Model): link = models.URLField(max_length=1000, blank=True) last_refreshed_at = models.DateTimeField(null=True, blank=True) added_at = models.DateTimeField(auto_now_add=True) + auto_queue = models.BooleanField(default=False) class Meta: unique_together = ('user', 'rss_url') @@ -47,6 +48,7 @@ class EpisodeProgress(models.Model): episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='progress') position_seconds = models.IntegerField(default=0) played = models.BooleanField(default=False) + dismissed = models.BooleanField(default=False) updated_at = models.DateTimeField(auto_now=True) class Meta: diff --git a/podcasts/urls.py b/podcasts/urls.py index 62fb482..4d7db04 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path('feeds/import/', views.import_opml, name='podcast_import_opml'), path('feeds/refresh/', views.refresh_feed_now, name='podcast_refresh_feed'), path('feeds//remove/', views.remove_feed, name='podcast_remove_feed'), + path('feeds//set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'), path('feeds//episodes/', views.feed_episodes, name='podcast_feed_episodes'), path('queue/', views.queue_get, name='podcast_queue_get'), path('queue/add/', views.queue_add, name='podcast_queue_add'), @@ -15,5 +16,6 @@ urlpatterns = [ path('queue/reorder/', views.queue_reorder, name='podcast_queue_reorder'), path('progress/save/', views.save_progress, name='podcast_save_progress'), path('progress/mark-played/', views.mark_played, name='podcast_mark_played'), + path('progress/dismiss/', views.dismiss_episodes, name='podcast_dismiss_episodes'), path('inbox/', views.inbox, name='podcast_inbox'), ] diff --git a/podcasts/views.py b/podcasts/views.py index 7f99f54..08b14ae 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -5,7 +5,7 @@ import xml.etree.ElementTree as ET import feedparser import requests from django.conf import settings -from django.db.models import Count, Q +from django.db.models import Count, F, Q from django.http import JsonResponse from django.utils import timezone from django.views.decorators.csrf import csrf_exempt @@ -63,6 +63,7 @@ def _refresh_feed(feed_obj): max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200) new_count = 0 + new_episodes_list = [] for entry in parsed.entries[:max_ep]: # Find audio enclosure @@ -103,7 +104,7 @@ def _refresh_feed(feed_obj): artwork_url = entry.get('itunes_image', {}).get('href', '')[:1000] - _, created = PodcastEpisode.objects.get_or_create( + episode_obj, created = PodcastEpisode.objects.get_or_create( feed=feed_obj, guid=guid, defaults={ @@ -119,6 +120,16 @@ def _refresh_feed(feed_obj): ) if created: new_count += 1 + new_episodes_list.append(episode_obj) + + if feed_obj.auto_queue and new_episodes_list: + count = len(new_episodes_list) + PodcastQueue.objects.filter(user=feed_obj.user).update(position=F('position') + count) + for idx, ep_obj in enumerate(new_episodes_list): + PodcastQueue.objects.get_or_create( + user=feed_obj.user, episode=ep_obj, + defaults={'position': idx}, + ) return new_count @@ -165,12 +176,14 @@ def feed_list(request): feeds = list( request.user.podcast_feeds - .values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author') + .values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue') ) for f in feeds: if f['last_refreshed_at']: f['last_refreshed_at'] = f['last_refreshed_at'].isoformat() + if f['added_at']: + f['added_at'] = f['added_at'].isoformat() return JsonResponse({'feeds': feeds}) @@ -236,6 +249,28 @@ def remove_feed(request, pk): return JsonResponse({'error': 'not found'}, status=404) +# --------------------------------------------------------------------------- +# Set auto-queue +# --------------------------------------------------------------------------- + +@csrf_exempt +@require_http_methods(['POST']) +def set_auto_queue(request, pk): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + try: + feed = PodcastFeed.objects.get(pk=pk, user=request.user) + except PodcastFeed.DoesNotExist: + return JsonResponse({'error': 'not found'}, status=404) + feed.auto_queue = bool(body.get('auto_queue', not feed.auto_queue)) + feed.save(update_fields=['auto_queue']) + return JsonResponse({'ok': True, 'auto_queue': feed.auto_queue}) + + # --------------------------------------------------------------------------- # Feed episodes # --------------------------------------------------------------------------- @@ -288,6 +323,7 @@ def feed_episodes(request, pk): 'title': feed.title, 'artwork_url': feed.artwork_url, 'author': feed.author, + 'auto_queue': feed.auto_queue, }, 'episodes': episodes, }) @@ -386,6 +422,16 @@ def queue_get(request): ) ) + ep_ids = [item['episode__id'] for item in items] + progress_map = { + p['episode_id']: p['position_seconds'] + for p in EpisodeProgress.objects.filter( + user=request.user, episode_id__in=ep_ids, + ).values('episode_id', 'position_seconds') + } + for item in items: + item['position_seconds'] = progress_map.get(item['episode__id'], 0) + return JsonResponse({'queue': items}) @@ -515,6 +561,45 @@ def mark_played(request): return JsonResponse({'ok': True, 'played': played}) +# --------------------------------------------------------------------------- +# Dismiss episodes (hide from inbox without marking played) +# --------------------------------------------------------------------------- + +@csrf_exempt +@require_http_methods(['POST']) +def dismiss_episodes(request): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + episode_ids = body.get('episode_ids', []) + dismissed = bool(body.get('dismissed', True)) + + if not isinstance(episode_ids, list) or not episode_ids: + return JsonResponse({'error': 'episode_ids required'}, status=400) + + # Only allow dismissing episodes that belong to this user + valid_ids = list( + PodcastEpisode.objects.filter( + id__in=episode_ids, feed__user=request.user + ).values_list('id', flat=True) + ) + + for ep_id in valid_ids: + progress, _ = EpisodeProgress.objects.get_or_create( + user=request.user, + episode_id=ep_id, + ) + progress.dismissed = dismissed + progress.save(update_fields=['dismissed', 'updated_at']) + + return JsonResponse({'ok': True, 'count': len(valid_ids)}) + + # --------------------------------------------------------------------------- # Inbox # --------------------------------------------------------------------------- @@ -524,27 +609,47 @@ def inbox(request): if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) - played_ids = set( + hidden_ids = set( EpisodeProgress.objects.filter( user=request.user, - played=True, + ).filter( + Q(played=True) | Q(dismissed=True) ).values_list('episode_id', flat=True) ) + limit = min(int(request.GET.get('limit', 200)), 1000) + offset = max(int(request.GET.get('offset', 0)), 0) + episodes = list( PodcastEpisode.objects.filter(feed__user=request.user) - .exclude(id__in=played_ids) + .exclude(id__in=hidden_ids) .select_related('feed') .order_by('-pub_date') .values( 'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url', 'feed__id', 'feed__title', 'feed__artwork_url', - )[:100] + )[offset:offset + limit] ) + progress_map = { + ep['episode_id']: ep + for ep in EpisodeProgress.objects.filter( + user=request.user, + episode_id__in=[e['id'] for e in episodes], + ).values('episode_id', 'position_seconds') + } + queued_ids = set( + PodcastQueue.objects.filter( + user=request.user, + episode_id__in=[e['id'] for e in episodes], + ).values_list('episode_id', flat=True) + ) for ep in episodes: if ep['pub_date']: ep['pub_date'] = ep['pub_date'].isoformat() + prog = progress_map.get(ep['id'], {}) + ep['position_seconds'] = prog.get('position_seconds', 0) + ep['in_queue'] = ep['id'] in queued_ids - return JsonResponse({'episodes': episodes}) + return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit}) diff --git a/static/css/app.css b/static/css/app.css index 960d515..9ba406e 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1030,6 +1030,7 @@ body.dnd-mode .timer-display { padding: 0 12px; flex: 1; min-width: 0; + position: relative; } .skip-btn { @@ -1233,6 +1234,7 @@ body.dnd-mode .timer-display { align-items: center; gap: 14px; } +.feed-refresh-btn { margin-left: auto; flex-shrink: 0; } /* Clickable episode title (episode list) */ .ep-clickable { @@ -1323,6 +1325,88 @@ body.dnd-mode .timer-display { margin: 12px 0 4px; } +/* Inbox toolbar + checkboxes */ +.inbox-toolbar { + display: flex; align-items: center; gap: 8px; + padding: 0 0 8px; flex-wrap: wrap; min-height: 32px; +} +.inbox-select-all-label { + display: flex; align-items: center; gap: 4px; + font-size: 0.82rem; color: var(--text-muted, #888); cursor: pointer; + white-space: nowrap; +} +.inbox-bulk-actions { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; +} +.inbox-load-more { + display: flex; align-items: center; gap: 6px; margin-left: auto; +} +.inbox-checkbox-label { + display: flex; align-items: center; flex-shrink: 0; + padding-right: 4px; cursor: pointer; +} +.inbox-checkbox-label input[type="checkbox"] { + width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent, #e63946); +} +.episode-item.inbox-selected { background: var(--surface-alt, #2a2a3e); } + +/* Sleep timer */ +.sleep-timer-btn { + font-size: 0.68rem; padding: 1px 5px; white-space: nowrap; + border: 1px solid var(--border, #444); border-radius: 3px; opacity: 0.7; +} +.sleep-timer-btn:hover { opacity: 1; } +.sleep-timer-menu { + position: absolute; background: var(--surface, #1e1e2e); + border: 1px solid var(--border, #444); border-radius: 6px; padding: 4px; + display: flex; flex-direction: column; gap: 2px; z-index: 200; + bottom: calc(100% + 4px); right: 0; +} +.sleep-timer-option { + background: none; border: none; color: var(--fg, #fff); + padding: 5px 12px; text-align: left; cursor: pointer; + font-size: 0.85rem; border-radius: 4px; +} +.sleep-timer-option:hover { background: var(--accent, #e63946); color: #fff; } + +/* Episode filter */ +.episode-search-bar { padding: 6px 0 8px; } +.episode-search-bar .search-input { width: 100%; box-sizing: border-box; } + +/* Episode meta line */ +.episode-meta { + display: flex; gap: 8px; font-size: 0.78rem; + color: var(--text-muted, #888); margin-top: 2px; flex-wrap: wrap; +} +.episode-date { font-weight: 500; color: var(--fg, #ccc); } +.episode-dur { color: var(--text-muted, #888); } + +/* Episode progress bar */ +.episode-progress-bar { + height: 3px; background: var(--border, #333); + border-radius: 2px; margin-top: 5px; overflow: hidden; +} +.episode-progress-fill { + height: 100%; background: var(--accent, #e63946); border-radius: 2px; +} + +/* Queue drag-and-drop */ +.drag-handle { cursor: grab; color: var(--text-muted, #666); padding: 0 6px; user-select: none; } +.drag-handle:active { cursor: grabbing; } +#podcast-queue-ol li.dragging { opacity: 0.4; border: 1px dashed var(--accent, #e63946); } + +/* Feed list toolbar */ +.feed-list-toolbar { + display: flex; gap: 8px; align-items: center; + padding: 0 0 10px; flex-wrap: wrap; +} +.feed-list-toolbar .search-input { flex: 1; min-width: 140px; } +.feed-sort-select { + background: var(--surface, #1e1e2e); color: var(--fg, #fff); + border: 1px solid var(--border, #444); border-radius: 4px; + padding: 4px 6px; font-size: 0.82rem; cursor: pointer; +} + @media (max-width: 600px) { .sidebar { width: 100vw; } .podcast-seek-bar { padding: 0 6px; } diff --git a/static/js/app.js b/static/js/app.js index 43464dd..8e0c96e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -25,6 +25,12 @@ let podcastCurrentView = 'feeds'; let podcastCurrentFeedId = null; const podcastEpCache = {}; // id → episode data, avoids encoding strings in onclick attrs +let sleepTimerInterval = null; +let sleepTimerEndSecs = 0; +let sleepTimerEndOfEp = false; +let _dragSrcEl = null; +let feedSortOrder = 'alpha'; + const audio = new Audio(); // --------------------------------------------------------------------------- @@ -82,6 +88,18 @@ function playStation(url, name, stationId) { startMetadataSSE(url); startPlaySession(name, url); maybeShowDonationHint(url, name); + + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'}); + navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; }); + navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; }); + navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true)); + try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {} + try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {} + try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {} + try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {} + navigator.mediaSession.playbackState = 'playing'; + } } function stopPlayback(clearStation = true) { @@ -94,8 +112,10 @@ function stopPlayback(clearStation = true) { audio.pause(); audio.src = ''; audio.ontimeupdate = null; + audio.onended = null; isPlaying = false; podcastMode = false; + if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none'; const seekBar = $('podcast-seek-bar'); if (seekBar) seekBar.style.display = 'none'; @@ -1146,11 +1166,52 @@ function renderFeedList() {
+
`; container.appendChild(div); }); + + const filterVal = ($('feed-filter-input') || {}).value || ''; + if (filterVal) filterFeeds(filterVal); +} + +function filterFeeds(query) { + const container = $('podcast-feed-list'); + if (!container) return; + const q = query.toLowerCase().trim(); + container.querySelectorAll('.podcast-feed-item').forEach(item => { + const title = (item.querySelector('.podcast-feed-title') || {}).textContent?.toLowerCase() || ''; + const author = (item.querySelector('.muted') || {}).textContent?.toLowerCase() || ''; + item.style.display = (!q || title.includes(q) || author.includes(q)) ? '' : 'none'; + }); +} + +function sortFeeds(order) { + feedSortOrder = order; + if (order === 'alpha') podcastFeeds.sort((a, b) => a.title.localeCompare(b.title)); + if (order === 'alpha-desc') podcastFeeds.sort((a, b) => b.title.localeCompare(a.title)); + if (order === 'added') podcastFeeds.sort((a, b) => (b.added_at || '').localeCompare(a.added_at || '')); + if (order === 'refreshed') podcastFeeds.sort((a, b) => (b.last_refreshed_at || '').localeCompare(a.last_refreshed_at || '')); + renderFeedList(); +} + +async function toggleFeedAutoQueue(feedId, btn) { + try { + const res = await fetch(`/podcasts/feeds/${feedId}/set-auto-queue/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({}), + }); + const data = await res.json(); + if (data.ok) { + const feed = podcastFeeds.find(f => f.id === feedId); + if (feed) feed.auto_queue = data.auto_queue; + btn.classList.toggle('active', data.auto_queue); + btn.title = data.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes'; + } + } catch (e) {} } async function openFeed(feedId) { @@ -1175,16 +1236,29 @@ async function openFeed(feedId) {
${escapeHtml(feed.title)}
${feed.author ? `
${escapeHtml(feed.author)}
` : ''} + `; } renderEpisodeList(episodes, feedId, listEl); + const filterBar = $('episode-search-bar'); + if (filterBar) { filterBar.style.display = ''; $('episode-filter-input').value = ''; } } catch (e) { if (headerEl) headerEl.innerHTML = '

Failed to load episodes.

'; } } +function filterEpisodes(query) { + const listEl = $('podcast-episode-list'); + if (!listEl) return; + const q = query.toLowerCase().trim(); + listEl.querySelectorAll('.episode-item').forEach(item => { + const text = (item.querySelector('.episode-title') || {}).textContent?.toLowerCase() || ''; + item.style.display = (!q || text.includes(q)) ? '' : 'none'; + }); +} + function renderEpisodeList(episodes, feedId, container) { if (!container) return; if (!episodes.length) { @@ -1214,16 +1288,24 @@ function renderEpisodeList(episodes, feedId, container) { const artSrc = ep.artwork_url || (feedId ? (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '' : ''); const dur = formatDuration(ep.duration_seconds); const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : ''; - const posStr = ep.position_seconds > 0 ? ` · ${formatDuration(ep.position_seconds)} played` : ''; + const posStr = ep.position_seconds > 0 ? formatDuration(ep.position_seconds) + ' played' : ''; + const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0) + ? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0; div.innerHTML = ` ${artSrc ? `` : '
'}
${escapeHtml(ep.title)}
-
${escapeHtml(dateStr)} · ${escapeHtml(dur)}${escapeHtml(posStr)}
+
+ ${dateStr ? `${escapeHtml(dateStr)}` : ''} + ${dur !== '0:00' ? `${escapeHtml(dur)}` : ''} + ${posStr ? `${escapeHtml(posStr)}` : ''} +
+ ${progressPct > 0 ? `
` : ''}
+ @@ -1246,6 +1328,23 @@ function downloadEpisodeById(id, btn) { } function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) { + // Auto-enqueue if not already in queue + const inQueue = podcastQueue.some(q => q['episode__id'] === id); + if (!inQueue) { + fetch('/podcasts/queue/add/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_id: id}), + }).then(() => { + // update local queue state and any visible +Q buttons + if (podcastCurrentView === 'queue') loadAndRenderQueue(); + const qBtn = document.querySelector(`#episode-item-${id} .episode-actions .btn-sm:nth-child(3)`); + if (qBtn && (qBtn.textContent === '+Q' || qBtn.textContent.includes('Q'))) { + qBtn.textContent = '✓Q'; qBtn.title = 'In queue'; + } + }).catch(() => {}); + } + stopPlayback(false); podcastMode = true; @@ -1289,17 +1388,26 @@ function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) { setPlaybackRate(1); audio.ontimeupdate = podcastTimeUpdate; + audio.onended = podcastOnEnded; - // Media Session API — maps hardware media keys & lock-screen controls + // Media Session API — maps hardware media keys, lock-screen controls, and + // Windows taskbar thumbnail buttons (play/pause, previous, next) if ('mediaSession' in navigator) { + const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || ''; + const artSrc = (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || ''; navigator.mediaSession.metadata = new MediaMetadata({ - title: title, - artist: (podcastFeeds.find(f => f.id === feedId) || {}).title || '', + title, + artist: feedTitle, + artwork: artSrc ? [{src: artSrc, sizes: '512x512', type: 'image/jpeg'}] : [], }); + navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; }); + navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; }); + navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true)); navigator.mediaSession.setActionHandler('seekbackward', () => skipBack()); navigator.mediaSession.setActionHandler('seekforward', () => skipForward()); - navigator.mediaSession.setActionHandler('play', () => { audio.play(); }); - navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); }); + navigator.mediaSession.setActionHandler('nexttrack', () => podcastOnEnded()); + try { navigator.mediaSession.setActionHandler('previoustrack', () => skipBack()); } catch (_) {} + navigator.mediaSession.playbackState = 'playing'; } if (seekSaveTimer) clearInterval(seekSaveTimer); @@ -1347,10 +1455,100 @@ function setPlaybackRate(rate) { btn.classList.toggle('active', parseFloat(btn.textContent) === rate || (rate === 0.75 && btn.textContent.startsWith('¾')) || (rate === 1.25 && btn.textContent.startsWith('1¼')) - || (rate === 1.5 && btn.textContent.startsWith('1½'))); + || (rate === 1.5 && btn.textContent.startsWith('1½')) + || (rate === 1.75 && btn.textContent.startsWith('1¾')) + || (rate === 2.5 && btn.textContent.startsWith('2½'))); }); } +async function podcastOnEnded() { + if (!podcastMode || !currentEpisode) return; + + await fetch('/podcasts/progress/mark-played/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_id: currentEpisode.id, played: true}), + }).catch(() => {}); + + if (sleepTimerEndOfEp) { clearSleepTimer(); audio.pause(); return; } + + const finishedId = currentEpisode.id; + try { + const res = await fetch('/podcasts/queue/'); + const data = await res.json(); + const items = data.queue || []; + const currentIdx = items.findIndex(item => item['episode__id'] === finishedId); + const nextItem = currentIdx >= 0 ? items[currentIdx + 1] : null; + + await fetch('/podcasts/queue/remove/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_id: finishedId}), + }).catch(() => {}); + + if (nextItem) { + const nextEpId = nextItem['episode__id']; + const cached = podcastEpCache[nextEpId] || {}; + playEpisode(nextEpId, nextItem['episode__title'], nextItem['episode__audio_url'], + nextItem['episode__duration_seconds'], cached.positionSeconds || 0, nextItem['episode__feed__id']); + } else { + stopPlayback(false); + } + if (podcastCurrentView === 'queue') loadAndRenderQueue(); + } catch (e) { + stopPlayback(false); + } +} + +function openSleepTimerMenu() { + const existing = document.getElementById('sleep-timer-menu'); + if (existing) { existing.remove(); return; } + const options = [ + {label: 'Off', value: 0}, {label: '5m', value: 5}, {label: '10m', value: 10}, + {label: '15m', value: 15}, {label: '30m', value: 30}, {label: '45m', value: 45}, + {label: '60m', value: 60}, {label: 'End of episode', value: -1}, + ]; + const menu = document.createElement('div'); + menu.id = 'sleep-timer-menu'; + menu.className = 'sleep-timer-menu'; + options.forEach(opt => { + const btn = document.createElement('button'); + btn.className = 'sleep-timer-option'; + btn.textContent = opt.label; + btn.onclick = () => { setSleepTimer(opt.value); menu.remove(); }; + menu.appendChild(btn); + }); + document.getElementById('sleep-timer-btn').insertAdjacentElement('afterend', menu); +} + +function setSleepTimer(minutes) { + clearSleepTimer(); + const btn = document.getElementById('sleep-timer-btn'); + if (minutes === 0) { if (btn) btn.textContent = 'Sleep'; return; } + if (minutes === -1) { + sleepTimerEndOfEp = true; + if (btn) btn.textContent = 'Sleep:EoE'; + return; + } + sleepTimerEndOfEp = false; + sleepTimerEndSecs = Math.floor(Date.now() / 1000) + minutes * 60; + sleepTimerInterval = setInterval(() => { + const remaining = sleepTimerEndSecs - Math.floor(Date.now() / 1000); + if (remaining <= 0) { clearSleepTimer(); audio.pause(); isPlaying = false; return; } + const m = Math.floor(remaining / 60); + const s = remaining % 60; + if (btn) btn.textContent = `${m}:${String(s).padStart(2, '0')}`; + }, 1000); +} + +function clearSleepTimer() { + if (sleepTimerInterval) { clearInterval(sleepTimerInterval); sleepTimerInterval = null; } + sleepTimerEndOfEp = false; + sleepTimerEndSecs = 0; + const btn = document.getElementById('sleep-timer-btn'); + if (btn) btn.textContent = 'Sleep'; +} + async function savePodcastProgress() { if (!currentEpisode) return; const pos = Math.floor(audio.currentTime); @@ -1363,19 +1561,29 @@ async function savePodcastProgress() { } catch (e) {} } -async function loadAndRenderInbox() { +let _inboxOffset = 0; +const _inboxPageSize = 200; + +async function loadAndRenderInbox(append = false) { const listEl = $('podcast-inbox-list'); if (!listEl) return; - listEl.innerHTML = '

Loading…

'; + + if (!append) { + _inboxOffset = 0; + listEl.innerHTML = '

Loading…

'; + inboxUpdateBulkBar(); + } try { - const res = await fetch('/podcasts/inbox/'); + const res = await fetch(`/podcasts/inbox/?limit=${_inboxPageSize}&offset=${_inboxOffset}`); const data = await res.json(); const episodes = data.episodes || []; - listEl.innerHTML = ''; - if (!episodes.length) { + if (!append) listEl.innerHTML = ''; + + if (!episodes.length && !append) { listEl.innerHTML = '

Inbox empty — all caught up!

'; + $('inbox-load-more-bar').style.display = 'none'; return; } @@ -1385,29 +1593,181 @@ async function loadAndRenderInbox() { title: ep.title, audioUrl: ep.audio_url, durationSeconds: ep.duration_seconds, - positionSeconds: 0, + positionSeconds: ep.position_seconds || 0, feedId: ep['feed__id'], played: false, }; + const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0) + ? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0; + const dur = formatDuration(ep.duration_seconds); + const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : ''; + const inQueue = ep.in_queue; + const div = document.createElement('div'); div.className = 'episode-item'; + div.dataset.epId = ep.id; div.innerHTML = ` + ${ep['feed__artwork_url'] ? `` : '
'}
${escapeHtml(ep.title)}
-
${escapeHtml(ep['feed__title'])} · ${ep.pub_date ? ep.pub_date.slice(0,10) : ''} · ${formatDuration(ep.duration_seconds)}
+
+ ${escapeHtml(ep['feed__title'])} + ${dateStr ? `${escapeHtml(dateStr)}` : ''} + ${dur !== '0:00' ? `${escapeHtml(dur)}` : ''} +
+ ${progressPct > 0 ? `
` : ''}
- + +
`; listEl.appendChild(div); }); + + _inboxOffset += episodes.length; + const moreBar = $('inbox-load-more-bar'); + const countLabel = $('inbox-count-label'); + if (episodes.length === _inboxPageSize) { + moreBar.style.display = ''; + if (countLabel) countLabel.textContent = `${_inboxOffset} loaded`; + } else { + moreBar.style.display = 'none'; + if (countLabel) countLabel.textContent = ''; + } } catch (e) { - listEl.innerHTML = '

Failed to load inbox.

'; + if (!append) listEl.innerHTML = '

Failed to load inbox.

'; + } +} + +function inboxLoadMore() { + loadAndRenderInbox(true); +} + +function inboxGetSelectedIds() { + return Array.from(document.querySelectorAll('.inbox-cb:checked')) + .map(cb => parseInt(cb.dataset.epId, 10)); +} + +function inboxOnCheck() { + inboxUpdateBulkBar(); + // sync select-all state + const all = document.querySelectorAll('.inbox-cb'); + const checked = document.querySelectorAll('.inbox-cb:checked'); + const selectAll = $('inbox-select-all'); + if (selectAll) { + selectAll.indeterminate = checked.length > 0 && checked.length < all.length; + selectAll.checked = all.length > 0 && checked.length === all.length; + } +} + +function inboxSelectAll(checked) { + document.querySelectorAll('.inbox-cb').forEach(cb => { cb.checked = checked; }); + inboxUpdateBulkBar(); +} + +function inboxUpdateBulkBar() { + const ids = inboxGetSelectedIds(); + const bar = $('inbox-bulk-actions'); + const countEl = $('inbox-selection-count'); + if (!bar) return; + if (ids.length > 0) { + bar.style.display = ''; + if (countEl) countEl.textContent = `${ids.length} selected`; + } else { + bar.style.display = 'none'; + } +} + +async function inboxBulkDismiss() { + const ids = inboxGetSelectedIds(); + if (!ids.length) return; + try { + await fetch('/podcasts/progress/dismiss/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_ids: ids, dismissed: true}), + }); + ids.forEach(id => { + const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`); + if (div) div.remove(); + }); + inboxUpdateBulkBar(); + const selectAll = $('inbox-select-all'); + if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } + if (!document.querySelector('.inbox-cb')) { + const listEl = $('podcast-inbox-list'); + if (listEl && !listEl.querySelector('.episode-item')) { + listEl.innerHTML = '

Inbox empty — all caught up!

'; + } + } + } catch (e) {} +} + +async function inboxDismissOne(epId, btn) { + try { + await fetch('/podcasts/progress/dismiss/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_ids: [epId], dismissed: true}), + }); + const div = document.querySelector(`.episode-item[data-ep-id="${epId}"]`); + if (div) div.remove(); + if (!document.querySelector('.inbox-cb')) { + const listEl = $('podcast-inbox-list'); + if (listEl && !listEl.querySelector('.episode-item')) { + listEl.innerHTML = '

Inbox empty — all caught up!

'; + } + } + } catch (e) {} +} + +async function inboxBulkQueueAdd() { + const ids = inboxGetSelectedIds(); + for (const id of ids) { + await queueAddEpisode(id); + } + // Update queue button states + ids.forEach(id => { + const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`); + if (div) { + const qBtn = div.querySelector('.episode-actions .btn-sm:nth-child(2)'); + if (qBtn) { qBtn.textContent = '✓Q'; qBtn.title = 'In queue'; } + } + }); +} + +async function inboxBulkMarkPlayed() { + const ids = inboxGetSelectedIds(); + try { + for (const id of ids) { + await fetch('/podcasts/progress/mark-played/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_id: id, played: true}), + }); + } + ids.forEach(id => { + const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`); + if (div) div.remove(); + }); + inboxUpdateBulkBar(); + const selectAll = $('inbox-select-all'); + if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } + } catch (e) {} +} + +async function inboxBulkDownload() { + const ids = inboxGetSelectedIds(); + for (const id of ids) { + const ep = podcastEpCache[id]; + if (ep) await downloadEpisode(ep.audioUrl, ep.title, null); } } @@ -1435,17 +1795,28 @@ async function loadAndRenderQueue() { title: item['episode__title'], audioUrl: item['episode__audio_url'], durationSeconds: item['episode__duration_seconds'], - positionSeconds: 0, + positionSeconds: item['position_seconds'] || 0, feedId: item['episode__feed__id'], played: false, }; + const progressPct = (item['episode__duration_seconds'] > 0 && item['position_seconds'] > 0) + ? Math.min(100, (item['position_seconds'] / item['episode__duration_seconds']) * 100) : 0; + const dur = formatDuration(item['episode__duration_seconds']); + const li = document.createElement('li'); li.className = 'episode-item'; + li.draggable = true; + li.dataset.epId = epId; li.innerHTML = ` +
${escapeHtml(item['episode__title'])}
-
${escapeHtml(item['episode__feed__title'])} · ${formatDuration(item['episode__duration_seconds'])}
+
+ ${escapeHtml(item['episode__feed__title'])} + ${dur !== '0:00' ? `${escapeHtml(dur)}` : ''} +
+ ${progressPct > 0 ? `
` : ''}
@@ -1453,6 +1824,10 @@ async function loadAndRenderQueue() {
`; + li.addEventListener('dragstart', queueDragStart); + li.addEventListener('dragover', queueDragOver); + li.addEventListener('drop', queueDrop); + li.addEventListener('dragend', queueDragEnd); ol.appendChild(li); }); } catch (e) { @@ -1460,6 +1835,36 @@ async function loadAndRenderQueue() { } } +function queueDragStart(e) { + _dragSrcEl = this; + e.dataTransfer.effectAllowed = 'move'; + this.classList.add('dragging'); +} + +function queueDragOver(e) { + e.preventDefault(); + const ol = document.getElementById('podcast-queue-ol'); + const dragging = ol.querySelector('.dragging'); + if (!dragging || dragging === this) return; + const rect = this.getBoundingClientRect(); + ol.insertBefore(dragging, e.clientY < rect.top + rect.height / 2 ? this : this.nextSibling); +} + +function queueDrop(e) { e.preventDefault(); } + +function queueDragEnd() { + this.classList.remove('dragging'); + _dragSrcEl = null; + const ol = document.getElementById('podcast-queue-ol'); + const newOrder = Array.from(ol.querySelectorAll('li[data-ep-id]')) + .map(li => parseInt(li.dataset.epId, 10)); + fetch('/podcasts/queue/reorder/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({order: newOrder}), + }).catch(() => {}); +} + async function queueAddEpisode(id) { try { await fetch('/podcasts/queue/add/', { @@ -1514,6 +1919,43 @@ async function refreshFeed(feedId) { } catch (e) {} } +async function refreshOpenFeed(btn) { + if (!podcastCurrentFeedId) return; + if (btn) { btn.disabled = true; btn.textContent = '↻ …'; } + try { + const res = await fetch('/podcasts/feeds/refresh/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({feed_id: podcastCurrentFeedId}), + }); + const data = await res.json(); + if (data.ok) { + await openFeed(podcastCurrentFeedId); + // openFeed re-renders the header, btn reference is stale — nothing to restore + return; + } + } catch (e) {} + if (btn) { btn.disabled = false; btn.textContent = '↻ Refresh'; } +} + +async function refreshAllFeeds() { + if (!IS_AUTHENTICATED || !podcastFeeds.length) return; + for (const feed of podcastFeeds) { + try { + await fetch('/podcasts/feeds/refresh/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({feed_id: feed.id}), + }); + } catch (e) {} + } + // Reload feed metadata and refresh the currently open view + await loadFeedList(); + if (podcastCurrentView === 'feeds') renderFeedList(); + if (podcastCurrentView === 'inbox') loadAndRenderInbox(); + if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId); +} + async function removeFeed(feedId) { if (!confirm('Remove this podcast?')) return; try { @@ -3942,4 +4384,9 @@ async function saveFocusStation(url, name) { const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved'; showTab(savedTab); showRadioTab(savedRadioTab); + + // Hourly background feed refresh (only when authenticated) + if (IS_AUTHENTICATED) { + setInterval(refreshAllFeeds, 60 * 60 * 1000); + } })(); diff --git a/templates/radio/player.html b/templates/radio/player.html index 403d7ef..ccab3e7 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -31,8 +31,11 @@ + +
+
focus @@ -246,6 +249,16 @@
+
+ + +

Loading…

@@ -253,11 +266,32 @@