feat: podcasts
This commit is contained in:
parent
3cd54a2b5f
commit
c2f8d3fca1
9 changed files with 669 additions and 3 deletions
44
index.html
44
index.html
|
|
@ -1858,6 +1858,7 @@
|
|||
<button class="search-tab" data-tab="albums">Albums</button>
|
||||
<button class="search-tab" data-tab="artists">Artists</button>
|
||||
<button class="search-tab" data-tab="playlists">Playlists</button>
|
||||
<button class="search-tab" data-tab="podcasts">Podcasts</button>
|
||||
</div>
|
||||
<div class="search-tab-content active" id="search-tab-tracks">
|
||||
<div class="track-list" id="search-tracks-container"></div>
|
||||
|
|
@ -1874,6 +1875,9 @@
|
|||
<div class="search-tab-content" id="search-tab-playlists">
|
||||
<div class="card-grid" id="search-playlists-container"></div>
|
||||
</div>
|
||||
<div class="search-tab-content" id="search-tab-podcasts">
|
||||
<div class="card-grid" id="search-podcasts-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-library" class="page">
|
||||
|
|
@ -2017,6 +2021,46 @@
|
|||
<div id="unreleased-content" style="padding: 1rem 0"></div>
|
||||
</div>
|
||||
|
||||
<div id="page-podcasts" class="page">
|
||||
<header class="detail-header">
|
||||
<img
|
||||
id="podcasts-detail-image"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
alt=""
|
||||
class="detail-header-image artist"
|
||||
/>
|
||||
<div class="detail-header-info">
|
||||
<div class="type">Podcast</div>
|
||||
<h1 class="title" id="podcasts-detail-name"></h1>
|
||||
<div class="meta" id="podcasts-detail-meta"></div>
|
||||
<div class="detail-header-actions">
|
||||
<button id="play-podcasts-btn" class="btn-primary" title="Play Latest Episode">
|
||||
<use svg="./images/play.svg" size="20" />
|
||||
<span>Play Latest</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content-section">
|
||||
<h2 class="section-title">Episodes</h2>
|
||||
<div class="track-list" id="podcasts-episodes-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-podcasts-browse" class="page">
|
||||
<h2 class="section-title">Browse Podcasts</h2>
|
||||
<div class="search-tabs">
|
||||
<button class="search-tab active" data-tab="trending">Trending</button>
|
||||
<button class="search-tab" data-tab="recent">Recent</button>
|
||||
</div>
|
||||
<div class="search-tab-content active" id="podcasts-tab-trending">
|
||||
<div class="card-grid" id="podcasts-trending-container"></div>
|
||||
</div>
|
||||
<div class="search-tab-content" id="podcasts-tab-recent">
|
||||
<div class="card-grid" id="podcasts-recent-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-tracker-artist" class="page">
|
||||
<header class="detail-header">
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -225,6 +225,10 @@ const syncManager = {
|
|||
streamStartDate: item.streamStartDate || null,
|
||||
version: item.version || null,
|
||||
mixes: item.mixes || null,
|
||||
isPodcast: item.isPodcast || (item.id && String(item.id).startsWith('podcast_')) || null,
|
||||
enclosureUrl: item.enclosureUrl || null,
|
||||
enclosureType: item.enclosureType || null,
|
||||
enclosureLength: item.enclosureLength || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
4
js/db.js
4
js/db.js
|
|
@ -257,6 +257,10 @@ export class MusicDatabase {
|
|||
mixes: item.mixes || null,
|
||||
isTracker: item.isTracker || (item.id && String(item.id).startsWith('tracker-')),
|
||||
trackerInfo: item.trackerInfo || null,
|
||||
isPodcast: item.isPodcast || (item.id && String(item.id).startsWith('podcast_')) || null,
|
||||
enclosureUrl: item.enclosureUrl || null,
|
||||
enclosureType: item.enclosureType || null,
|
||||
enclosureLength: item.enclosureLength || null,
|
||||
audioUrl: item.remoteUrl || item.audioUrl || null,
|
||||
remoteUrl: item.remoteUrl || null,
|
||||
audioQuality: item.audioQuality || null,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { LosslessAPI } from './api.js';
|
||||
import { QobuzAPI } from './qobuz-api.js';
|
||||
import { PodcastsAPI } from './podcasts-api.js';
|
||||
import { musicProviderSettings } from './storage.js';
|
||||
|
||||
export class MusicAPI {
|
||||
|
|
@ -18,6 +19,7 @@ export class MusicAPI {
|
|||
constructor(settings) {
|
||||
this.tidalAPI = new LosslessAPI(settings);
|
||||
this.qobuzAPI = new QobuzAPI();
|
||||
this.podcastsAPI = new PodcastsAPI();
|
||||
this._settings = settings;
|
||||
this.videoArtworkCache = new Map();
|
||||
}
|
||||
|
|
@ -71,6 +73,22 @@ export class MusicAPI {
|
|||
return this.tidalAPI.searchVideos(query, options);
|
||||
}
|
||||
|
||||
async searchPodcasts(query, options = {}) {
|
||||
return this.podcastsAPI.searchPodcasts(query, options);
|
||||
}
|
||||
|
||||
async getPodcast(id, options = {}) {
|
||||
return this.podcastsAPI.getPodcastById(id, options);
|
||||
}
|
||||
|
||||
async getPodcastEpisodes(id, options = {}) {
|
||||
return this.podcastsAPI.getPodcastEpisodes(id, options);
|
||||
}
|
||||
|
||||
async getTrendingPodcasts(options = {}) {
|
||||
return this.podcastsAPI.getTrendingPodcasts(options);
|
||||
}
|
||||
|
||||
// Get methods
|
||||
async getTrack(id, quality, provider = null) {
|
||||
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
||||
|
|
|
|||
31
js/player.js
31
js/player.js
|
|
@ -467,7 +467,8 @@ export class Player {
|
|||
for (const { track } of tracksToPreload) {
|
||||
if (this.preloadCache.has(track.id)) continue;
|
||||
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
||||
if (track.isLocal || isTracker || (track.audioUrl && !track.isLocal)) continue;
|
||||
const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_'));
|
||||
if (track.isLocal || isTracker || isPodcast || (track.audioUrl && !track.isLocal)) continue;
|
||||
try {
|
||||
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
|
||||
|
||||
|
|
@ -781,8 +782,34 @@ export class Player {
|
|||
let streamUrl;
|
||||
|
||||
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
||||
const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_'));
|
||||
|
||||
if (isTracker || (track.audioUrl && !track.isLocal)) {
|
||||
if (isPodcast) {
|
||||
streamUrl = track.enclosureUrl;
|
||||
if (!streamUrl) {
|
||||
console.warn(`Podcast episode ${trackTitle} audio URL is missing. Skipping.`);
|
||||
track.isUnavailable = true;
|
||||
this.playNext();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playbackSequence !== currentSequence) return;
|
||||
|
||||
this.currentRgValues = null;
|
||||
this.applyReplayGain();
|
||||
|
||||
activeElement.src = streamUrl;
|
||||
this.applyAudioEffects();
|
||||
|
||||
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
|
||||
if (!canPlay || this.playbackSequence !== currentSequence) return;
|
||||
|
||||
if (startTime > 0) {
|
||||
activeElement.currentTime = startTime;
|
||||
}
|
||||
const played = await this.safePlay(activeElement);
|
||||
if (!played) return;
|
||||
} else if (isTracker || (track.audioUrl && !track.isLocal)) {
|
||||
streamUrl = track.audioUrl;
|
||||
|
||||
if (
|
||||
|
|
|
|||
255
js/podcasts-api.js
Normal file
255
js/podcasts-api.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
// js/podcasts-api.js
|
||||
// PodcastIndex.org API integration for Monochrome Music
|
||||
|
||||
const PODCASTINDEX_API_BASE = 'https://api.podcastindex.org/api/1.0';
|
||||
|
||||
const PODCAST_API_KEY = 'YU5HMSDYBQQVYDF6QN4P';
|
||||
const PODCAST_API_SECRET = '8hCvpjSL7T$S7^5ftnf5MhqQwYUYVjM^fmUL3Ld$';
|
||||
|
||||
export class PodcastsAPI {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.cacheTimeout = 1000 * 60 * 5;
|
||||
}
|
||||
|
||||
async getAuthHeaders() {
|
||||
const apiHeaderTime = Math.floor(Date.now() / 1000).toString();
|
||||
const combined = PODCAST_API_KEY + PODCAST_API_SECRET + apiHeaderTime;
|
||||
const authHeader = await this.sha1(combined);
|
||||
return {
|
||||
'User-Agent': 'MonochromeMusic/1.0',
|
||||
'X-Auth-Key': PODCAST_API_KEY,
|
||||
'X-Auth-Date': apiHeaderTime,
|
||||
Authorization: authHeader,
|
||||
};
|
||||
}
|
||||
|
||||
async sha1(str) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
async fetchWithRetry(endpoint, options = {}) {
|
||||
const url = `${PODCASTINDEX_API_BASE}${endpoint}`;
|
||||
const cacheKey = url;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = await this.getAuthHeaders();
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
console.error('PodcastIndex API request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchPodcasts(query, options = {}) {
|
||||
try {
|
||||
const max = options.max || 20;
|
||||
const clean = options.clean ? '&clean' : '';
|
||||
const data = await this.fetchWithRetry(
|
||||
`/search/byterm?q=${encodeURIComponent(query)}&max=${max}${clean}&pretty`,
|
||||
options
|
||||
);
|
||||
|
||||
if (data.status !== 'true' || !data.feeds) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
const podcasts = data.feeds.map((feed) => this.transformPodcast(feed));
|
||||
return {
|
||||
items: podcasts,
|
||||
total: data.count || podcasts.length,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
console.error('Podcast search failed:', error);
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async searchPodcastsByTitle(query, options = {}) {
|
||||
try {
|
||||
const max = options.max || 20;
|
||||
const clean = options.clean ? '&clean' : '';
|
||||
const data = await this.fetchWithRetry(
|
||||
`/search/bytitle?q=${encodeURIComponent(query)}&max=${max}${clean}&pretty`,
|
||||
options
|
||||
);
|
||||
|
||||
if (data.status !== 'true' || !data.feeds) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
const podcasts = data.feeds.map((feed) => this.transformPodcast(feed));
|
||||
return {
|
||||
items: podcasts,
|
||||
total: data.count || podcasts.length,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
console.error('Podcast search by title failed:', error);
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async getPodcastById(id, options = {}) {
|
||||
try {
|
||||
const data = await this.fetchWithRetry(`/podcasts/byfeedid?id=${id}&pretty`, options);
|
||||
|
||||
if (data.status !== 'true' || !data.feed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.transformPodcastFull(data.feed);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
console.error('Get podcast by ID failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getPodcastEpisodes(id, options = {}) {
|
||||
try {
|
||||
const max = options.max || 50;
|
||||
const offset = options.offset || 0;
|
||||
const data = await this.fetchWithRetry(
|
||||
`/episodes/byfeedid?id=${id}&max=${max}&offset=${offset}&pretty`,
|
||||
options
|
||||
);
|
||||
|
||||
if (data.status !== 'true' || !data.items) {
|
||||
return { items: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const episodes = data.items.map((item) => this.transformEpisode(item));
|
||||
return {
|
||||
items: episodes,
|
||||
total: data.count || episodes.length,
|
||||
hasMore: episodes.length === max,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
console.error('Get podcast episodes failed:', error);
|
||||
return { items: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getTrendingPodcasts(options = {}) {
|
||||
try {
|
||||
const max = options.max || 20;
|
||||
const lang = options.lang || '';
|
||||
const cat = options.cat || '';
|
||||
const since = options.since || '';
|
||||
const params = new URLSearchParams({ max, pretty: '' });
|
||||
if (lang) params.append('lang', lang);
|
||||
if (cat) params.append('cat', cat);
|
||||
if (since) params.append('since', since);
|
||||
const queryString = params.toString().replace(/&pretty=$/, '');
|
||||
const data = await this.fetchWithRetry(`/podcasts/trending?${queryString}`, options);
|
||||
|
||||
if (data.status !== 'true' || !data.feeds) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
const podcasts = data.feeds.map((feed) => this.transformPodcast(feed));
|
||||
return {
|
||||
items: podcasts,
|
||||
total: data.count || podcasts.length,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
console.error('Get trending podcasts failed:', error);
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async testAuth() {
|
||||
console.log('Testing PodcastIndex auth...');
|
||||
try {
|
||||
const response = await fetch(`${PODCASTINDEX_API_BASE}/hub/pubnotify?id=75075&pretty`, {
|
||||
headers: await this.getAuthHeaders(),
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('Test response:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Auth test failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
transformPodcast(feed) {
|
||||
return {
|
||||
id: feed.id?.toString() || '',
|
||||
podcastGuid: feed.podcastGuid || '',
|
||||
title: feed.title || 'Unknown Podcast',
|
||||
author: feed.author || feed.ownerName || '',
|
||||
description: feed.description || '',
|
||||
image: feed.image || feed.artwork || '',
|
||||
link: feed.link || '',
|
||||
feedUrl: feed.url || '',
|
||||
language: feed.language || '',
|
||||
categories: feed.categories || {},
|
||||
explicit: feed.explicit || false,
|
||||
episodeCount: feed.episodeCount || 0,
|
||||
newestItemPublishTime: feed.newestItemPubdate || feed.newestItemPublishTime || null,
|
||||
};
|
||||
}
|
||||
|
||||
transformPodcastFull(feed) {
|
||||
const podcast = this.transformPodcast(feed);
|
||||
podcast.generator = feed.generator || '';
|
||||
podcast.locked = feed.locked || 0;
|
||||
podcast.medium = feed.medium || '';
|
||||
podcast.dead = feed.dead || 0;
|
||||
podcast.value = feed.value || null;
|
||||
podcast.funding = feed.funding || null;
|
||||
return podcast;
|
||||
}
|
||||
|
||||
transformEpisode(item) {
|
||||
return {
|
||||
id: item.id?.toString() || '',
|
||||
title: item.title || 'Unknown Episode',
|
||||
description: item.description || '',
|
||||
link: item.link || '',
|
||||
guid: item.guid || '',
|
||||
datePublished: item.datePublished || 0,
|
||||
datePublishedPretty: item.datePublishedPretty || '',
|
||||
enclosureUrl: item.enclosureUrl || '',
|
||||
enclosureType: item.enclosureType || '',
|
||||
enclosureLength: item.enclosureLength || 0,
|
||||
duration: item.duration || null,
|
||||
explicit: item.explicit || 0,
|
||||
episode: item.episode || null,
|
||||
episodeType: item.episodeType || 'full',
|
||||
season: item.season || null,
|
||||
image: item.image || '',
|
||||
feedId: item.feedId || null,
|
||||
feedTitle: item.feedTitle || '',
|
||||
feedImage: item.feedImage || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const podcastsAPI = new PodcastsAPI();
|
||||
|
|
@ -101,6 +101,13 @@ export function createRouter(ui) {
|
|||
await ui.renderUnreleasedPage();
|
||||
}
|
||||
break;
|
||||
case 'podcasts':
|
||||
if (param) {
|
||||
await ui.renderPodcastPage(param);
|
||||
} else {
|
||||
await ui.renderPodcastsBrowsePage();
|
||||
}
|
||||
break;
|
||||
case 'home':
|
||||
await ui.renderHomePage();
|
||||
break;
|
||||
|
|
|
|||
303
js/ui.js
303
js/ui.js
|
|
@ -2798,11 +2798,13 @@ export class UIRenderer {
|
|||
const artistsContainer = document.getElementById('search-artists-container');
|
||||
const albumsContainer = document.getElementById('search-albums-container');
|
||||
const playlistsContainer = document.getElementById('search-playlists-container');
|
||||
const podcastsContainer = document.getElementById('search-podcasts-container');
|
||||
|
||||
tracksContainer.innerHTML = this.createSkeletonTracks(8, true);
|
||||
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||
playlistsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||
podcastsContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||
|
||||
if (this.searchAbortController) {
|
||||
this.searchAbortController.abort();
|
||||
|
|
@ -2918,6 +2920,8 @@ export class UIRenderer {
|
|||
this.updateLikeState(el, 'playlist', playlist.uuid);
|
||||
}
|
||||
});
|
||||
|
||||
await this.renderPodcastSearchResults(query);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
console.error('Search failed:', error);
|
||||
|
|
@ -2926,6 +2930,7 @@ export class UIRenderer {
|
|||
artistsContainer.innerHTML = errorMsg;
|
||||
albumsContainer.innerHTML = errorMsg;
|
||||
playlistsContainer.innerHTML = errorMsg;
|
||||
podcastsContainer.innerHTML = errorMsg;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5307,4 +5312,302 @@ export class UIRenderer {
|
|||
artistEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async renderPodcastsBrowsePage() {
|
||||
this.showPage('podcasts-browse');
|
||||
const trendingContainer = document.getElementById('podcasts-trending-container');
|
||||
const recentContainer = document.getElementById('podcasts-recent-container');
|
||||
trendingContainer.innerHTML = this.createSkeletonCards(12, true);
|
||||
recentContainer.innerHTML = this.createSkeletonCards(12, true);
|
||||
|
||||
try {
|
||||
const { podcastsAPI } = await import('./podcasts-api.js');
|
||||
const trendingResult = await podcastsAPI.getTrendingPodcasts({ max: 24 });
|
||||
if (trendingResult.items.length > 0) {
|
||||
trendingContainer.innerHTML = trendingResult.items
|
||||
.map((podcast) => this.createPodcastCardHTML(podcast))
|
||||
.join('');
|
||||
this.attachPodcastCardListeners(trendingContainer, trendingResult.items);
|
||||
} else {
|
||||
trendingContainer.innerHTML = createPlaceholder('No trending podcasts found.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load trending podcasts:', error);
|
||||
trendingContainer.innerHTML = createPlaceholder('Failed to load trending podcasts.');
|
||||
}
|
||||
|
||||
document.title = 'Podcasts - Monochrome Music';
|
||||
}
|
||||
|
||||
cleanupPodcastState() {
|
||||
if (this.podcastScrollHandler) {
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
if (mainContent) {
|
||||
mainContent.removeEventListener('scroll', this.podcastScrollHandler);
|
||||
}
|
||||
this.podcastScrollHandler = null;
|
||||
}
|
||||
this.podcastState = null;
|
||||
}
|
||||
|
||||
async renderPodcastPage(podcastId) {
|
||||
this.cleanupPodcastState();
|
||||
this.showPage('podcasts');
|
||||
|
||||
this.podcastState = {
|
||||
id: podcastId,
|
||||
episodes: [],
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
const nameEl = document.getElementById('podcasts-detail-name');
|
||||
const metaEl = document.getElementById('podcasts-detail-meta');
|
||||
const imageEl = document.getElementById('podcasts-detail-image');
|
||||
const episodesContainer = document.getElementById('podcasts-episodes-container');
|
||||
|
||||
nameEl.textContent = 'Loading...';
|
||||
metaEl.textContent = '';
|
||||
episodesContainer.innerHTML = this.createSkeletonTracks(8, true);
|
||||
|
||||
try {
|
||||
const { podcastsAPI } = await import('./podcasts-api.js');
|
||||
const podcastResult = await podcastsAPI.getPodcastById(podcastId);
|
||||
|
||||
if (podcastResult) {
|
||||
nameEl.textContent = podcastResult.title;
|
||||
metaEl.textContent = `${podcastResult.episodeCount} episodes • ${podcastResult.author}`;
|
||||
if (podcastResult.image) {
|
||||
imageEl.src = podcastResult.image;
|
||||
this.setPageBackground(podcastResult.image);
|
||||
}
|
||||
|
||||
this.podcastState.podcastTitle = podcastResult.title;
|
||||
const playBtn = document.getElementById('play-podcasts-btn');
|
||||
} else {
|
||||
this.podcastState.podcastTitle = 'Unknown Podcast';
|
||||
}
|
||||
|
||||
document.title = `${podcastResult?.title || 'Podcast'} - Monochrome Music`;
|
||||
|
||||
episodesContainer.innerHTML = '';
|
||||
await this.loadMorePodcastEpisodes();
|
||||
} catch (error) {
|
||||
console.error('Failed to load podcast:', error);
|
||||
nameEl.textContent = 'Podcast not found';
|
||||
episodesContainer.innerHTML = createPlaceholder('Failed to load podcast.');
|
||||
}
|
||||
}
|
||||
|
||||
async loadMorePodcastEpisodes() {
|
||||
if (this.podcastState.isLoading || !this.podcastState.hasMore) return;
|
||||
|
||||
this.podcastState.isLoading = true;
|
||||
const episodesContainer = document.getElementById('podcasts-episodes-container');
|
||||
|
||||
if (this.podcastState.offset === 0) {
|
||||
episodesContainer.innerHTML = this.createSkeletonTracks(8, true);
|
||||
} else {
|
||||
const loader = document.createElement('div');
|
||||
loader.id = 'podcast-load-more';
|
||||
loader.className = 'loading-more';
|
||||
loader.innerHTML = '<div class="skeleton-track"></div>'.repeat(4);
|
||||
episodesContainer.appendChild(loader);
|
||||
}
|
||||
|
||||
try {
|
||||
const { podcastsAPI } = await import('./podcasts-api.js');
|
||||
const result = await podcastsAPI.getPodcastEpisodes(this.podcastState.id, {
|
||||
max: 50,
|
||||
offset: this.podcastState.offset,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'Podcast episodes loaded:',
|
||||
result.items.length,
|
||||
'hasMore:',
|
||||
result.hasMore,
|
||||
'offset:',
|
||||
this.podcastState.offset
|
||||
);
|
||||
|
||||
const isFirstLoad = this.podcastState.offset === 0;
|
||||
|
||||
this.podcastState.episodes.push(...result.items);
|
||||
this.podcastState.offset += result.items.length;
|
||||
this.podcastState.hasMore = result.hasMore;
|
||||
|
||||
if (isFirstLoad) {
|
||||
const podcastTitle = this.podcastState.podcastTitle || 'Unknown Podcast';
|
||||
const tracks = result.items.map((ep) => this.transformPodcastEpisodeToTrack(ep, podcastTitle));
|
||||
this.renderListWithTracks(episodesContainer, tracks, true);
|
||||
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'podcast-scroll-sentinel';
|
||||
sentinel.style.height = '1px';
|
||||
episodesContainer.appendChild(sentinel);
|
||||
|
||||
const playBtn = document.getElementById('play-podcasts-btn');
|
||||
if (playBtn && result.items.length > 0) {
|
||||
playBtn.onclick = () => {
|
||||
const tracksToPlay = this.podcastState.episodes.map((ep) =>
|
||||
this.transformPodcastEpisodeToTrack(ep, podcastTitle)
|
||||
);
|
||||
if (this.player) {
|
||||
this.player.setQueue(tracksToPlay, 0);
|
||||
this.player.playTrackFromQueue();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.setupPodcastInfiniteScroll();
|
||||
} else {
|
||||
const loader = document.getElementById('podcast-load-more');
|
||||
if (loader) loader.remove();
|
||||
const podcastTitle = this.podcastState.podcastTitle || 'Unknown Podcast';
|
||||
const tracks = result.items.map((ep) => this.transformPodcastEpisodeToTrack(ep, podcastTitle));
|
||||
this.appendListWithTracks(tracks);
|
||||
}
|
||||
|
||||
if (!this.podcastState.hasMore) {
|
||||
const loader = document.getElementById('podcast-load-more');
|
||||
if (loader) loader.remove();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load more episodes:', error);
|
||||
const loader = document.getElementById('podcast-load-more');
|
||||
if (loader) loader.remove();
|
||||
}
|
||||
|
||||
this.podcastState.isLoading = false;
|
||||
}
|
||||
|
||||
setupPodcastInfiniteScroll() {
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
if (!mainContent) return;
|
||||
|
||||
const scrollHandler = () => {
|
||||
const scrollTop = mainContent.scrollTop;
|
||||
const scrollHeight = mainContent.scrollHeight;
|
||||
const clientHeight = mainContent.clientHeight;
|
||||
|
||||
if (scrollTop + clientHeight >= scrollHeight - 200) {
|
||||
if (this.podcastState?.hasMore && !this.podcastState?.isLoading) {
|
||||
console.log('Loading more podcast episodes...');
|
||||
this.loadMorePodcastEpisodes();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mainContent.addEventListener('scroll', scrollHandler);
|
||||
this.podcastScrollHandler = scrollHandler;
|
||||
}
|
||||
|
||||
appendListWithTracks(tracks) {
|
||||
const listContainer = document.getElementById('podcasts-episodes-container');
|
||||
const sentinel = document.getElementById('podcast-scroll-sentinel');
|
||||
const existingTracks = listContainer.querySelectorAll('.track-row, .track-item').length;
|
||||
|
||||
tracks.forEach((track, index) => {
|
||||
const trackHtml = this.createTrackItemHTML(track, existingTracks + index, true);
|
||||
const trackEl = document.createElement('div');
|
||||
trackEl.innerHTML = trackHtml;
|
||||
const row = trackEl.firstElementChild;
|
||||
|
||||
if (sentinel) {
|
||||
listContainer.insertBefore(row, sentinel);
|
||||
} else {
|
||||
listContainer.appendChild(row);
|
||||
}
|
||||
|
||||
trackDataStore.set(row, track);
|
||||
});
|
||||
}
|
||||
|
||||
async renderPodcastSearchResults(query) {
|
||||
const podcastsContainer = document.getElementById('search-podcasts-container');
|
||||
podcastsContainer.innerHTML = this.createSkeletonCards(12, true);
|
||||
|
||||
try {
|
||||
const { podcastsAPI } = await import('./podcasts-api.js');
|
||||
const result = await podcastsAPI.searchPodcasts(query, { max: 20 });
|
||||
|
||||
if (result.items.length > 0) {
|
||||
podcastsContainer.innerHTML = result.items
|
||||
.map((podcast) => this.createPodcastCardHTML(podcast))
|
||||
.join('');
|
||||
this.attachPodcastCardListeners(podcastsContainer, result.items);
|
||||
} else {
|
||||
podcastsContainer.innerHTML = createPlaceholder('No podcasts found.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Podcast search failed:', error);
|
||||
podcastsContainer.innerHTML = createPlaceholder('Failed to search podcasts.');
|
||||
}
|
||||
}
|
||||
|
||||
createPodcastCardHTML(podcast) {
|
||||
const title = escapeHtml(podcast.title || 'Unknown Podcast');
|
||||
const author = escapeHtml(podcast.author || '');
|
||||
const image = podcast.image || '';
|
||||
const description = escapeHtml((podcast.description || '').substring(0, 120));
|
||||
const episodeCount = podcast.episodeCount || 0;
|
||||
|
||||
return `
|
||||
<div class="card" data-podcast-id="${podcast.id}">
|
||||
<div class="card-image-container">
|
||||
<img src="${image}" alt="${title}" loading="lazy" onerror="this.style.display='none'" />
|
||||
<div class="card-image-placeholder" ${image ? 'style="display:none"' : ''}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23333" width="100" height="100"/><circle cx="50" cy="45" r="20" fill="%23666"/><rect x="35" y="70" width="30" height="15" rx="3" fill="%23666"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3 class="card-title">${title}</h3>
|
||||
<p class="card-subtitle">${author}</p>
|
||||
<p class="card-description">${description}${podcast.description?.length > 120 ? '...' : ''}</p>
|
||||
<span class="card-meta">${episodeCount} episodes</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachPodcastCardListeners(container, podcasts) {
|
||||
const cards = container.querySelectorAll('.card[data-podcast-id]');
|
||||
cards.forEach((card) => {
|
||||
const podcastId = card.dataset.podcastId;
|
||||
const podcast = podcasts.find((p) => p.id === podcastId);
|
||||
if (podcast) {
|
||||
card.addEventListener('click', () => {
|
||||
navigate(`/podcasts/${podcastId}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
transformPodcastEpisodeToTrack(episode, podcastTitle = 'Unknown Podcast') {
|
||||
return {
|
||||
id: `podcast_${episode.id}`,
|
||||
title: episode.title,
|
||||
artist: { id: null, name: podcastTitle },
|
||||
artists: [{ id: null, name: podcastTitle }],
|
||||
album: {
|
||||
id: null,
|
||||
title: podcastTitle,
|
||||
cover: episode.image || episode.feedImage || '',
|
||||
},
|
||||
duration: episode.duration,
|
||||
explicit: episode.explicit,
|
||||
dateAdded: episode.datePublished,
|
||||
isPodcast: true,
|
||||
enclosureUrl: episode.enclosureUrl,
|
||||
enclosureType: episode.enclosureType,
|
||||
enclosureLength: episode.enclosureLength,
|
||||
episodeNumber: episode.episode,
|
||||
episodeType: episode.episodeType,
|
||||
season: episode.season,
|
||||
description: episode.description,
|
||||
podcastEpisode: episode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,12 @@ export const RATE_LIMIT_ERROR_MESSAGE = 'Too Many Requests. Please wait a moment
|
|||
|
||||
export const formatTime = (seconds) => {
|
||||
if (isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue