Initial Commit
This commit is contained in:
commit
f4fa723863
22 changed files with 1402 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
passport-c-media-player
|
||||||
|
media/
|
||||||
38
Makefile
Normal file
38
Makefile
Normal 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
54
README.md
Normal 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
BIN
guide-ui.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
199
src/app.c
Normal file
199
src/app.c
Normal 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
29
src/app.h
Normal 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
BIN
src/app.o
Normal file
Binary file not shown.
191
src/channel.c
Normal file
191
src/channel.c
Normal 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
25
src/channel.h
Normal 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
BIN
src/channel.o
Normal file
Binary file not shown.
112
src/frame_queue.c
Normal file
112
src/frame_queue.c
Normal 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
31
src/frame_queue.h
Normal 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
BIN
src/frame_queue.o
Normal file
Binary file not shown.
17
src/main.c
Normal file
17
src/main.c
Normal 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
BIN
src/main.o
Normal file
Binary file not shown.
327
src/player.c
Normal file
327
src/player.c
Normal 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
30
src/player.h
Normal 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
BIN
src/player.o
Normal file
Binary file not shown.
26
src/theme.h
Normal file
26
src/theme.h
Normal 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
290
src/ui.c
Normal 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
31
src/ui.h
Normal 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
BIN
src/ui.o
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue