From 23bb57d0269dde1c54da6803bc52ac90206093c9 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Sat, 28 Mar 2026 17:26:25 +0100 Subject: [PATCH] feat: video player --- programs/GNUmakefile | 13 +- programs/src/desktop/apps/app_filemanager.cpp | 6 + programs/src/video/Makefile | 77 + programs/src/video/avi.cpp | 610 +++++++ programs/src/video/avi.hpp | 73 + programs/src/video/main.cpp | 1440 +++++++++++++++++ programs/src/video/manifest.toml | 8 + programs/src/video/stb_image_impl.cpp | 34 + programs/src/video/stb_truetype_impl.cpp | 32 + 9 files changed, 2289 insertions(+), 4 deletions(-) create mode 100644 programs/src/video/Makefile create mode 100644 programs/src/video/avi.cpp create mode 100644 programs/src/video/avi.hpp create mode 100644 programs/src/video/main.cpp create mode 100644 programs/src/video/manifest.toml create mode 100644 programs/src/video/stb_image_impl.cpp create mode 100644 programs/src/video/stb_truetype_impl.cpp diff --git a/programs/GNUmakefile b/programs/GNUmakefile index a440193..5d0bcc5 100644 --- a/programs/GNUmakefile +++ b/programs/GNUmakefile @@ -59,7 +59,7 @@ BINDIR := bin PROGRAMS := $(notdir $(wildcard src/*)) # Programs with custom Makefiles (built separately). -CUSTOM_BUILDS := doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music bluetooth terminal klog procmgr desktop login shell rpgdemo paint tcc lua screenshot texteditor mandelbrot +CUSTOM_BUILDS := doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music video bluetooth terminal klog procmgr desktop login shell rpgdemo paint tcc lua screenshot texteditor mandelbrot SYSTEM_PROGRAMS := $(filter-out $(CUSTOM_BUILDS),$(PROGRAMS)) # Build targets: system programs go to bin/os/, apps go to bin/apps//. @@ -81,9 +81,9 @@ CA_CERTS := $(BINDIR)/etc/ca-certificates.crt # Common shared assets (wallpapers, etc.) COMMONKEEP := $(BINDIR)/common/.keep -.PHONY: all clean doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music bluetooth terminal klog procmgr login desktop shell rpgdemo paint tcc lua screenshot texteditor mandelbrot icons fonts bearssl libc tls libjpeg libjpegwrite install-apps +.PHONY: all clean doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music video bluetooth terminal klog procmgr login desktop shell rpgdemo paint tcc lua screenshot texteditor mandelbrot icons fonts bearssl libc tls libjpeg libjpegwrite install-apps -all: bearssl libc libjpeg libjpegwrite tls $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music bluetooth terminal klog procmgr doom rpgdemo paint tcc lua screenshot texteditor mandelbrot login desktop shell icons fonts install-apps $(MANDST) $(WWWDST) $(CA_CERTS) $(COMMONKEEP) +all: bearssl libc libjpeg libjpegwrite tls $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music video bluetooth terminal klog procmgr doom rpgdemo paint tcc lua screenshot texteditor mandelbrot login desktop shell icons fonts install-apps $(MANDST) $(WWWDST) $(CA_CERTS) $(COMMONKEEP) # Build BearSSL static library (cross-compiled for freestanding x86_64). BEARSSL_INCLUDES := -isystem $(shell cd .. && pwd)/kernel/freestnd-c-hdrs/x86_64/include -isystem $(abspath include/libc) @@ -168,6 +168,10 @@ volume: libc music: libc $(MAKE) -C src/music +# Build videos standalone GUI tool (depends on libc). +video: libc + $(MAKE) -C src/video + # Build bluetooth manager standalone GUI tool (depends on libc). bluetooth: libc $(MAKE) -C src/bluetooth @@ -242,7 +246,7 @@ lua: libc $(MAKE) -C src/lua # Install app bundles (manifests, icons, data files) into bin/apps//. -install-apps: doom rpgdemo paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer volume music bluetooth terminal klog procmgr screenshot texteditor mandelbrot +install-apps: doom rpgdemo paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer volume music video bluetooth terminal klog procmgr screenshot texteditor mandelbrot ../scripts/install_apps.sh # Copy man pages into bin/man/ so mkramdisk.sh picks them up. @@ -295,6 +299,7 @@ clean: $(MAKE) -C src/installer clean $(MAKE) -C src/volume clean $(MAKE) -C src/music clean + $(MAKE) -C src/video clean $(MAKE) -C src/bluetooth clean $(MAKE) -C src/terminal clean $(MAKE) -C src/klog clean diff --git a/programs/src/desktop/apps/app_filemanager.cpp b/programs/src/desktop/apps/app_filemanager.cpp index 4ff29a2..837678c 100644 --- a/programs/src/desktop/apps/app_filemanager.cpp +++ b/programs/src/desktop/apps/app_filemanager.cpp @@ -163,6 +163,10 @@ static bool is_audio_file(const char* name) { return str_ends_with(name, ".mp3") || str_ends_with(name, ".wav"); } +static bool is_video_file(const char* name) { + return str_ends_with(name, ".avi"); +} + static int detect_file_type(const char* name, bool is_dir) { if (is_dir) return 1; if (str_ends_with(name, ".elf")) return 2; @@ -1079,6 +1083,8 @@ static void filemanager_open_entry(FileManagerState* fm, int idx) { montauk::spawn("0:/apps/wordprocessor/wordprocessor.elf", fullpath); } else if (is_audio_file(fm->entry_names[idx])) { montauk::spawn("0:/apps/music/music.elf", fullpath); + } else if (is_video_file(fm->entry_names[idx])) { + montauk::spawn("0:/apps/video/video.elf", fullpath); } else if (str_ends_with(fm->entry_names[idx], ".elf")) { montauk::spawn(fullpath); } else { diff --git a/programs/src/video/Makefile b/programs/src/video/Makefile new file mode 100644 index 0000000..67539e8 --- /dev/null +++ b/programs/src/video/Makefile @@ -0,0 +1,77 @@ +# Makefile for video (standalone Videos app) on MontaukOS +# Copyright (c) 2026 Daniel Hammer + +MAKEFLAGS += -rR +.SUFFIXES: + +TOOLCHAIN_PREFIX := $(shell cd ../../.. && pwd)/toolchain/local/bin/x86_64-elf- +ifneq ($(wildcard $(TOOLCHAIN_PREFIX)gcc),) + CXX := $(TOOLCHAIN_PREFIX)g++ +else + CXX := g++ +endif + +PROG_INC := ../../include +LINK_LD := ../../link.ld +BINDIR := ../../bin +OBJDIR := obj +LIBDIR := ../../lib + +CXXFLAGS := \ + -std=gnu++20 \ + -g -O2 -pipe \ + -Wall \ + -Wextra \ + -Wno-unused-parameter \ + -Wno-unused-function \ + -nostdinc \ + -ffreestanding \ + -fno-stack-protector \ + -fno-stack-check \ + -fno-PIC \ + -fno-rtti \ + -fno-exceptions \ + -ffunction-sections \ + -fdata-sections \ + -m64 \ + -march=x86-64 \ + -msse \ + -msse2 \ + -mno-red-zone \ + -mcmodel=small \ + -I $(PROG_INC) \ + -I . \ + -isystem $(PROG_INC)/libc \ + -isystem ../../../kernel/freestnd-c-hdrs/x86_64/include \ + -isystem ../../../kernel/freestnd-cxx-hdrs/x86_64/include + +LDFLAGS := \ + -nostdlib \ + -static \ + -Wl,--build-id=none \ + -Wl,--gc-sections \ + -Wl,-m,elf_x86_64 \ + -z max-page-size=0x1000 \ + -T $(LINK_LD) + +SRCS := main.cpp avi.cpp stb_image_impl.cpp stb_truetype_impl.cpp +OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) + +TARGET := $(BINDIR)/apps/video/video.elf + +.PHONY: all clean + +all: $(TARGET) + +$(TARGET): $(OBJS) $(LINK_LD) Makefile + mkdir -p $(BINDIR)/apps/video + $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBDIR)/libc/liblibc.a -o $@ + +HEADERS := avi.hpp + +$(OBJDIR)/%.o: %.cpp $(HEADERS) Makefile + mkdir -p $(OBJDIR) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +clean: + rm -rf $(OBJDIR) $(TARGET) diff --git a/programs/src/video/avi.cpp b/programs/src/video/avi.cpp new file mode 100644 index 0000000..e8ae2f0 --- /dev/null +++ b/programs/src/video/avi.cpp @@ -0,0 +1,610 @@ +#include "avi.hpp" + +#include +#include +#include + +extern "C" { +#include +#include +#include +} + +namespace avi { + +static constexpr uint32_t fourcc(char a, char b, char c, char d) { + return (uint32_t)(uint8_t)a | + ((uint32_t)(uint8_t)b << 8) | + ((uint32_t)(uint8_t)c << 16) | + ((uint32_t)(uint8_t)d << 24); +} + +static constexpr uint32_t FCC_RIFF = fourcc('R', 'I', 'F', 'F'); +static constexpr uint32_t FCC_LIST = fourcc('L', 'I', 'S', 'T'); +static constexpr uint32_t FCC_AVI = fourcc('A', 'V', 'I', ' '); +static constexpr uint32_t FCC_hdrl = fourcc('h', 'd', 'r', 'l'); +static constexpr uint32_t FCC_strl = fourcc('s', 't', 'r', 'l'); +static constexpr uint32_t FCC_movi = fourcc('m', 'o', 'v', 'i'); +static constexpr uint32_t FCC_rec = fourcc('r', 'e', 'c', ' '); +static constexpr uint32_t FCC_avih = fourcc('a', 'v', 'i', 'h'); +static constexpr uint32_t FCC_strh = fourcc('s', 't', 'r', 'h'); +static constexpr uint32_t FCC_strf = fourcc('s', 't', 'r', 'f'); +static constexpr uint32_t FCC_vids = fourcc('v', 'i', 'd', 's'); +static constexpr uint32_t FCC_auds = fourcc('a', 'u', 'd', 's'); +static constexpr uint32_t FCC_MJPG = fourcc('M', 'J', 'P', 'G'); +static constexpr uint32_t FCC_DIB = fourcc('D', 'I', 'B', ' '); + +static uint16_t rd_u16(const uint8_t* p) { + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +static uint32_t rd_u32(const uint8_t* p) { + return (uint32_t)p[0] | + ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | + ((uint32_t)p[3] << 24); +} + +static int32_t rd_s32(const uint8_t* p) { + return (int32_t)rd_u32(p); +} + +static uint32_t min_u32(uint32_t a, uint32_t b) { + return a < b ? a : b; +} + +static uint64_t align2(uint64_t n) { + return (n + 1u) & ~1u; +} + +static void set_error(File& file, const char* msg) { + montauk::strncpy(file.error, msg, (int)sizeof(file.error)); +} + +static bool read_exact(const File& file, uint64_t offset, uint8_t* out, uint32_t size) { + if (size == 0) return true; + if (file.fd < 0 || !out) return false; + if (offset > file.size || (uint64_t)size > file.size - offset) return false; + + uint32_t done = 0; + while (done < size) { + uint32_t chunk = size - done; + if (chunk > 32768u) chunk = 32768u; + int rd = montauk::read(file.fd, out + done, offset + (uint64_t)done, chunk); + if (rd <= 0) return false; + done += (uint32_t)rd; + } + + return true; +} + +bool read_bytes(const File& file, uint64_t offset, uint8_t* out, uint32_t size) { + return read_exact(file, offset, out, size); +} + +static bool push_chunk(ChunkRef*& arr, int& count, int& cap, + uint32_t offset, uint32_t size) { + if (count >= cap) { + int new_cap = cap ? cap * 2 : 256; + auto* next = (ChunkRef*)montauk::realloc(arr, (uint64_t)new_cap * sizeof(ChunkRef)); + if (!next) return false; + arr = next; + cap = new_cap; + } + arr[count].offset = offset; + arr[count].size = size; + count++; + return true; +} + +struct StreamDesc { + uint32_t type = 0; + uint32_t handler = 0; + uint32_t scale = 0; + uint32_t rate = 0; + uint32_t length = 0; + + int width = 0; + int height = 0; + bool top_down = false; + int bits_per_pixel = 0; + VideoCodec codec = VideoCodec::None; + + uint16_t audio_format = 0; + uint16_t audio_channels = 0; + uint16_t audio_bits = 0; + uint16_t audio_block_align = 0; + uint32_t audio_rate = 0; + uint32_t audio_avg_bytes_per_sec = 0; +}; + +static bool chunk_stream_id(uint32_t id, int* stream_index, char* kind0, char* kind1) { + char a = (char)(id & 0xFF); + char b = (char)((id >> 8) & 0xFF); + char c = (char)((id >> 16) & 0xFF); + char d = (char)((id >> 24) & 0xFF); + + if (a < '0' || a > '9' || b < '0' || b > '9') + return false; + + if (stream_index) *stream_index = (a - '0') * 10 + (b - '0'); + if (kind0) *kind0 = c; + if (kind1) *kind1 = d; + return true; +} + +static void apply_video_stream(File& file, int stream_index, const StreamDesc& desc, + int& video_stream_index) { + if (video_stream_index >= 0) return; + if (desc.type != FCC_vids) return; + if (desc.codec == VideoCodec::None) return; + if (desc.width <= 0 || desc.height <= 0) return; + + file.width = desc.width; + file.height = desc.height; + file.top_down = desc.top_down; + file.bits_per_pixel = desc.bits_per_pixel; + file.codec = desc.codec; + file.stream_scale = desc.scale; + file.stream_rate = desc.rate; + file.declared_frame_count = desc.length; + video_stream_index = stream_index; +} + +static void apply_audio_stream(File& file, int stream_index, const StreamDesc& desc, + int& audio_stream_index) { + if (audio_stream_index >= 0) return; + if (desc.type != FCC_auds) return; + if (desc.audio_format != 1) return; + if (desc.audio_channels < 1 || desc.audio_channels > 2) return; + if (desc.audio_bits != 8 && desc.audio_bits != 16) return; + if (desc.audio_block_align == 0 || desc.audio_rate == 0) return; + + file.audio_format = desc.audio_format; + file.audio_channels = desc.audio_channels; + file.audio_bits = desc.audio_bits; + file.audio_block_align = desc.audio_block_align; + file.audio_rate = desc.audio_rate; + file.audio_avg_bytes_per_sec = desc.audio_avg_bytes_per_sec; + file.audio_supported = true; + audio_stream_index = stream_index; +} + +static bool parse_strl(File& file, uint64_t start, uint64_t size, + int& stream_counter, int& video_stream_index, int& audio_stream_index) { + StreamDesc desc = {}; + uint64_t pos = start; + uint64_t end = start + size; + uint8_t buf[64]; + + while (pos + 8 <= end) { + if (!read_exact(file, pos, buf, 8)) { + set_error(file, "Failed to read AVI stream header"); + return false; + } + + uint32_t id = rd_u32(buf + 0); + uint32_t chunk_size = rd_u32(buf + 4); + uint64_t payload = pos + 8; + uint64_t next = payload + align2(chunk_size); + if (next > end || next > file.size) { + set_error(file, "AVI stream chunk is truncated"); + return false; + } + + if (id == FCC_strh && chunk_size >= 48) { + uint32_t bytes = min_u32(chunk_size, (uint32_t)sizeof(buf)); + if (!read_exact(file, payload, buf, bytes)) { + set_error(file, "Failed to read AVI stream format"); + return false; + } + desc.type = rd_u32(buf + 0); + desc.handler = rd_u32(buf + 4); + desc.scale = rd_u32(buf + 20); + desc.rate = rd_u32(buf + 24); + desc.length = rd_u32(buf + 32); + } else if (id == FCC_strf) { + uint32_t bytes = min_u32(chunk_size, (uint32_t)sizeof(buf)); + if (!read_exact(file, payload, buf, bytes)) { + set_error(file, "Failed to read AVI stream data"); + return false; + } + + if (desc.type == FCC_vids && chunk_size >= 40) { + int32_t width = rd_s32(buf + 4); + int32_t height = rd_s32(buf + 8); + uint16_t bit_count = rd_u16(buf + 14); + uint32_t compression = rd_u32(buf + 16); + + desc.width = width > 0 ? width : -width; + desc.height = height > 0 ? height : -height; + desc.top_down = height < 0; + desc.bits_per_pixel = bit_count; + + if ((compression == 0 || compression == FCC_DIB) && bit_count == 24) + desc.codec = VideoCodec::RGB24; + else if ((compression == 0 || compression == FCC_DIB) && bit_count == 32) + desc.codec = VideoCodec::RGB32; + else if (compression == FCC_MJPG) + desc.codec = VideoCodec::MJPG; + } else if (desc.type == FCC_auds && chunk_size >= 16) { + desc.audio_format = rd_u16(buf + 0); + desc.audio_channels = rd_u16(buf + 2); + desc.audio_rate = rd_u32(buf + 4); + desc.audio_avg_bytes_per_sec = rd_u32(buf + 8); + desc.audio_block_align = rd_u16(buf + 12); + desc.audio_bits = rd_u16(buf + 14); + } + } + + pos = next; + } + + apply_video_stream(file, stream_counter, desc, video_stream_index); + apply_audio_stream(file, stream_counter, desc, audio_stream_index); + stream_counter++; + return true; +} + +static bool parse_hdrl(File& file, uint64_t start, uint64_t size, + int& stream_counter, int& video_stream_index, int& audio_stream_index) { + uint64_t pos = start; + uint64_t end = start + size; + uint8_t buf[48]; + + while (pos + 8 <= end) { + if (!read_exact(file, pos, buf, 8)) { + set_error(file, "Failed to read AVI header"); + return false; + } + + uint32_t id = rd_u32(buf + 0); + uint32_t chunk_size = rd_u32(buf + 4); + uint64_t payload = pos + 8; + uint64_t next = payload + align2(chunk_size); + if (next > end || next > file.size) { + set_error(file, "AVI header chunk is truncated"); + return false; + } + + if (id == FCC_avih && chunk_size >= 40) { + if (!read_exact(file, payload, buf, 40)) { + set_error(file, "Failed to read AVI main header"); + return false; + } + file.microsec_per_frame = rd_u32(buf + 0); + if (file.declared_frame_count == 0) + file.declared_frame_count = rd_u32(buf + 16); + } else if (id == FCC_LIST && chunk_size >= 4) { + if (!read_exact(file, payload, buf, 4)) { + set_error(file, "Failed to read AVI list header"); + return false; + } + uint32_t list_type = rd_u32(buf + 0); + if (list_type == FCC_strl) { + if (!parse_strl(file, payload + 4, chunk_size - 4, + stream_counter, video_stream_index, audio_stream_index)) { + return false; + } + } + } + + pos = next; + } + + return true; +} + +static bool parse_movi_list(File& file, uint64_t start, uint64_t size, + int video_stream_index, int audio_stream_index) { + uint64_t pos = start; + uint64_t end = start + size; + uint8_t buf[12]; + + while (pos + 8 <= end) { + if (!read_exact(file, pos, buf, 8)) { + set_error(file, "Failed to read AVI media index"); + return false; + } + + uint32_t id = rd_u32(buf + 0); + uint32_t chunk_size = rd_u32(buf + 4); + uint64_t payload = pos + 8; + uint64_t next = payload + align2(chunk_size); + if (next > end || next > file.size) { + set_error(file, "AVI media chunk is truncated"); + return false; + } + + if (id == FCC_LIST && chunk_size >= 4) { + if (!read_exact(file, payload, buf, 4)) { + set_error(file, "Failed to read AVI media list header"); + return false; + } + uint32_t list_type = rd_u32(buf + 0); + if (list_type == FCC_rec || list_type == FCC_movi) { + if (!parse_movi_list(file, payload + 4, chunk_size - 4, + video_stream_index, audio_stream_index)) { + return false; + } + } + pos = next; + continue; + } + + int stream_index = -1; + char kind0 = 0; + char kind1 = 0; + if (!chunk_stream_id(id, &stream_index, &kind0, &kind1)) { + pos = next; + continue; + } + + if (stream_index == video_stream_index && + ((kind0 == 'd' && kind1 == 'b') || (kind0 == 'd' && kind1 == 'c') || + (kind0 == 'D' && kind1 == 'B') || (kind0 == 'D' && kind1 == 'C'))) { + if (!push_chunk(file.video_chunks, file.video_chunk_count, file.video_chunk_cap, + (uint32_t)payload, chunk_size)) { + set_error(file, "Out of memory indexing video frames"); + return false; + } + } else if (stream_index == audio_stream_index && kind0 == 'w' && kind1 == 'b') { + if (!push_chunk(file.audio_chunks, file.audio_chunk_count, file.audio_chunk_cap, + (uint32_t)payload, chunk_size)) { + set_error(file, "Out of memory indexing audio chunks"); + return false; + } + file.total_audio_bytes += chunk_size; + } + + pos = next; + } + + return true; +} + +void reset(File& file) { + if (file.fd >= 0) montauk::close(file.fd); + if (file.video_chunks) montauk::mfree(file.video_chunks); + if (file.audio_chunks) montauk::mfree(file.audio_chunks); + if (file.decode_buffer) montauk::mfree(file.decode_buffer); + montauk::memset(&file, 0, sizeof(file)); + file.fd = -1; +} + +bool parse(File& file, int fd, uint64_t size) { + reset(file); + file.fd = fd; + file.size = size; + + uint8_t buf[12]; + if (size < 12) { + set_error(file, "File is too small to be a valid AVI"); + return false; + } + + if (!read_exact(file, 0, buf, 12)) { + set_error(file, "Failed to read AVI header"); + return false; + } + + if (rd_u32(buf + 0) != FCC_RIFF || rd_u32(buf + 8) != FCC_AVI) { + set_error(file, "File is not a RIFF AVI container"); + return false; + } + + int stream_counter = 0; + int video_stream_index = -1; + int audio_stream_index = -1; + + uint64_t pos = 12; + while (pos + 8 <= size) { + if (!read_exact(file, pos, buf, 8)) { + set_error(file, "Failed to read AVI top-level chunk"); + return false; + } + + uint32_t id = rd_u32(buf + 0); + uint32_t chunk_size = rd_u32(buf + 4); + uint64_t payload = pos + 8; + uint64_t next = payload + align2(chunk_size); + if (next > size) { + set_error(file, "AVI top-level chunk is truncated"); + return false; + } + + if (id == FCC_LIST && chunk_size >= 4) { + if (!read_exact(file, payload, buf, 4)) { + set_error(file, "Failed to read AVI list type"); + return false; + } + uint32_t list_type = rd_u32(buf + 0); + if (list_type == FCC_hdrl) { + if (!parse_hdrl(file, payload + 4, chunk_size - 4, + stream_counter, video_stream_index, audio_stream_index)) { + return false; + } + } else if (list_type == FCC_movi) { + if (!parse_movi_list(file, payload + 4, chunk_size - 4, + video_stream_index, audio_stream_index)) { + return false; + } + } + } + + pos = next; + } + + if (video_stream_index < 0 || file.video_chunk_count <= 0 || file.width <= 0 || file.height <= 0) { + set_error(file, "AVI video stream is missing or unsupported"); + return false; + } + + if (file.audio_supported && file.audio_block_align > 0) { + file.total_audio_frames = file.total_audio_bytes / file.audio_block_align; + } + + file.valid = true; + return true; +} + +static bool ensure_decode_buffer(File& file, uint32_t need) { + if (need <= file.decode_buffer_cap) return true; + + uint32_t new_cap = file.decode_buffer_cap ? file.decode_buffer_cap : 4096u; + while (new_cap < need) { + if (new_cap > 0x7FFFFFFFu / 2u) { + new_cap = need; + break; + } + new_cap *= 2u; + } + + auto* next = (uint8_t*)montauk::realloc(file.decode_buffer, new_cap); + if (!next) return false; + file.decode_buffer = next; + file.decode_buffer_cap = new_cap; + return true; +} + +static bool decode_rgb(File& file, const ChunkRef& chunk, uint32_t* out_argb) { + if (!out_argb || file.width <= 0 || file.height <= 0) return false; + + int bytes_per_pixel = (file.codec == VideoCodec::RGB32) ? 4 : 3; + int stride = ((file.width * bytes_per_pixel) + 3) & ~3; + uint64_t needed64 = (uint64_t)stride * (uint64_t)file.height; + if (chunk.size < needed64 || needed64 > 0xFFFFFFFFull) return false; + + uint32_t needed = (uint32_t)needed64; + if (!ensure_decode_buffer(file, needed)) return false; + if (!read_exact(file, chunk.offset, file.decode_buffer, needed)) return false; + + const uint8_t* src = file.decode_buffer; + for (int y = 0; y < file.height; y++) { + int src_y = file.top_down ? y : (file.height - 1 - y); + const uint8_t* row = src + (uint64_t)src_y * (uint64_t)stride; + uint32_t* dst = out_argb + (uint64_t)y * (uint64_t)file.width; + + for (int x = 0; x < file.width; x++) { + uint8_t b = row[x * bytes_per_pixel + 0]; + uint8_t g = row[x * bytes_per_pixel + 1]; + uint8_t r = row[x * bytes_per_pixel + 2]; + dst[x] = 0xFF000000u | ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; + } + } + + return true; +} + +static void scale_rgba_to_argb(uint32_t* out_argb, int out_w, int out_h, + const uint8_t* src_rgba, int src_w, int src_h) { + for (int y = 0; y < out_h; y++) { + int sy = (int)((uint64_t)y * (uint64_t)src_h / (uint64_t)out_h); + const uint8_t* src_row = src_rgba + (uint64_t)sy * (uint64_t)src_w * 4u; + uint32_t* dst = out_argb + (uint64_t)y * (uint64_t)out_w; + + for (int x = 0; x < out_w; x++) { + int sx = (int)((uint64_t)x * (uint64_t)src_w / (uint64_t)out_w); + const uint8_t* p = src_row + (uint64_t)sx * 4u; + dst[x] = 0xFF000000u | ((uint32_t)p[0] << 16) | ((uint32_t)p[1] << 8) | p[2]; + } + } +} + +static bool decode_mjpg(File& file, const ChunkRef& chunk, uint32_t* out_argb) { + if (chunk.size > 0x7FFFFFFFu) return false; + if (!ensure_decode_buffer(file, chunk.size)) return false; + if (!read_exact(file, chunk.offset, file.decode_buffer, chunk.size)) return false; + + int w = 0; + int h = 0; + int channels = 0; + unsigned char* rgba = stbi_load_from_memory(file.decode_buffer, (int)chunk.size, + &w, &h, &channels, 4); + if (!rgba || w <= 0 || h <= 0) { + if (rgba) stbi_image_free(rgba); + return false; + } + + if (w == file.width && h == file.height) { + for (int i = 0; i < w * h; i++) { + uint8_t r = rgba[i * 4 + 0]; + uint8_t g = rgba[i * 4 + 1]; + uint8_t b = rgba[i * 4 + 2]; + out_argb[i] = 0xFF000000u | ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; + } + } else { + scale_rgba_to_argb(out_argb, file.width, file.height, rgba, w, h); + } + + stbi_image_free(rgba); + return true; +} + +bool decode_frame(File& file, int frame_index, uint32_t* out_argb) { + if (!file.valid || !out_argb) return false; + if (frame_index < 0 || frame_index >= file.video_chunk_count) return false; + + const ChunkRef& chunk = file.video_chunks[frame_index]; + if (file.codec == VideoCodec::RGB24 || file.codec == VideoCodec::RGB32) + return decode_rgb(file, chunk, out_argb); + if (file.codec == VideoCodec::MJPG) + return decode_mjpg(file, chunk, out_argb); + return false; +} + +uint64_t video_duration_us(const File& file) { + if (file.video_chunk_count <= 0) return 0; + + if (file.stream_rate > 0 && file.stream_scale > 0) { + return ((uint64_t)file.video_chunk_count * (uint64_t)file.stream_scale * 1000000ull) / + (uint64_t)file.stream_rate; + } + if (file.microsec_per_frame > 0) + return (uint64_t)file.video_chunk_count * (uint64_t)file.microsec_per_frame; + return 0; +} + +uint64_t audio_duration_us(const File& file) { + if (!file.audio_supported || file.audio_rate == 0 || file.total_audio_frames == 0) return 0; + return (file.total_audio_frames * 1000000ull) / (uint64_t)file.audio_rate; +} + +uint64_t duration_us(const File& file) { + uint64_t v = video_duration_us(file); + uint64_t a = audio_duration_us(file); + return v > a ? v : a; +} + +int frame_index_for_time(const File& file, uint64_t time_us) { + if (file.video_chunk_count <= 0) return -1; + if (file.video_chunk_count == 1) return 0; + + int index = 0; + if (file.stream_rate > 0 && file.stream_scale > 0) { + uint64_t denom = (uint64_t)file.stream_scale * 1000000ull; + if (denom > 0) + index = (int)((time_us * (uint64_t)file.stream_rate) / denom); + } else if (file.microsec_per_frame > 0) { + index = (int)(time_us / (uint64_t)file.microsec_per_frame); + } + + if (index < 0) index = 0; + if (index >= file.video_chunk_count) index = file.video_chunk_count - 1; + return index; +} + +const char* codec_name(const File& file) { + switch (file.codec) { + case VideoCodec::RGB24: return "RGB24"; + case VideoCodec::RGB32: return "RGB32"; + case VideoCodec::MJPG: return "MJPEG"; + default: return "Unknown"; + } +} + +bool has_supported_audio(const File& file) { + return file.audio_supported && file.audio_chunk_count > 0; +} + +} // namespace avi diff --git a/programs/src/video/avi.hpp b/programs/src/video/avi.hpp new file mode 100644 index 0000000..3f270bc --- /dev/null +++ b/programs/src/video/avi.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include + +namespace avi { + +enum class VideoCodec { + None, + RGB24, + RGB32, + MJPG, +}; + +struct ChunkRef { + uint32_t offset; + uint32_t size; +}; + +struct File { + int fd; + uint64_t size; + + int width; + int height; + bool top_down; + int bits_per_pixel; + VideoCodec codec; + + uint32_t stream_scale; + uint32_t stream_rate; + uint32_t microsec_per_frame; + uint32_t declared_frame_count; + + uint16_t audio_format; + uint16_t audio_channels; + uint16_t audio_bits; + uint16_t audio_block_align; + uint32_t audio_rate; + uint32_t audio_avg_bytes_per_sec; + bool audio_supported; + + ChunkRef* video_chunks; + int video_chunk_count; + int video_chunk_cap; + + ChunkRef* audio_chunks; + int audio_chunk_count; + int audio_chunk_cap; + + uint64_t total_audio_bytes; + uint64_t total_audio_frames; + + uint8_t* decode_buffer; + uint32_t decode_buffer_cap; + + bool valid; + char error[128]; +}; + +void reset(File& file); +bool parse(File& file, int fd, uint64_t size); +bool read_bytes(const File& file, uint64_t offset, uint8_t* out, uint32_t size); +bool decode_frame(File& file, int frame_index, uint32_t* out_argb); + +uint64_t video_duration_us(const File& file); +uint64_t audio_duration_us(const File& file); +uint64_t duration_us(const File& file); + +int frame_index_for_time(const File& file, uint64_t time_us); +const char* codec_name(const File& file); +bool has_supported_audio(const File& file); + +} // namespace avi diff --git a/programs/src/video/main.cpp b/programs/src/video/main.cpp new file mode 100644 index 0000000..b3afd23 --- /dev/null +++ b/programs/src/video/main.cpp @@ -0,0 +1,1440 @@ +/* + * main.cpp + * MontaukOS Videos app -- AVI playback with PCM audio + * Copyright (c) 2026 Daniel Hammer + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +#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 = 512ull * 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 = "Open an AVI from Files"; + const char* line2 = "Videos supports MJPEG and uncompressed RGB AVI files"; + 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_text(pixels, g.win_w, g.win_h, inner.x + (inner.w - text_w(line2, FONT_SIZE_SM)) / 2, + y + font_h(FONT_SIZE_LG) + 8, line2, TEXT_DIM, FONT_SIZE_SM); + } + + 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() { + auto doc = montauk::config::load("session"); + const char* name = doc.get_string("session.username", ""); + if (name[0]) { + snprintf(g.dir_path, sizeof(g.dir_path), "0:/users/%s/Videos", name); + 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", name); + } + } else { + montauk::strncpy(g.dir_path, "0:/home", (int)sizeof(g.dir_path)); + } + doc.destroy(); +} + +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); +} diff --git a/programs/src/video/manifest.toml b/programs/src/video/manifest.toml new file mode 100644 index 0000000..d941820 --- /dev/null +++ b/programs/src/video/manifest.toml @@ -0,0 +1,8 @@ +[app] +name = "Videos" +binary = "video.elf" +icon = "multimedia-video-player.svg" + +[menu] +category = "Applications" +visible = true diff --git a/programs/src/video/stb_image_impl.cpp b/programs/src/video/stb_image_impl.cpp new file mode 100644 index 0000000..48da840 --- /dev/null +++ b/programs/src/video/stb_image_impl.cpp @@ -0,0 +1,34 @@ +/* + * stb_image_impl.cpp + * Single compilation unit for JPEG decoding in the MontaukOS Videos app + * Copyright (c) 2026 Daniel Hammer + */ + +#include +#include + +extern "C" { +#include +#include +} + +#define STBI_ONLY_JPEG +#define STBI_NO_PNG +#define STBI_NO_BMP +#define STBI_NO_PSD +#define STBI_NO_TGA +#define STBI_NO_GIF +#define STBI_NO_HDR +#define STBI_NO_PIC +#define STBI_NO_PNM +#define STBI_NO_LINEAR +#define STBI_NO_STDIO +#define STBI_NO_THREAD_LOCALS + +#define STBI_MALLOC(sz) malloc(sz) +#define STBI_FREE(p) free(p) +#define STBI_REALLOC(p, newsz) realloc(p, newsz) +#define STBI_ASSERT(x) ((void)(x)) + +#define STB_IMAGE_IMPLEMENTATION +#include diff --git a/programs/src/video/stb_truetype_impl.cpp b/programs/src/video/stb_truetype_impl.cpp new file mode 100644 index 0000000..128dadd --- /dev/null +++ b/programs/src/video/stb_truetype_impl.cpp @@ -0,0 +1,32 @@ +/* + * stb_truetype_impl.cpp + * Single compilation unit for stb_truetype in MontaukOS freestanding environment + * Copyright (c) 2026 Daniel Hammer + */ + +#include +#include +#include +#include +#include + +#define STBTT_ifloor(x) ((int) stb_floor(x)) +#define STBTT_iceil(x) ((int) stb_ceil(x)) +#define STBTT_sqrt(x) stb_sqrt(x) +#define STBTT_pow(x,y) stb_pow(x,y) +#define STBTT_fmod(x,y) stb_fmod(x,y) +#define STBTT_cos(x) stb_cos(x) +#define STBTT_acos(x) stb_acos(x) +#define STBTT_fabs(x) stb_fabs(x) + +#define STBTT_malloc(x,u) ((void)(u), montauk::malloc(x)) +#define STBTT_free(x,u) ((void)(u), montauk::mfree(x)) + +#define STBTT_memcpy(d,s,n) montauk::memcpy(d,s,n) +#define STBTT_memset(d,v,n) montauk::memset(d,v,n) + +#define STBTT_strlen(x) montauk::slen(x) +#define STBTT_assert(x) ((void)(x)) + +#define STB_TRUETYPE_IMPLEMENTATION +#include