Files
MontaukOS/programs/src/video/main.cpp
T

1435 lines
45 KiB
C++

/*
* main.cpp
* MontaukOS Videos app -- AVI playback with PCM audio
* Copyright (c) 2026 Daniel Hammer
*/
#include <montauk/syscall.h>
#include <montauk/string.h>
#include <montauk/heap.h>
#include <gui/gui.hpp>
#include <gui/standalone.hpp>
#include <gui/truetype.hpp>
#include <gui/svg.hpp>
extern "C" {
#include <stdio.h>
#include <string.h>
}
#include "avi.hpp"
using namespace gui;
static constexpr int INIT_W = 1040;
static constexpr int INIT_H = 720;
static constexpr int PROGRESS_H = 40;
static constexpr int TRANSPORT_H = 72;
static constexpr int TOOL_ICON_SIZE = 16;
static constexpr int FONT_SIZE = 18;
static constexpr int FONT_SIZE_SM = 16;
static constexpr int FONT_SIZE_LG = 22;
static constexpr int MAX_FILES = 128;
static constexpr int MAX_PATH = 256;
static constexpr int MAX_FILTER = 64;
static constexpr uint64_t MAX_FILE_SIZE = 1024ull * 1024ull * 1024ull;
static constexpr int AUDIO_OUT_CHANNELS = 2;
static constexpr int AUDIO_OUT_BITS = 16;
static constexpr int AUDIO_RING_BYTES = 0x8000;
static constexpr int AUDIO_CHUNK_BYTES = 4096;
static constexpr Color BG_TOP = Color::from_rgb(0x07, 0x09, 0x0C);
static constexpr Color BG_BOTTOM = Color::from_rgb(0x12, 0x15, 0x1A);
static constexpr Color TOOLBAR_BG = Color::from_rgb(0x10, 0x13, 0x18);
static constexpr Color PANEL_BG = Color::from_rgb(0x14, 0x18, 0x1F);
static constexpr Color PANEL_BG_ALT = Color::from_rgb(0x0E, 0x12, 0x17);
static constexpr Color VIDEO_BG = Color::from_rgb(0x03, 0x04, 0x06);
static constexpr Color BORDER = Color::from_rgb(0x2A, 0x31, 0x3A);
static constexpr Color TEXT = Color::from_rgb(0xF4, 0xF0, 0xE8);
static constexpr Color TEXT_DIM = Color::from_rgb(0xA2, 0xA8, 0xB3);
static constexpr Color TEXT_MUTED = Color::from_rgb(0x73, 0x7A, 0x84);
static constexpr Color ACCENT = Color::from_rgb(0xD9, 0x4F, 0x32);
static constexpr Color ACCENT_DARK = Color::from_rgb(0xA9, 0x35, 0x20);
static constexpr Color ACCENT_SOFT = Color::from_rgb(0x3A, 0x1C, 0x18);
static constexpr Color TRACK_BG = Color::from_rgb(0x29, 0x31, 0x3B);
static constexpr Color BTN_BG = Color::from_rgb(0x1C, 0x23, 0x2C);
static constexpr Color BTN_ACTIVE = Color::from_rgb(0xD9, 0x4F, 0x32);
static constexpr Color BTN_STOP = Color::from_rgb(0xC9, 0x3A, 0x31);
static constexpr Color WHITE = Color::from_rgb(0xFF, 0xFF, 0xFF);
enum class PlayState {
Stopped,
Playing,
Paused,
};
struct VideoEntry {
char name[128];
};
struct AppState {
int win_w;
int win_h;
TrueTypeFont* font;
char dir_path[MAX_PATH];
VideoEntry files[MAX_FILES];
int file_count;
int scroll_y;
int hovered_item;
int selected_item;
int filtered_indices[MAX_FILES];
int filtered_count;
char filter_text[MAX_FILTER];
bool filter_active;
bool loop_enabled;
int current_video;
avi::File movie;
uint32_t* frame_pixels;
int displayed_frame_index;
char status_text[128];
PlayState play_state;
uint64_t base_time_us;
uint64_t play_start_ms;
int audio_handle;
uint64_t audio_hw_total_bytes;
uint64_t audio_output_written_bytes;
uint64_t audio_output_total_bytes;
int audio_hw_prev_pos;
bool audio_hw_valid;
int audio_chunk_index;
uint32_t audio_chunk_pos;
uint8_t audio_buf[AUDIO_CHUNK_BYTES];
int audio_buf_len;
int audio_buf_pos;
bool dragging_progress;
uint64_t drag_time_us;
SvgIcon ico_rewind;
SvgIcon ico_play;
SvgIcon ico_pause;
SvgIcon ico_stop;
SvgIcon ico_forward;
SvgIcon ico_play_w;
SvgIcon ico_pause_w;
SvgIcon ico_stop_w;
SvgIcon ico_repeat;
SvgIcon ico_repeat_w;
};
static AppState g = {};
static void transport_layout(int* out_x, int* out_y, int* out_w, int* out_h, int* out_gap);
static int text_w(const char* text, int size = FONT_SIZE) {
return (g.font && text) ? g.font->measure_text(text, size) : 0;
}
static int font_h(int size = FONT_SIZE) {
if (!g.font) return size;
GlyphCache* cache = g.font->get_cache(size);
return cache ? (cache->ascent - cache->descent) : size;
}
static void px_fill(uint32_t* px, int bw, int bh, int x, int y, int w, int h, Color c) {
if (!px || w <= 0 || h <= 0) return;
int x0 = gui_max(0, x);
int y0 = gui_max(0, y);
int x1 = gui_min(bw, x + w);
int y1 = gui_min(bh, y + h);
if (x0 >= x1 || y0 >= y1) return;
uint32_t v = c.to_pixel();
for (int row = y0; row < y1; row++) {
for (int col = x0; col < x1; col++) {
px[row * bw + col] = v;
}
}
}
static void px_vgradient(uint32_t* px, int bw, int bh, int x, int y, int w, int h,
Color top, Color bottom) {
if (!px || w <= 0 || h <= 0) return;
int x0 = gui_max(0, x);
int y0 = gui_max(0, y);
int x1 = gui_min(bw, x + w);
int y1 = gui_min(bh, y + h);
if (x0 >= x1 || y0 >= y1) return;
for (int row = y0; row < y1; row++) {
int t = (h <= 1) ? 255 : ((row - y) * 255) / (h - 1);
int inv = 255 - t;
Color c = {
(uint8_t)((top.r * inv + bottom.r * t + 127) / 255),
(uint8_t)((top.g * inv + bottom.g * t + 127) / 255),
(uint8_t)((top.b * inv + bottom.b * t + 127) / 255),
255
};
uint32_t v = c.to_pixel();
for (int col = x0; col < x1; col++) {
px[row * bw + col] = v;
}
}
}
static void px_hline(uint32_t* px, int bw, int bh, int x, int y, int w, Color c) {
if (!px || y < 0 || y >= bh || w <= 0) return;
int x0 = gui_max(0, x);
int x1 = gui_min(bw, x + w);
uint32_t v = c.to_pixel();
for (int col = x0; col < x1; col++) {
px[y * bw + col] = v;
}
}
static void px_vline(uint32_t* px, int bw, int bh, int x, int y, int h, Color c) {
if (!px || x < 0 || x >= bw || h <= 0) return;
int y0 = gui_max(0, y);
int y1 = gui_min(bh, y + h);
uint32_t v = c.to_pixel();
for (int row = y0; row < y1; row++) {
px[row * bw + x] = v;
}
}
static void px_rect(uint32_t* px, int bw, int bh, int x, int y, int w, int h, Color c) {
if (w <= 0 || h <= 0) return;
px_hline(px, bw, bh, x, y, w, c);
px_hline(px, bw, bh, x, y + h - 1, w, c);
px_vline(px, bw, bh, x, y, h, c);
px_vline(px, bw, bh, x + w - 1, y, h, c);
}
static void px_fill_rounded(uint32_t* px, int bw, int bh,
int x, int y, int w, int h, int r, Color c) {
if (!px || w <= 0 || h <= 0) return;
if (r < 0) r = 0;
int max_r = gui_min(w / 2, h / 2);
if (r > max_r) r = max_r;
uint32_t v = c.to_pixel();
for (int row = 0; row < h; row++) {
int py = y + row;
if (py < 0 || py >= bh) continue;
for (int col = 0; col < w; col++) {
int px_x = x + col;
if (px_x < 0 || px_x >= bw) continue;
bool skip = false;
int cx = 0, cy = 0;
if (col < r && row < r) { cx = r - col - 1; cy = r - row - 1; skip = (cx * cx + cy * cy >= r * r); }
else if (col >= w - r && row < r) { cx = col - (w - r); cy = r - row - 1; skip = (cx * cx + cy * cy >= r * r); }
else if (col < r && row >= h - r) { cx = r - col - 1; cy = row - (h - r); skip = (cx * cx + cy * cy >= r * r); }
else if (col >= w - r && row >= h - r) { cx = col - (w - r); cy = row - (h - r); skip = (cx * cx + cy * cy >= r * r); }
if (!skip) px[py * bw + px_x] = v;
}
}
}
static void px_circle(uint32_t* px, int bw, int bh, int cx, int cy, int r, Color c) {
if (!px || r <= 0) return;
uint32_t v = c.to_pixel();
for (int dy = -r; dy <= r; dy++) {
int py = cy + dy;
if (py < 0 || py >= bh) continue;
for (int dx = -r; dx <= r; dx++) {
int px_x = cx + dx;
if (px_x < 0 || px_x >= bw) continue;
if (dx * dx + dy * dy <= r * r)
px[py * bw + px_x] = v;
}
}
}
static void px_icon(uint32_t* px, int bw, int bh, int x, int y, const SvgIcon& icon) {
if (!px || !icon.pixels) return;
for (int row = 0; row < icon.height; row++) {
int dy = y + row;
if (dy < 0 || dy >= bh) continue;
for (int col = 0; col < icon.width; col++) {
int dx = x + col;
if (dx < 0 || dx >= bw) continue;
uint32_t src = icon.pixels[row * icon.width + col];
uint8_t sa = (src >> 24) & 0xFF;
if (sa == 0) continue;
if (sa == 255) {
px[dy * bw + dx] = src;
} else {
uint32_t dst = px[dy * bw + dx];
uint32_t inv = 255 - sa;
uint32_t r = (sa * ((src >> 16) & 0xFF) + inv * ((dst >> 16) & 0xFF) + 127) / 255;
uint32_t g0 = (sa * ((src >> 8) & 0xFF) + inv * ((dst >> 8) & 0xFF) + 127) / 255;
uint32_t b = (sa * (src & 0xFF) + inv * (dst & 0xFF) + 127) / 255;
px[dy * bw + dx] = 0xFF000000u | (r << 16) | (g0 << 8) | b;
}
}
}
}
static void px_text(uint32_t* px, int bw, int bh, int x, int y,
const char* text, Color c, int size = FONT_SIZE) {
if (g.font && text && text[0])
g.font->draw_to_buffer(px, bw, bh, x, y, text, c, size);
}
static void copy_text_fit(char* out, int out_cap, const char* text, int max_w, int size) {
if (!out || out_cap <= 0) return;
if (!text) {
out[0] = '\0';
return;
}
montauk::strncpy(out, text, out_cap);
if (!g.font || text_w(out, size) <= max_w) return;
int len = montauk::slen(out);
const char* ellipsis = "...";
while (len > 0) {
len--;
out[len] = '\0';
char trial[160];
montauk::strncpy(trial, out, (int)sizeof(trial));
int tlen = montauk::slen(trial);
if (tlen < (int)sizeof(trial) - 4) {
trial[tlen + 0] = '.';
trial[tlen + 1] = '.';
trial[tlen + 2] = '.';
trial[tlen + 3] = '\0';
}
if (text_w(trial, size) <= max_w || len == 0) {
montauk::strncpy(out, trial, out_cap);
return;
}
}
montauk::strncpy(out, ellipsis, out_cap);
}
static void px_text_fit(uint32_t* px, int bw, int bh, int x, int y, int max_w,
const char* text, Color c, int size = FONT_SIZE) {
char fitted[160];
copy_text_fit(fitted, (int)sizeof(fitted), text, max_w, size);
px_text(px, bw, bh, x, y, fitted, c, size);
}
static void px_icon_button(uint32_t* px, int bw, int bh, int x, int y, int w, int h,
const SvgIcon& icon, Color bg, int r) {
px_fill_rounded(px, bw, bh, x, y, w, h, r, bg);
px_icon(px, bw, bh, x + (w - icon.width) / 2, y + (h - icon.height) / 2, icon);
}
static char ascii_lower(char c) {
return (c >= 'A' && c <= 'Z') ? (char)(c + 32) : c;
}
static bool is_printable_ascii(char c) {
return c >= 32 && c < 127;
}
static bool str_ends_with(const char* s, const char* suffix) {
int sl = montauk::slen(s);
int xl = montauk::slen(suffix);
if (xl > sl) return false;
for (int i = 0; i < xl; i++) {
if (ascii_lower(s[sl - xl + i]) != ascii_lower(suffix[i]))
return false;
}
return true;
}
static bool str_eq_case_insensitive(const char* a, const char* b) {
int i = 0;
while (a[i] || b[i]) {
if (ascii_lower(a[i]) != ascii_lower(b[i])) return false;
i++;
}
return true;
}
static bool str_contains_case_insensitive(const char* text, const char* needle) {
if (!needle || !needle[0]) return true;
if (!text || !text[0]) return false;
int tlen = montauk::slen(text);
int nlen = montauk::slen(needle);
if (nlen > tlen) return false;
for (int i = 0; i <= tlen - nlen; i++) {
bool match = true;
for (int j = 0; j < nlen; j++) {
if (ascii_lower(text[i + j]) != ascii_lower(needle[j])) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
static void set_status(const char* text) {
montauk::strncpy(g.status_text, text ? text : "", (int)sizeof(g.status_text));
}
static uint64_t media_duration_us() {
return g.movie.valid ? avi::duration_us(g.movie) : 0;
}
static Rect content_rect() {
int bottom = g.win_h - PROGRESS_H - TRANSPORT_H;
if (bottom < 160) bottom = 160;
return {0, 0, g.win_w, bottom};
}
static Rect video_rect() {
return content_rect();
}
static Rect repeat_button_rect() {
int bx, by, bw, bh, gap;
transport_layout(&bx, &by, &bw, &bh, &gap);
(void)bx;
(void)gap;
return {g.win_w - bw - 24, by, bw, bh};
}
static Rect progress_rect() {
int y = g.win_h - TRANSPORT_H - PROGRESS_H;
return {16, y + 28, g.win_w - 32, 6};
}
static void transport_layout(int* out_x, int* out_y, int* out_w, int* out_h, int* out_gap) {
int btn_w = 48;
int btn_h = 32;
int gap = 10;
int total_w = btn_w * 4 + gap * 3;
int x = (g.win_w - total_w) / 2;
int y = g.win_h - TRANSPORT_H + (TRANSPORT_H - btn_h) / 2;
if (out_x) *out_x = x;
if (out_y) *out_y = y;
if (out_w) *out_w = btn_w;
if (out_h) *out_h = btn_h;
if (out_gap) *out_gap = gap;
}
static int visible_items() { return 0; }
static int filtered_raw_at(int visible_index) {
if (visible_index < 0 || visible_index >= g.filtered_count) return -1;
return g.filtered_indices[visible_index];
}
static bool filtered_contains(int raw_index) {
if (raw_index < 0) return false;
for (int i = 0; i < g.filtered_count; i++) {
if (g.filtered_indices[i] == raw_index) return true;
}
return false;
}
static int selected_visible_index() {
for (int i = 0; i < g.filtered_count; i++) {
if (g.filtered_indices[i] == g.selected_item) return i;
}
return -1;
}
static void clamp_list_scroll() {
int max_scroll = g.filtered_count;
if (max_scroll < 0) max_scroll = 0;
if (g.scroll_y < 0) g.scroll_y = 0;
if (g.scroll_y > max_scroll) g.scroll_y = max_scroll;
}
static void ensure_selected_visible() {
int sel = selected_visible_index();
if (sel < 0) {
g.scroll_y = 0;
return;
}
g.scroll_y = sel;
}
static void rebuild_filtered_view() {
int prev_selected = g.selected_item;
g.filtered_count = 0;
for (int i = 0; i < g.file_count && g.filtered_count < MAX_FILES; i++) {
if (str_contains_case_insensitive(g.files[i].name, g.filter_text))
g.filtered_indices[g.filtered_count++] = i;
}
if (!filtered_contains(g.hovered_item)) g.hovered_item = -1;
if (g.filtered_count == 0) {
g.selected_item = -1;
g.scroll_y = 0;
return;
}
if (filtered_contains(prev_selected)) g.selected_item = prev_selected;
else if (filtered_contains(g.current_video)) g.selected_item = g.current_video;
else g.selected_item = g.filtered_indices[0];
clamp_list_scroll();
ensure_selected_visible();
}
static void append_filter_char(char c) {
int len = montauk::slen(g.filter_text);
if (len >= MAX_FILTER - 1) return;
g.filter_text[len] = c;
g.filter_text[len + 1] = '\0';
rebuild_filtered_view();
}
static void pop_filter_char() {
int len = montauk::slen(g.filter_text);
if (len <= 0) return;
g.filter_text[len - 1] = '\0';
rebuild_filtered_view();
}
static void clear_filter() {
if (!g.filter_text[0]) return;
g.filter_text[0] = '\0';
rebuild_filtered_view();
}
static void move_selection(int delta) {
if (g.filtered_count <= 0) {
g.selected_item = -1;
g.scroll_y = 0;
return;
}
int sel = selected_visible_index();
if (sel < 0) sel = (delta >= 0) ? 0 : (g.filtered_count - 1);
else {
sel += delta;
if (sel < 0) sel = 0;
if (sel >= g.filtered_count) sel = g.filtered_count - 1;
}
g.selected_item = g.filtered_indices[sel];
ensure_selected_visible();
}
static void scan_directory() {
g.file_count = 0;
const char* names[256];
int count = montauk::readdir(g.dir_path, names, 256);
if (count < 0) {
rebuild_filtered_view();
return;
}
for (int i = 0; i < count && g.file_count < MAX_FILES; i++) {
if (!str_ends_with(names[i], ".avi")) continue;
montauk::strncpy(g.files[g.file_count].name, names[i], (int)sizeof(g.files[g.file_count].name));
g.file_count++;
}
rebuild_filtered_view();
}
static void close_audio_session() {
if (g.audio_handle >= 0) {
montauk::audio_close(g.audio_handle);
g.audio_handle = -1;
}
g.audio_hw_total_bytes = 0;
g.audio_output_written_bytes = 0;
g.audio_output_total_bytes = 0;
g.audio_hw_prev_pos = 0;
g.audio_hw_valid = false;
g.audio_chunk_index = 0;
g.audio_chunk_pos = 0;
g.audio_buf_len = 0;
g.audio_buf_pos = 0;
}
static void unload_movie() {
close_audio_session();
avi::reset(g.movie);
if (g.frame_pixels) {
montauk::mfree(g.frame_pixels);
g.frame_pixels = nullptr;
}
g.current_video = -1;
g.displayed_frame_index = -1;
g.base_time_us = 0;
g.play_start_ms = 0;
g.play_state = PlayState::Stopped;
g.dragging_progress = false;
}
static void format_time(char* buf, int buf_size, uint64_t time_us) {
uint64_t secs = time_us / 1000000ull;
int h = (int)(secs / 3600ull);
int m = (int)((secs / 60ull) % 60ull);
int s = (int)(secs % 60ull);
if (h > 0) snprintf(buf, buf_size, "%d:%02d:%02d", h, m, s);
else snprintf(buf, buf_size, "%d:%02d", m, s);
}
static void format_fps(char* buf, int buf_size) {
if (!g.movie.valid) {
snprintf(buf, buf_size, "-- fps");
return;
}
if (g.movie.stream_rate > 0 && g.movie.stream_scale > 0) {
uint64_t fps10 = ((uint64_t)g.movie.stream_rate * 10ull + (uint64_t)g.movie.stream_scale / 2ull) /
(uint64_t)g.movie.stream_scale;
if ((fps10 % 10ull) == 0) snprintf(buf, buf_size, "%llu fps", (unsigned long long)(fps10 / 10ull));
else snprintf(buf, buf_size, "%llu.%llu fps",
(unsigned long long)(fps10 / 10ull),
(unsigned long long)(fps10 % 10ull));
return;
}
if (g.movie.microsec_per_frame > 0) {
uint64_t fps10 = (10000000ull + (uint64_t)g.movie.microsec_per_frame / 2ull) /
(uint64_t)g.movie.microsec_per_frame;
if ((fps10 % 10ull) == 0) snprintf(buf, buf_size, "%llu fps", (unsigned long long)(fps10 / 10ull));
else snprintf(buf, buf_size, "%llu.%llu fps",
(unsigned long long)(fps10 / 10ull),
(unsigned long long)(fps10 % 10ull));
return;
}
snprintf(buf, buf_size, "-- fps");
}
static bool movie_has_supported_audio() {
return avi::has_supported_audio(g.movie);
}
static void update_audio_hw_position() {
if (g.audio_handle < 0) return;
int hw = montauk::audio_get_pos(g.audio_handle);
if (hw < 0) return;
int pos = hw % AUDIO_RING_BYTES;
if (!g.audio_hw_valid) {
g.audio_hw_prev_pos = pos;
g.audio_hw_valid = true;
return;
}
int delta = pos - g.audio_hw_prev_pos;
if (delta < 0) delta += AUDIO_RING_BYTES;
g.audio_hw_total_bytes += (uint64_t)delta;
if (g.audio_hw_total_bytes > g.audio_output_written_bytes)
g.audio_hw_total_bytes = g.audio_output_written_bytes;
g.audio_hw_prev_pos = pos;
}
static uint64_t current_time_us() {
uint64_t total = media_duration_us();
if (!g.movie.valid) return 0;
if (g.dragging_progress) return g.drag_time_us > total ? total : g.drag_time_us;
if (g.play_state == PlayState::Playing) {
if (g.audio_handle >= 0) {
update_audio_hw_position();
uint64_t bytes_per_sec = (uint64_t)g.movie.audio_rate * 4ull;
if (bytes_per_sec > 0) {
uint64_t delta_us = (g.audio_hw_total_bytes * 1000000ull) / bytes_per_sec;
uint64_t t = g.base_time_us + delta_us;
return t > total ? total : t;
}
} else {
uint64_t elapsed_us = (montauk::get_milliseconds() - g.play_start_ms) * 1000ull;
uint64_t t = g.base_time_us + elapsed_us;
return t > total ? total : t;
}
}
return g.base_time_us > total ? total : g.base_time_us;
}
static bool decode_frame_index(int index) {
if (!g.movie.valid || !g.frame_pixels) return false;
if (index < 0 || index >= g.movie.video_chunk_count) return false;
if (index == g.displayed_frame_index) return true;
if (!avi::decode_frame(g.movie, index, g.frame_pixels)) {
set_status("Frame decode failed");
return false;
}
g.displayed_frame_index = index;
return true;
}
static void sync_frame_to_time(uint64_t time_us) {
if (!g.movie.valid) return;
int index = avi::frame_index_for_time(g.movie, time_us);
if (index >= 0) decode_frame_index(index);
}
static void seek_audio_to_time(uint64_t time_us) {
g.audio_chunk_index = 0;
g.audio_chunk_pos = 0;
g.audio_buf_len = 0;
g.audio_buf_pos = 0;
if (!movie_has_supported_audio()) {
g.audio_output_total_bytes = 0;
return;
}
uint64_t target_frames = (time_us * (uint64_t)g.movie.audio_rate) / 1000000ull;
if (target_frames > g.movie.total_audio_frames)
target_frames = g.movie.total_audio_frames;
uint64_t remaining_frames = g.movie.total_audio_frames - target_frames;
g.audio_output_total_bytes = remaining_frames * 4ull;
uint64_t skip_frames = target_frames;
for (int i = 0; i < g.movie.audio_chunk_count; i++) {
uint64_t chunk_frames = g.movie.audio_chunks[i].size / g.movie.audio_block_align;
if (skip_frames >= chunk_frames) {
skip_frames -= chunk_frames;
continue;
}
g.audio_chunk_index = i;
g.audio_chunk_pos = (uint32_t)(skip_frames * (uint64_t)g.movie.audio_block_align);
return;
}
g.audio_chunk_index = g.movie.audio_chunk_count;
g.audio_chunk_pos = 0;
}
static void fill_audio_buffer() {
g.audio_buf_pos = 0;
g.audio_buf_len = 0;
if (!movie_has_supported_audio()) return;
uint8_t src_buf[AUDIO_CHUNK_BYTES];
while (g.audio_buf_len + 4 <= AUDIO_CHUNK_BYTES && g.audio_chunk_index < g.movie.audio_chunk_count) {
const avi::ChunkRef& chunk = g.movie.audio_chunks[g.audio_chunk_index];
if (g.audio_chunk_pos >= chunk.size) {
g.audio_chunk_index++;
g.audio_chunk_pos = 0;
continue;
}
if (chunk.size - g.audio_chunk_pos < g.movie.audio_block_align) {
g.audio_chunk_index++;
g.audio_chunk_pos = 0;
continue;
}
uint32_t out_space = (uint32_t)(AUDIO_CHUNK_BYTES - g.audio_buf_len);
uint32_t max_frames = out_space / 4u;
uint32_t src_space = chunk.size - g.audio_chunk_pos;
src_space -= src_space % g.movie.audio_block_align;
uint32_t read_size = max_frames * (uint32_t)g.movie.audio_block_align;
if (read_size > src_space) read_size = src_space;
if (read_size > AUDIO_CHUNK_BYTES) {
read_size = AUDIO_CHUNK_BYTES;
read_size -= read_size % g.movie.audio_block_align;
}
if (read_size < g.movie.audio_block_align) return;
if (!avi::read_bytes(g.movie, (uint64_t)chunk.offset + (uint64_t)g.audio_chunk_pos, src_buf, read_size)) {
set_status("Audio read failed");
g.audio_chunk_index = g.movie.audio_chunk_count;
g.audio_chunk_pos = 0;
return;
}
uint32_t frames = read_size / g.movie.audio_block_align;
for (uint32_t i = 0; i < frames; i++) {
const uint8_t* src = src_buf + (uint64_t)i * (uint64_t)g.movie.audio_block_align;
int16_t left = 0;
int16_t right = 0;
if (g.movie.audio_bits == 8) {
int s0 = ((int)src[0] - 128) << 8;
left = (int16_t)s0;
if (g.movie.audio_channels >= 2) {
int s1 = ((int)src[1] - 128) << 8;
right = (int16_t)s1;
} else {
right = left;
}
} else {
left = (int16_t)((uint16_t)src[0] | ((uint16_t)src[1] << 8));
if (g.movie.audio_channels >= 2) {
right = (int16_t)((uint16_t)src[2] | ((uint16_t)src[3] << 8));
} else {
right = left;
}
}
g.audio_buf[g.audio_buf_len + 0] = (uint8_t)(left & 0xFF);
g.audio_buf[g.audio_buf_len + 1] = (uint8_t)((left >> 8) & 0xFF);
g.audio_buf[g.audio_buf_len + 2] = (uint8_t)(right & 0xFF);
g.audio_buf[g.audio_buf_len + 3] = (uint8_t)((right >> 8) & 0xFF);
g.audio_buf_len += 4;
}
g.audio_chunk_pos += read_size;
}
}
static void feed_audio() {
if (g.play_state != PlayState::Playing || g.audio_handle < 0) return;
update_audio_hw_position();
for (int iter = 0; iter < 4; iter++) {
if (g.audio_buf_len > 0) {
int written = montauk::audio_write(g.audio_handle, g.audio_buf + g.audio_buf_pos, g.audio_buf_len);
if (written <= 0) return;
g.audio_buf_pos += written;
g.audio_buf_len -= written;
g.audio_output_written_bytes += (uint64_t)written;
continue;
}
fill_audio_buffer();
if (g.audio_buf_len <= 0) return;
}
}
static bool begin_playback_from_time(uint64_t time_us) {
if (!g.movie.valid) return false;
uint64_t duration = media_duration_us();
if (duration == 0) time_us = 0;
else if (time_us > duration) time_us = duration;
close_audio_session();
g.base_time_us = time_us;
g.play_state = PlayState::Playing;
sync_frame_to_time(time_us);
if (movie_has_supported_audio()) {
seek_audio_to_time(time_us);
g.audio_handle = montauk::audio_open(g.movie.audio_rate, AUDIO_OUT_CHANNELS, AUDIO_OUT_BITS);
if (g.audio_handle >= 0) {
g.audio_hw_prev_pos = montauk::audio_get_pos(g.audio_handle);
if (g.audio_hw_prev_pos < 0) g.audio_hw_prev_pos = 0;
g.audio_hw_prev_pos %= AUDIO_RING_BYTES;
g.audio_hw_valid = true;
} else {
set_status("Audio device unavailable; continuing without sound");
g.play_start_ms = montauk::get_milliseconds();
}
} else {
g.play_start_ms = montauk::get_milliseconds();
}
return true;
}
static void pause_playback() {
if (g.play_state != PlayState::Playing || !g.movie.valid) return;
g.base_time_us = current_time_us();
close_audio_session();
g.play_state = PlayState::Paused;
sync_frame_to_time(g.base_time_us);
}
static void stop_playback(bool keep_last_frame) {
if (!g.movie.valid) {
close_audio_session();
g.play_state = PlayState::Stopped;
g.base_time_us = 0;
return;
}
close_audio_session();
g.play_state = PlayState::Stopped;
g.base_time_us = keep_last_frame ? media_duration_us() : 0;
sync_frame_to_time(g.base_time_us);
}
static void seek_to_time(uint64_t time_us) {
if (!g.movie.valid) return;
uint64_t duration = media_duration_us();
if (duration > 0 && time_us > duration) time_us = duration;
if (g.play_state == PlayState::Playing) {
begin_playback_from_time(time_us);
} else {
close_audio_session();
g.base_time_us = time_us;
sync_frame_to_time(time_us);
}
}
static bool load_video(int index) {
if (index < 0 || index >= g.file_count) return false;
unload_movie();
char path[MAX_PATH * 2];
snprintf(path, sizeof(path), "%s/%s", g.dir_path, g.files[index].name);
int fd = montauk::open(path);
if (fd < 0) {
set_status("Could not open AVI file");
return false;
}
uint64_t file_size = montauk::getsize(fd);
if (file_size == 0 || file_size > MAX_FILE_SIZE) {
montauk::close(fd);
set_status("AVI file is empty or too large");
return false;
}
if (!avi::parse(g.movie, fd, file_size)) {
char msg[128];
snprintf(msg, sizeof(msg), "AVI parse failed: %s", g.movie.error[0] ? g.movie.error : "unsupported file");
unload_movie();
set_status(msg);
return false;
}
uint64_t pixels_size = (uint64_t)g.movie.width * (uint64_t)g.movie.height * 4ull;
g.frame_pixels = (uint32_t*)montauk::malloc(pixels_size);
if (!g.frame_pixels) {
unload_movie();
set_status("Out of memory for video frame buffer");
return false;
}
montauk::memset(g.frame_pixels, 0, pixels_size);
g.current_video = index;
g.selected_item = index;
g.displayed_frame_index = -1;
g.base_time_us = 0;
g.play_state = PlayState::Stopped;
sync_frame_to_time(0);
if (movie_has_supported_audio()) {
char fps[32];
format_fps(fps, sizeof(fps));
snprintf(g.status_text, sizeof(g.status_text), "%s %dx%d %s PCM %u Hz",
avi::codec_name(g.movie), g.movie.width, g.movie.height, fps, g.movie.audio_rate);
} else if (g.movie.audio_chunk_count > 0) {
char fps[32];
format_fps(fps, sizeof(fps));
snprintf(g.status_text, sizeof(g.status_text), "%s %dx%d %s audio unsupported",
avi::codec_name(g.movie), g.movie.width, g.movie.height, fps);
} else {
char fps[32];
format_fps(fps, sizeof(fps));
snprintf(g.status_text, sizeof(g.status_text), "%s %dx%d %s silent",
avi::codec_name(g.movie), g.movie.width, g.movie.height, fps);
}
return true;
}
static bool start_video(int index) {
if (g.current_video != index || !g.movie.valid) {
if (!load_video(index)) return false;
}
return begin_playback_from_time(0);
}
static bool play_selected_or_current() {
if (g.selected_item >= 0 && filtered_contains(g.selected_item)) {
if (g.current_video == g.selected_item && g.movie.valid) {
uint64_t duration = media_duration_us();
uint64_t start = g.base_time_us;
if (duration > 0 && start >= duration) start = 0;
return begin_playback_from_time(start);
}
return start_video(g.selected_item);
}
if (g.current_video >= 0 && g.movie.valid) {
uint64_t duration = media_duration_us();
uint64_t start = g.base_time_us;
if (duration > 0 && start >= duration) start = 0;
return begin_playback_from_time(start);
}
if (g.filtered_count > 0) return start_video(g.filtered_indices[0]);
return false;
}
static bool next_video(bool auto_advanced = false) {
if (g.file_count == 0) return false;
if (g.current_video < 0) return start_video(0);
if (auto_advanced && g.loop_enabled)
return begin_playback_from_time(0);
int next = g.current_video + 1;
if (next >= g.file_count) {
if (g.loop_enabled) next = 0;
else {
stop_playback(true);
return false;
}
}
return start_video(next);
}
static void prev_video() {
if (g.file_count == 0) return;
if (g.current_video >= 0 && current_time_us() > 3000000ull) {
begin_playback_from_time(0);
return;
}
int prev = g.current_video - 1;
if (prev < 0) prev = g.file_count - 1;
start_video(prev);
}
static uint64_t progress_time_from_x(int mx) {
Rect bar = progress_rect();
if (bar.w <= 0) return 0;
int rel = mx - bar.x;
if (rel < 0) rel = 0;
if (rel > bar.w) rel = bar.w;
uint64_t duration = media_duration_us();
return (duration == 0) ? 0 : ((uint64_t)rel * duration) / (uint64_t)bar.w;
}
static void blit_scaled_argb(uint32_t* dst, int dw, int dh, const Rect& rect,
const uint32_t* src, int sw, int sh) {
if (!dst || !src || rect.w <= 0 || rect.h <= 0 || sw <= 0 || sh <= 0) return;
for (int y = 0; y < rect.h; y++) {
int dy = rect.y + y;
if (dy < 0 || dy >= dh) continue;
int sy = (int)((uint64_t)y * (uint64_t)sh / (uint64_t)rect.h);
const uint32_t* src_row = src + (uint64_t)sy * (uint64_t)sw;
for (int x = 0; x < rect.w; x++) {
int dx = rect.x + x;
if (dx < 0 || dx >= dw) continue;
int sx = (int)((uint64_t)x * (uint64_t)sw / (uint64_t)rect.w);
dst[dy * dw + dx] = src_row[sx];
}
}
}
static void render_video_panel(uint32_t* pixels, const Rect& panel) {
Rect inner = panel;
px_fill(pixels, g.win_w, g.win_h, inner.x, inner.y, inner.w, inner.h, VIDEO_BG);
if (g.movie.valid && g.frame_pixels) {
int avail_w = inner.w;
int avail_h = inner.h;
int draw_w = avail_w;
int draw_h = (int)((uint64_t)avail_w * (uint64_t)g.movie.height / (uint64_t)g.movie.width);
if (draw_h > avail_h) {
draw_h = avail_h;
draw_w = (int)((uint64_t)avail_h * (uint64_t)g.movie.width / (uint64_t)g.movie.height);
}
if (draw_w < 1) draw_w = 1;
if (draw_h < 1) draw_h = 1;
Rect dst = {
inner.x + (inner.w - draw_w) / 2,
inner.y + (inner.h - draw_h) / 2,
draw_w,
draw_h
};
px_fill(pixels, g.win_w, g.win_h, inner.x, inner.y, inner.w, inner.h, VIDEO_BG);
blit_scaled_argb(pixels, g.win_w, g.win_h, dst, g.frame_pixels, g.movie.width, g.movie.height);
} else {
const char* line1 = "No file";
int y = inner.y + inner.h / 2 - font_h(FONT_SIZE_LG);
px_text(pixels, g.win_w, g.win_h, inner.x + (inner.w - text_w(line1, FONT_SIZE_LG)) / 2,
y, line1, TEXT, FONT_SIZE_LG);
}
px_hline(pixels, g.win_w, g.win_h, panel.x, panel.y + panel.h - 1, panel.w, BORDER);
}
static void render_progress(uint32_t* pixels) {
int y = g.win_h - TRANSPORT_H - PROGRESS_H;
px_fill(pixels, g.win_w, g.win_h, 0, y, g.win_w, PROGRESS_H, PANEL_BG_ALT);
Rect bar = progress_rect();
px_fill_rounded(pixels, g.win_w, g.win_h, bar.x, bar.y, bar.w, bar.h, 3, TRACK_BG);
uint64_t current = current_time_us();
uint64_t total = media_duration_us();
if (total > 0) {
int fill = (int)((current * (uint64_t)bar.w) / total);
if (fill > bar.w) fill = bar.w;
if (fill > 0)
px_fill_rounded(pixels, g.win_w, g.win_h, bar.x, bar.y, fill, bar.h, 3, ACCENT);
px_circle(pixels, g.win_w, g.win_h, bar.x + fill, bar.y + bar.h / 2, 5, ACCENT);
px_circle(pixels, g.win_w, g.win_h, bar.x + fill, bar.y + bar.h / 2, 2, WHITE);
}
char cur_str[24];
char total_str[24];
format_time(cur_str, sizeof(cur_str), current);
format_time(total_str, sizeof(total_str), total);
px_text(pixels, g.win_w, g.win_h, 18, y + 4, cur_str, TEXT_DIM, FONT_SIZE_SM);
int tw0 = text_w(total_str, FONT_SIZE_SM);
px_text(pixels, g.win_w, g.win_h, g.win_w - tw0 - 18, y + 4, total_str, TEXT_DIM, FONT_SIZE_SM);
}
static void render_transport(uint32_t* pixels) {
int y = g.win_h - TRANSPORT_H;
px_fill(pixels, g.win_w, g.win_h, 0, y, g.win_w, TRANSPORT_H, BG_BOTTOM);
int bx, by, bw, bh, gap;
transport_layout(&bx, &by, &bw, &bh, &gap);
px_icon_button(pixels, g.win_w, g.win_h, bx, by, bw, bh, g.ico_rewind, BTN_BG, 8);
bx += bw + gap;
{
bool active = (g.play_state == PlayState::Playing);
px_icon_button(pixels, g.win_w, g.win_h, bx, by, bw, bh,
active ? g.ico_pause_w : g.ico_play,
active ? BTN_ACTIVE : BTN_BG, 8);
}
bx += bw + gap;
{
bool active = (g.current_video >= 0 && g.movie.valid);
px_icon_button(pixels, g.win_w, g.win_h, bx, by, bw, bh,
active ? g.ico_stop_w : g.ico_stop,
active ? BTN_STOP : BTN_BG, 8);
}
bx += bw + gap;
px_icon_button(pixels, g.win_w, g.win_h, bx, by, bw, bh, g.ico_forward, BTN_BG, 8);
Rect repeat = repeat_button_rect();
px_icon_button(pixels, g.win_w, g.win_h, repeat.x, repeat.y, repeat.w, repeat.h,
g.loop_enabled ? g.ico_repeat_w : g.ico_repeat,
g.loop_enabled ? ACCENT : BTN_BG, 8);
}
static void render(uint32_t* pixels) {
px_vgradient(pixels, g.win_w, g.win_h, 0, 0, g.win_w, g.win_h, BG_TOP, BG_BOTTOM);
render_video_panel(pixels, video_rect());
render_progress(pixels);
render_transport(pixels);
}
static bool tick_playback() {
if (g.play_state != PlayState::Playing || !g.movie.valid) return false;
feed_audio();
uint64_t now_ms = montauk::get_milliseconds();
uint64_t t = current_time_us();
uint64_t total = media_duration_us();
sync_frame_to_time(t);
if (g.audio_handle >= 0 &&
g.audio_output_total_bytes > 0 &&
g.audio_hw_total_bytes >= g.audio_output_total_bytes) {
if (total > 0 && t < total) {
close_audio_session();
g.base_time_us = t;
g.play_start_ms = now_ms;
} else {
next_video(true);
return true;
}
}
if (total > 0 && current_time_us() >= total) {
next_video(true);
return true;
}
return false;
}
static bool handle_click(int mx, int my) {
if (repeat_button_rect().contains(mx, my)) {
g.loop_enabled = !g.loop_enabled;
return true;
}
Rect bar = progress_rect();
if (my >= bar.y - 8 && my < bar.y + bar.h + 8 && g.movie.valid) {
g.dragging_progress = true;
g.drag_time_us = progress_time_from_x(mx);
sync_frame_to_time(g.drag_time_us);
return true;
}
int bx, by, bw, bh, gap;
transport_layout(&bx, &by, &bw, &bh, &gap);
Rect prev = {bx, by, bw, bh};
Rect play = {bx + bw + gap, by, bw, bh};
Rect stop = {bx + (bw + gap) * 2, by, bw, bh};
Rect next = {bx + (bw + gap) * 3, by, bw, bh};
if (prev.contains(mx, my)) {
prev_video();
return true;
}
if (play.contains(mx, my)) {
if (g.play_state == PlayState::Playing) pause_playback();
else play_selected_or_current();
return true;
}
if (stop.contains(mx, my)) {
stop_playback(false);
return true;
}
if (next.contains(mx, my)) {
next_video(false);
return true;
}
return false;
}
static bool handle_key(const Montauk::KeyEvent& key, bool& quit) {
if (!key.pressed) return false;
if (key.scancode == 0x01) {
quit = true;
return false;
}
if (key.ascii == ' ') {
if (g.play_state == PlayState::Playing) pause_playback();
else play_selected_or_current();
return true;
}
if (key.ascii == 's' || key.ascii == 'S') {
stop_playback(false);
return true;
}
if (key.ascii == 'l' || key.ascii == 'L') {
g.loop_enabled = !g.loop_enabled;
return true;
}
if (key.ascii == 'n' || key.ascii == 'N') {
next_video(false);
return true;
}
if (key.ascii == 'p' || key.ascii == 'P') {
prev_video();
return true;
}
if (key.scancode == 0x4B) {
uint64_t cur = current_time_us();
if (cur > 5000000ull) cur -= 5000000ull;
else cur = 0;
seek_to_time(cur);
return true;
}
if (key.scancode == 0x4D) {
seek_to_time(current_time_us() + 5000000ull);
return true;
}
return false;
}
static void set_default_dir() {
char username[32] = {};
if (montauk::getuser(username, sizeof(username)) > 0 && username[0]) {
snprintf(g.dir_path, sizeof(g.dir_path), "0:/users/%s/Videos", username);
const char* probe[1];
int ok = montauk::readdir(g.dir_path, probe, 1);
if (ok < 0) {
snprintf(g.dir_path, sizeof(g.dir_path), "0:/users/%s", username);
}
} else {
montauk::strncpy(g.dir_path, "0:/home", (int)sizeof(g.dir_path));
}
}
static void parse_args(char* auto_file, int auto_file_cap) {
char args[512] = {};
int arglen = montauk::getargs(args, sizeof(args));
if (arglen <= 0 || !args[0]) {
set_default_dir();
return;
}
if (str_ends_with(args, ".avi")) {
int last_slash = -1;
for (int i = 0; args[i]; i++) {
if (args[i] == '/') last_slash = i;
}
if (last_slash > 0) {
int len = last_slash;
if (len >= (int)sizeof(g.dir_path)) len = sizeof(g.dir_path) - 1;
montauk::memcpy(g.dir_path, args, len);
g.dir_path[len] = '\0';
montauk::strncpy(auto_file, args + last_slash + 1, auto_file_cap);
} else {
set_default_dir();
montauk::strncpy(auto_file, args, auto_file_cap);
}
} else {
montauk::strncpy(g.dir_path, args, (int)sizeof(g.dir_path));
}
}
extern "C" void _start() {
montauk::memset(&g, 0, sizeof(g));
g.win_w = INIT_W;
g.win_h = INIT_H;
g.movie.fd = -1;
g.current_video = -1;
g.selected_item = -1;
g.hovered_item = -1;
g.audio_handle = -1;
g.displayed_frame_index = -1;
g.play_state = PlayState::Stopped;
set_status("Open an AVI from your Videos folder");
char auto_file[128] = {};
parse_args(auto_file, (int)sizeof(auto_file));
TrueTypeFont* font = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont));
if (font) {
montauk::memset(font, 0, sizeof(TrueTypeFont));
if (!font->init("0:/fonts/Roboto-Medium.ttf")) {
montauk::mfree(font);
font = nullptr;
}
}
g.font = font;
Color icon_fg = TEXT;
g.ico_rewind = svg_load("0:/icons/media-rewind.svg", 16, 16, icon_fg);
g.ico_play = svg_load("0:/icons/media-play.svg", 16, 16, icon_fg);
g.ico_pause = svg_load("0:/icons/media-pause.svg", 16, 16, icon_fg);
g.ico_stop = svg_load("0:/icons/media-stop.svg", 16, 16, icon_fg);
g.ico_forward = svg_load("0:/icons/media-forward.svg", 16, 16, icon_fg);
g.ico_play_w = svg_load("0:/icons/media-play.svg", 16, 16, WHITE);
g.ico_pause_w = svg_load("0:/icons/media-pause.svg", 16, 16, WHITE);
g.ico_stop_w = svg_load("0:/icons/media-stop.svg", 16, 16, WHITE);
g.ico_repeat = svg_load("0:/icons/media-playlist-repeat.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, icon_fg);
g.ico_repeat_w = svg_load("0:/icons/media-playlist-repeat.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, WHITE);
scan_directory();
int auto_play_idx = -1;
if (auto_file[0]) {
for (int i = 0; i < g.file_count; i++) {
if (str_eq_case_insensitive(g.files[i].name, auto_file)) {
auto_play_idx = i;
break;
}
}
}
WsWindow win;
if (!win.create("Videos", INIT_W, INIT_H))
montauk::exit(1);
if (auto_play_idx >= 0) {
g.selected_item = auto_play_idx;
start_video(auto_play_idx);
}
render(win.pixels);
win.present();
uint64_t last_draw = montauk::get_milliseconds();
while (true) {
Montauk::WinEvent ev;
int r = win.poll(&ev);
if (r < 0) break;
bool redraw = false;
bool quit = false;
if (r == 0) {
if (g.play_state == PlayState::Playing) {
if (tick_playback()) redraw = true;
uint64_t now = montauk::get_milliseconds();
if (now - last_draw >= 16) redraw = true;
}
if (redraw) {
render(win.pixels);
win.present();
last_draw = montauk::get_milliseconds();
}
montauk::sleep_ms(g.play_state == PlayState::Playing ? 4 : 16);
continue;
}
if (ev.type == 3) break;
if (ev.type == 2) {
g.win_w = win.width;
g.win_h = win.height;
ensure_selected_visible();
redraw = true;
} else if (ev.type == 0 && ev.key.pressed) {
if (handle_key(ev.key, quit)) redraw = true;
} else if (ev.type == 1) {
bool clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1);
bool released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1);
if (clicked) {
if (handle_click(ev.mouse.x, ev.mouse.y)) redraw = true;
}
if (g.dragging_progress && (ev.mouse.buttons & 1)) {
g.drag_time_us = progress_time_from_x(ev.mouse.x);
sync_frame_to_time(g.drag_time_us);
redraw = true;
}
if (released && g.dragging_progress) {
uint64_t target = g.drag_time_us;
g.dragging_progress = false;
seek_to_time(target);
redraw = true;
}
}
if (quit) break;
if (g.play_state == PlayState::Playing) {
if (tick_playback()) redraw = true;
}
if (redraw) {
render(win.pixels);
win.present();
last_draw = montauk::get_milliseconds();
}
}
unload_movie();
if (g.font) montauk::mfree(g.font);
win.destroy();
montauk::exit(0);
}