Add channel banner when switching channels

This commit is contained in:
markmental 2026-03-28 12:46:58 -04:00
commit be4e8b768f
9 changed files with 358 additions and 70 deletions

View file

@ -9,6 +9,11 @@
#include <stdio.h>
#include <string.h>
#define GUIDE_BROWSE_STEP_MINUTES 30
#define GUIDE_BROWSE_MAX_AHEAD_MINUTES (12 * 60)
#define GUIDE_BROWSE_MAX_OFFSET_MINUTES (GUIDE_BROWSE_MAX_AHEAD_MINUTES - ((int) (TIMELINE_VISIBLE_SECONDS / 60.0) - 30))
#define CHANNEL_BANNER_DURATION_MS 3000
static void configure_runtime_environment(void) {
char runtime_dir[64];
char pulse_socket[96];
@ -135,6 +140,24 @@ static void tune_relative(App *app, int delta) {
destroy_video_texture(app);
begin_startup_handoff(app);
player_tune(&app->player, next_index);
app->channel_banner_until = SDL_GetTicks() + CHANNEL_BANNER_DURATION_MS;
}
static void browse_guide_time(App *app, int delta_minutes) {
int next_offset;
if (!app || app->mode != MODE_GUIDE) {
return;
}
next_offset = app->guide_time_offset_minutes + delta_minutes;
if (next_offset < 0) {
next_offset = 0;
}
if (next_offset > GUIDE_BROWSE_MAX_OFFSET_MINUTES) {
next_offset = GUIDE_BROWSE_MAX_OFFSET_MINUTES;
}
app->guide_time_offset_minutes = next_offset;
}
static void toggle_fullscreen(App *app) {
@ -230,6 +253,12 @@ static void handle_event(App *app, const SDL_Event *event) {
case SDLK_DOWN:
tune_relative(app, 1);
break;
case SDLK_LEFT:
browse_guide_time(app, -GUIDE_BROWSE_STEP_MINUTES);
break;
case SDLK_RIGHT:
browse_guide_time(app, GUIDE_BROWSE_STEP_MINUTES);
break;
default:
break;
}
@ -300,6 +329,7 @@ int app_init(App *app) {
if (app->channels.count > 0) {
begin_startup_handoff(app);
player_tune(&app->player, 0);
app->channel_banner_until = SDL_GetTicks() + CHANNEL_BANNER_DURATION_MS;
}
return 0;
@ -356,9 +386,9 @@ void app_run(App *app) {
&app->ui_cache,
&app->channels,
app->player.current_index,
app->app_start_ticks,
now_ticks,
now_wall);
app->app_start_time,
now_wall,
app->guide_time_offset_minutes);
if (app->theme_picker_open) {
ui_render_theme_picker(app->renderer,
&app->fonts,
@ -383,7 +413,14 @@ void app_run(App *app) {
app->texture_width,
app->texture_height,
output_width,
output_height);
output_height,
&GUIDE_THEMES[app->theme_index],
&app->fonts,
&app->channels,
app->player.current_index,
app->app_start_time,
now_wall,
SDL_GetTicks() < app->channel_banner_until);
}
SDL_RenderPresent(app->renderer);

View file

@ -24,8 +24,10 @@ typedef struct App {
int startup_handoff_active;
int last_blackout_state;
Uint32 startup_handoff_until;
Uint32 channel_banner_until;
time_t app_start_time;
Uint64 app_start_ticks;
int guide_time_offset_minutes;
ChannelList channels;
Player player;
UiFonts fonts;

BIN
src/app.o

Binary file not shown.

View file

@ -333,6 +333,16 @@ time_t channel_wall_time_from_ticks(time_t app_start_time, Uint64 app_start_tick
return app_start_time + (time_t) (elapsed_ticks / 1000);
}
double channel_schedule_elapsed_seconds(time_t app_start_time, time_t target_time) {
double elapsed = difftime(target_time, app_start_time);
if (elapsed < 0.0) {
return 0.0;
}
return elapsed;
}
double channel_live_position(const Channel *channel, time_t app_start_time, time_t now) {
double elapsed;
@ -363,16 +373,8 @@ double channel_live_position_precise(const Channel *channel, Uint64 app_start_ti
return fmod(elapsed, channel->total_duration_seconds);
}
const ProgramEntry *channel_program_at_index(const Channel *channel, int program_index) {
if (!channel || program_index < 0 || program_index >= channel->program_count) {
return NULL;
}
return &channel->programs[program_index];
}
const ProgramEntry *channel_resolve_program(const Channel *channel,
Uint64 app_start_ticks,
Uint64 now_ticks,
const ProgramEntry *channel_resolve_program_at_elapsed(const Channel *channel,
double elapsed_seconds,
double *program_seek_seconds,
int *program_index) {
double channel_offset;
@ -381,10 +383,23 @@ const ProgramEntry *channel_resolve_program(const Channel *channel,
return NULL;
}
channel_offset = channel_live_position_precise(channel, app_start_ticks, now_ticks);
if (elapsed_seconds < 0.0) {
elapsed_seconds = 0.0;
}
channel_offset = fmod(elapsed_seconds, channel->total_duration_seconds);
if (channel_offset < 0.0) {
channel_offset += channel->total_duration_seconds;
}
for (int i = 0; i < channel->program_count; ++i) {
const ProgramEntry *program = &channel->programs[i];
double end_offset = program->start_offset_seconds + program->duration_seconds;
if (program->duration_seconds <= 0.0) {
continue;
}
if (channel_offset < end_offset || i == channel->program_count - 1) {
if (program_seek_seconds) {
*program_seek_seconds = channel_offset - program->start_offset_seconds;
@ -401,3 +416,29 @@ const ProgramEntry *channel_resolve_program(const Channel *channel,
return &channel->programs[0];
}
const ProgramEntry *channel_program_at_index(const Channel *channel, int program_index) {
if (!channel || program_index < 0 || program_index >= channel->program_count) {
return NULL;
}
return &channel->programs[program_index];
}
const ProgramEntry *channel_resolve_program(const Channel *channel,
Uint64 app_start_ticks,
Uint64 now_ticks,
double *program_seek_seconds,
int *program_index) {
double elapsed;
if (!channel || channel->program_count == 0 || channel->total_duration_seconds <= 0.0) {
return NULL;
}
if (now_ticks < app_start_ticks) {
now_ticks = app_start_ticks;
}
elapsed = (double) (now_ticks - app_start_ticks) / 1000.0;
return channel_resolve_program_at_elapsed(channel, elapsed, program_seek_seconds, program_index);
}

View file

@ -30,8 +30,13 @@ typedef struct ChannelList {
int channel_list_load(ChannelList *list, const char *media_dir);
void channel_list_destroy(ChannelList *list);
time_t channel_wall_time_from_ticks(time_t app_start_time, Uint64 app_start_ticks, Uint64 now_ticks);
double channel_schedule_elapsed_seconds(time_t app_start_time, time_t target_time);
double channel_live_position(const Channel *channel, time_t app_start_time, time_t now);
double channel_live_position_precise(const Channel *channel, Uint64 app_start_ticks, Uint64 now_ticks);
const ProgramEntry *channel_resolve_program_at_elapsed(const Channel *channel,
double elapsed_seconds,
double *program_seek_seconds,
int *program_index);
const ProgramEntry *channel_resolve_program(const Channel *channel,
Uint64 app_start_ticks,
Uint64 now_ticks,

Binary file not shown.

260
src/ui.c
View file

@ -12,6 +12,7 @@ static const char *FONT_CANDIDATES[] = {
};
static void fill_rect(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color);
static void stroke_rect(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color);
static SDL_Texture *text_to_texture(SDL_Renderer *renderer, TTF_Font *font, const char *text, SDL_Color color, int *width, int *height);
static void text_texture_destroy(UiTextTexture *text_texture) {
@ -235,6 +236,37 @@ static void draw_beveled_bar(SDL_Renderer *renderer,
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
}
static void draw_program_block(SDL_Renderer *renderer,
const SDL_Rect *rect,
SDL_Color top,
SDL_Color mid,
SDL_Color bottom,
SDL_Color gloss,
SDL_Color border,
int is_selected,
SDL_Color selection_edge) {
SDL_Rect inner;
if (!rect || rect->w <= 2 || rect->h <= 2) {
return;
}
draw_beveled_bar(renderer, rect, top, mid, bottom, gloss, border);
stroke_rect(renderer, rect, border);
inner = (SDL_Rect){rect->x + 1, rect->y + 1, rect->w - 2, rect->h - 2};
if (inner.w > 1 && inner.h > 1) {
stroke_rect_alpha(renderer, &inner, color_with_alpha(COLOR_TEXT_LIGHT, is_selected ? 64 : 42));
fill_rect_alpha(renderer,
&(SDL_Rect){inner.x + 1, inner.y + 1, SDL_max(inner.w - 2, 0), 2},
color_with_alpha(COLOR_TEXT_LIGHT, is_selected ? 34 : 20));
}
if (is_selected) {
draw_selection_glow(renderer, rect, selection_edge);
}
}
static void draw_pill_button(SDL_Renderer *renderer, const GuideTheme *theme, const SDL_Rect *rect, SDL_Color fill, SDL_Color border) {
SDL_Rect shadow;
@ -468,9 +500,8 @@ static void draw_info_panel(SDL_Renderer *renderer,
const GuideTheme *theme,
const Channel *selected_channel,
const SDL_Rect *rect,
Uint64 app_start_ticks,
Uint64 now_ticks,
time_t now_wall) {
time_t app_start_time,
time_t guide_focus_time) {
SDL_Rect accent;
SDL_Rect clip_rect;
char time_range[64];
@ -495,11 +526,14 @@ static void draw_info_panel(SDL_Renderer *renderer,
draw_panel_bevel(renderer, rect, theme->gloss);
stroke_rect(renderer, rect, theme->panel_border);
program = channel_resolve_program(selected_channel, app_start_ticks, now_ticks, &program_seek, NULL);
program = channel_resolve_program_at_elapsed(selected_channel,
channel_schedule_elapsed_seconds(app_start_time, guide_focus_time),
&program_seek,
NULL);
if (!program) {
return;
}
start_time = now_wall - (time_t) program_seek;
start_time = guide_focus_time - (time_t) program_seek;
end_time = start_time + (time_t) program->duration_seconds;
format_time_compact(time_range, sizeof(time_range), start_time);
format_time_compact(end_text, sizeof(end_text), end_time);
@ -554,7 +588,7 @@ static void draw_footer_legend(SDL_Renderer *renderer,
int window_width,
int window_height) {
SDL_Rect footer = {0, window_height - 54, window_width, 54};
SDL_Rect chip = {window_width / 2 - 120, window_height - 38, 34, 20};
SDL_Rect chip = {window_width / 2 - 220, window_height - 38, 34, 20};
SDL_Color footer_text = readable_text_color(theme->footer_mid);
fill_three_stop_gradient(renderer,
@ -573,6 +607,11 @@ static void draw_footer_legend(SDL_Renderer *renderer,
draw_pill_button(renderer, theme, &chip, theme->block_mid, theme->panel_border);
draw_text_clipped(renderer, fonts->small, "B", &footer, chip.x + 11, chip.y + 2, footer_text);
draw_text_clipped(renderer, fonts->small, "THEME", &footer, chip.x + 42, chip.y - 1, footer_text);
chip.x += 160;
draw_pill_button(renderer, theme, &chip, theme->row_mid, theme->panel_border);
draw_text_clipped(renderer, fonts->small, "<>", &footer, chip.x + 5, chip.y + 2, footer_text);
draw_text_clipped(renderer, fonts->small, "TIME", &footer, chip.x + 42, chip.y - 1, footer_text);
}
static void draw_scanline_overlay(SDL_Renderer *renderer, int width, int height, const GuideTheme *theme) {
@ -582,6 +621,100 @@ static void draw_scanline_overlay(SDL_Renderer *renderer, int width, int height,
(void) theme;
}
static void draw_channel_status_banner(SDL_Renderer *renderer,
const UiFonts *fonts,
const GuideTheme *theme,
const ChannelList *channels,
int active_channel,
time_t app_start_time,
time_t now_wall,
int window_width,
int window_height) {
SDL_Rect banner;
SDL_Rect channel_pill;
SDL_Rect info_clip;
SDL_Color banner_text;
SDL_Color sub_text;
char channel_text[96];
char time_range[64];
char end_text[32];
time_t start_time;
time_t end_time;
double program_seek = 0.0;
const Channel *channel;
const ProgramEntry *program;
if (!renderer || !fonts || !theme || !channels || active_channel < 0 || active_channel >= channels->count) {
return;
}
channel = &channels->items[active_channel];
program = channel_resolve_program_at_elapsed(channel,
channel_schedule_elapsed_seconds(app_start_time, now_wall),
&program_seek,
NULL);
if (!program) {
return;
}
banner = (SDL_Rect){window_width / 2 - 360, window_height - 92, 720, 64};
if (banner.x < 24) {
banner.x = 24;
banner.w = window_width - 48;
}
channel_pill = (SDL_Rect){banner.x + 10, banner.y + 10, 210, banner.h - 20};
info_clip = (SDL_Rect){channel_pill.x + channel_pill.w + 14, banner.y + 8, banner.w - channel_pill.w - 28, banner.h - 16};
banner_text = ensure_contrast(theme->ribbon_text, theme->status_mid);
sub_text = ensure_contrast(theme->row_subtext, theme->status_mid);
start_time = now_wall - (time_t) program_seek;
end_time = start_time + (time_t) program->duration_seconds;
format_time_compact(time_range, sizeof(time_range), start_time);
format_time_compact(end_text, sizeof(end_text), end_time);
strncat(time_range, " - ", sizeof(time_range) - strlen(time_range) - 1);
strncat(time_range, end_text, sizeof(time_range) - strlen(time_range) - 1);
snprintf(channel_text, sizeof(channel_text), "%s %d", channel->name, channel->number);
draw_panel_shadow(renderer, &banner);
draw_beveled_bar(renderer,
&banner,
blend_color(theme->status_top, theme->status_mid, 220),
theme->status_mid,
blend_color(theme->status_bottom, theme->status_mid, 220),
color_with_alpha(theme->gloss, 42),
theme->panel_border);
stroke_rect(renderer, &banner, theme->panel_border);
draw_pill_button(renderer, theme, &channel_pill, theme->panel_fill, theme->panel_border);
draw_text_shadowed(renderer,
fonts->medium,
channel_text,
&channel_pill,
channel_pill.x + 14,
channel_pill.y + 8,
ensure_contrast(theme->panel_text, theme->panel_fill),
color_with_alpha(COLOR_BLACK, 255));
set_draw_color(renderer, theme->status_divider);
SDL_RenderDrawLine(renderer,
channel_pill.x + channel_pill.w + 6,
banner.y + 10,
channel_pill.x + channel_pill.w + 6,
banner.y + banner.h - 10);
draw_text_clipped(renderer,
fonts->medium,
program->program_title,
&info_clip,
info_clip.x,
banner.y + 10,
banner_text);
draw_text_clipped(renderer,
fonts->small,
time_range,
&info_clip,
info_clip.x,
banner.y + 36,
sub_text);
}
void ui_render_about_modal(SDL_Renderer *renderer,
const UiFonts *fonts,
int window_width,
@ -668,10 +801,28 @@ void ui_render_fullscreen(SDL_Renderer *renderer,
int texture_width,
int texture_height,
int window_width,
int window_height) {
int window_height,
const GuideTheme *theme,
const UiFonts *fonts,
const ChannelList *channels,
int active_channel,
time_t app_start_time,
time_t now_wall,
int show_channel_banner) {
SDL_Rect window = {0, 0, window_width, window_height};
fill_rect(renderer, &window, COLOR_BLACK);
draw_video(renderer, video_texture, texture_width, texture_height, window);
if (show_channel_banner) {
draw_channel_status_banner(renderer,
fonts,
theme,
channels,
active_channel,
app_start_time,
now_wall,
window_width,
window_height);
}
}
void ui_render_guide(SDL_Renderer *renderer,
@ -685,9 +836,9 @@ void ui_render_guide(SDL_Renderer *renderer,
UiCache *cache,
const ChannelList *channels,
int active_channel,
Uint64 app_start_ticks,
Uint64 now_ticks,
time_t now_wall) {
time_t app_start_time,
time_t now_wall,
int guide_time_offset_minutes) {
double scale_x = (double) window_width / WINDOW_WIDTH;
double scale_y = (double) window_height / WINDOW_HEIGHT;
int guide_x_start = (int) (GUIDE_X_START * scale_x);
@ -703,7 +854,11 @@ void ui_render_guide(SDL_Renderer *renderer,
int start_index = active_channel - 2;
const Channel *selected_channel = NULL;
double pixels_per_minute = timeline_w / 90.0;
time_t guide_view_start_time = now_wall - (30 * 60);
time_t guide_view_start_time = now_wall - (30 * 60) + (guide_time_offset_minutes * 60);
time_t guide_focus_time = guide_view_start_time + (30 * 60);
double guide_view_start_seconds = channel_schedule_elapsed_seconds(app_start_time, guide_view_start_time);
double guide_view_end_seconds = guide_view_start_seconds + TIMELINE_VISIBLE_SECONDS;
double guide_focus_seconds = channel_schedule_elapsed_seconds(app_start_time, guide_focus_time);
if (channels && channels->count > 0 && active_channel >= 0 && active_channel < channels->count) {
selected_channel = &channels->items[active_channel];
@ -714,20 +869,20 @@ void ui_render_guide(SDL_Renderer *renderer,
theme->background_top,
theme->background_mid,
theme->background_bottom);
draw_info_panel(renderer, fonts, theme, selected_channel, &info_panel, app_start_ticks, now_ticks, now_wall);
draw_info_panel(renderer, fonts, theme, selected_channel, &info_panel, app_start_time, guide_focus_time);
draw_panel_shadow(renderer, &preview);
fill_rect(renderer, &preview, COLOR_BLACK);
draw_video(renderer, video_texture, texture_width, texture_height, preview);
draw_status_bar(renderer, fonts->medium, theme, selected_channel, &status_bar, now_wall);
if (cache->timeline_label_slot != now_wall / 60 || cache->timeline_theme != theme) {
if (cache->timeline_label_slot != guide_view_start_time / 60 || cache->timeline_theme != theme) {
char label[32];
for (int i = 0; i < 4; ++i) {
format_clock_label(label, sizeof(label), guide_view_start_time, 30 * i);
text_texture_destroy(&cache->timeline_labels[i]);
text_texture_init(&cache->timeline_labels[i], renderer, fonts->small, label, COLOR_TEXT_LIGHT);
}
cache->timeline_label_slot = now_wall / 60;
cache->timeline_label_slot = guide_view_start_time / 60;
cache->timeline_theme = theme;
}
draw_timeline_header_cached(renderer,
@ -778,14 +933,8 @@ void ui_render_guide(SDL_Renderer *renderer,
{
const Channel *channel = &channels->items[channel_index];
double program_seek = 0.0;
const ProgramEntry *program = channel_resolve_program(channel, app_start_ticks, now_ticks, &program_seek, NULL);
double guide_view_start_seconds = (double) guide_view_start_time;
double program_start_time = (double) now_wall;
int block_x = guide_x_start;
int block_w = 48;
SDL_Rect block = {guide_x_start, timeline_rect.y + 4, 48, timeline_rect.h - 8};
SDL_Rect title_rect = {block.x + 8, block.y + 8, block.w - 16, block.h - 16};
double focus_program_seek = 0.0;
const ProgramEntry *focus_program = channel_resolve_program_at_elapsed(channel, guide_focus_seconds, &focus_program_seek, NULL);
char title[128];
SDL_Color row_primary = ensure_contrast(is_selected ? theme->row_active_text : theme->row_text,
is_selected ? theme->row_active_mid : theme->row_mid);
@ -797,14 +946,9 @@ void ui_render_guide(SDL_Renderer *renderer,
? color_with_alpha(COLOR_BLACK, 255)
: color_with_alpha(COLOR_TEXT_LIGHT, 255);
if (!program) {
if (!focus_program) {
continue;
}
program_start_time -= program_seek;
block_x = guide_x_start + (int) ((((program_start_time - guide_view_start_seconds) / 60.0) * pixels_per_minute) + 0.5);
block_w = (int) ((program->duration_seconds / 60.0) * pixels_per_minute);
block = (SDL_Rect){block_x, timeline_rect.y + 4, SDL_max(block_w, 48), timeline_rect.h - 8};
title_rect = (SDL_Rect){block.x + 8, block.y + 8, block.w - 16, block.h - 16};
if (is_selected) {
draw_text_shadowed(renderer, fonts->medium, channel->name, &sidebar, 20, row_rect.y + 12, row_primary, color_with_alpha(COLOR_BLACK, 255));
@ -823,7 +967,7 @@ void ui_render_guide(SDL_Renderer *renderer,
}
draw_text_shadowed(renderer,
fonts->small,
program->file_name,
focus_program->file_name,
&sidebar,
20,
row_rect.y + 38,
@ -831,13 +975,61 @@ void ui_render_guide(SDL_Renderer *renderer,
color_with_alpha(COLOR_BLACK, 255));
SDL_RenderSetClipRect(renderer, &clip);
draw_beveled_bar(renderer,
{
double block_cursor_seconds = guide_view_start_seconds;
int block_guard = channel->program_count;
if (channel->total_duration_seconds > 0.0) {
block_guard *= 1 + (int) (TIMELINE_VISIBLE_SECONDS / channel->total_duration_seconds);
}
block_guard += 8;
if (block_guard < 16) {
block_guard = 16;
}
while (block_cursor_seconds < guide_view_end_seconds && block_guard-- > 0) {
double program_seek = 0.0;
const ProgramEntry *program = channel_resolve_program_at_elapsed(channel, block_cursor_seconds, &program_seek, NULL);
double program_start_seconds;
double program_end_seconds;
double visible_start_seconds;
double visible_end_seconds;
int block_x;
int block_w;
SDL_Rect block;
SDL_Rect title_rect;
if (!program || program->duration_seconds <= 0.0) {
break;
}
program_start_seconds = block_cursor_seconds - program_seek;
program_end_seconds = program_start_seconds + program->duration_seconds;
visible_start_seconds = SDL_max(program_start_seconds, guide_view_start_seconds);
visible_end_seconds = SDL_min(program_end_seconds, guide_view_end_seconds);
block_cursor_seconds = program_end_seconds;
if (visible_end_seconds <= visible_start_seconds) {
continue;
}
block_x = guide_x_start + (int) ((((visible_start_seconds - guide_view_start_seconds) / 60.0) * pixels_per_minute) + 0.5);
block_w = (int) ((((visible_end_seconds - visible_start_seconds) / 60.0) * pixels_per_minute) + 0.5);
block = (SDL_Rect){block_x + 1, timeline_rect.y + 4, SDL_max(block_w - 2, 22), timeline_rect.h - 8};
title_rect = (SDL_Rect){block.x + 8, block.y + 8, block.w - 16, block.h - 16};
draw_program_block(renderer,
&block,
is_selected ? theme->block_active_top : theme->block_top,
is_selected ? theme->block_active_mid : theme->block_mid,
is_selected ? theme->block_active_bottom : theme->block_bottom,
is_selected ? theme->selection_edge : theme->gloss,
theme->panel_border);
theme->gloss,
theme->panel_border,
is_selected,
theme->selection_edge);
if (title_rect.w > 24) {
fit_text_with_ellipsis(is_selected ? fonts->medium : fonts->small,
program->program_title,
title_rect.w,
@ -851,6 +1043,10 @@ void ui_render_guide(SDL_Renderer *renderer,
title_rect.y,
block_text,
block_shadow);
}
}
}
SDL_RenderSetClipRect(renderer, NULL);
}
}

View file

@ -52,7 +52,14 @@ void ui_render_fullscreen(SDL_Renderer *renderer,
int texture_width,
int texture_height,
int window_width,
int window_height);
int window_height,
const GuideTheme *theme,
const UiFonts *fonts,
const ChannelList *channels,
int active_channel,
time_t app_start_time,
time_t now_wall,
int show_channel_banner);
void ui_render_guide(SDL_Renderer *renderer,
SDL_Texture *video_texture,
int texture_width,
@ -64,9 +71,9 @@ void ui_render_guide(SDL_Renderer *renderer,
UiCache *cache,
const ChannelList *channels,
int active_channel,
Uint64 app_start_ticks,
Uint64 now_ticks,
time_t now_wall);
time_t app_start_time,
time_t now_wall,
int guide_time_offset_minutes);
void ui_render_theme_picker(SDL_Renderer *renderer,
const UiFonts *fonts,
int window_width,

BIN
src/ui.o

Binary file not shown.