Initial Commit

This commit is contained in:
markmental 2026-03-27 21:45:40 -04:00
commit f4fa723863
22 changed files with 1402 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
passport-c-media-player
media/

38
Makefile Normal file
View file

@ -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)

54
README.md Normal file
View file

@ -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.

BIN
guide-ui.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

199
src/app.c Normal file
View file

@ -0,0 +1,199 @@
#include "app.h"
#include <SDL2/SDL_ttf.h>
#include <stdio.h>
#include <string.h>
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();
}

29
src/app.h Normal file
View file

@ -0,0 +1,29 @@
#ifndef APP_H
#define APP_H
#include <SDL2/SDL.h>
#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

BIN
src/app.o Normal file

Binary file not shown.

191
src/channel.c Normal file
View file

@ -0,0 +1,191 @@
#include "channel.h"
#include <ctype.h>
#include <dirent.h>
#include <math.h>
#include <libavformat/avformat.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
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);
}

25
src/channel.h Normal file
View file

@ -0,0 +1,25 @@
#ifndef CHANNEL_H
#define CHANNEL_H
#include <limits.h>
#include <time.h>
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

BIN
src/channel.o Normal file

Binary file not shown.

112
src/frame_queue.c Normal file
View file

@ -0,0 +1,112 @@
#include "frame_queue.h"
#include <stdlib.h>
#include <string.h>
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;
}

31
src/frame_queue.h Normal file
View file

@ -0,0 +1,31 @@
#ifndef FRAME_QUEUE_H
#define FRAME_QUEUE_H
#include <SDL2/SDL.h>
#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

BIN
src/frame_queue.o Normal file

Binary file not shown.

17
src/main.c Normal file
View file

@ -0,0 +1,17 @@
#include "app.h"
#include <stdio.h>
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;
}

BIN
src/main.o Normal file

Binary file not shown.

327
src/player.c Normal file
View file

@ -0,0 +1,327 @@
#include "player.h"
#include <limits.h>
#include <stdint.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libswscale/swscale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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);
}

30
src/player.h Normal file
View file

@ -0,0 +1,30 @@
#ifndef PLAYER_H
#define PLAYER_H
#include <SDL2/SDL.h>
#include <time.h>
#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

BIN
src/player.o Normal file

Binary file not shown.

26
src/theme.h Normal file
View file

@ -0,0 +1,26 @@
#ifndef THEME_H
#define THEME_H
#include <SDL2/SDL.h>
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

290
src/ui.c Normal file
View file

@ -0,0 +1,290 @@
#include "ui.h"
#include <stdio.h>
#include <string.h>
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));
}

31
src/ui.h Normal file
View file

@ -0,0 +1,31 @@
#ifndef UI_H
#define UI_H
#include <SDL2/SDL.h>
#include <SDL2/SDL_ttf.h>
#include <time.h>
#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

BIN
src/ui.o Normal file

Binary file not shown.