diff --git a/.gitignore b/.gitignore
index e6421fb..f396fb5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,8 @@
/.vscode
/build
/extensions/nanoled/build
-/extensions/grayscale/extension.so
\ No newline at end of file
+/extensions/grayscale/extension.so
+/examples/csharp/obj
+/examples/csharp/bin
+.claude/
+CLAUDE.md
\ No newline at end of file
diff --git a/examples/csharp/PanelPlayer.cs b/examples/csharp/PanelPlayer.cs
new file mode 100644
index 0000000..cd6383c
--- /dev/null
+++ b/examples/csharp/PanelPlayer.cs
@@ -0,0 +1,122 @@
+using System;
+using System.IO;
+
+namespace PanelPlayerExample
+{
+ ///
+ /// Managed wrapper for PanelPlayer native library
+ ///
+ public class PanelPlayer : IDisposable
+ {
+ private bool disposed = false;
+
+ public PanelPlayer(string interfaceName, int width, int height, int brightness = 255)
+ {
+ int result = PanelPlayerNative.panelplayer_init(interfaceName, width, height, brightness);
+ if (result != PanelPlayerNative.PANELPLAYER_SUCCESS)
+ {
+ throw new Exception($"Failed to initialize PanelPlayer: {result}");
+ }
+ }
+
+ public void SetMix(int mixPercentage)
+ {
+ int result = PanelPlayerNative.panelplayer_set_mix(mixPercentage);
+ if (result != PanelPlayerNative.PANELPLAYER_SUCCESS)
+ {
+ throw new Exception($"Failed to set mix: {result}");
+ }
+ }
+
+ public void SetFrameRate(int frameRate)
+ {
+ int result = PanelPlayerNative.panelplayer_set_rate(frameRate);
+ if (result != PanelPlayerNative.PANELPLAYER_SUCCESS)
+ {
+ throw new Exception($"Failed to set frame rate: {result}");
+ }
+ }
+
+ public void SetDuplicate(bool enable)
+ {
+ int result = PanelPlayerNative.panelplayer_set_duplicate(enable);
+ if (result != PanelPlayerNative.PANELPLAYER_SUCCESS)
+ {
+ throw new Exception($"Failed to set duplicate mode: {result}");
+ }
+ }
+
+ public void LoadExtension(string extensionPath)
+ {
+ int result = PanelPlayerNative.panelplayer_load_extension(extensionPath);
+ if (result != PanelPlayerNative.PANELPLAYER_SUCCESS)
+ {
+ throw new Exception($"Failed to load extension: {result}");
+ }
+ }
+
+ public void PlayFile(string filePath)
+ {
+ if (!File.Exists(filePath))
+ {
+ throw new FileNotFoundException($"Image file not found: {filePath}");
+ }
+
+ int result = PanelPlayerNative.panelplayer_play_file(filePath);
+ if (result != PanelPlayerNative.PANELPLAYER_SUCCESS)
+ {
+ throw new Exception($"Failed to play image file: {result}");
+ }
+ }
+
+ public void PlayFrameBGR(byte[] bgrData, int width, int height)
+ {
+ if (bgrData == null)
+ {
+ throw new ArgumentNullException(nameof(bgrData));
+ }
+
+ if (bgrData.Length != width * height * 3)
+ {
+ throw new ArgumentException("BGR data length doesn't match width * height * 3");
+ }
+
+ int result = PanelPlayerNative.panelplayer_play_frame_bgr(bgrData, width, height);
+ if (result != PanelPlayerNative.PANELPLAYER_SUCCESS)
+ {
+ throw new Exception($"Failed to play frame: {result}");
+ }
+ }
+
+ public void SetBrightness(byte red, byte green, byte blue)
+ {
+ int result = PanelPlayerNative.panelplayer_send_brightness(red, green, blue);
+ if (result != PanelPlayerNative.PANELPLAYER_SUCCESS)
+ {
+ throw new Exception($"Failed to set brightness: {result}");
+ }
+ }
+
+ public bool IsInitialized => PanelPlayerNative.panelplayer_is_initialized();
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposed)
+ {
+ PanelPlayerNative.panelplayer_cleanup();
+ disposed = true;
+ }
+ }
+
+ ~PanelPlayer()
+ {
+ Dispose(false);
+ }
+ }
+}
diff --git a/examples/csharp/PanelPlayerExample.csproj b/examples/csharp/PanelPlayerExample.csproj
new file mode 100644
index 0000000..a6fa901
--- /dev/null
+++ b/examples/csharp/PanelPlayerExample.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net8.0;net9.0
+ enable
+ true
+
+
+
+
+ PreserveNewest
+
+
+
+
\ No newline at end of file
diff --git a/examples/csharp/PanelPlayerNative.cs b/examples/csharp/PanelPlayerNative.cs
new file mode 100644
index 0000000..7226c12
--- /dev/null
+++ b/examples/csharp/PanelPlayerNative.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace PanelPlayerExample
+{
+ ///
+ /// Native P/Invoke declarations for libpanelplayer.so
+ ///
+ public static class PanelPlayerNative
+ {
+ private const string LibraryName = "libpanelplayer.so";
+
+ public const int PANELPLAYER_SUCCESS = 0;
+ public const int PANELPLAYER_ERROR = -1;
+ public const int PANELPLAYER_INVALID_PARAM = -2;
+ public const int PANELPLAYER_NOT_INITIALIZED = -3;
+ public const int PANELPLAYER_ALREADY_INITIALIZED = -4;
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int panelplayer_init(
+ [MarshalAs(UnmanagedType.LPStr)] string interface_name,
+ int width,
+ int height,
+ int brightness
+ );
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int panelplayer_set_mix(int mix_percentage);
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int panelplayer_set_rate(int frame_rate);
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int panelplayer_set_duplicate(
+ [MarshalAs(UnmanagedType.I1)] bool enable
+ );
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int panelplayer_load_extension(
+ [MarshalAs(UnmanagedType.LPStr)] string extension_path
+ );
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int panelplayer_play_file(
+ [MarshalAs(UnmanagedType.LPStr)] string file_path
+ );
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int panelplayer_play_frame_bgr(
+ [MarshalAs(UnmanagedType.LPArray)] byte[] bgr_data,
+ int width,
+ int height
+ );
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int panelplayer_send_brightness(
+ byte red,
+ byte green,
+ byte blue
+ );
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ public static extern bool panelplayer_is_initialized();
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern void panelplayer_cleanup();
+ }
+}
diff --git a/examples/csharp/Program.cs b/examples/csharp/Program.cs
new file mode 100644
index 0000000..f5b0e30
--- /dev/null
+++ b/examples/csharp/Program.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace PanelPlayerExample
+{
+ class Program
+ {
+ static void ShowUsage()
+ {
+ Console.WriteLine("Usage:");
+ Console.WriteLine(" PanelPlayerExample -p -w -h [options] ");
+ Console.WriteLine();
+ Console.WriteLine("Options:");
+ Console.WriteLine(" -p Set ethernet port (required)");
+ Console.WriteLine(" -w Set display width (required)");
+ Console.WriteLine(" -h Set display height (required)");
+ Console.WriteLine(" -b Set display brightness (0-255, default: 255)");
+ Console.WriteLine(" -m Set frame mixing percentage (0-99, default: 0)");
+ Console.WriteLine(" -r Override source frame rate");
+ Console.WriteLine(" -e Load extension from file");
+ Console.WriteLine(" -d Duplicate each row vertically");
+ Console.WriteLine(" -v Enable verbose output");
+ Console.WriteLine();
+ Console.WriteLine("Sources:");
+ Console.WriteLine(" One or more image/animation files (WebP, JPEG, PNG, GIF, BMP)");
+ }
+
+ static int Main(string[] args)
+ {
+ try
+ {
+ // Parse command-line arguments
+ string port = null;
+ int width = 0;
+ int height = 0;
+ int brightness = 255;
+ int mix = 0;
+ int rate = 0;
+ string extension = null;
+ bool duplicate = false;
+ bool verbose = false;
+ List sources = new List();
+
+ for (int i = 0; i < args.Length; i++)
+ {
+ if (args[i].StartsWith("-"))
+ {
+ switch (args[i])
+ {
+ case "-p":
+ if (++i >= args.Length)
+ {
+ Console.WriteLine("Error: -p requires a value");
+ ShowUsage();
+ return 1;
+ }
+ port = args[i];
+ break;
+
+ case "-w":
+ if (++i >= args.Length || !int.TryParse(args[i], out width))
+ {
+ Console.WriteLine("Error: -w requires an integer value");
+ ShowUsage();
+ return 1;
+ }
+ break;
+
+ case "-h":
+ if (++i >= args.Length || !int.TryParse(args[i], out height))
+ {
+ Console.WriteLine("Error: -h requires an integer value");
+ ShowUsage();
+ return 1;
+ }
+ break;
+
+ case "-b":
+ if (++i >= args.Length || !int.TryParse(args[i], out brightness))
+ {
+ Console.WriteLine("Error: -b requires an integer value");
+ ShowUsage();
+ return 1;
+ }
+ break;
+
+ case "-m":
+ if (++i >= args.Length || !int.TryParse(args[i], out mix))
+ {
+ Console.WriteLine("Error: -m requires an integer value");
+ ShowUsage();
+ return 1;
+ }
+ break;
+
+ case "-r":
+ if (++i >= args.Length || !int.TryParse(args[i], out rate))
+ {
+ Console.WriteLine("Error: -r requires an integer value");
+ ShowUsage();
+ return 1;
+ }
+ break;
+
+ case "-e":
+ if (++i >= args.Length)
+ {
+ Console.WriteLine("Error: -e requires a value");
+ ShowUsage();
+ return 1;
+ }
+ extension = args[i];
+ break;
+
+ case "-d":
+ duplicate = true;
+ break;
+
+ case "-v":
+ verbose = true;
+ break;
+
+ default:
+ Console.WriteLine($"Error: Unknown option {args[i]}");
+ ShowUsage();
+ return 1;
+ }
+ }
+ else
+ {
+ sources.Add(args[i]);
+ }
+ }
+
+ // Validate required parameters
+ if (port == null)
+ {
+ Console.WriteLine("Error: Port must be specified!");
+ ShowUsage();
+ return 1;
+ }
+
+ if (width < 1 || height < 1)
+ {
+ Console.WriteLine("Error: Width and height must be specified as positive integers!");
+ ShowUsage();
+ return 1;
+ }
+
+ if (brightness < 0 || brightness > 255)
+ {
+ Console.WriteLine("Error: Brightness must be an integer between 0 and 255!");
+ ShowUsage();
+ return 1;
+ }
+
+ if (mix < 0 || mix >= 100)
+ {
+ Console.WriteLine("Error: Mix must be an integer between 0 and 99!");
+ ShowUsage();
+ return 1;
+ }
+
+ if (sources.Count == 0)
+ {
+ Console.WriteLine("Error: At least one source must be specified!");
+ ShowUsage();
+ return 1;
+ }
+
+ // Verify source files exist
+ foreach (var source in sources)
+ {
+ if (!File.Exists(source))
+ {
+ Console.WriteLine($"Error: Source file not found: {source}");
+ return 1;
+ }
+ }
+
+ if (verbose)
+ {
+ Console.WriteLine($"Initializing PanelPlayer:");
+ Console.WriteLine($" Port: {port}");
+ Console.WriteLine($" Dimensions: {width}x{height}");
+ Console.WriteLine($" Brightness: {brightness}");
+ if (mix > 0)
+ Console.WriteLine($" Mix: {mix}%");
+ if (rate > 0)
+ Console.WriteLine($" Frame Rate: {rate} fps");
+ if (duplicate)
+ Console.WriteLine($" Duplicate: enabled");
+ if (extension != null)
+ Console.WriteLine($" Extension: {extension}");
+ Console.WriteLine($" Sources: {string.Join(", ", sources)}");
+ Console.WriteLine();
+ }
+
+ // Initialize PanelPlayer
+ using (var player = new PanelPlayer(port, width, height, brightness))
+ {
+ if (verbose)
+ Console.WriteLine("PanelPlayer initialized successfully!");
+
+ // Apply settings
+ if (mix > 0)
+ {
+ player.SetMix(mix);
+ if (verbose)
+ Console.WriteLine($"Frame mixing set to {mix}%");
+ }
+
+ if (rate > 0)
+ {
+ player.SetFrameRate(rate);
+ if (verbose)
+ Console.WriteLine($"Frame rate override set to {rate} fps");
+ }
+
+ if (duplicate)
+ {
+ player.SetDuplicate(true);
+ if (verbose)
+ Console.WriteLine("Duplicate mode enabled");
+ }
+
+ if (extension != null)
+ {
+ if (!File.Exists(extension))
+ {
+ Console.WriteLine($"Error: Extension file not found: {extension}");
+ return 1;
+ }
+ player.LoadExtension(extension);
+ if (verbose)
+ Console.WriteLine($"Extension loaded: {extension}");
+ }
+
+ // Play all source files
+ foreach (var source in sources)
+ {
+ if (verbose)
+ Console.WriteLine($"Playing: {source}");
+
+ player.PlayFile(source);
+
+ if (verbose)
+ Console.WriteLine($"Finished: {source}");
+ }
+
+ if (verbose)
+ Console.WriteLine("Playback completed successfully!");
+ }
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error: {ex.Message}");
+ Console.WriteLine();
+ Console.WriteLine("Note: This application requires root privileges and a valid ethernet interface.");
+ Console.WriteLine(" Run with sudo and ensure the network interface is correct.");
+ return 1;
+ }
+ }
+ }
+}
diff --git a/examples/csharp/README.md b/examples/csharp/README.md
new file mode 100644
index 0000000..12a49f1
--- /dev/null
+++ b/examples/csharp/README.md
@@ -0,0 +1,136 @@
+# PanelPlayer C# Example
+
+This directory contains a C# example application that demonstrates how to use the PanelPlayer library from .NET applications.
+
+## Prerequisites
+
+- .NET 8.0 or later (supports both .NET 8.0 and 9.0)
+- libpanelplayer.so compiled and available
+- Root privileges (required for raw ethernet access)
+
+## Building
+
+### 1. Build the PanelPlayer Library
+
+First, you need to build the shared library from the main project directory:
+
+```bash
+cd /path/to/PanelPlayer
+make library
+```
+
+This will create `build/libpanelplayer.so`.
+
+### 2. Copy Library to Output Directory
+
+The C# project is configured to automatically copy the library to the output directory during build, but you can also copy it manually:
+
+```bash
+cp ../../build/libpanelplayer.so ./bin/Debug/net9.0/
+```
+
+### 3. Build the C# Application
+
+```bash
+dotnet build
+```
+
+## Running
+
+The application must be run as root due to raw ethernet socket requirements. It uses command-line arguments similar to the native PanelPlayer:
+
+```bash
+sudo dotnet run -- -p -w -h [options]
+```
+
+### Options
+
+- `-p ` - Set ethernet port (required, e.g., eth0, end0)
+- `-w ` - Set display width in pixels (required)
+- `-h ` - Set display height in pixels (required)
+- `-b ` - Set display brightness (0-255, default: 255)
+- `-m ` - Set frame mixing percentage (0-99, default: 0)
+- `-r ` - Override source frame rate
+- `-e ` - Load extension from file
+- `-d` - Duplicate each row vertically
+- `-v` - Enable verbose output
+
+### Examples
+
+Play a single image file:
+```bash
+sudo dotnet run -- -p eth0 -w 192 -h 64 image.jpg
+```
+
+Play multiple animation files with verbose output:
+```bash
+sudo dotnet run -- -p eth0 -w 192 -h 64 -v animation1.webp animation2.gif
+```
+
+Play with frame mixing and custom brightness:
+```bash
+sudo dotnet run -- -p eth0 -w 192 -h 64 -b 200 -m 50 video.webp
+```
+
+Play with duplicate mode (for stacked displays):
+```bash
+sudo dotnet run -- -p eth0 -w 192 -h 64 -d content.png
+```
+
+Load an extension:
+```bash
+sudo dotnet run -- -p eth0 -w 192 -h 64 -e ../../extensions/grayscale/extension.so image.jpg
+```
+
+## Publish
+
+Create a self-contained application:
+```bash
+dotnet publish -r linux-arm64 --self-contained true -c Release
+```
+
+After publishing, the executable will be in `bin/Release/net8.0/linux-arm64/publish/`:
+```bash
+sudo ./bin/Release/net8.0/linux-arm64/publish/PanelPlayerExample -p eth0 -w 192 -h 64 image.jpg
+```
+
+Note: The build creates binaries for both .NET 8.0 and 9.0. Use the appropriate version based on your runtime.
+
+## Project Structure
+
+- `Program.cs` - Main entry point with command-line argument parsing
+- `PanelPlayer.cs` - Managed wrapper class for the native library
+- `PanelPlayerNative.cs` - P/Invoke declarations for libpanelplayer.so
+
+## Tested Environment
+
+This example has been tested on:
+- **Hardware**: Orange Pi Zero 3
+- **OS**: Debian Bookworm
+- **Runtime**: .NET 8.0 and .NET 9.0
+
+## Troubleshooting
+
+### Permission Errors
+Make sure you're running with `sudo` - raw ethernet access requires root privileges.
+
+### Library Not Found
+Ensure `libpanelplayer.so` is in the output directory or in your system's library path.
+
+### Network Interface Issues
+Verify that the network interface name (`end0`) matches your system's ethernet interface.
+
+## API Reference
+
+The C# wrapper provides these main methods:
+
+- `PanelPlayer(interface, width, height, brightness)` - Initialize the panel
+- `PlayFile(path)` - Play image/animation file (WebP, JPEG, PNG, GIF, BMP)
+- `PlayFrameBGR(data, width, height)` - Display raw BGR frame data
+- `SetBrightness(red, green, blue)` - Adjust color balance
+- `LoadExtension(path)` - Load processing extensions
+- `SetMix(percentage)` - Control frame blending
+- `SetFrameRate(fps)` - Override animation timing
+- `SetDuplicate(enable)` - Enable vertical duplication for stacked displays
+
+For detailed API documentation, see the native library header file `source/panelplayer_api.h`.
\ No newline at end of file
diff --git a/makefile b/makefile
index 6fd70a0..1ae9d88 100644
--- a/makefile
+++ b/makefile
@@ -1,25 +1,63 @@
-CC = gcc
-CFLAGS = -Wall -Werror -pthread -O3
-LDFLAGS = -pthread
-LDLIBS = -ldl -lwebpdemux
-
-SOURCE = ./source
-BUILD = ./build
-TARGET = $(BUILD)/panelplayer
-
-HEADERS = $(wildcard $(SOURCE)/*.h)
-OBJECTS = $(patsubst $(SOURCE)/%.c,$(BUILD)/%.o,$(wildcard $(SOURCE)/*.c))
-
-.PHONY: clean
-
-$(TARGET): $(BUILD) $(OBJECTS)
- $(CC) $(LDFLAGS) $(OBJECTS) $(LDLIBS) -o $@
-
-$(BUILD):
- mkdir $(BUILD)
-
-$(BUILD)/%.o: $(SOURCE)/%.c $(HEADERS) makefile
- $(CC) $(CFLAGS) -c $< -o $@
-
-clean:
+CC = gcc
+CFLAGS = -Wall -Werror -pthread -O3
+LDFLAGS = -pthread
+LDLIBS = -ldl -lwebpdemux -ljpeg -lpng -lgif
+
+SOURCE = ./source
+BUILD = ./build
+TARGET = $(BUILD)/panelplayer
+LIBRARY = $(BUILD)/libpanelplayer.so
+
+# Installation directories
+PREFIX ?= /usr/local
+BINDIR ?= $(PREFIX)/bin
+LIBDIR ?= $(PREFIX)/lib
+INCLUDEDIR ?= $(PREFIX)/include
+
+HEADERS = $(wildcard $(SOURCE)/*.h)
+MAIN_OBJECTS = $(patsubst $(SOURCE)/%.c,$(BUILD)/%.o,$(filter-out $(SOURCE)/panelplayer_api.c,$(wildcard $(SOURCE)/*.c)))
+API_OBJECTS = $(patsubst $(SOURCE)/%.c,$(BUILD)/%.o,$(filter-out $(SOURCE)/main.c,$(wildcard $(SOURCE)/*.c)))
+
+.PHONY: clean all library install uninstall
+
+all: $(TARGET) $(LIBRARY)
+
+$(TARGET): $(BUILD) $(MAIN_OBJECTS)
+ $(CC) $(LDFLAGS) $(MAIN_OBJECTS) $(LDLIBS) -o $@
+
+$(LIBRARY): $(BUILD) $(API_OBJECTS)
+ $(CC) $(LDFLAGS) -shared -fPIC $(API_OBJECTS) $(LDLIBS) -o $@
+
+library: $(LIBRARY)
+
+$(BUILD):
+ mkdir $(BUILD)
+
+$(BUILD)/%.o: $(SOURCE)/%.c $(HEADERS) makefile
+ $(CC) $(CFLAGS) -fPIC -c $< -o $@
+
+install: $(TARGET) $(LIBRARY)
+ @echo "Installing PanelPlayer to $(PREFIX)..."
+ install -d $(DESTDIR)$(BINDIR)
+ install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
+ install -d $(DESTDIR)$(LIBDIR)
+ install -m 644 $(LIBRARY) $(DESTDIR)$(LIBDIR)/
+ install -d $(DESTDIR)$(INCLUDEDIR)/panelplayer
+ install -m 644 $(SOURCE)/panelplayer_api.h $(DESTDIR)$(INCLUDEDIR)/panelplayer/
+ @echo "Installation complete!"
+ @echo ""
+ @echo "Executable installed to: $(BINDIR)/panelplayer"
+ @echo "Library installed to: $(LIBDIR)/libpanelplayer.so"
+ @echo "Header installed to: $(INCLUDEDIR)/panelplayer/panelplayer_api.h"
+ @echo ""
+ @echo "You may need to run 'sudo ldconfig' to update the library cache."
+
+uninstall:
+ @echo "Uninstalling PanelPlayer from $(PREFIX)..."
+ rm -f $(DESTDIR)$(BINDIR)/panelplayer
+ rm -f $(DESTDIR)$(LIBDIR)/libpanelplayer.so
+ rm -rf $(DESTDIR)$(INCLUDEDIR)/panelplayer
+ @echo "Uninstall complete!"
+
+clean:
rm -r $(BUILD)
\ No newline at end of file
diff --git a/readme.md b/readme.md
index 8866e5e..78b618a 100644
--- a/readme.md
+++ b/readme.md
@@ -1,41 +1,318 @@
-# PanelPlayer
-A [WebP](https://developers.google.com/speed/webp) player for Colorlight receiving cards. Tested with the Colorlight 5A-75B.
-
-## Usage
-PanelPlayer can be launched with `panelplayer ` where `` is one or more WebP files. The available options are:
-
-### `-p `
-Sets which ethernet port to use for sending. This option is required.
-
-### `-w `
-Set the display width in pixels. This option is required.
-
-### `-h `
-Set the display height in pixels. This option is required.
-
-### `-b `
-Set the display brightness between 0 and 255. A value of 255 will be used if not specified.
-
-### `-m `
-Controls the percentage of the previous frame to be blended with the current frame. Frame blending is disabled when set to 0 or not specified.
-
-### `-r `
-Overrides the source frame rate if specified.
-
-### `-e `
-Load an extension from the path given. Only a single extension can be loaded.
-
-### `-s`
-Play sources randomly instead of in a fixed order. If used with a single source, this option will loop playback.
-
-### `-v`
-Enable verbose output.
-
-## Building
-Ensure `libwebp` is installed. PanelPlayer can be built by running `make` from within the root directory.
-
-## Extensions
-Extensions are a way to read or alter frames without modifying PanelPlayer. A minimal extension consists of an `update` function which gets called before each frame is sent. An extension may also include `init` and `destroy` functions. The `destroy` function will always be called if present, even when the `init` function indicates an error has occurred. Example extensions are located in the `extensions` directory.
-
-## Protocol
-Protocol documentation can be found in the `protocol` directory. A Wireshark plugin is included to help with reverse engineering and debugging.
\ No newline at end of file
+# PanelPlayer
+
+A high-performance media player for Colorlight LED receiving cards, supporting multiple image and animation formats. Tested with the Colorlight 5A-75B.
+
+## Features
+
+- ✅ **Multi-format Support**: WebP, JPEG, PNG, GIF, and BMP
+- ✅ **Hardware Acceleration**: Direct ethernet control via raw sockets
+- ✅ **Extension System**: Custom frame processing plugins
+- ✅ **C API**: Full-featured library for integration
+- ✅ **Cross-language Bindings**: C# wrapper included
+- ✅ **Frame Mixing**: Smooth transitions between frames
+- ✅ **Duplicate Mode**: Drive stacked displays easily
+
+## Quick Start
+
+```bash
+# Install dependencies
+sudo apt install libwebp-dev libjpeg-dev libpng-dev libgif-dev
+
+# Build and install
+make
+sudo make install
+sudo ldconfig
+
+# Run
+panelplayer -p eth0 -w 128 -h 64 animation.webp
+```
+
+## Usage
+
+### Command Line
+
+```bash
+panelplayer [options]
+```
+
+#### Required Options
+
+| Option | Description | Example |
+|--------|-------------|---------|
+| `-p ` | Ethernet interface | `-p eth0` |
+| `-w ` | Display width in pixels | `-w 128` |
+| `-h ` | Display height in pixels | `-h 64` |
+
+#### Optional Settings
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `-b ` | Brightness (0-255) | 255 |
+| `-m ` | Frame mixing percentage (0-99) | 0 |
+| `-r ` | Override frame rate (fps) | Auto |
+| `-e ` | Load extension | None |
+| `-d` | Duplicate mode (for stacked displays) | Disabled |
+| `-s` | Shuffle/loop playback | Disabled |
+| `-v` | Verbose output | Disabled |
+
+### Examples
+
+**Play a single image:**
+```bash
+panelplayer -p eth0 -w 192 -h 64 photo.jpg
+```
+
+**Play animated WebP with verbose output:**
+```bash
+panelplayer -p eth0 -w 128 -h 64 -v animation.webp
+```
+
+**Play multiple files with frame blending:**
+```bash
+panelplayer -p eth0 -w 192 -h 64 -m 50 video1.webp video2.gif video3.png
+```
+
+**Loop with shuffle mode:**
+```bash
+panelplayer -p eth0 -w 128 -h 64 -s *.webp
+```
+
+**Use extension for grayscale effect:**
+```bash
+panelplayer -p eth0 -w 128 -h 64 -e extensions/grayscale/extension.so image.jpg
+```
+
+**Duplicate mode for two stacked 128x64 panels (total 128x128):**
+```bash
+panelplayer -p eth0 -w 128 -h 64 -d content.png
+```
+
+## Building
+
+### Prerequisites
+
+Install development libraries:
+```bash
+# Debian/Ubuntu
+sudo apt install libwebp-dev libjpeg-dev libpng-dev libgif-dev
+
+# Fedora/RHEL
+sudo dnf install libwebp-devel libjpeg-devel libpng-devel giflib-devel
+```
+
+### Compile
+
+```bash
+make # Build both executable and library
+make library # Build only the shared library
+make clean # Clean build artifacts
+```
+
+### Installation
+
+Install system-wide (requires root):
+```bash
+sudo make install
+sudo ldconfig
+```
+
+This installs:
+- **Executable**: `/usr/local/bin/panelplayer`
+- **Library**: `/usr/local/lib/libpanelplayer.so`
+- **Header**: `/usr/local/include/panelplayer/panelplayer_api.h`
+
+Uninstall:
+```bash
+sudo make uninstall
+```
+
+Custom installation prefix:
+```bash
+make install PREFIX=/opt/panelplayer
+```
+
+## Library Usage
+
+### C API
+
+The library provides a stateful API for programmatic control:
+
+```c
+#include
+
+int main() {
+ // Initialize
+ panelplayer_init("eth0", 128, 64, 255);
+
+ // Configure
+ panelplayer_set_mix(30);
+ panelplayer_set_rate(30);
+
+ // Play content
+ panelplayer_play_file("animation.webp");
+
+ // Or send raw frames
+ uint8_t frame[128 * 64 * 3]; // BGR format
+ // ... fill frame data ...
+ panelplayer_play_frame_bgr(frame, 128, 64);
+
+ // Cleanup
+ panelplayer_cleanup();
+ return 0;
+}
+```
+
+Compile:
+```bash
+gcc -lpanelplayer myapp.c -o myapp
+```
+
+### C# Wrapper
+
+A full-featured C# wrapper is included in `examples/csharp/`:
+
+```csharp
+using var player = new PanelPlayer("eth0", 128, 64, 255);
+player.SetMix(30);
+player.PlayFile("animation.webp");
+```
+
+See `examples/csharp/README.md` for detailed usage.
+
+## Extensions
+
+Extensions allow custom frame processing without modifying PanelPlayer. They are dynamically loaded shared libraries.
+
+### Creating an Extension
+
+Minimal extension with `update` function:
+
+```c
+// my_extension.c
+#include
+
+void update(int width, int height, uint8_t *frame) {
+ // Modify frame data (BGR format, 3 bytes per pixel)
+ for (int i = 0; i < width * height * 3; i += 3) {
+ uint8_t blue = frame[i];
+ uint8_t green = frame[i + 1];
+ uint8_t red = frame[i + 2];
+
+ // Your processing here
+ uint8_t gray = (red + green + blue) / 3;
+ frame[i] = frame[i + 1] = frame[i + 2] = gray;
+ }
+}
+```
+
+Optional `init` and `destroy` functions:
+
+```c
+#include
+#include
+
+bool init() {
+ printf("Extension initialized\n");
+ return false; // Return true on error
+}
+
+void destroy() {
+ printf("Extension cleanup\n");
+}
+
+void update(int width, int height, uint8_t *frame) {
+ // Process frame
+}
+```
+
+Build extension:
+```bash
+gcc -shared -fPIC my_extension.c -o my_extension.so
+```
+
+Use extension:
+```bash
+panelplayer -p eth0 -w 128 -h 64 -e my_extension.so video.webp
+```
+
+Example extensions are in the `extensions/` directory:
+- **grayscale**: Converts frames to grayscale
+- **nanoled**: Custom LED matrix processing
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────┐
+│ main.c │
+│ (CLI argument parsing, file queue) │
+└────────────────┬────────────────────────────────┘
+ │
+┌────────────────▼────────────────────────────────┐
+│ core.c │
+│ (Timing, frame processing, playback loop) │
+└────┬───────────────────────────────────┬────────┘
+ │ │
+┌────▼───────────┐ ┌────────▼──────────┐
+│ decoder.c │ │ colorlight.c │
+│ (Multi-format │ │ (Ethernet proto- │
+│ decoding) │ │ col & sending) │
+└────────────────┘ └───────────────────┘
+```
+
+### Key Components
+
+- **main.c**: CLI interface, file queue, shuffle mode
+- **core.c**: Shared logic (timing, frame processing, playback)
+- **decoder.c**: Multi-format image decoder (WebP/JPEG/PNG/GIF/BMP)
+- **colorlight.c**: Ethernet protocol implementation
+- **loader.c**: Background file loading with queue
+- **panelplayer_api.c**: Library API wrapper
+
+## Protocol
+
+The Colorlight protocol uses raw ethernet frames (EtherType `0x0101`) with custom packet types:
+
+| Type | Description |
+|------|-------------|
+| 0x01 | Display update (trigger screen refresh) |
+| 0x0A | Set brightness/color balance |
+| 0x55 | Image data (row-by-row transmission) |
+
+Protocol documentation and Wireshark dissector are in the `protocol/` directory.
+
+## Hardware Requirements
+
+- **LED Panel**: Colorlight 5A-75B or compatible receiving card
+- **Connection**: Direct ethernet connection (raw socket access required)
+- **Permissions**: Root/sudo access for raw ethernet packets
+- **Platform**: Linux (tested on Debian/Ubuntu, Orange Pi)
+
+## Troubleshooting
+
+### Permission Denied
+```bash
+# PanelPlayer requires raw socket access
+sudo panelplayer -p eth0 -w 128 -h 64 image.jpg
+
+# Or use capabilities (no sudo needed after setup)
+sudo setcap cap_net_raw+ep /usr/local/bin/panelplayer
+```
+
+### Library Not Found
+```bash
+# Update library cache after installation
+sudo ldconfig
+
+# Or set LD_LIBRARY_PATH temporarily
+export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
+```
+
+### Wrong Ethernet Interface
+```bash
+# List network interfaces
+ip link show
+
+# Use the correct interface name
+panelplayer -p -w 128 -h 64 image.jpg
+```
+
diff --git a/source/core.c b/source/core.c
new file mode 100644
index 0000000..50a63f5
--- /dev/null
+++ b/source/core.c
@@ -0,0 +1,177 @@
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core.h"
+
+long core_get_time(void)
+{
+ struct timespec time;
+ clock_gettime(CLOCK_MONOTONIC, &time);
+ return time.tv_sec * 1000 + time.tv_nsec / 1000000;
+}
+
+void core_await(long time)
+{
+ long delay = time - core_get_time();
+
+ if (delay > 0)
+ {
+ usleep(delay * 1000);
+ }
+}
+
+void core_process_frame(uint8_t *buffer, uint8_t *decoded, decoder_info *info,
+ int width, int height, int mix, bool initial)
+{
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ int source = (y * info->canvas_width + x) * 4;
+ int destination = (y * width + x) * 3;
+
+ int oldFactor = initial ? 0 : mix;
+ int newFactor = CORE_MIX_MAXIMUM - oldFactor;
+
+ buffer[destination] = (buffer[destination] * oldFactor + decoded[source + 2] * newFactor) / CORE_MIX_MAXIMUM;
+ buffer[destination + 1] = (buffer[destination + 1] * oldFactor + decoded[source + 1] * newFactor) / CORE_MIX_MAXIMUM;
+ buffer[destination + 2] = (buffer[destination + 2] * oldFactor + decoded[source] * newFactor) / CORE_MIX_MAXIMUM;
+ }
+ }
+}
+
+void core_send_frame(colorlight *cl, uint8_t *buffer, int width, int height,
+ bool duplicate, void (*update_func)(int, int, uint8_t*))
+{
+ if (update_func != NULL)
+ {
+ update_func(width, height, buffer);
+ }
+
+ for (int y = 0; y < height; y++)
+ {
+ colorlight_send_row(cl, y, width, buffer + y * width * 3);
+
+ if (duplicate)
+ {
+ colorlight_send_row(cl, y + height, width, buffer + y * width * 3);
+ }
+ }
+}
+
+int core_load_extension(const char *path, void **extension, void (**update_func)(int, int, uint8_t*))
+{
+ if (path == NULL || extension == NULL || update_func == NULL)
+ {
+ return -1;
+ }
+
+ *extension = dlopen(path, RTLD_NOW);
+
+ if (*extension == NULL)
+ {
+ return -1;
+ }
+
+ bool (*init)() = dlsym(*extension, "init");
+
+ if (init != NULL)
+ {
+ if (init())
+ {
+ dlclose(*extension);
+ *extension = NULL;
+ return -1;
+ }
+ }
+
+ *update_func = dlsym(*extension, "update");
+
+ if (*update_func == NULL)
+ {
+ void (*destroy)() = dlsym(*extension, "destroy");
+ if (destroy != NULL)
+ {
+ destroy();
+ }
+ dlclose(*extension);
+ *extension = NULL;
+ return -1;
+ }
+
+ return 0;
+}
+
+void core_unload_extension(void *extension)
+{
+ if (extension != NULL)
+ {
+ void (*destroy)() = dlsym(extension, "destroy");
+
+ if (destroy != NULL)
+ {
+ destroy();
+ }
+
+ dlclose(extension);
+ }
+}
+
+int core_play_decoded_file(decoder *dec, colorlight *cl, uint8_t *buffer,
+ int width, int height, int brightness, int mix,
+ int rate, bool duplicate, void (*update_func)(int, int, uint8_t*))
+{
+ if (dec == NULL || cl == NULL || buffer == NULL)
+ {
+ return -1;
+ }
+
+ decoder_info info;
+ decoder_get_info(dec, &info);
+
+ if (info.canvas_width < width || info.canvas_height < height)
+ {
+ return -1;
+ }
+
+ long next = core_get_time();
+ int previous = 0;
+ bool initial = true;
+
+ while (decoder_has_more_frames(dec))
+ {
+ uint8_t *decoded;
+ int timestamp;
+
+ decoder_get_next(dec, &decoded, ×tamp);
+
+ core_process_frame(buffer, decoded, &info, width, height, mix, initial);
+ core_send_frame(cl, buffer, width, height, duplicate, update_func);
+
+ if (next - core_get_time() < CORE_UPDATE_DELAY)
+ {
+ next = core_get_time() + CORE_UPDATE_DELAY;
+ }
+
+ core_await(next);
+ colorlight_send_update(cl, brightness, brightness, brightness);
+
+ if (rate > 0)
+ {
+ next = core_get_time() + 1000 / rate;
+ }
+ else
+ {
+ next = core_get_time() + timestamp - previous;
+ previous = timestamp;
+ }
+
+ initial = false;
+ }
+
+ return 0;
+}
diff --git a/source/core.h b/source/core.h
new file mode 100644
index 0000000..a4a9962
--- /dev/null
+++ b/source/core.h
@@ -0,0 +1,86 @@
+#ifndef CORE_H
+#define CORE_H
+
+#include
+#include
+
+#include "colorlight.h"
+#include "decoder.h"
+
+#define CORE_MIX_MAXIMUM 100
+#define CORE_UPDATE_DELAY 10
+
+/**
+ * Get current time in milliseconds
+ */
+long core_get_time(void);
+
+/**
+ * Sleep until the specified time (in milliseconds)
+ */
+void core_await(long time);
+
+/**
+ * Process a decoded frame: convert RGBA to BGR and apply frame mixing
+ *
+ * @param buffer Destination BGR buffer (width * height * 3)
+ * @param decoded Source RGBA data (canvas_width * canvas_height * 4)
+ * @param info Decoder info containing canvas dimensions
+ * @param width Target width
+ * @param height Target height
+ * @param mix Mix percentage (0-99, 0 = no mixing)
+ * @param initial True if this is the first frame (disables mixing)
+ */
+void core_process_frame(uint8_t *buffer, uint8_t *decoded, decoder_info *info,
+ int width, int height, int mix, bool initial);
+
+/**
+ * Send a frame to the hardware, applying extensions and duplicate mode
+ *
+ * @param cl Colorlight instance
+ * @param buffer BGR frame data (width * height * 3)
+ * @param width Frame width
+ * @param height Frame height
+ * @param duplicate If true, duplicate each row vertically
+ * @param update_func Optional extension update function (can be NULL)
+ */
+void core_send_frame(colorlight *cl, uint8_t *buffer, int width, int height,
+ bool duplicate, void (*update_func)(int, int, uint8_t*));
+
+/**
+ * Load an extension from a shared library file
+ *
+ * @param path Path to the extension .so file
+ * @param extension Output pointer to store the loaded extension handle
+ * @param update_func Output pointer to store the update function pointer
+ * @return 0 on success, -1 on error
+ */
+int core_load_extension(const char *path, void **extension, void (**update_func)(int, int, uint8_t*));
+
+/**
+ * Unload an extension, calling its destroy function if present
+ *
+ * @param extension Extension handle to unload (can be NULL)
+ */
+void core_unload_extension(void *extension);
+
+/**
+ * Play a decoded file through the complete playback loop
+ *
+ * @param dec Decoder instance with the loaded file
+ * @param cl Colorlight instance for hardware output
+ * @param buffer Frame buffer (width * height * 3 bytes)
+ * @param width Display width
+ * @param height Display height
+ * @param brightness Brightness level (0-255)
+ * @param mix Frame mixing percentage (0-99)
+ * @param rate Frame rate override (0 = use source timing)
+ * @param duplicate Enable vertical duplication mode
+ * @param update_func Optional extension update function (can be NULL)
+ * @return 0 on success, -1 on error
+ */
+int core_play_decoded_file(decoder *dec, colorlight *cl, uint8_t *buffer,
+ int width, int height, int brightness, int mix,
+ int rate, bool duplicate, void (*update_func)(int, int, uint8_t*));
+
+#endif
diff --git a/source/decoder.c b/source/decoder.c
new file mode 100644
index 0000000..8e886b6
--- /dev/null
+++ b/source/decoder.c
@@ -0,0 +1,510 @@
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include "decoder.h"
+
+typedef enum
+{
+ FORMAT_UNKNOWN,
+ FORMAT_WEBP,
+ FORMAT_JPEG,
+ FORMAT_PNG,
+ FORMAT_GIF,
+ FORMAT_BMP
+} decoder_format;
+
+typedef struct decoder
+{
+ decoder_format format;
+ void *data;
+ int size;
+
+ union
+ {
+ struct
+ {
+ WebPData webp_data;
+ WebPAnimDecoder *webp_decoder;
+ };
+
+ struct
+ {
+ uint8_t *frame_data;
+ int frame_width;
+ int frame_height;
+ bool frame_consumed;
+ };
+
+ struct
+ {
+ GifFileType *gif_file;
+ int gif_current_frame;
+ int gif_frame_count;
+ uint8_t *gif_frame_data;
+ int gif_last_timestamp;
+ };
+ };
+} decoder;
+
+static decoder_format detect_format(void *data, int size)
+{
+ if (size < 12)
+ {
+ return FORMAT_UNKNOWN;
+ }
+
+ uint8_t *bytes = data;
+
+ if (bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F' &&
+ bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P')
+ {
+ return FORMAT_WEBP;
+ }
+
+ if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
+ {
+ return FORMAT_JPEG;
+ }
+
+ if (bytes[0] == 0x89 && bytes[1] == 'P' && bytes[2] == 'N' && bytes[3] == 'G')
+ {
+ return FORMAT_PNG;
+ }
+
+ if (bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F')
+ {
+ return FORMAT_GIF;
+ }
+
+ if (bytes[0] == 'B' && bytes[1] == 'M')
+ {
+ return FORMAT_BMP;
+ }
+
+ return FORMAT_UNKNOWN;
+}
+
+static int gif_read_func(GifFileType *gif, GifByteType *buf, int size)
+{
+ decoder *instance = gif->UserData;
+
+ static int position = 0;
+
+ if (position + size > instance->size)
+ {
+ size = instance->size - position;
+ }
+
+ if (size <= 0)
+ {
+ return 0;
+ }
+
+ memcpy(buf, (uint8_t *)instance->data + position, size);
+ position += size;
+
+ return size;
+}
+
+decoder *decoder_init(void *data, int size)
+{
+ decoder *instance;
+
+ if ((instance = calloc(1, sizeof(*instance))) == NULL)
+ {
+ perror("Failed to allocate memory for decoder");
+ return NULL;
+ }
+
+ instance->data = data;
+ instance->size = size;
+ instance->format = detect_format(data, size);
+
+ switch (instance->format)
+ {
+ case FORMAT_WEBP:
+ instance->webp_data.bytes = data;
+ instance->webp_data.size = size;
+
+ if ((instance->webp_decoder = WebPAnimDecoderNew(&instance->webp_data, NULL)) == NULL)
+ {
+ goto free_instance;
+ }
+ break;
+
+ case FORMAT_JPEG:
+ {
+ struct jpeg_decompress_struct cinfo;
+ struct jpeg_error_mgr jerr;
+
+ cinfo.err = jpeg_std_error(&jerr);
+ jpeg_create_decompress(&cinfo);
+ jpeg_mem_src(&cinfo, data, size);
+ jpeg_read_header(&cinfo, TRUE);
+
+ cinfo.out_color_space = JCS_EXT_RGBA;
+ jpeg_start_decompress(&cinfo);
+
+ instance->frame_width = cinfo.output_width;
+ instance->frame_height = cinfo.output_height;
+
+ if ((instance->frame_data = malloc(instance->frame_width * instance->frame_height * 4)) == NULL)
+ {
+ jpeg_destroy_decompress(&cinfo);
+ goto free_instance;
+ }
+
+ int row_stride = cinfo.output_width * 4;
+
+ while (cinfo.output_scanline < cinfo.output_height)
+ {
+ uint8_t *row = instance->frame_data + cinfo.output_scanline * row_stride;
+ jpeg_read_scanlines(&cinfo, &row, 1);
+ }
+
+ jpeg_finish_decompress(&cinfo);
+ jpeg_destroy_decompress(&cinfo);
+ instance->frame_consumed = false;
+ break;
+ }
+
+ case FORMAT_PNG:
+ {
+ png_image image;
+ memset(&image, 0, sizeof(image));
+ image.version = PNG_IMAGE_VERSION;
+
+ if (png_image_begin_read_from_memory(&image, data, size) == 0)
+ {
+ goto free_instance;
+ }
+
+ image.format = PNG_FORMAT_RGBA;
+
+ instance->frame_width = image.width;
+ instance->frame_height = image.height;
+
+ if ((instance->frame_data = malloc(PNG_IMAGE_SIZE(image))) == NULL)
+ {
+ png_image_free(&image);
+ goto free_instance;
+ }
+
+ if (png_image_finish_read(&image, NULL, instance->frame_data, 0, NULL) == 0)
+ {
+ free(instance->frame_data);
+ png_image_free(&image);
+ goto free_instance;
+ }
+
+ png_image_free(&image);
+ instance->frame_consumed = false;
+ break;
+ }
+
+ case FORMAT_GIF:
+ {
+ int error;
+
+ if ((instance->gif_file = DGifOpen(instance, gif_read_func, &error)) == NULL)
+ {
+ goto free_instance;
+ }
+
+ if (DGifSlurp(instance->gif_file) == GIF_ERROR)
+ {
+ DGifCloseFile(instance->gif_file, NULL);
+ goto free_instance;
+ }
+
+ instance->gif_frame_count = instance->gif_file->ImageCount;
+ instance->gif_current_frame = 0;
+
+ if ((instance->gif_frame_data = malloc(instance->gif_file->SWidth * instance->gif_file->SHeight * 4)) == NULL)
+ {
+ DGifCloseFile(instance->gif_file, NULL);
+ goto free_instance;
+ }
+
+ memset(instance->gif_frame_data, 0, instance->gif_file->SWidth * instance->gif_file->SHeight * 4);
+ instance->gif_last_timestamp = 0;
+ break;
+ }
+
+ case FORMAT_BMP:
+ {
+ uint8_t *bytes = data;
+
+ if (size < 54)
+ {
+ goto free_instance;
+ }
+
+ int offset = bytes[10] | (bytes[11] << 8) | (bytes[12] << 16) | (bytes[13] << 24);
+ int header_size = bytes[14] | (bytes[15] << 8) | (bytes[16] << 16) | (bytes[17] << 24);
+
+ if (header_size < 40)
+ {
+ goto free_instance;
+ }
+
+ instance->frame_width = bytes[18] | (bytes[19] << 8) | (bytes[20] << 16) | (bytes[21] << 24);
+ instance->frame_height = bytes[22] | (bytes[23] << 8) | (bytes[24] << 16) | (bytes[25] << 24);
+ int bits_per_pixel = bytes[28] | (bytes[29] << 8);
+ int compression = bytes[30] | (bytes[31] << 8) | (bytes[32] << 16) | (bytes[33] << 24);
+
+ if (compression != 0 || (bits_per_pixel != 24 && bits_per_pixel != 32))
+ {
+ goto free_instance;
+ }
+
+ bool bottom_up = instance->frame_height > 0;
+ if (instance->frame_height < 0)
+ {
+ instance->frame_height = -instance->frame_height;
+ }
+
+ if ((instance->frame_data = malloc(instance->frame_width * instance->frame_height * 4)) == NULL)
+ {
+ goto free_instance;
+ }
+
+ int row_size = ((bits_per_pixel * instance->frame_width + 31) / 32) * 4;
+ uint8_t *src = bytes + offset;
+
+ for (int y = 0; y < instance->frame_height; y++)
+ {
+ int dest_y = bottom_up ? (instance->frame_height - 1 - y) : y;
+ uint8_t *row = src + y * row_size;
+
+ for (int x = 0; x < instance->frame_width; x++)
+ {
+ int dest_index = (dest_y * instance->frame_width + x) * 4;
+ int src_index = x * (bits_per_pixel / 8);
+
+ instance->frame_data[dest_index + 0] = row[src_index + 2];
+ instance->frame_data[dest_index + 1] = row[src_index + 1];
+ instance->frame_data[dest_index + 2] = row[src_index + 0];
+ instance->frame_data[dest_index + 3] = (bits_per_pixel == 32) ? row[src_index + 3] : 255;
+ }
+ }
+
+ instance->frame_consumed = false;
+ break;
+ }
+
+ default:
+ goto free_instance;
+ }
+
+ return instance;
+
+free_instance:
+ free(instance);
+ return NULL;
+}
+
+bool decoder_get_info(decoder *instance, decoder_info *info)
+{
+ if (instance == NULL || info == NULL)
+ {
+ return false;
+ }
+
+ switch (instance->format)
+ {
+ case FORMAT_WEBP:
+ {
+ WebPAnimInfo webp_info;
+ WebPAnimDecoderGetInfo(instance->webp_decoder, &webp_info);
+
+ info->frame_count = webp_info.frame_count;
+ info->canvas_width = webp_info.canvas_width;
+ info->canvas_height = webp_info.canvas_height;
+ break;
+ }
+
+ case FORMAT_JPEG:
+ case FORMAT_PNG:
+ case FORMAT_BMP:
+ info->frame_count = 1;
+ info->canvas_width = instance->frame_width;
+ info->canvas_height = instance->frame_height;
+ break;
+
+ case FORMAT_GIF:
+ info->frame_count = instance->gif_frame_count;
+ info->canvas_width = instance->gif_file->SWidth;
+ info->canvas_height = instance->gif_file->SHeight;
+ break;
+
+ default:
+ return false;
+ }
+
+ return true;
+}
+
+bool decoder_has_more_frames(decoder *instance)
+{
+ if (instance == NULL)
+ {
+ return false;
+ }
+
+ switch (instance->format)
+ {
+ case FORMAT_WEBP:
+ return WebPAnimDecoderHasMoreFrames(instance->webp_decoder);
+
+ case FORMAT_JPEG:
+ case FORMAT_PNG:
+ case FORMAT_BMP:
+ return !instance->frame_consumed;
+
+ case FORMAT_GIF:
+ return instance->gif_current_frame < instance->gif_frame_count;
+
+ default:
+ return false;
+ }
+}
+
+bool decoder_get_next(decoder *instance, uint8_t **frame, int *timestamp)
+{
+ if (instance == NULL || frame == NULL || timestamp == NULL)
+ {
+ return false;
+ }
+
+ switch (instance->format)
+ {
+ case FORMAT_WEBP:
+ return WebPAnimDecoderGetNext(instance->webp_decoder, frame, timestamp);
+
+ case FORMAT_JPEG:
+ case FORMAT_PNG:
+ case FORMAT_BMP:
+ if (instance->frame_consumed)
+ {
+ return false;
+ }
+
+ *frame = instance->frame_data;
+ *timestamp = 0;
+ instance->frame_consumed = true;
+ return true;
+
+ case FORMAT_GIF:
+ {
+ if (instance->gif_current_frame >= instance->gif_frame_count)
+ {
+ return false;
+ }
+
+ SavedImage *image = &instance->gif_file->SavedImages[instance->gif_current_frame];
+ ColorMapObject *colormap = image->ImageDesc.ColorMap != NULL ? image->ImageDesc.ColorMap : instance->gif_file->SColorMap;
+
+ if (colormap == NULL)
+ {
+ return false;
+ }
+
+ int left = image->ImageDesc.Left;
+ int top = image->ImageDesc.Top;
+ int width = image->ImageDesc.Width;
+ int height = image->ImageDesc.Height;
+
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ int gif_index = y * width + x;
+ int frame_index = ((top + y) * instance->gif_file->SWidth + (left + x)) * 4;
+
+ uint8_t color_index = image->RasterBits[gif_index];
+ GifColorType color = colormap->Colors[color_index];
+
+ int transparent = -1;
+ for (int i = 0; i < image->ExtensionBlockCount; i++)
+ {
+ if (image->ExtensionBlocks[i].Function == GRAPHICS_EXT_FUNC_CODE)
+ {
+ uint8_t *ext = image->ExtensionBlocks[i].Bytes;
+ if (ext[0] & 0x01)
+ {
+ transparent = ext[3];
+ }
+ }
+ }
+
+ if (color_index != transparent)
+ {
+ instance->gif_frame_data[frame_index + 0] = color.Red;
+ instance->gif_frame_data[frame_index + 1] = color.Green;
+ instance->gif_frame_data[frame_index + 2] = color.Blue;
+ instance->gif_frame_data[frame_index + 3] = 255;
+ }
+ }
+ }
+
+ int delay = 100;
+ for (int i = 0; i < image->ExtensionBlockCount; i++)
+ {
+ if (image->ExtensionBlocks[i].Function == GRAPHICS_EXT_FUNC_CODE)
+ {
+ uint8_t *ext = image->ExtensionBlocks[i].Bytes;
+ delay = (ext[2] << 8) | ext[1];
+ delay *= 10;
+ }
+ }
+
+ *frame = instance->gif_frame_data;
+ *timestamp = instance->gif_last_timestamp + delay;
+ instance->gif_last_timestamp = *timestamp;
+ instance->gif_current_frame++;
+
+ return true;
+ }
+
+ default:
+ return false;
+ }
+}
+
+void decoder_destroy(decoder *instance)
+{
+ if (instance == NULL)
+ {
+ return;
+ }
+
+ switch (instance->format)
+ {
+ case FORMAT_WEBP:
+ WebPAnimDecoderDelete(instance->webp_decoder);
+ break;
+
+ case FORMAT_JPEG:
+ case FORMAT_PNG:
+ case FORMAT_BMP:
+ free(instance->frame_data);
+ break;
+
+ case FORMAT_GIF:
+ free(instance->gif_frame_data);
+ DGifCloseFile(instance->gif_file, NULL);
+ break;
+
+ default:
+ break;
+ }
+
+ free(instance);
+}
diff --git a/source/decoder.h b/source/decoder.h
new file mode 100644
index 0000000..22d30cc
--- /dev/null
+++ b/source/decoder.h
@@ -0,0 +1,154 @@
+/**
+ * @file decoder.h
+ * @brief Multi-format image decoder abstraction for PanelPlayer
+ *
+ * This header provides a unified interface for decoding multiple image formats
+ * including WebP, JPEG, PNG, GIF, and BMP. The decoder automatically detects
+ * the format based on file headers and provides a consistent API regardless
+ * of the underlying format.
+ *
+ * Supported formats:
+ * - WebP (animated and static)
+ * - JPEG (static)
+ * - PNG (static)
+ * - GIF (animated)
+ * - BMP (static, 24/32-bit uncompressed)
+ */
+
+#ifndef DECODER_H
+#define DECODER_H
+
+#include
+#include
+
+/**
+ * @brief Opaque decoder instance structure
+ *
+ * Internal structure that holds format-specific decoder state.
+ * Users should treat this as an opaque handle.
+ */
+typedef struct decoder decoder;
+
+/**
+ * @brief Image/animation information structure
+ *
+ * Contains metadata about the decoded image or animation.
+ */
+typedef struct decoder_info
+{
+ int frame_count; /**< Total number of frames (1 for static images) */
+ int canvas_width; /**< Image width in pixels */
+ int canvas_height; /**< Image height in pixels */
+} decoder_info;
+
+/**
+ * @brief Initialize a decoder from memory data
+ *
+ * Creates a new decoder instance by analyzing the provided image data.
+ * The format is automatically detected from the file header/magic bytes.
+ *
+ * The decoder supports:
+ * - WebP: Animated and static images
+ * - JPEG: Static images (24-bit RGB)
+ * - PNG: Static images with alpha channel
+ * - GIF: Animated images with transparency
+ * - BMP: Static images (24/32-bit uncompressed)
+ *
+ * @param data Pointer to image file data in memory
+ * @param size Size of the image data in bytes
+ *
+ * @return Decoder instance on success, NULL on failure
+ * @retval NULL if format is unsupported, data is corrupted, or memory allocation fails
+ *
+ * @note The caller retains ownership of the data buffer and must ensure it
+ * remains valid for the lifetime of the decoder instance.
+ *
+ * @see decoder_destroy()
+ */
+decoder *decoder_init(void *data, int size);
+
+/**
+ * @brief Get information about the decoded image/animation
+ *
+ * Retrieves metadata including dimensions and frame count from the decoder.
+ * This should be called after successful initialization to determine the
+ * image properties before processing frames.
+ *
+ * @param instance Decoder instance
+ * @param info Pointer to decoder_info structure to be filled
+ *
+ * @return true on success, false on failure
+ * @retval true Information retrieved successfully
+ * @retval false NULL instance or info pointer provided
+ *
+ * @note For static images (JPEG, PNG, BMP), frame_count will be 1.
+ * For animations (WebP, GIF), frame_count reflects the total frames.
+ */
+bool decoder_get_info(decoder *instance, decoder_info *info);
+
+/**
+ * @brief Check if more frames are available
+ *
+ * Determines whether there are additional frames to decode.
+ * For static images, this returns true only before the first frame is read.
+ * For animations, this returns true until all frames have been decoded.
+ *
+ * @param instance Decoder instance
+ *
+ * @return true if more frames available, false otherwise
+ * @retval true More frames can be decoded
+ * @retval false No more frames or NULL instance
+ *
+ * @see decoder_get_next()
+ */
+bool decoder_has_more_frames(decoder *instance);
+
+/**
+ * @brief Decode and retrieve the next frame
+ *
+ * Decodes the next available frame from the image/animation.
+ * Returns a pointer to the frame data in RGBA format (4 bytes per pixel)
+ * and the timestamp for animation timing.
+ *
+ * Frame data format:
+ * - 4 bytes per pixel: Red, Green, Blue, Alpha
+ * - Row-major order (left-to-right, top-to-bottom)
+ * - Size: canvas_width × canvas_height × 4 bytes
+ *
+ * Timestamp behavior:
+ * - Static images: Always 0
+ * - GIF: Cumulative time in milliseconds from start
+ * - WebP: Cumulative time in milliseconds from start
+ *
+ * @param instance Decoder instance
+ * @param frame Output pointer to frame data (RGBA format)
+ * @param timestamp Output pointer to frame timestamp in milliseconds
+ *
+ * @return true on success, false on failure
+ * @retval true Frame decoded successfully
+ * @retval false No more frames, NULL parameters, or decode error
+ *
+ * @warning The frame pointer is valid only until the next call to
+ * decoder_get_next() or decoder_destroy(). Do not free this pointer.
+ *
+ * @see decoder_has_more_frames()
+ */
+bool decoder_get_next(decoder *instance, uint8_t **frame, int *timestamp);
+
+/**
+ * @brief Destroy decoder and free resources
+ *
+ * Releases all resources associated with the decoder instance including
+ * internal buffers and format-specific decoder state. The decoder instance
+ * becomes invalid after this call.
+ *
+ * @param instance Decoder instance to destroy (can be NULL)
+ *
+ * @note Safe to call with NULL instance (no-op).
+ * @note Does not free the original data buffer passed to decoder_init().
+ *
+ * @see decoder_init()
+ */
+void decoder_destroy(decoder *instance);
+
+#endif
diff --git a/source/main.c b/source/main.c
index 62f1a1d..b6c736b 100644
--- a/source/main.c
+++ b/source/main.c
@@ -1,383 +1,276 @@
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include
-
-#include "colorlight.h"
-#include "loader.h"
-
-#define QUEUE_SIZE 4
-#define MIX_MAXIMUM 100
-#define UPDATE_DELAY 10
-
-bool parse(const char *source, int *destination)
-{
- char *end;
- *destination = strtol(source, &end, 10);
- return end[0] != 0;
-}
-
-long get_time()
-{
- struct timespec time;
- clock_gettime(CLOCK_MONOTONIC, &time);
- return time.tv_sec * 1000 + time.tv_nsec / 1000000;
-}
-
-void await(long time)
-{
- long delay = time - get_time();
-
- if (delay > 0)
- {
- usleep(delay * 1000);
- }
-}
-
-int main(int argc, char *argv[])
-{
- int status = EXIT_FAILURE;
- char *port = NULL;
- int width = 0;
- int height = 0;
- int brightness = 255;
- int mix = 0;
- int rate = 0;
- char *extensionFile = NULL;
- bool shuffle = false;
- bool verbose = false;
- int sourcesLength = 0;
- char **sources;
-
- srand(time(NULL));
-
- if ((sources = malloc((argc - 1) * sizeof(*sources))) == NULL)
- {
- perror("Failed to allocate memory for sources");
- goto exit;
- }
-
- for (int index = 1; index < argc; index++)
- {
- char *argument = argv[index];
-
- if (argument[0] != '-')
- {
- sources[sourcesLength++] = argument;
- continue;
- }
-
- bool failed = false;
-
- switch (argument[1])
- {
- case 'p':
- failed = ++index >= argc;
- port = argv[index];
- break;
-
- case 'w':
- failed = ++index >= argc || parse(argv[index], &width);
- break;
-
- case 'h':
- failed = ++index >= argc || parse(argv[index], &height);
- break;
-
- case 'b':
- failed = ++index >= argc || parse(argv[index], &brightness);
- break;
-
- case 'm':
- failed = ++index >= argc || parse(argv[index], &mix);
- break;
-
- case 'r':
- failed = ++index >= argc || parse(argv[index], &rate);
- break;
-
- case 'e':
- failed = ++index >= argc;
- extensionFile = argv[index];
- break;
-
- case 's':
- shuffle = true;
- break;
-
- case 'v':
- verbose = true;
- break;
-
- default:
- failed = true;
- }
-
- if (failed || argument[2] != 0)
- {
- puts("Usage:");
- puts(" panelplayer -p -w -h [options] ");
- puts("");
- puts("Options:");
- puts(" -p Set ethernet port");
- puts(" -w Set display width");
- puts(" -h Set display height");
- puts(" -b Set display brightness");
- puts(" -m Set frame mixing percentage");
- puts(" -r Override source frame rate");
- puts(" -e Load extension from file");
- puts(" -s Shuffle sources");
- puts(" -v Enable verbose output");
-
- goto free_sources;
- }
- }
-
- if (port == NULL)
- {
- puts("Port must be specified!");
- goto free_sources;
- }
-
- if (width < 1 || height < 1)
- {
- puts("Width and height must be specified as positive integers!");
- goto free_sources;
- }
-
- if (brightness < 0 || brightness > 255)
- {
- puts("Brightness must be an integer between 0 and 255!");
- goto free_sources;
- }
-
- if (mix < 0 || mix >= MIX_MAXIMUM)
- {
- printf("Mix must be an integer between 0 and %d!\n", MIX_MAXIMUM - 1);
- goto free_sources;
- }
-
- if (sourcesLength == 0)
- {
- puts("At least one source must be specified!");
- goto free_sources;
- }
-
- uint8_t *buffer;
-
- if ((buffer = malloc(width * height * 3)) == NULL)
- {
- perror("Failed to allocate frame buffer");
- goto free_sources;
- }
-
- loader *loader;
-
- if ((loader = loader_init(QUEUE_SIZE)) == NULL)
- {
- puts("Failed to create loader instance!");
- goto free_buffer;
- }
-
- colorlight *colorlight;
-
- if ((colorlight = colorlight_init(port)) == NULL)
- {
- puts("Failed to create Colorlight instance!");
- goto destroy_loader;
- }
-
- void *extension = NULL;
- void (*update)() = NULL;
-
- if (extensionFile != NULL)
- {
- extension = dlopen(extensionFile, RTLD_NOW);
-
- if (extension == NULL)
- {
- puts("Failed to load extension!");
- goto destroy_colorlight;
- }
-
- bool (*init)() = dlsym(extension, "init");
-
- if (init != NULL)
- {
- if (init())
- {
- puts("Failed to initialise extension!");
- goto destroy_extension;
- }
- }
-
- update = dlsym(extension, "update");
-
- if (update == NULL)
- {
- puts("Extension does not provide update function!");
- goto destroy_extension;
- }
- }
-
- int queued = 0;
- long next = get_time();
- bool initial = true;
-
- for (int source = 0; shuffle || source < sourcesLength; source++)
- {
- while (queued < source + QUEUE_SIZE)
- {
- if (shuffle)
- {
- loader_add(loader, sources[rand() % sourcesLength]);
- }
- else if (queued < sourcesLength)
- {
- loader_add(loader, sources[queued]);
- }
-
- queued++;
- }
-
- int size;
- void *file;
-
- if ((file = loader_get(loader, &size)) == NULL)
- {
- continue;
- }
-
- WebPData data = {
- .bytes = file,
- .size = size
- };
-
- WebPAnimDecoder *decoder;
-
- if ((decoder = WebPAnimDecoderNew(&data, NULL)) == NULL)
- {
- puts("Failed to decode file!");
- goto free_file;
- }
-
- WebPAnimInfo info;
- WebPAnimDecoderGetInfo(decoder, &info);
-
- if (verbose)
- {
- printf("Decoding %d frames at a resolution of %dx%d.\n", info.frame_count, info.canvas_width, info.canvas_height);
- }
-
- if (info.canvas_width < width || info.canvas_height < height)
- {
- puts("Image is smaller than display!");
- goto delete_decoder;
- }
-
- int previous = 0;
- long start = next;
-
- while (WebPAnimDecoderHasMoreFrames(decoder))
- {
- uint8_t *decoded;
- int timestamp;
-
- WebPAnimDecoderGetNext(decoder, &decoded, ×tamp);
-
- for (int y = 0; y < height; y++)
- {
- for (int x = 0; x < width; x++)
- {
- int source = (y * info.canvas_width + x) * 4;
- int destination = (y * width + x) * 3;
-
- int oldFactor = initial ? 0 : mix;
- int newFactor = MIX_MAXIMUM - oldFactor;
-
- buffer[destination] = (buffer[destination] * oldFactor + decoded[source + 2] * newFactor) / MIX_MAXIMUM;
- buffer[destination + 1] = (buffer[destination + 1] * oldFactor + decoded[source + 1] * newFactor) / MIX_MAXIMUM;
- buffer[destination + 2] = (buffer[destination + 2] * oldFactor + decoded[source] * newFactor) / MIX_MAXIMUM;
- }
- }
-
- if (update != NULL)
- {
- update(width, height, buffer);
- }
-
- for (int y = 0; y < height; y++)
- {
- colorlight_send_row(colorlight, y, width, buffer + y * width * 3);
- }
-
- if (next - get_time() < UPDATE_DELAY)
- {
- next = get_time() + UPDATE_DELAY;
- }
-
- await(next);
- colorlight_send_update(colorlight, brightness, brightness, brightness);
-
- if (rate > 0)
- {
- next = get_time() + 1000 / rate;
- }
- else
- {
- next = get_time() + timestamp - previous;
- previous = timestamp;
- }
-
- initial = false;
- }
-
- if (verbose)
- {
- float seconds = (next - start) / 1000.0;
- printf("Played %d frames in %.2f seconds at an average rate of %.2f frames per second.\n", info.frame_count, seconds, info.frame_count / seconds);
- }
-
- delete_decoder:
- WebPAnimDecoderDelete(decoder);
-
- free_file:
- free(file);
- }
-
- await(next);
- status = EXIT_SUCCESS;
-
-destroy_extension:
- if (extension != NULL)
- {
- void (*destroy)() = dlsym(extension, "destroy");
-
- if (destroy != NULL)
- {
- destroy();
- }
-
- dlclose(extension);
- }
-
-destroy_colorlight:
- colorlight_destroy(colorlight);
-
-destroy_loader:
- loader_destroy(loader);
-
-free_buffer:
- free(buffer);
-
-free_sources:
- free(sources);
-
-exit:
- return status;
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "colorlight.h"
+#include "core.h"
+#include "decoder.h"
+#include "loader.h"
+
+#define QUEUE_SIZE 4
+
+bool parse(const char *source, int *destination)
+{
+ char *end;
+ *destination = strtol(source, &end, 10);
+ return end[0] != 0;
+}
+
+int main(int argc, char *argv[])
+{
+ int status = EXIT_FAILURE;
+ char *port = NULL;
+ int width = 0;
+ int height = 0;
+ int brightness = 255;
+ int mix = 0;
+ int rate = 0;
+ char *extensionFile = NULL;
+ bool shuffle = false;
+ bool verbose = false;
+ bool duplicate = false;
+ int sourcesLength = 0;
+ char **sources;
+
+ srand(time(NULL));
+
+ if ((sources = malloc((argc - 1) * sizeof(*sources))) == NULL)
+ {
+ perror("Failed to allocate memory for sources");
+ goto exit;
+ }
+
+ for (int index = 1; index < argc; index++)
+ {
+ char *argument = argv[index];
+
+ if (argument[0] != '-')
+ {
+ sources[sourcesLength++] = argument;
+ continue;
+ }
+
+ bool failed = false;
+
+ switch (argument[1])
+ {
+ case 'p':
+ failed = ++index >= argc;
+ port = argv[index];
+ break;
+
+ case 'w':
+ failed = ++index >= argc || parse(argv[index], &width);
+ break;
+
+ case 'h':
+ failed = ++index >= argc || parse(argv[index], &height);
+ break;
+
+ case 'b':
+ failed = ++index >= argc || parse(argv[index], &brightness);
+ break;
+
+ case 'm':
+ failed = ++index >= argc || parse(argv[index], &mix);
+ break;
+
+ case 'r':
+ failed = ++index >= argc || parse(argv[index], &rate);
+ break;
+
+ case 'e':
+ failed = ++index >= argc;
+ extensionFile = argv[index];
+ break;
+
+ case 's':
+ shuffle = true;
+ break;
+
+ case 'v':
+ verbose = true;
+ break;
+
+ case 'd':
+ duplicate = true;
+ break;
+
+ default:
+ failed = true;
+ }
+
+ if (failed || argument[2] != 0)
+ {
+ puts("Usage:");
+ puts(" panelplayer -p -w -h [options] ");
+ puts("");
+ puts("Options:");
+ puts(" -p Set ethernet port");
+ puts(" -w Set display width");
+ puts(" -h Set display height");
+ puts(" -b Set display brightness");
+ puts(" -m Set frame mixing percentage");
+ puts(" -r Override source frame rate");
+ puts(" -e Load extension from file");
+ puts(" -s Shuffle sources");
+ puts(" -v Enable verbose output");
+ puts(" -d Duplicate each row vertically");
+
+ goto free_sources;
+ }
+ }
+
+ if (port == NULL)
+ {
+ puts("Port must be specified!");
+ goto free_sources;
+ }
+
+ if (width < 1 || height < 1)
+ {
+ puts("Width and height must be specified as positive integers!");
+ goto free_sources;
+ }
+
+ if (brightness < 0 || brightness > 255)
+ {
+ puts("Brightness must be an integer between 0 and 255!");
+ goto free_sources;
+ }
+
+ if (mix < 0 || mix >= CORE_MIX_MAXIMUM)
+ {
+ printf("Mix must be an integer between 0 and %d!\n", CORE_MIX_MAXIMUM - 1);
+ goto free_sources;
+ }
+
+ if (sourcesLength == 0)
+ {
+ puts("At least one source must be specified!");
+ goto free_sources;
+ }
+
+ uint8_t *buffer;
+
+ if ((buffer = malloc(width * height * 3)) == NULL)
+ {
+ perror("Failed to allocate frame buffer");
+ goto free_sources;
+ }
+
+ loader *loader;
+
+ if ((loader = loader_init(QUEUE_SIZE)) == NULL)
+ {
+ puts("Failed to create loader instance!");
+ goto free_buffer;
+ }
+
+ colorlight *colorlight;
+
+ if ((colorlight = colorlight_init(port)) == NULL)
+ {
+ puts("Failed to create Colorlight instance!");
+ goto destroy_loader;
+ }
+
+ void *extension = NULL;
+ void (*update)(int, int, uint8_t*) = NULL;
+
+ if (extensionFile != NULL)
+ {
+ if (core_load_extension(extensionFile, &extension, &update) != 0)
+ {
+ puts("Failed to load extension!");
+ goto destroy_colorlight;
+ }
+ }
+
+ int queued = 0;
+
+ for (int source = 0; shuffle || source < sourcesLength; source++)
+ {
+ while (queued < source + QUEUE_SIZE)
+ {
+ if (shuffle)
+ {
+ loader_add(loader, sources[rand() % sourcesLength]);
+ }
+ else if (queued < sourcesLength)
+ {
+ loader_add(loader, sources[queued]);
+ }
+
+ queued++;
+ }
+
+ int size;
+ void *file;
+
+ if ((file = loader_get(loader, &size)) == NULL)
+ {
+ continue;
+ }
+
+ decoder *decoder;
+
+ if ((decoder = decoder_init(file, size)) == NULL)
+ {
+ puts("Failed to decode file!");
+ goto free_file;
+ }
+
+ decoder_info info;
+ decoder_get_info(decoder, &info);
+
+ if (verbose)
+ {
+ printf("Decoding %d frames at a resolution of %dx%d.\n", info.frame_count, info.canvas_width, info.canvas_height);
+ }
+
+ long start = core_get_time();
+
+ if (core_play_decoded_file(decoder, colorlight, buffer, width, height, brightness, mix, rate, duplicate, update) != 0)
+ {
+ puts("Image is smaller than display!");
+ goto delete_decoder;
+ }
+
+ if (verbose)
+ {
+ long end = core_get_time();
+ float seconds = (end - start) / 1000.0;
+ printf("Played %d frames in %.2f seconds at an average rate of %.2f frames per second.\n", info.frame_count, seconds, info.frame_count / seconds);
+ }
+
+ delete_decoder:
+ decoder_destroy(decoder);
+
+ free_file:
+ free(file);
+ }
+
+ status = EXIT_SUCCESS;
+
+ core_unload_extension(extension);
+
+destroy_colorlight:
+ colorlight_destroy(colorlight);
+
+destroy_loader:
+ loader_destroy(loader);
+
+free_buffer:
+ free(buffer);
+
+free_sources:
+ free(sources);
+
+exit:
+ return status;
}
\ No newline at end of file
diff --git a/source/panelplayer_api.c b/source/panelplayer_api.c
new file mode 100644
index 0000000..c3084fd
--- /dev/null
+++ b/source/panelplayer_api.c
@@ -0,0 +1,219 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "panelplayer_api.h"
+#include "colorlight.h"
+#include "core.h"
+#include "decoder.h"
+#include "loader.h"
+
+typedef struct {
+ colorlight *colorlight;
+ uint8_t *buffer;
+ int width;
+ int height;
+ int brightness;
+ int mix;
+ int rate;
+ bool duplicate;
+ void *extension;
+ void (*update_func)(int width, int height, uint8_t *frame);
+ bool initialized;
+} panelplayer_state;
+
+static panelplayer_state g_state = {0};
+
+int panelplayer_init(const char* interface_name, int width, int height, int brightness) {
+ if (g_state.initialized) {
+ return PANELPLAYER_ALREADY_INITIALIZED;
+ }
+
+ if (!interface_name || width <= 0 || height <= 0 || brightness < 0 || brightness > 255) {
+ return PANELPLAYER_INVALID_PARAM;
+ }
+
+ g_state.colorlight = colorlight_init((char*)interface_name);
+ if (!g_state.colorlight) {
+ return PANELPLAYER_ERROR;
+ }
+
+ g_state.buffer = malloc(width * height * 3);
+ if (!g_state.buffer) {
+ colorlight_destroy(g_state.colorlight);
+ return PANELPLAYER_ERROR;
+ }
+
+ g_state.width = width;
+ g_state.height = height;
+ g_state.brightness = brightness;
+ g_state.mix = 0;
+ g_state.rate = 0;
+ g_state.duplicate = false;
+ g_state.extension = NULL;
+ g_state.update_func = NULL;
+ g_state.initialized = true;
+
+ return PANELPLAYER_SUCCESS;
+}
+
+int panelplayer_set_mix(int mix_percentage) {
+ if (!g_state.initialized) {
+ return PANELPLAYER_NOT_INITIALIZED;
+ }
+
+ if (mix_percentage < 0 || mix_percentage >= CORE_MIX_MAXIMUM) {
+ return PANELPLAYER_INVALID_PARAM;
+ }
+
+ g_state.mix = mix_percentage;
+ return PANELPLAYER_SUCCESS;
+}
+
+int panelplayer_set_rate(int frame_rate) {
+ if (!g_state.initialized) {
+ return PANELPLAYER_NOT_INITIALIZED;
+ }
+
+ if (frame_rate < 0) {
+ return PANELPLAYER_INVALID_PARAM;
+ }
+
+ g_state.rate = frame_rate;
+ return PANELPLAYER_SUCCESS;
+}
+
+int panelplayer_set_duplicate(bool enable) {
+ if (!g_state.initialized) {
+ return PANELPLAYER_NOT_INITIALIZED;
+ }
+
+ g_state.duplicate = enable;
+ return PANELPLAYER_SUCCESS;
+}
+
+int panelplayer_load_extension(const char* extension_path) {
+ if (!g_state.initialized) {
+ return PANELPLAYER_NOT_INITIALIZED;
+ }
+
+ if (!extension_path) {
+ return PANELPLAYER_INVALID_PARAM;
+ }
+
+ if (g_state.extension) {
+ core_unload_extension(g_state.extension);
+ g_state.extension = NULL;
+ g_state.update_func = NULL;
+ }
+
+ if (core_load_extension(extension_path, &g_state.extension, &g_state.update_func) != 0) {
+ return PANELPLAYER_ERROR;
+ }
+
+ return PANELPLAYER_SUCCESS;
+}
+
+int panelplayer_play_file(const char* file_path) {
+ if (!g_state.initialized) {
+ return PANELPLAYER_NOT_INITIALIZED;
+ }
+
+ if (!file_path) {
+ return PANELPLAYER_INVALID_PARAM;
+ }
+
+ FILE *file = fopen(file_path, "rb");
+ if (!file) {
+ return PANELPLAYER_ERROR;
+ }
+
+ fseek(file, 0, SEEK_END);
+ long file_size = ftell(file);
+ fseek(file, 0, SEEK_SET);
+
+ uint8_t *file_data = malloc(file_size);
+ if (!file_data) {
+ fclose(file);
+ return PANELPLAYER_ERROR;
+ }
+
+ if (fread(file_data, 1, file_size, file) != file_size) {
+ free(file_data);
+ fclose(file);
+ return PANELPLAYER_ERROR;
+ }
+ fclose(file);
+
+ decoder *dec = decoder_init(file_data, file_size);
+ if (!dec) {
+ free(file_data);
+ return PANELPLAYER_ERROR;
+ }
+
+ int result = core_play_decoded_file(dec, g_state.colorlight, g_state.buffer,
+ g_state.width, g_state.height, g_state.brightness,
+ g_state.mix, g_state.rate, g_state.duplicate,
+ g_state.update_func);
+
+ decoder_destroy(dec);
+ free(file_data);
+
+ return (result == 0) ? PANELPLAYER_SUCCESS : PANELPLAYER_ERROR;
+}
+
+int panelplayer_play_frame_bgr(const uint8_t* bgr_data, int width, int height) {
+ if (!g_state.initialized) {
+ return PANELPLAYER_NOT_INITIALIZED;
+ }
+
+ if (!bgr_data || width != g_state.width || height != g_state.height) {
+ return PANELPLAYER_INVALID_PARAM;
+ }
+
+ memcpy(g_state.buffer, bgr_data, width * height * 3);
+
+ core_send_frame(g_state.colorlight, g_state.buffer, g_state.width, g_state.height, g_state.duplicate, g_state.update_func);
+
+ long next = core_get_time() + CORE_UPDATE_DELAY;
+ core_await(next);
+ colorlight_send_update(g_state.colorlight, g_state.brightness, g_state.brightness, g_state.brightness);
+
+ return PANELPLAYER_SUCCESS;
+}
+
+int panelplayer_send_brightness(uint8_t red, uint8_t green, uint8_t blue) {
+ if (!g_state.initialized) {
+ return PANELPLAYER_NOT_INITIALIZED;
+ }
+
+ colorlight_send_brightness(g_state.colorlight, red, green, blue);
+ return PANELPLAYER_SUCCESS;
+}
+
+bool panelplayer_is_initialized(void) {
+ return g_state.initialized;
+}
+
+void panelplayer_cleanup(void) {
+ if (!g_state.initialized) {
+ return;
+ }
+
+ core_unload_extension(g_state.extension);
+
+ if (g_state.colorlight) {
+ colorlight_destroy(g_state.colorlight);
+ }
+
+ if (g_state.buffer) {
+ free(g_state.buffer);
+ }
+
+ memset(&g_state, 0, sizeof(g_state));
+}
\ No newline at end of file
diff --git a/source/panelplayer_api.h b/source/panelplayer_api.h
new file mode 100644
index 0000000..73f2ec0
--- /dev/null
+++ b/source/panelplayer_api.h
@@ -0,0 +1,188 @@
+/**
+ * @file panelplayer_api.h
+ * @brief PanelPlayer C API for controlling Colorlight LED panels
+ *
+ * This header provides a C library interface for the PanelPlayer application,
+ * allowing programmatic control of Colorlight LED receiving cards via raw ethernet.
+ */
+
+#ifndef PANELPLAYER_API_H
+#define PANELPLAYER_API_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** @defgroup ReturnCodes Return Codes
+ * @{
+ */
+#define PANELPLAYER_SUCCESS 0 /**< Operation completed successfully */
+#define PANELPLAYER_ERROR -1 /**< General error occurred */
+#define PANELPLAYER_INVALID_PARAM -2 /**< Invalid parameter provided */
+#define PANELPLAYER_NOT_INITIALIZED -3 /**< Library not initialized */
+#define PANELPLAYER_ALREADY_INITIALIZED -4 /**< Library already initialized */
+/** @} */
+
+typedef struct panelplayer_instance panelplayer_instance;
+
+/**
+ * @brief Initialize the PanelPlayer library
+ *
+ * Initializes the PanelPlayer library with the specified LED panel parameters.
+ * This function must be called before any other API functions.
+ *
+ * @param interface_name Network interface name (e.g., "eth0")
+ * @param width Panel width in pixels
+ * @param height Panel height in pixels
+ * @param brightness Initial brightness value (0-255)
+ *
+ * @return PANELPLAYER_SUCCESS on success, error code otherwise
+ * @retval PANELPLAYER_SUCCESS Initialization successful
+ * @retval PANELPLAYER_INVALID_PARAM Invalid parameters provided
+ * @retval PANELPLAYER_ALREADY_INITIALIZED Library already initialized
+ * @retval PANELPLAYER_ERROR Failed to initialize network interface or allocate memory
+ */
+int panelplayer_init(const char* interface_name, int width, int height, int brightness);
+
+/**
+ * @brief Set frame mixing percentage
+ *
+ * Controls how new frames are blended with existing content on the panel.
+ * A value of 0 completely replaces the current frame, while higher values
+ * blend the new frame with the existing content.
+ *
+ * @param mix_percentage Mixing percentage (0-99)
+ *
+ * @return PANELPLAYER_SUCCESS on success, error code otherwise
+ * @retval PANELPLAYER_SUCCESS Mix percentage set successfully
+ * @retval PANELPLAYER_NOT_INITIALIZED Library not initialized
+ * @retval PANELPLAYER_INVALID_PARAM Mix percentage out of range
+ */
+int panelplayer_set_mix(int mix_percentage);
+
+/**
+ * @brief Set playback frame rate
+ *
+ * Sets the frame rate for WebP animation playback. When set to 0,
+ * uses the timing information embedded in the WebP file.
+ *
+ * @param frame_rate Target frame rate in frames per second (0 for auto)
+ *
+ * @return PANELPLAYER_SUCCESS on success, error code otherwise
+ * @retval PANELPLAYER_SUCCESS Frame rate set successfully
+ * @retval PANELPLAYER_NOT_INITIALIZED Library not initialized
+ * @retval PANELPLAYER_INVALID_PARAM Negative frame rate provided
+ */
+int panelplayer_set_rate(int frame_rate);
+
+/**
+ * @brief Enable or disable vertical duplication mode
+ *
+ * When enabled, each row of the image is sent twice - once at its original
+ * position (y) and once at y+height. This is useful for driving two identical
+ * displays stacked vertically where the image height is half the total display height.
+ *
+ * Example: With a 240x80 image and duplicate enabled, each row is sent to both
+ * y and y+80, effectively filling a 240x160 display.
+ *
+ * @param enable true to enable duplication, false to disable
+ *
+ * @return PANELPLAYER_SUCCESS on success, error code otherwise
+ * @retval PANELPLAYER_SUCCESS Duplicate mode set successfully
+ * @retval PANELPLAYER_NOT_INITIALIZED Library not initialized
+ */
+int panelplayer_set_duplicate(bool enable);
+
+/**
+ * @brief Load a frame processing extension
+ *
+ * Loads a shared library extension that can modify frame data before
+ * it is sent to the LED panel. Extensions must implement the required
+ * interface with update() function.
+ *
+ * @param extension_path Path to the shared library (.so file)
+ *
+ * @return PANELPLAYER_SUCCESS on success, error code otherwise
+ * @retval PANELPLAYER_SUCCESS Extension loaded successfully
+ * @retval PANELPLAYER_NOT_INITIALIZED Library not initialized
+ * @retval PANELPLAYER_INVALID_PARAM NULL extension path provided
+ * @retval PANELPLAYER_ERROR Failed to load extension or missing required functions
+ */
+int panelplayer_load_extension(const char* extension_path);
+
+/**
+ * @brief Play an image or animation file
+ *
+ * Loads and plays an image or animation file on the LED panel.
+ * Supports WebP, JPEG, PNG, GIF, and BMP formats.
+ * The function handles frame timing and loops through all frames for animated formats.
+ *
+ * @param file_path Path to the image file to play
+ *
+ * @return PANELPLAYER_SUCCESS on success, error code otherwise
+ * @retval PANELPLAYER_SUCCESS File played successfully
+ * @retval PANELPLAYER_NOT_INITIALIZED Library not initialized
+ * @retval PANELPLAYER_INVALID_PARAM NULL file path provided
+ * @retval PANELPLAYER_ERROR Failed to open file, decode image, or incompatible dimensions
+ */
+int panelplayer_play_file(const char* file_path);
+
+/**
+ * @brief Display a single frame from BGR data
+ *
+ * Displays a single frame on the LED panel using raw BGR pixel data.
+ * The data must be in BGR format with 3 bytes per pixel.
+ *
+ * @param bgr_data Pointer to BGR pixel data (Blue, Green, Red order)
+ * @param width Frame width in pixels (must match initialized width)
+ * @param height Frame height in pixels (must match initialized height)
+ *
+ * @return PANELPLAYER_SUCCESS on success, error code otherwise
+ * @retval PANELPLAYER_SUCCESS Frame displayed successfully
+ * @retval PANELPLAYER_NOT_INITIALIZED Library not initialized
+ * @retval PANELPLAYER_INVALID_PARAM NULL data pointer or mismatched dimensions
+ */
+int panelplayer_play_frame_bgr(const uint8_t* bgr_data, int width, int height);
+
+/**
+ * @brief Send brightness/color balance command
+ *
+ * Sends a brightness and color balance command directly to the LED panel.
+ * This allows independent control of red, green, and blue channel brightness.
+ *
+ * @param red Red channel brightness (0-255)
+ * @param green Green channel brightness (0-255)
+ * @param blue Blue channel brightness (0-255)
+ *
+ * @return PANELPLAYER_SUCCESS on success, error code otherwise
+ * @retval PANELPLAYER_SUCCESS Brightness command sent successfully
+ * @retval PANELPLAYER_NOT_INITIALIZED Library not initialized
+ */
+int panelplayer_send_brightness(uint8_t red, uint8_t green, uint8_t blue);
+
+/**
+ * @brief Check if library is initialized
+ *
+ * Returns the current initialization state of the PanelPlayer library.
+ *
+ * @return true if library is initialized, false otherwise
+ */
+bool panelplayer_is_initialized(void);
+
+/**
+ * @brief Cleanup and shutdown the library
+ *
+ * Cleans up all resources, closes network connections, unloads extensions,
+ * and resets the library to an uninitialized state. Should be called
+ * when finished using the library.
+ */
+void panelplayer_cleanup(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
\ No newline at end of file