text/plain
•
9.85 KB
•
376 lines
#include "podcast_feed_screen.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../podcast_xml.h"
#include "../storage.h"
#include "../topbar.h"
#include "files_screen.h"
#define PODCASTS_OPML "/Podcasts/podcasts.xml"
static void cancel_event_cb(lv_event_t* e) {
(void)e;
navigate_back(SCREEN_PODCASTS);
}
// Look up feed URL from OPML by matching directory basename
static bool lookup_feed_url(const char* dir,
char* out_title,
size_t title_size,
char* out_url,
size_t url_size) {
const char* basename = strrchr(dir, '/');
basename = (basename && basename[1]) ? basename + 1 : dir;
podcast_feed_t* feeds = malloc(PODCAST_MAX_FEEDS * sizeof(podcast_feed_t));
if (!feeds) {
return false;
}
int count = podcast_parse_opml(PODCASTS_OPML, feeds, PODCAST_MAX_FEEDS);
// Match by sanitizing each feed title and comparing to basename
for (int i = 0; i < count; i++) {
char sanitized[65];
podcast_sanitize_title(feeds[i].title, sanitized, sizeof(sanitized));
if (strcmp(sanitized, basename) == 0) {
snprintf(out_title, title_size, "%s", feeds[i].title);
snprintf(out_url, url_size, "%s", feeds[i].url);
free(feeds);
return true;
}
}
free(feeds);
return false;
}
static void sync_click_cb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) {
return;
}
podcast_feed_screen_state_t* state = ui_state.podcast_feed;
if (!state) {
return;
}
// Look up feed URL from OPML
if (!lookup_feed_url(state->dir, app_state.podcast_title,
sizeof(app_state.podcast_title), app_state.podcast_url,
sizeof(app_state.podcast_url))) {
return;
}
navigate_to(SCREEN_PODCAST_SYNC);
}
static void back_event_cb(lv_event_t* e) {
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
cancel_event_cb(e);
}
}
static void episode_click_cb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) {
return;
}
podcast_feed_screen_state_t* state = ui_state.podcast_feed;
if (!state) {
return;
}
int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (idx < 0 || idx >= state->episode_count) {
return;
}
int n = snprintf(app_state.podcast_episode_dir,
sizeof(app_state.podcast_episode_dir), "%s/%s", state->dir,
state->episode_dirs[idx]);
if (n <= 0 || (size_t)n >= sizeof(app_state.podcast_episode_dir)) {
return;
}
navigate_to(SCREEN_PODCAST_EPISODE);
}
// Parse RFC 822 month name to 1-12, or 0 if unknown
static int parse_month(const char* m) {
static const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
for (int i = 0; i < 12; i++) {
if (strncmp(m, months[i], 3) == 0) {
return i + 1;
}
}
return 0;
}
// Parse RFC 822 date string into YYYYMMDD integer for sorting.
// Format: "Wed, 25 Dec 2024 10:30:00 +0000" or "25 Dec 2024 10:30:00 +0000"
// Returns 0 on parse failure.
static int parse_rfc822_date(const char* date) {
if (!date || !date[0]) {
return 0;
}
// Skip optional day-of-week
const char* p = date;
const char* comma = strchr(p, ',');
if (comma) {
p = comma + 1;
}
while (*p == ' ') {
p++;
}
int day = 0;
while (*p >= '0' && *p <= '9') {
day = day * 10 + (*p - '0');
p++;
}
while (*p == ' ') {
p++;
}
int month = parse_month(p);
while (*p && *p != ' ') {
p++;
}
while (*p == ' ') {
p++;
}
int year = 0;
while (*p >= '0' && *p <= '9') {
year = year * 10 + (*p - '0');
p++;
}
if (year < 1970 || month == 0 || day == 0) {
return 0;
}
return year * 10000 + month * 100 + day;
}
// Format a short date string from RFC 822 date.
// Output like "Dec 25, 2024". Falls back to empty string.
static void format_short_date(const char* rfc822, char* out, size_t out_size) {
int val = parse_rfc822_date(rfc822);
if (val == 0 || out_size < 13) {
out[0] = '\0';
return;
}
int year = val / 10000;
int month = (val / 100) % 100;
int day = val % 100;
static const char* months[] = {"", "Jan", "Feb", "Mar", "Apr",
"May", "Jun", "Jul", "Aug", "Sep",
"Oct", "Nov", "Dec"};
snprintf(out, out_size, "%s %d, %d", months[month], day, year);
}
// Episode info for sorting
typedef struct {
char dirname[STORAGE_MAX_NAME];
char title[128];
char date_str[64];
int date_sort; // YYYYMMDD
bool has_audio;
} episode_info_t;
static int episode_date_cmp(const void* a, const void* b) {
const episode_info_t* ea = a;
const episode_info_t* eb = b;
// Sort newest first (descending)
return eb->date_sort - ea->date_sort;
}
// Check if an episode directory has a downloaded audio file
static bool episode_has_audio(const char* ep_dir) {
storage_entry_t entries[STORAGE_MAX_DIR_ENTRIES];
int count = storage_list_dir(ep_dir, entries, STORAGE_MAX_DIR_ENTRIES);
for (int i = 0; i < count; i++) {
if (entries[i].type == STORAGE_TYPE_FILE &&
is_audio_file(entries[i].name)) {
return true;
}
}
return false;
}
ui_state_t setup_podcast_feed_screen(lv_obj_t* parent,
const char* dir,
const char* focus_item) {
podcast_feed_screen_state_t* state =
calloc(1, sizeof(podcast_feed_screen_state_t));
if (!state) {
return (ui_state_t){0};
}
if (dir && dir[0]) {
strncpy(state->dir, dir, sizeof(state->dir) - 1);
state->dir[sizeof(state->dir) - 1] = '\0';
}
state->list = bvs_list_create(parent);
// Set topbar title to directory basename
const char* basename = strrchr(state->dir, '/');
basename = (basename && basename[1]) ? basename + 1 : "Episodes";
topbar_set_title(basename);
lv_obj_t* focus_btn = NULL;
// Sync button for this feed
lv_obj_t* sync_btn = bvs_list_add_nav_button(
state->list, NULL, "Sync now", sync_click_cb, NULL, cancel_event_cb);
focus_btn = sync_btn;
// Collect episode info from directory entries
storage_entry_t* entries =
malloc(STORAGE_MAX_DIR_ENTRIES * sizeof(storage_entry_t));
int count = entries ? get_sorted_entries(state->dir, entries,
STORAGE_MAX_DIR_ENTRIES)
: 0;
episode_info_t* episodes = NULL;
int ep_count = 0;
if (count > 0) {
episodes = malloc((size_t)count * sizeof(episode_info_t));
}
if (episodes) {
for (int i = 0; i < count; i++) {
if (entries[i].type != STORAGE_TYPE_DIR) {
continue;
}
// Build episode dir path
char ep_dir[STORAGE_MAX_PATH + STORAGE_MAX_NAME + 2];
snprintf(ep_dir, sizeof(ep_dir), "%s/%s", state->dir,
entries[i].name);
// Parse entry.xml for episode metadata
char entry_path[sizeof(ep_dir) + 16];
snprintf(entry_path, sizeof(entry_path), "%s/entry.xml", ep_dir);
podcast_episode_t parsed;
if (!podcast_parse_entry(entry_path, &parsed)) {
continue;
}
episode_info_t* ep = &episodes[ep_count];
snprintf(ep->dirname, sizeof(ep->dirname), "%s", entries[i].name);
snprintf(ep->title, sizeof(ep->title), "%s", parsed.title);
if (!ep->title[0]) {
strncpy(ep->title, entries[i].name, sizeof(ep->title) - 1);
ep->title[sizeof(ep->title) - 1] = '\0';
}
snprintf(ep->date_str, sizeof(ep->date_str), "%s", parsed.pub_date);
ep->date_sort = parse_rfc822_date(ep->date_str);
// Check download status
ep->has_audio = episode_has_audio(ep_dir);
ep_count++;
}
// Sort by date, newest first
qsort(episodes, (size_t)ep_count, sizeof(episode_info_t),
episode_date_cmp);
}
free(entries);
// Create buttons for episodes (limit to MAX_EPISODE_BUTTONS)
int btn_count =
ep_count > MAX_EPISODE_BUTTONS ? MAX_EPISODE_BUTTONS : ep_count;
state->episode_count = btn_count;
for (int i = 0; i < btn_count; i++) {
episode_info_t* ep = &episodes[i];
// Store dirname in state for click callback
strncpy(state->episode_dirs[i], ep->dirname,
sizeof(state->episode_dirs[i]) - 1);
state->episode_dirs[i][sizeof(state->episode_dirs[i]) - 1] = '\0';
// Build date/status subtitle
char short_date[20];
format_short_date(ep->date_str, short_date, sizeof(short_date));
char subtitle[40];
if (short_date[0] && ep->has_audio) {
snprintf(subtitle, sizeof(subtitle), "%s Ready", short_date);
} else if (short_date[0]) {
snprintf(subtitle, sizeof(subtitle), "%s", short_date);
} else if (ep->has_audio) {
snprintf(subtitle, sizeof(subtitle), "Ready");
} else {
subtitle[0] = '\0';
}
lv_obj_t* btn = bvs_list_add_nav_button(
state->list, NULL, ep->title, episode_click_cb, (void*)(intptr_t)i,
cancel_event_cb);
// Add fixed subtitle label below the scrolling title
if (subtitle[0]) {
lv_obj_set_layout(btn, LV_LAYOUT_NONE);
lv_obj_set_style_pad_ver(btn, 0, 0);
lv_obj_set_height(btn, 34);
lv_obj_t* title_label = lv_obj_get_child(btn, 0);
lv_obj_set_width(title_label, lv_pct(100));
lv_obj_set_pos(title_label, 0, 0);
lv_obj_set_style_max_height(title_label, 18, 0);
lv_obj_t* sub = lv_label_create(btn);
lv_label_set_text(sub, subtitle);
lv_obj_set_width(sub, lv_pct(100));
lv_obj_set_pos(sub, 0, 16);
lv_label_set_long_mode(sub, LV_LABEL_LONG_CLIP);
lv_obj_set_style_text_opa(sub, LV_OPA_60, 0);
}
if (!focus_btn) {
focus_btn = btn;
}
if (focus_item && focus_item[0] &&
strcmp(ep->dirname, focus_item) == 0) {
focus_btn = btn;
}
}
free(episodes);
if (btn_count == 0) {
bvs_list_add_button(state->list, NULL, "No episodes");
}
// Back button
lv_obj_t* back_btn = bvs_list_add_nav_button(
state->list, NULL, "Back", back_event_cb, NULL, cancel_event_cb);
lv_group_focus_obj(focus_btn ? focus_btn : back_btn);
return (ui_state_t){.type = SCREEN_PODCAST_FEED, .podcast_feed = state};
}
void free_podcast_feed_screen(podcast_feed_screen_state_t* state) {
if (!state) {
return;
}
free(state);
}
void update_podcast_feed_screen(podcast_feed_screen_state_t* state) {
(void)state;
}