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="albums">Albums</button>
|
||||||
<button class="search-tab" data-tab="artists">Artists</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="playlists">Playlists</button>
|
||||||
|
<button class="search-tab" data-tab="podcasts">Podcasts</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-tab-content active" id="search-tab-tracks">
|
<div class="search-tab-content active" id="search-tab-tracks">
|
||||||
<div class="track-list" id="search-tracks-container"></div>
|
<div class="track-list" id="search-tracks-container"></div>
|
||||||
|
|
@ -1874,6 +1875,9 @@
|
||||||
<div class="search-tab-content" id="search-tab-playlists">
|
<div class="search-tab-content" id="search-tab-playlists">
|
||||||
<div class="card-grid" id="search-playlists-container"></div>
|
<div class="card-grid" id="search-playlists-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="search-tab-content" id="search-tab-podcasts">
|
||||||
|
<div class="card-grid" id="search-podcasts-container"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="page-library" class="page">
|
<div id="page-library" class="page">
|
||||||
|
|
@ -2017,6 +2021,46 @@
|
||||||
<div id="unreleased-content" style="padding: 1rem 0"></div>
|
<div id="unreleased-content" style="padding: 1rem 0"></div>
|
||||||
</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">
|
<div id="page-tracker-artist" class="page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<img
|
<img
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,10 @@ const syncManager = {
|
||||||
streamStartDate: item.streamStartDate || null,
|
streamStartDate: item.streamStartDate || null,
|
||||||
version: item.version || null,
|
version: item.version || null,
|
||||||
mixes: item.mixes || 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,
|
mixes: item.mixes || null,
|
||||||
isTracker: item.isTracker || (item.id && String(item.id).startsWith('tracker-')),
|
isTracker: item.isTracker || (item.id && String(item.id).startsWith('tracker-')),
|
||||||
trackerInfo: item.trackerInfo || null,
|
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,
|
audioUrl: item.remoteUrl || item.audioUrl || null,
|
||||||
remoteUrl: item.remoteUrl || null,
|
remoteUrl: item.remoteUrl || null,
|
||||||
audioQuality: item.audioQuality || null,
|
audioQuality: item.audioQuality || null,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import { LosslessAPI } from './api.js';
|
import { LosslessAPI } from './api.js';
|
||||||
import { QobuzAPI } from './qobuz-api.js';
|
import { QobuzAPI } from './qobuz-api.js';
|
||||||
|
import { PodcastsAPI } from './podcasts-api.js';
|
||||||
import { musicProviderSettings } from './storage.js';
|
import { musicProviderSettings } from './storage.js';
|
||||||
|
|
||||||
export class MusicAPI {
|
export class MusicAPI {
|
||||||
|
|
@ -18,6 +19,7 @@ export class MusicAPI {
|
||||||
constructor(settings) {
|
constructor(settings) {
|
||||||
this.tidalAPI = new LosslessAPI(settings);
|
this.tidalAPI = new LosslessAPI(settings);
|
||||||
this.qobuzAPI = new QobuzAPI();
|
this.qobuzAPI = new QobuzAPI();
|
||||||
|
this.podcastsAPI = new PodcastsAPI();
|
||||||
this._settings = settings;
|
this._settings = settings;
|
||||||
this.videoArtworkCache = new Map();
|
this.videoArtworkCache = new Map();
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +73,22 @@ export class MusicAPI {
|
||||||
return this.tidalAPI.searchVideos(query, options);
|
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
|
// Get methods
|
||||||
async getTrack(id, quality, provider = null) {
|
async getTrack(id, quality, provider = null) {
|
||||||
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
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) {
|
for (const { track } of tracksToPreload) {
|
||||||
if (this.preloadCache.has(track.id)) continue;
|
if (this.preloadCache.has(track.id)) continue;
|
||||||
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
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 {
|
try {
|
||||||
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
|
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
|
||||||
|
|
||||||
|
|
@ -781,8 +782,34 @@ export class Player {
|
||||||
let streamUrl;
|
let streamUrl;
|
||||||
|
|
||||||
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
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;
|
streamUrl = track.audioUrl;
|
||||||
|
|
||||||
if (
|
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();
|
await ui.renderUnreleasedPage();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'podcasts':
|
||||||
|
if (param) {
|
||||||
|
await ui.renderPodcastPage(param);
|
||||||
|
} else {
|
||||||
|
await ui.renderPodcastsBrowsePage();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'home':
|
case 'home':
|
||||||
await ui.renderHomePage();
|
await ui.renderHomePage();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
303
js/ui.js
303
js/ui.js
|
|
@ -2798,11 +2798,13 @@ export class UIRenderer {
|
||||||
const artistsContainer = document.getElementById('search-artists-container');
|
const artistsContainer = document.getElementById('search-artists-container');
|
||||||
const albumsContainer = document.getElementById('search-albums-container');
|
const albumsContainer = document.getElementById('search-albums-container');
|
||||||
const playlistsContainer = document.getElementById('search-playlists-container');
|
const playlistsContainer = document.getElementById('search-playlists-container');
|
||||||
|
const podcastsContainer = document.getElementById('search-podcasts-container');
|
||||||
|
|
||||||
tracksContainer.innerHTML = this.createSkeletonTracks(8, true);
|
tracksContainer.innerHTML = this.createSkeletonTracks(8, true);
|
||||||
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
|
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||||
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||||
playlistsContainer.innerHTML = this.createSkeletonCards(6, false);
|
playlistsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||||
|
podcastsContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||||
|
|
||||||
if (this.searchAbortController) {
|
if (this.searchAbortController) {
|
||||||
this.searchAbortController.abort();
|
this.searchAbortController.abort();
|
||||||
|
|
@ -2918,6 +2920,8 @@ export class UIRenderer {
|
||||||
this.updateLikeState(el, 'playlist', playlist.uuid);
|
this.updateLikeState(el, 'playlist', playlist.uuid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.renderPodcastSearchResults(query);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') return;
|
if (error.name === 'AbortError') return;
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
|
|
@ -2926,6 +2930,7 @@ export class UIRenderer {
|
||||||
artistsContainer.innerHTML = errorMsg;
|
artistsContainer.innerHTML = errorMsg;
|
||||||
albumsContainer.innerHTML = errorMsg;
|
albumsContainer.innerHTML = errorMsg;
|
||||||
playlistsContainer.innerHTML = errorMsg;
|
playlistsContainer.innerHTML = errorMsg;
|
||||||
|
podcastsContainer.innerHTML = errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5307,4 +5312,302 @@ export class UIRenderer {
|
||||||
artistEl.innerHTML = '';
|
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) => {
|
export const formatTime = (seconds) => {
|
||||||
if (isNaN(seconds)) return '0:00';
|
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);
|
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')}`;
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue