Add podcast enhancements: AntennaPod parity features + inbox management
- Auto-play next episode from queue when current episode ends
- Sleep timer (N minutes or end-of-episode) with countdown in button
- In-feed episode filter (client-side search)
- Auto-queue new episodes per feed (⚡Q toggle, inserts at top of queue)
- More playback speeds: 1¾× and 2½× added
- Progress bars + structured meta line in all episode list views (feed, inbox, queue)
- Queue drag-and-drop reorder
- Feed list search filter and sort options (A–Z, Z–A, recently added/refreshed)
- DB migration: PodcastFeed.auto_queue, EpisodeProgress.dismissed
- Inbox: dismiss episodes without marking played, checkboxes for multi-select,
bulk actions (add to queue, mark played, download, dismiss), load-more pagination
- Refresh button in single feed view header
- Hourly background refresh of all subscribed feeds
- Full Media Session API for radio and podcast: Windows taskbar thumbnail buttons
(play/pause/stop/next/seek) now work correctly for both modes
- Playing an episode auto-adds it to the queue if not already there
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fe5dd3e58a
commit
92801c9bbf
8 changed files with 725 additions and 27 deletions
12
podcasts/migrations/0002_podcastfeed_auto_queue.py
Normal file
12
podcasts/migrations/0002_podcastfeed_auto_queue.py
Normal file
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
12
podcasts/migrations/0003_episodeprogress_dismissed.py
Normal file
12
podcasts/migrations/0003_episodeprogress_dismissed.py
Normal file
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
|
||||
path('feeds/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
|
||||
path('feeds/<int:pk>/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'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
483
static/js/app.js
483
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() {
|
|||
<div class="podcast-feed-actions">
|
||||
<button class="btn btn-sm" onclick="openFeed(${feed.id})">Episodes</button>
|
||||
<button class="btn btn-sm" onclick="refreshFeed(${feed.id})" title="Refresh feed">↻</button>
|
||||
<button class="btn btn-sm ${feed.auto_queue ? 'active' : ''}" onclick="toggleFeedAutoQueue(${feed.id}, this)" title="${feed.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes'}">⚡Q</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="removeFeed(${feed.id})">Remove</button>
|
||||
</div>
|
||||
`;
|
||||
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) {
|
|||
<div class="podcast-feed-title">${escapeHtml(feed.title)}</div>
|
||||
${feed.author ? `<div class="muted">${escapeHtml(feed.author)}</div>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-sm feed-refresh-btn" id="feed-refresh-btn" onclick="refreshOpenFeed(this)" title="Refresh feed">↻ Refresh</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderEpisodeList(episodes, feedId, listEl);
|
||||
const filterBar = $('episode-search-bar');
|
||||
if (filterBar) { filterBar.style.display = ''; $('episode-filter-input').value = ''; }
|
||||
} catch (e) {
|
||||
if (headerEl) headerEl.innerHTML = '<p class="muted">Failed to load episodes.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
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 ? `<img class="podcast-thumb" src="${escapeHtml(artSrc)}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
|
||||
<div class="episode-info">
|
||||
<div class="episode-title ep-clickable" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">${escapeHtml(ep.title)}</div>
|
||||
<div class="muted">${escapeHtml(dateStr)} · ${escapeHtml(dur)}${escapeHtml(posStr)}</div>
|
||||
<div class="episode-meta">
|
||||
${dateStr ? `<span class="episode-date">${escapeHtml(dateStr)}</span>` : ''}
|
||||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
||||
${posStr ? `<span class="episode-pos muted">${escapeHtml(posStr)}</span>` : ''}
|
||||
</div>
|
||||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
||||
</div>
|
||||
<div class="episode-actions">
|
||||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
|
||||
<button class="btn btn-sm" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">📋</button>
|
||||
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${ep.in_queue ? 'In queue' : 'Add to queue'}">${ep.in_queue ? '✓Q' : '+Q'}</button>
|
||||
<button class="btn btn-sm" onclick="toggleMarkPlayed(${ep.id}, this)" title="Mark played">${ep.played ? '✓' : '○'}</button>
|
||||
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
|
||||
|
|
@ -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;
|
||||
|
||||
if (!append) {
|
||||
_inboxOffset = 0;
|
||||
listEl.innerHTML = '<p class="muted">Loading…</p>';
|
||||
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 = '<p class="muted">Inbox empty — all caught up!</p>';
|
||||
$('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 = `
|
||||
<label class="inbox-checkbox-label">
|
||||
<input type="checkbox" class="inbox-cb" data-ep-id="${ep.id}" onchange="inboxOnCheck()">
|
||||
</label>
|
||||
${ep['feed__artwork_url'] ? `<img class="podcast-thumb" src="${escapeHtml(ep['feed__artwork_url'])}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
|
||||
<div class="episode-info">
|
||||
<div class="episode-title">${escapeHtml(ep.title)}</div>
|
||||
<div class="muted">${escapeHtml(ep['feed__title'])} · ${ep.pub_date ? ep.pub_date.slice(0,10) : ''} · ${formatDuration(ep.duration_seconds)}</div>
|
||||
<div class="episode-meta">
|
||||
<span class="episode-date">${escapeHtml(ep['feed__title'])}</span>
|
||||
${dateStr ? `<span class="episode-dur">${escapeHtml(dateStr)}</span>` : ''}
|
||||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
||||
</div>
|
||||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
||||
</div>
|
||||
<div class="episode-actions">
|
||||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
|
||||
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="Add to queue">+Q</button>
|
||||
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${inQueue ? 'In queue' : 'Add to queue'}">${inQueue ? '✓Q' : '+Q'}</button>
|
||||
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="inboxDismissOne(${ep.id}, this)" title="Dismiss">✕</button>
|
||||
</div>
|
||||
`;
|
||||
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 = '<p class="muted">Failed to load inbox.</p>';
|
||||
if (!append) listEl.innerHTML = '<p class="muted">Failed to load inbox.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<p class="muted">Inbox empty — all caught up!</p>';
|
||||
}
|
||||
}
|
||||
} 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 = '<p class="muted">Inbox empty — all caught up!</p>';
|
||||
}
|
||||
}
|
||||
} 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 = `
|
||||
<span class="drag-handle">⠿</span>
|
||||
<div class="episode-info">
|
||||
<div class="episode-title">${escapeHtml(item['episode__title'])}</div>
|
||||
<div class="muted">${escapeHtml(item['episode__feed__title'])} · ${formatDuration(item['episode__duration_seconds'])}</div>
|
||||
<div class="episode-meta">
|
||||
<span class="episode-date">${escapeHtml(item['episode__feed__title'])}</span>
|
||||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
||||
</div>
|
||||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
||||
</div>
|
||||
<div class="episode-actions">
|
||||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${epId})">▶</button>
|
||||
|
|
@ -1453,6 +1824,10 @@ async function loadAndRenderQueue() {
|
|||
<button class="btn btn-sm btn-danger" onclick="queueRemoveEpisode(${epId})">✕</button>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -31,8 +31,11 @@
|
|||
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(1.25)">1¼×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(1.5)">1½×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(1.75)">1¾×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(2)">2×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(2.5)">2½×</button>
|
||||
</div>
|
||||
<button class="btn-icon sleep-timer-btn" id="sleep-timer-btn" onclick="openSleepTimerMenu()" title="Sleep timer">Sleep</button>
|
||||
</div>
|
||||
<div class="timer-widget" id="timer-widget">
|
||||
<span class="timer-phase" id="timer-phase-label">focus</span>
|
||||
|
|
@ -246,6 +249,16 @@
|
|||
|
||||
<!-- Feeds pane -->
|
||||
<div class="podcast-pane" id="podcast-feeds-pane">
|
||||
<div class="feed-list-toolbar">
|
||||
<input type="text" id="feed-filter-input" class="search-input" placeholder="Search subscriptions…"
|
||||
oninput="filterFeeds(this.value)">
|
||||
<select id="feed-sort-select" onchange="sortFeeds(this.value)" class="feed-sort-select">
|
||||
<option value="alpha">A–Z</option>
|
||||
<option value="alpha-desc">Z–A</option>
|
||||
<option value="added">Recently added</option>
|
||||
<option value="refreshed">Recently refreshed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="podcast-feed-list" class="podcast-feed-list">
|
||||
<p class="muted">Loading…</p>
|
||||
</div>
|
||||
|
|
@ -253,11 +266,32 @@
|
|||
|
||||
<!-- Inbox pane -->
|
||||
<div class="podcast-pane" id="podcast-inbox-pane" style="display:none;">
|
||||
<div class="inbox-toolbar">
|
||||
<label class="inbox-select-all-label">
|
||||
<input type="checkbox" id="inbox-select-all" onchange="inboxSelectAll(this.checked)">
|
||||
<span>All</span>
|
||||
</label>
|
||||
<div class="inbox-bulk-actions" id="inbox-bulk-actions" style="display:none;">
|
||||
<span id="inbox-selection-count" class="muted"></span>
|
||||
<button class="btn btn-sm" onclick="inboxBulkQueueAdd()">+Queue</button>
|
||||
<button class="btn btn-sm" onclick="inboxBulkMarkPlayed()">✓ Played</button>
|
||||
<button class="btn btn-sm" onclick="inboxBulkDownload()">⬇ Download</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="inboxBulkDismiss()">✕ Dismiss</button>
|
||||
</div>
|
||||
<div class="inbox-load-more" id="inbox-load-more-bar" style="display:none;">
|
||||
<button class="btn btn-sm" onclick="inboxLoadMore()">Load more</button>
|
||||
<span id="inbox-count-label" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="podcast-inbox-list" class="episode-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Episodes pane -->
|
||||
<div class="podcast-pane" id="podcast-episodes-pane" style="display:none;">
|
||||
<div class="episode-search-bar" id="episode-search-bar" style="display:none;">
|
||||
<input type="text" id="episode-filter-input" class="search-input"
|
||||
placeholder="Filter episodes…" oninput="filterEpisodes(this.value)">
|
||||
</div>
|
||||
<div id="podcast-feed-header" class="podcast-feed-header"></div>
|
||||
<div id="podcast-episode-list" class="episode-list"></div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue