#include "ui.h" #include #include static const char *FONT_CANDIDATES[] = { "BigBlueTermPlus Nerd Font Mono.ttf", "BigBlueTermPlusNerdFontMono-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", "/usr/share/fonts/TTF/DejaVuSansMono.ttf", "/usr/share/fonts/truetype/liberation2/LiberationMono-Regular.ttf" }; static void fill_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) { if (!text_texture) { return; } if (text_texture->texture) { SDL_DestroyTexture(text_texture->texture); } memset(text_texture, 0, sizeof(*text_texture)); } static int text_texture_init(UiTextTexture *text_texture, SDL_Renderer *renderer, TTF_Font *font, const char *text, SDL_Color color) { if (!text_texture) { return -1; } memset(text_texture, 0, sizeof(*text_texture)); text_texture->texture = text_to_texture(renderer, font, text, color, &text_texture->width, &text_texture->height); return text_texture->texture ? 0 : -1; } static void set_draw_color(SDL_Renderer *renderer, SDL_Color color) { SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); } static SDL_Texture *text_to_texture(SDL_Renderer *renderer, TTF_Font *font, const char *text, SDL_Color color, int *width, int *height) { SDL_Surface *surface; SDL_Texture *texture; if (!font || !text || text[0] == '\0') { return NULL; } surface = TTF_RenderUTF8_Solid(font, text, color); if (!surface) { return NULL; } texture = SDL_CreateTextureFromSurface(renderer, surface); if (width) { *width = surface->w; } if (height) { *height = surface->h; } SDL_FreeSurface(surface); return texture; } static void draw_cached_text(SDL_Renderer *renderer, const UiTextTexture *text_texture, int x, int y) { SDL_Rect dst; if (!text_texture || !text_texture->texture) { return; } dst.x = x; dst.y = y; dst.w = text_texture->width; dst.h = text_texture->height; SDL_RenderCopy(renderer, text_texture->texture, NULL, &dst); } static void fill_three_stop_gradient(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color top, SDL_Color middle, SDL_Color bottom) { int split; if (!rect || rect->h <= 0) { return; } split = rect->h / 2; for (int i = 0; i < rect->h; ++i) { SDL_Color from = i < split ? top : middle; SDL_Color to = i < split ? middle : bottom; int segment_height = i < split ? SDL_max(split, 1) : SDL_max(rect->h - split, 1); int segment_pos = i < split ? i : i - split; Uint8 r = (Uint8) (from.r + (to.r - from.r) * segment_pos / SDL_max(segment_height - 1, 1)); Uint8 g = (Uint8) (from.g + (to.g - from.g) * segment_pos / SDL_max(segment_height - 1, 1)); Uint8 b = (Uint8) (from.b + (to.b - from.b) * segment_pos / SDL_max(segment_height - 1, 1)); SDL_SetRenderDrawColor(renderer, r, g, b, 255); SDL_RenderDrawLine(renderer, rect->x, rect->y + i, rect->x + rect->w, rect->y + i); } } static void fill_rect_alpha(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); SDL_RenderFillRect(renderer, rect); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); } static void draw_panel_shadow(SDL_Renderer *renderer, const SDL_Rect *rect) { SDL_Rect shadow = {rect->x + 6, rect->y + 6, rect->w, rect->h}; fill_rect_alpha(renderer, &shadow, COLOR_PANEL_SHADOW); } static void draw_gloss_line(SDL_Renderer *renderer, const SDL_Rect *rect) { SDL_Rect gloss = {rect->x + 4, rect->y + 1, SDL_max(rect->w - 8, 0), 2}; if (gloss.w > 0) { fill_rect_alpha(renderer, &gloss, COLOR_GLOSS); } } static void draw_panel_bevel(SDL_Renderer *renderer, const SDL_Rect *rect) { SDL_Rect top_white = {rect->x + 8, rect->y, SDL_max(rect->w - 16, 0), 1}; SDL_Rect top_black = {rect->x + 8, rect->y + 1, SDL_max(rect->w - 16, 0), 1}; if (top_white.w > 0) { fill_rect_alpha(renderer, &top_white, (SDL_Color){255, 255, 255, 128}); fill_rect_alpha(renderer, &top_black, (SDL_Color){0, 0, 0, 51}); } } static void stroke_rect_alpha(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); SDL_RenderDrawRect(renderer, rect); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); } static void draw_selection_glow(SDL_Renderer *renderer, const SDL_Rect *rect) { SDL_Rect outer = *rect; SDL_Rect inner = {rect->x + 1, rect->y + 1, rect->w - 2, rect->h - 2}; stroke_rect_alpha(renderer, &outer, (SDL_Color){0xff, 0xd7, 0x00, 120}); if (inner.w > 0 && inner.h > 0) { stroke_rect_alpha(renderer, &inner, (SDL_Color){0xff, 0xd7, 0x00, 70}); } } static void draw_rounded_top_panel(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color fill_color) { SDL_Rect body; SDL_Rect top; if (!rect || rect->w < 8 || rect->h < 8) { return; } body = (SDL_Rect){rect->x, rect->y + 8, rect->w, rect->h - 8}; top = (SDL_Rect){rect->x + 8, rect->y, rect->w - 16, 8}; fill_rect(renderer, &body, fill_color); fill_rect(renderer, &top, fill_color); fill_rect(renderer, &(SDL_Rect){rect->x + 4, rect->y + 2, rect->w - 8, 6}, fill_color); fill_rect(renderer, &(SDL_Rect){rect->x + 2, rect->y + 4, rect->w - 4, 4}, fill_color); fill_rect(renderer, &(SDL_Rect){rect->x + 1, rect->y + 6, rect->w - 2, 2}, fill_color); set_draw_color(renderer, COLOR_BORDER_DARK); SDL_RenderDrawLine(renderer, rect->x + 8, rect->y, rect->x + rect->w - 9, rect->y); SDL_RenderDrawLine(renderer, rect->x + 4, rect->y + 2, rect->x + 5, rect->y + 1); SDL_RenderDrawLine(renderer, rect->x + rect->w - 6, rect->y + 1, rect->x + rect->w - 5, rect->y + 2); SDL_RenderDrawLine(renderer, rect->x + 1, rect->y + 6, rect->x + 1, rect->y + rect->h - 2); SDL_RenderDrawLine(renderer, rect->x + rect->w - 2, rect->y + 6, rect->x + rect->w - 2, rect->y + rect->h - 2); SDL_RenderDrawLine(renderer, rect->x + 1, rect->y + rect->h - 1, rect->x + rect->w - 2, rect->y + rect->h - 1); } static void draw_beveled_bar(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color top, SDL_Color mid, SDL_Color bottom, SDL_Color top_edge, SDL_Color bottom_edge) { if (!rect) { return; } fill_three_stop_gradient(renderer, rect, top, mid, bottom); SDL_SetRenderDrawColor(renderer, top_edge.r, top_edge.g, top_edge.b, 255); SDL_RenderDrawLine(renderer, rect->x, rect->y, rect->x + rect->w - 1, rect->y); SDL_SetRenderDrawColor(renderer, bottom_edge.r, bottom_edge.g, bottom_edge.b, 255); SDL_RenderDrawLine(renderer, rect->x, rect->y + rect->h - 1, rect->x + rect->w - 1, rect->y + rect->h - 1); } static void draw_pill_button(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color fill, SDL_Color border) { SDL_Rect shadow; if (!rect || rect->w < 10 || rect->h < 10) { return; } shadow = (SDL_Rect){rect->x + 2, rect->y + 2, rect->w, rect->h}; fill_rect_alpha(renderer, &shadow, COLOR_PILL_SHADOW); draw_beveled_bar(renderer, rect, COLOR_PILL_LIGHT, fill, fill, COLOR_GLOSS, border); set_draw_color(renderer, border); SDL_RenderDrawLine(renderer, rect->x + 4, rect->y, rect->x + rect->w - 5, rect->y); SDL_RenderDrawLine(renderer, rect->x + 4, rect->y + rect->h - 1, rect->x + rect->w - 5, rect->y + rect->h - 1); SDL_RenderDrawLine(renderer, rect->x, rect->y + 4, rect->x, rect->y + rect->h - 5); SDL_RenderDrawLine(renderer, rect->x + rect->w - 1, rect->y + 4, rect->x + rect->w - 1, rect->y + rect->h - 5); } static void draw_text_clipped(SDL_Renderer *renderer, TTF_Font *font, const char *text, const SDL_Rect *clip_rect, int x, int y, SDL_Color color) { SDL_Rect previous_clip; SDL_bool had_clip = SDL_RenderIsClipEnabled(renderer); if (had_clip) { SDL_RenderGetClipRect(renderer, &previous_clip); } SDL_RenderSetClipRect(renderer, clip_rect); { SDL_Texture *texture; int width = 0; int height = 0; SDL_Rect dst; texture = text_to_texture(renderer, font, text, color, &width, &height); if (texture) { dst.x = x; dst.y = y; dst.w = width; dst.h = height; SDL_RenderCopy(renderer, texture, NULL, &dst); SDL_DestroyTexture(texture); } } SDL_RenderSetClipRect(renderer, had_clip ? &previous_clip : NULL); } static void fit_text_with_ellipsis(TTF_Font *font, const char *text, int max_width, char *out, size_t out_size) { int width = 0; size_t len; if (!out || out_size == 0) { return; } out[0] = '\0'; if (!font || !text || max_width <= 0) { return; } if (TTF_SizeUTF8(font, text, &width, NULL) == 0 && width <= max_width) { snprintf(out, out_size, "%s", text); return; } len = strlen(text); while (len > 0) { snprintf(out, out_size, "%.*s...", (int) len, text); if (TTF_SizeUTF8(font, out, &width, NULL) == 0 && width <= max_width) { return; } len -= 1; } snprintf(out, out_size, "..."); } static void format_time_compact(char *buffer, size_t buffer_size, time_t value) { struct tm local_time; localtime_r(&value, &local_time); strftime(buffer, buffer_size, "%I:%M %p", &local_time); } static void fill_rect(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color) { set_draw_color(renderer, color); SDL_RenderFillRect(renderer, rect); } static void stroke_rect(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color) { set_draw_color(renderer, color); SDL_RenderDrawRect(renderer, rect); } static SDL_Rect fit_rect(int outer_x, int outer_y, int outer_w, int outer_h, int inner_w, int inner_h) { SDL_Rect dst = {outer_x, outer_y, outer_w, outer_h}; double scale; if (inner_w <= 0 || inner_h <= 0) { return dst; } scale = SDL_min((double) outer_w / (double) inner_w, (double) outer_h / (double) inner_h); dst.w = (int) (inner_w * scale); dst.h = (int) (inner_h * scale); dst.x = outer_x + (outer_w - dst.w) / 2; dst.y = outer_y + (outer_h - dst.h) / 2; return dst; } static void draw_video(SDL_Renderer *renderer, SDL_Texture *video_texture, int texture_width, int texture_height, SDL_Rect destination) { if (!video_texture) { fill_rect(renderer, &destination, COLOR_BLACK); return; } { SDL_Rect fitted = fit_rect(destination.x, destination.y, destination.w, destination.h, texture_width, texture_height); fill_rect(renderer, &destination, COLOR_BLACK); SDL_RenderCopy(renderer, video_texture, NULL, &fitted); } } static void format_clock_label(char *buffer, size_t buffer_size, time_t now, int minute_offset) { struct tm local_time; time_t slot = now + minute_offset * 60; localtime_r(&slot, &local_time); strftime(buffer, buffer_size, "%I:%M %p", &local_time); } static void draw_timeline_header_cached(SDL_Renderer *renderer, const UiCache *cache, SDL_Rect rect) { int segments = 4; draw_beveled_bar(renderer, &rect, COLOR_RIBBON_TOP, (SDL_Color){0x36, 0x2d, 0x83, 0xff}, COLOR_RIBBON_BOTTOM, COLOR_GLOSS, COLOR_BORDER_DARK); for (int i = 0; i < segments; ++i) { int x = rect.x + (rect.w * i) / segments; int centered_x = x - cache->timeline_labels[i].width / 2; draw_cached_text(renderer, &cache->timeline_labels[i], centered_x, rect.y + 10); set_draw_color(renderer, COLOR_GRID_LINE); SDL_RenderDrawLine(renderer, x, rect.y + rect.h - 2, x, rect.y + rect.h + 5 * 76); } } static void draw_status_bar(SDL_Renderer *renderer, TTF_Font *font, const Channel *selected_channel, const SDL_Rect *rect, time_t now_wall) { char clock_text[32]; char channel_text[160]; if (!rect) { return; } draw_beveled_bar(renderer, rect, COLOR_BUTTON_BAR_TOP, COLOR_HEADER_SILVER, COLOR_BUTTON_BAR_BOTTOM, COLOR_GLOSS, COLOR_BORDER_DARK); format_time_compact(clock_text, sizeof(clock_text), now_wall); draw_text_clipped(renderer, font, clock_text, rect, rect->x + 12, rect->y + 10, COLOR_PANEL_TEXT); set_draw_color(renderer, COLOR_STATUS_DIVIDER); SDL_RenderDrawLine(renderer, rect->x + rect->w / 2, rect->y + 8, rect->x + rect->w / 2, rect->y + rect->h - 8); if (selected_channel) { snprintf(channel_text, sizeof(channel_text), "%s %d", selected_channel->name, selected_channel->number); draw_text_clipped(renderer, font, channel_text, rect, rect->x + rect->w - 260, rect->y + 10, COLOR_PANEL_TEXT); } } static void draw_info_panel(SDL_Renderer *renderer, const UiFonts *fonts, const Channel *selected_channel, const SDL_Rect *rect, Uint64 app_start_ticks, Uint64 now_ticks, time_t now_wall) { SDL_Rect accent; SDL_Rect clip_rect; char time_range[64]; char end_text[32]; char description[384]; time_t start_time; time_t end_time; double program_seek; const ProgramEntry *program; if (!rect || !selected_channel) { return; } draw_panel_shadow(renderer, rect); draw_rounded_top_panel(renderer, rect, COLOR_HEADER_SILVER); accent = (SDL_Rect){rect->x + 1, rect->y + 1, rect->w - 2, 38}; fill_rect(renderer, &accent, COLOR_HEADER_SILVER); draw_panel_bevel(renderer, rect); stroke_rect(renderer, rect, COLOR_BORDER_DARK); program = channel_resolve_program(selected_channel, app_start_ticks, now_ticks, &program_seek, NULL); if (!program) { return; } 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(description, sizeof(description), "%s", selected_channel->description[0] != '\0' ? selected_channel->description : "Local programming lineup."); clip_rect = (SDL_Rect){rect->x + 16, rect->y + 12, rect->w - 32, rect->h - 24}; draw_text_clipped(renderer, fonts->large, selected_channel->name, &clip_rect, rect->x + 18, rect->y + 44, COLOR_PANEL_TEXT); draw_text_clipped(renderer, fonts->medium, program->program_title, &clip_rect, rect->x + 18, rect->y + 88, COLOR_PANEL_TEXT); draw_text_clipped(renderer, fonts->small, time_range, &clip_rect, rect->x + 18, rect->y + 124, COLOR_PANEL_TEXT); draw_text_clipped(renderer, fonts->small, description, &clip_rect, rect->x + 18, rect->y + 148, COLOR_PANEL_TEXT); } static void draw_grid_background(SDL_Renderer *renderer, const SDL_Rect *grid_rect, int row_height, double pixels_per_minute) { fill_three_stop_gradient(renderer, grid_rect, (SDL_Color){0x34, 0x26, 0x88, 0xff}, (SDL_Color){0x1b, 0x2f, 0x8f, 0xff}, (SDL_Color){0x00, 0x11, 0x33, 0xff}); for (int minute = 0; minute <= 90; minute += 30) { int x = GUIDE_X_START + (int) (minute * pixels_per_minute); set_draw_color(renderer, COLOR_GRID_LINE); SDL_RenderDrawLine(renderer, x, grid_rect->y, x, grid_rect->y + grid_rect->h); } for (int row = 0; row <= GUIDE_VISIBLE_ROWS; ++row) { int y = grid_rect->y + row * row_height; set_draw_color(renderer, COLOR_ROW_LINE_HI); SDL_RenderDrawLine(renderer, grid_rect->x, y, grid_rect->x + grid_rect->w, y); if (y + row_height - 1 <= grid_rect->y + grid_rect->h) { set_draw_color(renderer, COLOR_ROW_LINE_LO); SDL_RenderDrawLine(renderer, grid_rect->x, y + row_height - 1, grid_rect->x + grid_rect->w, y + row_height - 1); } } } static void draw_footer_legend(SDL_Renderer *renderer, const UiCache *cache, int window_width, int window_height) { SDL_Rect footer = {0, window_height - 54, window_width, 54}; SDL_Rect chip = {window_width / 2 - 180, window_height - 38, 34, 20}; fill_three_stop_gradient(renderer, &footer, COLOR_BUTTON_BAR_TOP, COLOR_HEADER_SILVER, COLOR_BUTTON_BAR_BOTTOM); draw_gloss_line(renderer, &footer); stroke_rect(renderer, &footer, COLOR_BORDER_DARK); draw_pill_button(renderer, &chip, COLOR_HIGHLIGHT_YELLOW, COLOR_BORDER_DARK); draw_cached_text(renderer, &cache->footer_a, chip.x + 11, chip.y + 1); draw_cached_text(renderer, &cache->footer_time, chip.x + 42, chip.y - 1); chip.x += 144; draw_pill_button(renderer, &chip, COLOR_BLOCK_UNSELECTED, COLOR_BORDER_DARK); draw_cached_text(renderer, &cache->footer_b, chip.x + 11, chip.y + 1); draw_cached_text(renderer, &cache->footer_theme, chip.x + 42, chip.y - 1); chip.x += 150; draw_pill_button(renderer, &chip, COLOR_HINT_RED, COLOR_BORDER_DARK); draw_cached_text(renderer, &cache->footer_c, chip.x + 11, chip.y + 1); draw_cached_text(renderer, &cache->footer_title, chip.x + 42, chip.y - 1); } static void draw_scanline_overlay(SDL_Renderer *renderer, int width, int height) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 26); for (int y = 0; y < height; y += 2) { SDL_RenderDrawLine(renderer, 0, y, width, y); } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); } void ui_render_no_media(SDL_Renderer *renderer, const UiCache *cache) { SDL_Rect full = {0, 0, WINDOW_WIDTH, WINDOW_HEIGHT}; fill_rect(renderer, &full, COLOR_NAVY_DARK); draw_cached_text(renderer, &cache->no_media_title, 58, 80); draw_cached_text(renderer, &cache->no_media_body, 58, 136); draw_cached_text(renderer, &cache->no_media_hint, 58, 176); } void ui_render_fullscreen(SDL_Renderer *renderer, SDL_Texture *video_texture, int texture_width, int texture_height) { 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); } void ui_render_guide(SDL_Renderer *renderer, SDL_Texture *video_texture, int texture_width, int texture_height, const UiFonts *fonts, UiCache *cache, const ChannelList *channels, int active_channel, Uint64 app_start_ticks, Uint64 now_ticks, time_t now_wall) { SDL_Rect full = {0, 0, WINDOW_WIDTH, WINDOW_HEIGHT}; SDL_Rect info_panel = {0, 0, GUIDE_INFO_WIDTH, GUIDE_INFO_HEIGHT}; SDL_Rect preview = {WINDOW_WIDTH - GUIDE_PREVIEW_WIDTH, 0, GUIDE_PREVIEW_WIDTH, GUIDE_PREVIEW_HEIGHT}; SDL_Rect status_bar = {GUIDE_INFO_WIDTH, 0, WINDOW_WIDTH - GUIDE_INFO_WIDTH, GUIDE_STATUS_HEIGHT}; SDL_Rect header_row = {0, GUIDE_GRID_TOP - GUIDE_STATUS_HEIGHT, WINDOW_WIDTH, GUIDE_STATUS_HEIGHT}; SDL_Rect grid = {0, GUIDE_GRID_TOP, WINDOW_WIDTH, WINDOW_HEIGHT - GUIDE_GRID_TOP - GUIDE_FOOTER_HEIGHT}; int row_height = grid.h / GUIDE_VISIBLE_ROWS; int timeline_w = WINDOW_WIDTH - GUIDE_X_START - 20; int start_index = active_channel - 2; const Channel *selected_channel = NULL; double pixels_per_minute = timeline_w / 90.0; if (channels && channels->count > 0 && active_channel >= 0 && active_channel < channels->count) { selected_channel = &channels->items[active_channel]; } fill_three_stop_gradient(renderer, &full, (SDL_Color){0x2a, 0x2e, 0x8f, 0xff}, COLOR_GUIDE_TOP, COLOR_GUIDE_BOTTOM); draw_info_panel(renderer, fonts, selected_channel, &info_panel, app_start_ticks, now_ticks, now_wall); 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, selected_channel, &status_bar, now_wall); if (cache->timeline_label_slot != now_wall / 60) { char label[32]; for (int i = 0; i < 4; ++i) { format_clock_label(label, sizeof(label), now_wall, (int) ((TIMELINE_VISIBLE_SECONDS / 60.0 / 4) * 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; } draw_timeline_header_cached(renderer, cache, header_row); draw_grid_background(renderer, &grid, row_height, pixels_per_minute); if (start_index < 0) { start_index = 0; } if (channels && start_index + GUIDE_VISIBLE_ROWS > channels->count) { start_index = SDL_max(0, channels->count - GUIDE_VISIBLE_ROWS); } for (int row = 0; row < GUIDE_VISIBLE_ROWS; ++row) { int channel_index = start_index + row; SDL_Rect row_rect = {0, grid.y + row * row_height, WINDOW_WIDTH, row_height}; SDL_Rect sidebar = {0, row_rect.y, GUIDE_SIDEBAR_WIDTH, row_height}; SDL_Rect timeline_rect = {GUIDE_X_START, row_rect.y + 6, timeline_w, row_height - 12}; SDL_Rect clip = timeline_rect; int is_selected = channel_index == active_channel; SDL_Rect inset = {row_rect.x + 4, row_rect.y + 3, row_rect.w - 8, row_rect.h - 6}; draw_beveled_bar(renderer, &row_rect, is_selected ? COLOR_ROW_ACTIVE_TOP : COLOR_ROW_TOP, is_selected ? COLOR_ROW_ACTIVE_MID : COLOR_ROW_MID, is_selected ? COLOR_ROW_ACTIVE_BOTTOM : COLOR_ROW_BOTTOM, COLOR_GLOSS, COLOR_BORDER_DARK); fill_rect_alpha(renderer, &inset, (SDL_Color){255, 255, 255, is_selected ? 12 : 6}); draw_beveled_bar(renderer, &sidebar, is_selected ? COLOR_ROW_ACTIVE_TOP : COLOR_ROW_TOP, is_selected ? COLOR_ROW_ACTIVE_MID : COLOR_ROW_MID, is_selected ? COLOR_ROW_ACTIVE_BOTTOM : COLOR_ROW_BOTTOM, COLOR_GLOSS, COLOR_BORDER_DARK); if (!channels || channel_index >= channels->count) { continue; } { const Channel *channel = &channels->items[channel_index]; double live_position = 0.0; double program_seek = 0.0; const ProgramEntry *program = channel_resolve_program(channel, app_start_ticks, now_ticks, &program_seek, NULL); time_t guide_view_start_time = now_wall - (30 * 60); time_t program_start_time = now_wall - (time_t) live_position; 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}; char title[128]; if (!program) { continue; } live_position = channel_live_position_precise(channel, app_start_ticks, now_ticks); program_start_time = now_wall - (time_t) program_seek; block_x = GUIDE_X_START + (int) (((double) (program_start_time - guide_view_start_time)) / 60.0 * pixels_per_minute); 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_clipped(renderer, fonts->medium, channel->name, &sidebar, 20, row_rect.y + 12, COLOR_ACTIVE_TEXT); { char number[16]; snprintf(number, sizeof(number), "%d", channel->number); draw_text_clipped(renderer, fonts->medium, number, &sidebar, 176, row_rect.y + 12, COLOR_ACTIVE_TEXT); } } else { draw_cached_text(renderer, &cache->channels[channel_index].name_medium, 20, row_rect.y + 12); draw_cached_text(renderer, &cache->channels[channel_index].number_medium, 176, row_rect.y + 12); } draw_text_clipped(renderer, fonts->small, program->file_name, &sidebar, 20, row_rect.y + 38, COLOR_PALE_BLUE); SDL_RenderSetClipRect(renderer, &clip); draw_beveled_bar(renderer, &block, is_selected ? COLOR_BLOCK_ACTIVE_TOP : COLOR_BLOCK_TOP, is_selected ? COLOR_BLOCK_ACTIVE_MID : COLOR_BLOCK_MID, is_selected ? COLOR_BLOCK_ACTIVE_BOTTOM : COLOR_BLOCK_BOTTOM, is_selected ? COLOR_SELECTION_EDGE : COLOR_GLOSS, COLOR_BORDER_DARK); fit_text_with_ellipsis(is_selected ? fonts->medium : fonts->small, program->program_title, title_rect.w, title, sizeof(title)); draw_text_clipped(renderer, is_selected ? fonts->medium : fonts->small, title, &title_rect, title_rect.x, title_rect.y, is_selected ? COLOR_TEXT_DARK : COLOR_TEXT_LIGHT); SDL_RenderSetClipRect(renderer, NULL); } } if (selected_channel && active_channel >= 0 && start_index <= active_channel && active_channel < start_index + GUIDE_VISIBLE_ROWS) { SDL_Rect highlight = {0, grid.y + (active_channel - start_index) * row_height, WINDOW_WIDTH, row_height}; draw_selection_glow(renderer, &highlight); draw_selection_glow(renderer, &(SDL_Rect){GUIDE_X_START, highlight.y + 6, timeline_w, row_height - 12}); } draw_footer_legend(renderer, cache, WINDOW_WIDTH, WINDOW_HEIGHT); draw_scanline_overlay(renderer, WINDOW_WIDTH, WINDOW_HEIGHT); } int ui_cache_init(UiCache *cache, SDL_Renderer *renderer, const UiFonts *fonts, const ChannelList *channels) { char buffer[256]; if (!cache || !renderer || !fonts) { return -1; } memset(cache, 0, sizeof(*cache)); cache->renderer = renderer; cache->timeline_label_slot = -1; cache->timeline_labels = calloc(4, sizeof(UiTextTexture)); if (!cache->timeline_labels) { ui_cache_destroy(cache); return -1; } if (text_texture_init(&cache->no_media_title, renderer, fonts->large, "Passport-C Media Player", COLOR_TEXT_LIGHT) != 0 || text_texture_init(&cache->no_media_body, renderer, fonts->medium, "No channels found in ./media", COLOR_HIGHLIGHT_YELLOW) != 0 || text_texture_init(&cache->no_media_hint, renderer, fonts->small, "Add MP4 or MKV files to ./media and relaunch.", COLOR_PALE_BLUE) != 0 || text_texture_init(&cache->footer_a, renderer, fonts->medium, "A", COLOR_TEXT_DARK) != 0 || text_texture_init(&cache->footer_time, renderer, fonts->small, "TIME", COLOR_TEXT_DARK) != 0 || text_texture_init(&cache->footer_b, renderer, fonts->medium, "B", COLOR_TEXT_LIGHT) != 0 || text_texture_init(&cache->footer_theme, renderer, fonts->small, "THEME", COLOR_TEXT_DARK) != 0 || text_texture_init(&cache->footer_c, renderer, fonts->medium, "C", COLOR_TEXT_LIGHT) != 0 || text_texture_init(&cache->footer_title, renderer, fonts->small, "TITLE", COLOR_TEXT_DARK) != 0) { ui_cache_destroy(cache); return -1; } if (channels && channels->count > 0) { cache->channels = calloc((size_t) channels->count, sizeof(UiChannelCache)); if (!cache->channels) { ui_cache_destroy(cache); return -1; } cache->channel_count = channels->count; for (int i = 0; i < channels->count; ++i) { const Channel *channel = &channels->items[i]; const ProgramEntry *program = channel->program_count > 0 ? &channel->programs[0] : NULL; snprintf(buffer, sizeof(buffer), "%d", channel->number); if (text_texture_init(&cache->channels[i].name_medium, renderer, fonts->medium, channel->name, COLOR_TEXT_LIGHT) != 0 || text_texture_init(&cache->channels[i].number_medium, renderer, fonts->medium, buffer, COLOR_TEXT_LIGHT) != 0 || text_texture_init(&cache->channels[i].file_small, renderer, fonts->small, program ? program->file_name : "", COLOR_PALE_BLUE) != 0 || text_texture_init(&cache->channels[i].program_small_light, renderer, fonts->small, program ? program->program_title : "", COLOR_TEXT_LIGHT) != 0 || text_texture_init(&cache->channels[i].program_medium_dark, renderer, fonts->medium, program ? program->program_title : "", COLOR_TEXT_DARK) != 0) { ui_cache_destroy(cache); return -1; } snprintf(buffer, sizeof(buffer), "%.0f min - %s", program ? program->duration_seconds / 60.0 : 0.0, program ? program->file_name : ""); if (text_texture_init(&cache->channels[i].detail_small_dark, renderer, fonts->small, buffer, COLOR_TEXT_DARK) != 0) { ui_cache_destroy(cache); return -1; } } } return 0; } void ui_cache_destroy(UiCache *cache) { if (!cache) { return; } text_texture_destroy(&cache->no_media_title); text_texture_destroy(&cache->no_media_body); text_texture_destroy(&cache->no_media_hint); text_texture_destroy(&cache->footer_a); text_texture_destroy(&cache->footer_time); text_texture_destroy(&cache->footer_b); text_texture_destroy(&cache->footer_theme); text_texture_destroy(&cache->footer_c); text_texture_destroy(&cache->footer_title); if (cache->timeline_labels) { for (int i = 0; i < 4; ++i) { text_texture_destroy(&cache->timeline_labels[i]); } free(cache->timeline_labels); } if (cache->channels) { for (int i = 0; i < cache->channel_count; ++i) { text_texture_destroy(&cache->channels[i].name_medium); text_texture_destroy(&cache->channels[i].number_medium); text_texture_destroy(&cache->channels[i].file_small); text_texture_destroy(&cache->channels[i].program_small_light); text_texture_destroy(&cache->channels[i].program_medium_dark); text_texture_destroy(&cache->channels[i].detail_small_dark); } free(cache->channels); } memset(cache, 0, sizeof(*cache)); } int ui_load_fonts(UiFonts *fonts) { if (!fonts) { return -1; } memset(fonts, 0, sizeof(*fonts)); for (size_t i = 0; i < sizeof(FONT_CANDIDATES) / sizeof(FONT_CANDIDATES[0]); ++i) { fonts->small = TTF_OpenFont(FONT_CANDIDATES[i], 18); fonts->medium = TTF_OpenFont(FONT_CANDIDATES[i], 24); fonts->large = TTF_OpenFont(FONT_CANDIDATES[i], 32); if (fonts->small && fonts->medium && fonts->large) { return 0; } ui_destroy_fonts(fonts); } return -1; } void ui_destroy_fonts(UiFonts *fonts) { if (!fonts) { return; } if (fonts->small) { TTF_CloseFont(fonts->small); } if (fonts->medium) { TTF_CloseFont(fonts->medium); } if (fonts->large) { TTF_CloseFont(fonts->large); } memset(fonts, 0, sizeof(*fonts)); }