commit f4fa723863a0e5fbf6a4622b77a2bbf7185e1340 Author: markmental Date: Fri Mar 27 21:45:40 2026 -0400 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdd7f50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +passport-c-media-player +media/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf24809 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +CC ?= cc +CSTD ?= -std=c11 +CFLAGS ?= -O2 -Wall -Wextra -Wpedantic $(CSTD) + +SDL_CFLAGS := $(shell pkg-config --cflags SDL2 SDL2_ttf 2>/dev/null) +SDL_LIBS := $(shell pkg-config --libs SDL2 SDL2_ttf 2>/dev/null) +FFMPEG_CFLAGS := $(shell pkg-config --cflags libavformat libavcodec libswscale libavutil 2>/dev/null) +FFMPEG_LIBS := $(shell pkg-config --libs libavformat libavcodec libswscale libavutil 2>/dev/null) +MULTIARCH_CFLAGS := $(if $(wildcard /usr/include/i386-linux-gnu/SDL2/_real_SDL_config.h),-I/usr/include/i386-linux-gnu,) + +ifeq ($(strip $(SDL_CFLAGS)),) +SDL_CFLAGS := $(shell sdl2-config --cflags 2>/dev/null) $(MULTIARCH_CFLAGS) +SDL_LIBS := $(shell sdl2-config --libs 2>/dev/null) -lSDL2_ttf +endif + +ifeq ($(strip $(FFMPEG_LIBS)),) +FFMPEG_LIBS := -lavformat -lavcodec -lswscale -lavutil +endif + +SRC := $(wildcard src/*.c) +OBJ := $(SRC:.c=.o) +BIN := passport-c-media-player + +CPPFLAGS += -I./src $(SDL_CFLAGS) $(FFMPEG_CFLAGS) +LDLIBS += $(SDL_LIBS) $(FFMPEG_LIBS) -lm + +.PHONY: all run clean + +all: $(BIN) + +$(BIN): $(OBJ) + $(CC) $(OBJ) -o $@ $(LDLIBS) + +run: $(BIN) + ./$(BIN) + +clean: + rm -f $(OBJ) $(BIN) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4332f83 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Passport-C Media Player + +Passport-C Media Player is a low-dependency SDL2 + FFmpeg prototype that recreates the 2004-era Passport DCT cable guide with live-seeking channels. + +## Features + +- Live-TV epoch clock based on `time(NULL)` +- 200 ms black-screen channel tuning delay +- Fullscreen and guide modes +- 5-row Passport-style guide with a 90-minute timeline +- Background decoder thread with a bounded frame queue +- Automatic channel discovery from `./media` + +## Layout + +- `src/` - application, UI, channel scan, and playback code +- `media/` - channel video files +- `guide-ui.jpg` - visual reference used for proportions and palette + +## Controls + +- `Up` / `Down` - surf channels +- `Tab` - toggle guide +- `Esc` - exit guide or quit the app + +## Build + +Install development packages for SDL2, SDL2_ttf, and FFmpeg headers before building. + +Typical Debian or Ubuntu packages: + +```bash +sudo apt install build-essential libsdl2-dev libsdl2-ttf-dev libavformat-dev libavcodec-dev libswscale-dev libavutil-dev +``` + +Then build and run: + +```bash +make +./passport-c-media-player +``` + +## Media + +The player scans `./media` for supported video files and converts filenames into channel names and current program titles. + +Current sample channels: + +- `media/reading.mp4` +- `media/computers.mp4` + +## Fonts + +The UI tries `BigBlueTermPlus Nerd Font` first, then falls back to common Linux monospace fonts. diff --git a/guide-ui.jpg b/guide-ui.jpg new file mode 100644 index 0000000..f440134 Binary files /dev/null and b/guide-ui.jpg differ diff --git a/src/app.c b/src/app.c new file mode 100644 index 0000000..e3f060f --- /dev/null +++ b/src/app.c @@ -0,0 +1,199 @@ +#include "app.h" + +#include +#include +#include + +static void destroy_video_texture(App *app) { + if (app->video_texture) { + SDL_DestroyTexture(app->video_texture); + app->video_texture = NULL; + app->texture_width = 0; + app->texture_height = 0; + } +} + +static int update_video_texture(App *app) { + FrameData frame = {0}; + + if (!player_consume_latest_frame(&app->player, &frame)) { + return 0; + } + + if (!app->video_texture || app->texture_width != frame.width || app->texture_height != frame.height) { + destroy_video_texture(app); + app->video_texture = SDL_CreateTexture(app->renderer, + SDL_PIXELFORMAT_RGBA32, + SDL_TEXTUREACCESS_STREAMING, + frame.width, + frame.height); + if (!app->video_texture) { + frame_data_free(&frame); + return -1; + } + app->texture_width = frame.width; + app->texture_height = frame.height; + SDL_SetTextureBlendMode(app->video_texture, SDL_BLENDMODE_NONE); + } + + SDL_UpdateTexture(app->video_texture, NULL, frame.pixels, frame.stride); + frame_data_free(&frame); + return 0; +} + +static void render_black(App *app) { + SDL_SetRenderDrawColor(app->renderer, 0, 0, 0, 255); + SDL_RenderClear(app->renderer); +} + +static void tune_relative(App *app, int delta) { + int next_index; + + if (app->channels.count == 0) { + return; + } + + next_index = app->player.current_index; + if (next_index < 0) { + next_index = 0; + } + next_index = (next_index + delta + app->channels.count) % app->channels.count; + player_tune(&app->player, next_index); +} + +static void handle_event(App *app, const SDL_Event *event) { + if (event->type == SDL_QUIT) { + app->running = 0; + return; + } + + if (event->type != SDL_KEYDOWN || event->key.repeat) { + return; + } + + switch (event->key.keysym.sym) { + case SDLK_ESCAPE: + if (app->mode == MODE_GUIDE) { + app->mode = MODE_FULLSCREEN; + } else { + app->running = 0; + } + break; + case SDLK_TAB: + app->mode = app->mode == MODE_FULLSCREEN ? MODE_GUIDE : MODE_FULLSCREEN; + break; + case SDLK_UP: + tune_relative(app, -1); + break; + case SDLK_DOWN: + tune_relative(app, 1); + break; + default: + break; + } +} + +int app_init(App *app) { + memset(app, 0, sizeof(*app)); + app->running = 1; + app->mode = MODE_FULLSCREEN; + app->app_start_time = time(NULL); + + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_EVENTS) != 0) { + fprintf(stderr, "SDL init failed: %s\n", SDL_GetError()); + return -1; + } + + if (TTF_Init() != 0) { + fprintf(stderr, "SDL_ttf init failed: %s\n", TTF_GetError()); + return -1; + } + + app->window = SDL_CreateWindow("Passport-C Media Player", + SDL_WINDOWPOS_CENTERED, + SDL_WINDOWPOS_CENTERED, + WINDOW_WIDTH, + WINDOW_HEIGHT, + SDL_WINDOW_SHOWN); + app->renderer = SDL_CreateRenderer(app->window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); + + if (!app->window || !app->renderer) { + fprintf(stderr, "SDL window or renderer creation failed: %s\n", SDL_GetError()); + return -1; + } + + if (ui_load_fonts(&app->fonts) != 0) { + fprintf(stderr, "Unable to load a fallback font.\n"); + return -1; + } + + if (channel_list_load(&app->channels, "./media") < 0) { + fprintf(stderr, "Unable to scan ./media.\n"); + return -1; + } + + if (player_init(&app->player, &app->channels, app->app_start_time) != 0) { + fprintf(stderr, "Unable to initialize player.\n"); + return -1; + } + + if (app->channels.count > 0) { + player_tune(&app->player, 0); + } + + return 0; +} + +void app_run(App *app) { + SDL_Event event; + + while (app->running) { + while (SDL_PollEvent(&event)) { + handle_event(app, &event); + } + + if (update_video_texture(app) != 0) { + app->running = 0; + break; + } + + if (app->channels.count == 0) { + ui_render_no_media(app->renderer, &app->fonts); + } else if (player_is_in_blackout(&app->player)) { + render_black(app); + } else if (app->mode == MODE_GUIDE) { + ui_render_guide(app->renderer, + app->video_texture, + app->texture_width, + app->texture_height, + &app->fonts, + &app->channels, + app->player.current_index, + app->app_start_time, + time(NULL)); + } else { + ui_render_fullscreen(app->renderer, app->video_texture, app->texture_width, app->texture_height); + } + + SDL_RenderPresent(app->renderer); + } +} + +void app_destroy(App *app) { + if (!app) { + return; + } + + player_destroy(&app->player); + channel_list_destroy(&app->channels); + destroy_video_texture(app); + ui_destroy_fonts(&app->fonts); + if (app->renderer) { + SDL_DestroyRenderer(app->renderer); + } + if (app->window) { + SDL_DestroyWindow(app->window); + } + TTF_Quit(); + SDL_Quit(); +} diff --git a/src/app.h b/src/app.h new file mode 100644 index 0000000..214c4f4 --- /dev/null +++ b/src/app.h @@ -0,0 +1,29 @@ +#ifndef APP_H +#define APP_H + +#include + +#include "channel.h" +#include "player.h" +#include "theme.h" +#include "ui.h" + +typedef struct App { + SDL_Window *window; + SDL_Renderer *renderer; + SDL_Texture *video_texture; + int texture_width; + int texture_height; + AppMode mode; + int running; + time_t app_start_time; + ChannelList channels; + Player player; + UiFonts fonts; +} App; + +int app_init(App *app); +void app_run(App *app); +void app_destroy(App *app); + +#endif diff --git a/src/app.o b/src/app.o new file mode 100644 index 0000000..f4227c4 Binary files /dev/null and b/src/app.o differ diff --git a/src/channel.c b/src/channel.c new file mode 100644 index 0000000..c4ef3a1 --- /dev/null +++ b/src/channel.c @@ -0,0 +1,191 @@ +#include "channel.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static int has_supported_extension(const char *name) { + const char *dot = strrchr(name, '.'); + if (!dot) { + return 0; + } + + dot++; + return strcasecmp(dot, "mp4") == 0 || + strcasecmp(dot, "mkv") == 0 || + strcasecmp(dot, "mov") == 0 || + strcasecmp(dot, "avi") == 0 || + strcasecmp(dot, "m4v") == 0; +} + +static void trim_extension(char *text) { + char *dot = strrchr(text, '.'); + if (dot) { + *dot = '\0'; + } +} + +static void make_title_case(char *text) { + int new_word = 1; + for (size_t i = 0; text[i] != '\0'; ++i) { + if (text[i] == '-' || text[i] == '_' || text[i] == '.') { + text[i] = ' '; + new_word = 1; + continue; + } + + if (isspace((unsigned char) text[i])) { + new_word = 1; + continue; + } + + text[i] = new_word ? (char) toupper((unsigned char) text[i]) : (char) tolower((unsigned char) text[i]); + new_word = 0; + } +} + +static void derive_channel_name(const char *file_name, char *out, size_t out_size) { + char temp[128]; + snprintf(temp, sizeof(temp), "%s", file_name); + trim_extension(temp); + + if (strstr(temp, "reading") || strstr(temp, "reads")) { + snprintf(out, out_size, "Reading"); + return; + } + + if (strstr(temp, "computer") || strstr(temp, "half-life")) { + snprintf(out, out_size, "Computers"); + return; + } + + make_title_case(temp); + snprintf(out, out_size, "%s", temp); +} + +static void derive_program_title(const char *file_name, char *out, size_t out_size) { + char temp[128]; + snprintf(temp, sizeof(temp), "%s", file_name); + trim_extension(temp); + make_title_case(temp); + snprintf(out, out_size, "%s", temp); +} + +static double probe_duration_seconds(const char *file_path) { + AVFormatContext *format_context = NULL; + double duration = 0.0; + + if (avformat_open_input(&format_context, file_path, NULL, NULL) < 0) { + return 0.0; + } + + if (avformat_find_stream_info(format_context, NULL) >= 0 && format_context->duration > 0) { + duration = (double) format_context->duration / (double) AV_TIME_BASE; + } + + avformat_close_input(&format_context); + return duration; +} + +static int compare_channels(const void *left, const void *right) { + const Channel *a = (const Channel *) left; + const Channel *b = (const Channel *) right; + return strcmp(a->file_name, b->file_name); +} + +int channel_list_load(ChannelList *list, const char *media_dir) { + DIR *directory; + struct dirent *entry; + int capacity = 8; + + if (!list || !media_dir) { + return -1; + } + + list->items = calloc((size_t) capacity, sizeof(Channel)); + list->count = 0; + if (!list->items) { + return -1; + } + + directory = opendir(media_dir); + if (!directory) { + free(list->items); + list->items = NULL; + return -1; + } + + while ((entry = readdir(directory)) != NULL) { + Channel *channel; + + if (entry->d_name[0] == '.' || !has_supported_extension(entry->d_name)) { + continue; + } + + if (list->count == capacity) { + Channel *resized; + capacity *= 2; + resized = realloc(list->items, (size_t) capacity * sizeof(Channel)); + if (!resized) { + closedir(directory); + channel_list_destroy(list); + return -1; + } + list->items = resized; + } + + channel = &list->items[list->count]; + memset(channel, 0, sizeof(*channel)); + channel->number = 50 + list->count; + snprintf(channel->file_name, sizeof(channel->file_name), "%s", entry->d_name); + snprintf(channel->file_path, sizeof(channel->file_path), "%s/%s", media_dir, entry->d_name); + derive_channel_name(entry->d_name, channel->name, sizeof(channel->name)); + derive_program_title(entry->d_name, channel->program_title, sizeof(channel->program_title)); + channel->duration_seconds = probe_duration_seconds(channel->file_path); + list->count += 1; + } + + closedir(directory); + + if (list->count == 0) { + channel_list_destroy(list); + return 0; + } + + qsort(list->items, (size_t) list->count, sizeof(Channel), compare_channels); + for (int i = 0; i < list->count; ++i) { + list->items[i].number = 50 + i; + } + + return list->count; +} + +void channel_list_destroy(ChannelList *list) { + if (!list) { + return; + } + + free(list->items); + list->items = NULL; + list->count = 0; +} + +double channel_live_position(const Channel *channel, time_t app_start_time, time_t now) { + double elapsed; + + if (!channel || channel->duration_seconds <= 0.0) { + return 0.0; + } + + elapsed = difftime(now, app_start_time); + if (elapsed < 0.0) { + elapsed = 0.0; + } + + return fmod(elapsed, channel->duration_seconds); +} diff --git a/src/channel.h b/src/channel.h new file mode 100644 index 0000000..28fb1d4 --- /dev/null +++ b/src/channel.h @@ -0,0 +1,25 @@ +#ifndef CHANNEL_H +#define CHANNEL_H + +#include +#include + +typedef struct Channel { + int number; + char name[64]; + char file_path[PATH_MAX]; + char file_name[128]; + char program_title[128]; + double duration_seconds; +} Channel; + +typedef struct ChannelList { + Channel *items; + int count; +} ChannelList; + +int channel_list_load(ChannelList *list, const char *media_dir); +void channel_list_destroy(ChannelList *list); +double channel_live_position(const Channel *channel, time_t app_start_time, time_t now); + +#endif diff --git a/src/channel.o b/src/channel.o new file mode 100644 index 0000000..542bcfa Binary files /dev/null and b/src/channel.o differ diff --git a/src/frame_queue.c b/src/frame_queue.c new file mode 100644 index 0000000..4122e4a --- /dev/null +++ b/src/frame_queue.c @@ -0,0 +1,112 @@ +#include "frame_queue.h" + +#include +#include + +void frame_data_free(FrameData *frame) { + if (!frame) { + return; + } + + free(frame->pixels); + memset(frame, 0, sizeof(*frame)); +} + +int frame_queue_init(FrameQueue *queue) { + if (!queue) { + return -1; + } + + memset(queue, 0, sizeof(*queue)); + queue->mutex = SDL_CreateMutex(); + queue->cond = SDL_CreateCond(); + + return (queue->mutex && queue->cond) ? 0 : -1; +} + +void frame_queue_clear(FrameQueue *queue) { + if (!queue || !queue->mutex) { + return; + } + + SDL_LockMutex(queue->mutex); + while (queue->count > 0) { + frame_data_free(&queue->frames[queue->head]); + queue->head = (queue->head + 1) % FRAME_QUEUE_CAPACITY; + queue->count -= 1; + } + queue->head = 0; + SDL_UnlockMutex(queue->mutex); +} + +void frame_queue_destroy(FrameQueue *queue) { + if (!queue) { + return; + } + + frame_queue_clear(queue); + if (queue->cond) { + SDL_DestroyCond(queue->cond); + } + if (queue->mutex) { + SDL_DestroyMutex(queue->mutex); + } + memset(queue, 0, sizeof(*queue)); +} + +int frame_queue_push(FrameQueue *queue, FrameData *frame) { + int tail; + + if (!queue || !frame || !queue->mutex) { + return -1; + } + + SDL_LockMutex(queue->mutex); + + if (queue->count == FRAME_QUEUE_CAPACITY) { + frame_data_free(&queue->frames[queue->head]); + queue->head = (queue->head + 1) % FRAME_QUEUE_CAPACITY; + queue->count -= 1; + } + + tail = (queue->head + queue->count) % FRAME_QUEUE_CAPACITY; + queue->frames[tail] = *frame; + memset(frame, 0, sizeof(*frame)); + queue->count += 1; + SDL_CondSignal(queue->cond); + SDL_UnlockMutex(queue->mutex); + return 0; +} + +int frame_queue_pop_latest(FrameQueue *queue, FrameData *out) { + int latest_index; + + if (!queue || !out || !queue->mutex) { + return 0; + } + + SDL_LockMutex(queue->mutex); + if (queue->count == 0) { + SDL_UnlockMutex(queue->mutex); + return 0; + } + + latest_index = (queue->head + queue->count - 1) % FRAME_QUEUE_CAPACITY; + *out = queue->frames[latest_index]; + memset(&queue->frames[latest_index], 0, sizeof(queue->frames[latest_index])); + + while (queue->count > 0) { + if (queue->head == latest_index) { + queue->head = 0; + queue->count = 0; + break; + } + + frame_data_free(&queue->frames[queue->head]); + queue->head = (queue->head + 1) % FRAME_QUEUE_CAPACITY; + queue->count -= 1; + } + + SDL_UnlockMutex(queue->mutex); + return 1; +} diff --git a/src/frame_queue.h b/src/frame_queue.h new file mode 100644 index 0000000..d028bc8 --- /dev/null +++ b/src/frame_queue.h @@ -0,0 +1,31 @@ +#ifndef FRAME_QUEUE_H +#define FRAME_QUEUE_H + +#include + +#define FRAME_QUEUE_CAPACITY 4 + +typedef struct FrameData { + unsigned char *pixels; + int width; + int height; + int stride; + double pts_seconds; +} FrameData; + +typedef struct FrameQueue { + FrameData frames[FRAME_QUEUE_CAPACITY]; + int head; + int count; + SDL_mutex *mutex; + SDL_cond *cond; +} FrameQueue; + +int frame_queue_init(FrameQueue *queue); +void frame_queue_destroy(FrameQueue *queue); +void frame_queue_clear(FrameQueue *queue); +int frame_queue_push(FrameQueue *queue, FrameData *frame); +int frame_queue_pop_latest(FrameQueue *queue, FrameData *out); +void frame_data_free(FrameData *frame); + +#endif diff --git a/src/frame_queue.o b/src/frame_queue.o new file mode 100644 index 0000000..db95206 Binary files /dev/null and b/src/frame_queue.o differ diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..9748f4e --- /dev/null +++ b/src/main.c @@ -0,0 +1,17 @@ +#include "app.h" + +#include + +int main(void) { + App app; + + if (app_init(&app) != 0) { + fprintf(stderr, "Failed to start Passport-C Media Player.\n"); + app_destroy(&app); + return 1; + } + + app_run(&app); + app_destroy(&app); + return 0; +} diff --git a/src/main.o b/src/main.o new file mode 100644 index 0000000..ce1de1b Binary files /dev/null and b/src/main.o differ diff --git a/src/player.c b/src/player.c new file mode 100644 index 0000000..3083f67 --- /dev/null +++ b/src/player.c @@ -0,0 +1,327 @@ +#include "player.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct DecoderThreadArgs { + Player *player; + const Channel *channel; +} DecoderThreadArgs; + +static void player_set_error(Player *player, const char *message) { + if (!player || !player->error_mutex) { + return; + } + + SDL_LockMutex(player->error_mutex); + snprintf(player->last_error, sizeof(player->last_error), "%s", message ? message : "Unknown playback error"); + SDL_UnlockMutex(player->error_mutex); +} + +static int should_stop(Player *player) { + return player ? SDL_AtomicGet(&player->stop_requested) : 1; +} + +static int decode_thread_main(void *userdata) { + DecoderThreadArgs *args = (DecoderThreadArgs *) userdata; + Player *player = args->player; + const Channel *channel = args->channel; + AVFormatContext *format_context = NULL; + AVCodecContext *codec_context = NULL; + AVPacket *packet = NULL; + AVFrame *decoded_frame = NULL; + struct SwsContext *sws_context = NULL; + const AVCodec *codec = NULL; + int video_stream_index = -1; + int rc = -1; + int rgb_stride = 0; + double seek_seconds = 0.0; + AVRational time_base; + Uint64 wall_base_ms = 0; + int first_frame = 1; + + free(args); + + if (avformat_open_input(&format_context, channel->file_path, NULL, NULL) < 0) { + player_set_error(player, "Unable to open media file"); + return -1; + } + + if (avformat_find_stream_info(format_context, NULL) < 0) { + player_set_error(player, "Unable to read stream metadata"); + goto cleanup; + } + + for (unsigned int i = 0; i < format_context->nb_streams; ++i) { + if (format_context->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { + video_stream_index = (int) i; + break; + } + } + + if (video_stream_index < 0) { + player_set_error(player, "No video stream found"); + goto cleanup; + } + + codec = avcodec_find_decoder(format_context->streams[video_stream_index]->codecpar->codec_id); + if (!codec) { + player_set_error(player, "Unsupported video codec"); + goto cleanup; + } + + codec_context = avcodec_alloc_context3(codec); + if (!codec_context) { + player_set_error(player, "Unable to allocate codec context"); + goto cleanup; + } + + if (avcodec_parameters_to_context(codec_context, format_context->streams[video_stream_index]->codecpar) < 0 || + avcodec_open2(codec_context, codec, NULL) < 0) { + player_set_error(player, "Unable to initialize decoder"); + goto cleanup; + } + + decoded_frame = av_frame_alloc(); + packet = av_packet_alloc(); + if (!decoded_frame || !packet) { + player_set_error(player, "Unable to allocate FFmpeg frame buffers"); + goto cleanup; + } + + rgb_stride = codec_context->width * 4; + time_base = format_context->streams[video_stream_index]->time_base; + sws_context = sws_getContext(codec_context->width, + codec_context->height, + codec_context->pix_fmt, + codec_context->width, + codec_context->height, + AV_PIX_FMT_RGBA, + SWS_BILINEAR, + NULL, + NULL, + NULL); + if (!sws_context) { + player_set_error(player, "Unable to initialize scaler"); + goto cleanup; + } + + seek_seconds = channel_live_position(channel, player->app_start_time, time(NULL)); + if (channel->duration_seconds > 0.0) { + int64_t seek_target = (int64_t) (seek_seconds / av_q2d(time_base)); + avformat_seek_file(format_context, video_stream_index, INT64_MIN, seek_target, INT64_MAX, 0); + avcodec_flush_buffers(codec_context); + } + + while (!should_stop(player)) { + if (av_read_frame(format_context, packet) < 0) { + if (channel->duration_seconds > 0.0) { + seek_seconds = 0.0; + wall_base_ms = SDL_GetTicks64(); + avformat_seek_file(format_context, video_stream_index, INT64_MIN, 0, INT64_MAX, 0); + avcodec_flush_buffers(codec_context); + continue; + } + break; + } + + if (packet->stream_index != video_stream_index) { + av_packet_unref(packet); + continue; + } + + if (avcodec_send_packet(codec_context, packet) < 0) { + av_packet_unref(packet); + continue; + } + av_packet_unref(packet); + + while (!should_stop(player)) { + double frame_seconds; + int delay_ms; + int receive = avcodec_receive_frame(codec_context, decoded_frame); + if (receive == AVERROR(EAGAIN) || receive == AVERROR_EOF) { + break; + } + if (receive < 0) { + player_set_error(player, "Video decode failed"); + goto cleanup; + } + + frame_seconds = decoded_frame->best_effort_timestamp == AV_NOPTS_VALUE + ? seek_seconds + : decoded_frame->best_effort_timestamp * av_q2d(time_base); + + if (first_frame) { + wall_base_ms = SDL_GetTicks64(); + first_frame = 0; + } + + delay_ms = (int) (((frame_seconds - seek_seconds) * 1000.0) - (double) (SDL_GetTicks64() - wall_base_ms)); + if (delay_ms > 1 && delay_ms < 250) { + SDL_Delay((Uint32) delay_ms); + } + + { + FrameData frame = {0}; + uint8_t *dest_data[4] = {0}; + int dest_linesize[4] = {0}; + + frame.width = codec_context->width; + frame.height = codec_context->height; + frame.stride = rgb_stride; + frame.pts_seconds = frame_seconds; + frame.pixels = malloc((size_t) frame.stride * (size_t) frame.height); + if (!frame.pixels) { + player_set_error(player, "Unable to allocate frame buffer"); + goto cleanup; + } + + dest_data[0] = frame.pixels; + dest_linesize[0] = frame.stride; + sws_scale(sws_context, + (const uint8_t *const *) decoded_frame->data, + decoded_frame->linesize, + 0, + codec_context->height, + dest_data, + dest_linesize); + frame_queue_push(&player->frame_queue, &frame); + } + } + } + + rc = 0; + +cleanup: + if (packet) { + av_packet_free(&packet); + } + if (decoded_frame) { + av_frame_free(&decoded_frame); + } + if (codec_context) { + avcodec_free_context(&codec_context); + } + if (format_context) { + avformat_close_input(&format_context); + } + if (sws_context) { + sws_freeContext(sws_context); + } + return rc; +} + +static void player_stop_thread(Player *player) { + if (!player || !player->thread) { + return; + } + + SDL_AtomicSet(&player->stop_requested, 1); + SDL_WaitThread(player->thread, NULL); + player->thread = NULL; +} + +int player_init(Player *player, const ChannelList *channels, time_t app_start_time) { + if (!player || !channels) { + return -1; + } + + memset(player, 0, sizeof(*player)); + player->channels = channels; + player->current_index = -1; + player->app_start_time = app_start_time; + player->error_mutex = SDL_CreateMutex(); + if (!player->error_mutex) { + return -1; + } + + if (frame_queue_init(&player->frame_queue) != 0) { + SDL_DestroyMutex(player->error_mutex); + memset(player, 0, sizeof(*player)); + return -1; + } + + return 0; +} + +void player_destroy(Player *player) { + if (!player) { + return; + } + + player_stop_thread(player); + frame_queue_destroy(&player->frame_queue); + if (player->error_mutex) { + SDL_DestroyMutex(player->error_mutex); + } + memset(player, 0, sizeof(*player)); +} + +int player_tune(Player *player, int channel_index) { + DecoderThreadArgs *args; + + if (!player || !player->channels || channel_index < 0 || channel_index >= player->channels->count) { + return -1; + } + + player_stop_thread(player); + frame_queue_clear(&player->frame_queue); + SDL_AtomicSet(&player->stop_requested, 0); + player->current_index = channel_index; + player->tuning_blackout_until = SDL_GetTicks() + 200; + player_set_error(player, ""); + + args = malloc(sizeof(*args)); + if (!args) { + player_set_error(player, "Unable to allocate thread args"); + return -1; + } + + args->player = player; + args->channel = &player->channels->items[channel_index]; + player->thread = SDL_CreateThread(decode_thread_main, "decoder", args); + if (!player->thread) { + free(args); + player_set_error(player, "Unable to start decoder thread"); + return -1; + } + + return 0; +} + +int player_consume_latest_frame(Player *player, FrameData *out) { + if (!player || !out) { + return 0; + } + + return frame_queue_pop_latest(&player->frame_queue, out); +} + +double player_live_position(const Player *player, time_t now) { + if (!player || !player->channels || player->current_index < 0 || player->current_index >= player->channels->count) { + return 0.0; + } + + return channel_live_position(&player->channels->items[player->current_index], player->app_start_time, now); +} + +int player_is_in_blackout(const Player *player) { + return player && SDL_GetTicks() < player->tuning_blackout_until; +} + +void player_get_error(Player *player, char *buffer, size_t buffer_size) { + if (!player || !buffer || buffer_size == 0) { + return; + } + + SDL_LockMutex(player->error_mutex); + snprintf(buffer, buffer_size, "%s", player->last_error); + SDL_UnlockMutex(player->error_mutex); +} diff --git a/src/player.h b/src/player.h new file mode 100644 index 0000000..b7fd911 --- /dev/null +++ b/src/player.h @@ -0,0 +1,30 @@ +#ifndef PLAYER_H +#define PLAYER_H + +#include +#include + +#include "channel.h" +#include "frame_queue.h" + +typedef struct Player { + FrameQueue frame_queue; + SDL_Thread *thread; + SDL_atomic_t stop_requested; + const ChannelList *channels; + int current_index; + time_t app_start_time; + Uint32 tuning_blackout_until; + SDL_mutex *error_mutex; + char last_error[256]; +} Player; + +int player_init(Player *player, const ChannelList *channels, time_t app_start_time); +void player_destroy(Player *player); +int player_tune(Player *player, int channel_index); +int player_consume_latest_frame(Player *player, FrameData *out); +double player_live_position(const Player *player, time_t now); +int player_is_in_blackout(const Player *player); +void player_get_error(Player *player, char *buffer, size_t buffer_size); + +#endif diff --git a/src/player.o b/src/player.o new file mode 100644 index 0000000..9ce7246 Binary files /dev/null and b/src/player.o differ diff --git a/src/theme.h b/src/theme.h new file mode 100644 index 0000000..b249b35 --- /dev/null +++ b/src/theme.h @@ -0,0 +1,26 @@ +#ifndef THEME_H +#define THEME_H + +#include + +typedef enum AppMode { + MODE_FULLSCREEN = 0, + MODE_GUIDE = 1 +} AppMode; + +static const SDL_Color COLOR_DEEP_BLUE = {0x00, 0x33, 0x99, 0xff}; +static const SDL_Color COLOR_HIGHLIGHT_YELLOW = {0xff, 0xd7, 0x00, 0xff}; +static const SDL_Color COLOR_HINT_RED = {0xcc, 0x00, 0x00, 0xff}; +static const SDL_Color COLOR_NAVY_DARK = {0x05, 0x16, 0x46, 0xff}; +static const SDL_Color COLOR_SLATE = {0x52, 0x67, 0x96, 0xff}; +static const SDL_Color COLOR_PALE_BLUE = {0xcc, 0xd8, 0xf3, 0xff}; +static const SDL_Color COLOR_TEXT_LIGHT = {0xf5, 0xf7, 0xfa, 0xff}; +static const SDL_Color COLOR_TEXT_DARK = {0x0b, 0x11, 0x1d, 0xff}; +static const SDL_Color COLOR_BLACK = {0x00, 0x00, 0x00, 0xff}; + +#define WINDOW_WIDTH 1280 +#define WINDOW_HEIGHT 720 +#define GUIDE_VISIBLE_ROWS 5 +#define TIMELINE_VISIBLE_SECONDS (90.0 * 60.0) + +#endif diff --git a/src/ui.c b/src/ui.c new file mode 100644 index 0000000..654be97 --- /dev/null +++ b/src/ui.c @@ -0,0 +1,290 @@ +#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 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_Blended(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_text(SDL_Renderer *renderer, TTF_Font *font, const char *text, int x, int y, SDL_Color color) { + SDL_Texture *texture; + SDL_Rect dst; + int width = 0; + int height = 0; + + texture = text_to_texture(renderer, font, text, color, &width, &height); + if (!texture) { + return; + } + + dst.x = x; + dst.y = y; + dst.w = width; + dst.h = height; + SDL_RenderCopy(renderer, texture, NULL, &dst); + SDL_DestroyTexture(texture); +} + +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(SDL_Renderer *renderer, const UiFonts *fonts, SDL_Rect rect, time_t now) { + char label[32]; + int segments = 4; + + draw_text(renderer, fonts->medium, "FRI 6/30", rect.x + 12, rect.y + 8, COLOR_TEXT_DARK); + + for (int i = 0; i < segments; ++i) { + int x = rect.x + (rect.w * i) / segments; + format_clock_label(label, sizeof(label), now, (int) ((TIMELINE_VISIBLE_SECONDS / 60.0 / segments) * i)); + draw_text(renderer, fonts->small, label, x + 6, rect.y + 10, COLOR_TEXT_DARK); + set_draw_color(renderer, COLOR_SLATE); + SDL_RenderDrawLine(renderer, x, rect.y + rect.h - 4, x, rect.y + rect.h + 5 * 76); + } +} + +static void draw_footer_legend(SDL_Renderer *renderer, const UiFonts *fonts, int window_width, int window_height) { + SDL_Rect footer = {0, window_height - 54, window_width, 54}; + SDL_Rect chip = {window_width / 2 - 170, window_height - 40, 24, 24}; + + fill_rect(renderer, &footer, COLOR_PALE_BLUE); + set_draw_color(renderer, COLOR_SLATE); + SDL_RenderDrawLine(renderer, footer.x, footer.y, footer.x + footer.w, footer.y); + + fill_rect(renderer, &chip, COLOR_HIGHLIGHT_YELLOW); + draw_text(renderer, fonts->medium, "A", chip.x + 7, chip.y + 2, COLOR_TEXT_DARK); + draw_text(renderer, fonts->medium, "Time", chip.x + 34, chip.y + 1, COLOR_TEXT_DARK); + + chip.x += 132; + fill_rect(renderer, &chip, COLOR_DEEP_BLUE); + draw_text(renderer, fonts->medium, "B", chip.x + 7, chip.y + 2, COLOR_TEXT_LIGHT); + draw_text(renderer, fonts->medium, "Theme", chip.x + 34, chip.y + 1, COLOR_TEXT_DARK); + + chip.x += 136; + fill_rect(renderer, &chip, COLOR_HINT_RED); + draw_text(renderer, fonts->medium, "C", chip.x + 7, chip.y + 2, COLOR_TEXT_LIGHT); + draw_text(renderer, fonts->medium, "Title", chip.x + 34, chip.y + 1, COLOR_TEXT_DARK); +} + +void ui_render_no_media(SDL_Renderer *renderer, const UiFonts *fonts) { + SDL_Rect full = {0, 0, WINDOW_WIDTH, WINDOW_HEIGHT}; + fill_rect(renderer, &full, COLOR_NAVY_DARK); + draw_text(renderer, fonts->large, "Passport-C Media Player", 58, 80, COLOR_TEXT_LIGHT); + draw_text(renderer, fonts->medium, "No channels found in ./media", 58, 136, COLOR_HIGHLIGHT_YELLOW); + draw_text(renderer, fonts->small, "Add MP4 or MKV files to ./media and relaunch.", 58, 176, COLOR_PALE_BLUE); +} + +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, + const ChannelList *channels, + int active_channel, + time_t app_start_time, + time_t now) { + SDL_Rect full = {0, 0, WINDOW_WIDTH, WINDOW_HEIGHT}; + SDL_Rect header_card = {0, 0, 390, 168}; + SDL_Rect preview = {820, 0, 460, 210}; + SDL_Rect header_row = {0, 210, WINDOW_WIDTH, 42}; + SDL_Rect grid = {0, 252, WINDOW_WIDTH, WINDOW_HEIGHT - 306}; + int row_height = grid.h / GUIDE_VISIBLE_ROWS; + int sidebar_width = 240; + int timeline_x = sidebar_width + 8; + int timeline_w = WINDOW_WIDTH - timeline_x - 12; + int start_index = active_channel - 2; + char detail[256]; + + fill_rect(renderer, &full, COLOR_NAVY_DARK); + fill_rect(renderer, &header_card, COLOR_PALE_BLUE); + fill_rect(renderer, &preview, COLOR_BLACK); + draw_video(renderer, video_texture, texture_width, texture_height, preview); + fill_rect(renderer, &header_row, COLOR_SLATE); + + if (channels && channels->count > 0 && active_channel >= 0 && active_channel < channels->count) { + const Channel *current = &channels->items[active_channel]; + snprintf(detail, sizeof(detail), "%.0f min - %s", current->duration_seconds / 60.0, current->file_name); + fill_rect(renderer, &(SDL_Rect){0, 0, 390, 38}, COLOR_HIGHLIGHT_YELLOW); + draw_text(renderer, fonts->large, current->name, 26, 44, COLOR_TEXT_DARK); + draw_text(renderer, fonts->medium, current->program_title, 20, 92, COLOR_TEXT_DARK); + draw_text(renderer, fonts->small, detail, 20, 124, COLOR_TEXT_DARK); + draw_text(renderer, fonts->medium, "Nick * 56", preview.x + preview.w - 126, preview.y + preview.h - 34, COLOR_TEXT_LIGHT); + } + + draw_timeline_header(renderer, fonts, header_row, now); + + 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, sidebar_width, row_height}; + SDL_Rect timeline_rect = {timeline_x, row_rect.y + 6, timeline_w, row_height - 12}; + SDL_Rect clip = timeline_rect; + int is_selected = channel_index == active_channel; + + fill_rect(renderer, &row_rect, is_selected ? COLOR_DEEP_BLUE : COLOR_NAVY_DARK); + fill_rect(renderer, &sidebar, is_selected ? COLOR_DEEP_BLUE : COLOR_SLATE); + set_draw_color(renderer, COLOR_PALE_BLUE); + SDL_RenderDrawLine(renderer, row_rect.x, row_rect.y, row_rect.x + row_rect.w, row_rect.y); + + if (!channels || channel_index >= channels->count) { + continue; + } + + { + const Channel *channel = &channels->items[channel_index]; + char number[16]; + double live_position = channel_live_position(channel, app_start_time, now); + double pixels_per_second = timeline_w / TIMELINE_VISIBLE_SECONDS; + int block_x = timeline_x - (int) (live_position * pixels_per_second); + int block_w = (int) (channel->duration_seconds * pixels_per_second); + SDL_Rect block = {block_x, timeline_rect.y + 4, SDL_max(block_w, 48), timeline_rect.h - 8}; + + snprintf(number, sizeof(number), "%d", channel->number); + draw_text(renderer, fonts->medium, channel->name, 20, row_rect.y + 12, COLOR_TEXT_LIGHT); + draw_text(renderer, fonts->medium, number, 176, row_rect.y + 12, COLOR_TEXT_LIGHT); + draw_text(renderer, fonts->small, channel->file_name, 20, row_rect.y + 38, COLOR_PALE_BLUE); + + fill_rect(renderer, &timeline_rect, COLOR_NAVY_DARK); + SDL_RenderSetClipRect(renderer, &clip); + fill_rect(renderer, &block, is_selected ? COLOR_HIGHLIGHT_YELLOW : COLOR_DEEP_BLUE); + stroke_rect(renderer, &block, COLOR_PALE_BLUE); + draw_text(renderer, + is_selected ? fonts->medium : fonts->small, + channel->program_title, + block.x + 10, + block.y + 10, + is_selected ? COLOR_TEXT_DARK : COLOR_TEXT_LIGHT); + SDL_RenderSetClipRect(renderer, NULL); + stroke_rect(renderer, &timeline_rect, COLOR_SLATE); + } + } + + draw_footer_legend(renderer, fonts, WINDOW_WIDTH, WINDOW_HEIGHT); +} + +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)); +} diff --git a/src/ui.h b/src/ui.h new file mode 100644 index 0000000..c251352 --- /dev/null +++ b/src/ui.h @@ -0,0 +1,31 @@ +#ifndef UI_H +#define UI_H + +#include +#include +#include + +#include "channel.h" +#include "theme.h" + +typedef struct UiFonts { + TTF_Font *small; + TTF_Font *medium; + TTF_Font *large; +} UiFonts; + +void ui_render_fullscreen(SDL_Renderer *renderer, SDL_Texture *video_texture, int texture_width, int texture_height); +void ui_render_guide(SDL_Renderer *renderer, + SDL_Texture *video_texture, + int texture_width, + int texture_height, + const UiFonts *fonts, + const ChannelList *channels, + int active_channel, + time_t app_start_time, + time_t now); +void ui_render_no_media(SDL_Renderer *renderer, const UiFonts *fonts); +int ui_load_fonts(UiFonts *fonts); +void ui_destroy_fonts(UiFonts *fonts); + +#endif diff --git a/src/ui.o b/src/ui.o new file mode 100644 index 0000000..de0ec25 Binary files /dev/null and b/src/ui.o differ