use crossbeam_channel as crossbeam;
use eframe::egui;
use rouille::{Request, Response};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock, mpsc};
use std::thread;
use std::time::Duration;
use uuid::Uuid;
#[derive(Debug)]
pub enum ApiCommand {
Play,
Pause,
Stop,
SetFrame(i32),
SetFps(f32),
ToggleLoop,
LoadSequence(String),
EmitEvent { event_type: String, payload: String },
Exit,
NextFrame,
PrevFrame,
Screenshot {
viewport_only: bool,
response: crossbeam::Sender<Result<Vec<u8>, String>>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerSnapshot {
pub frame: i32,
pub fps: f32,
pub playing: bool,
pub loop_enabled: bool,
pub active_comp: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompSnapshot {
pub uuid: Uuid,
pub name: String,
pub width: u32,
pub height: u32,
pub duration: i32,
pub in_frame: i32,
pub out_frame: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheSnapshot {
pub memory_used_mb: f32,
pub memory_limit_mb: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusResponse {
pub player: PlayerSnapshot,
pub comp: Option<CompSnapshot>,
pub cache: CacheSnapshot,
}
pub struct SharedApiState {
pub player: RwLock<PlayerSnapshot>,
pub comp: RwLock<Option<CompSnapshot>>,
pub cache: RwLock<CacheSnapshot>,
pub egui_ctx: RwLock<Option<egui::Context>>,
}
impl Default for SharedApiState {
fn default() -> Self {
Self {
player: RwLock::new(PlayerSnapshot {
frame: 0,
fps: 24.0,
playing: false,
loop_enabled: false,
active_comp: None,
}),
comp: RwLock::new(None),
cache: RwLock::new(CacheSnapshot {
memory_used_mb: 0.0,
memory_limit_mb: 0.0,
}),
egui_ctx: RwLock::new(None),
}
}
}
#[derive(Debug, Deserialize)]
struct LoadRequest {
path: String,
}
#[derive(Debug, Deserialize)]
struct EventRequest {
event_type: String,
#[serde(default)]
payload: serde_json::Value,
}
#[derive(Serialize)]
struct ApiResponse {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl ApiResponse {
fn ok() -> Self {
Self { success: true, message: None, error: None }
}
fn ok_msg(msg: &str) -> Self {
Self { success: true, message: Some(msg.to_string()), error: None }
}
fn err(msg: &str) -> Self {
Self { success: false, message: None, error: Some(msg.to_string()) }
}
}
pub struct ApiServer {
port: u16,
state: Arc<SharedApiState>,
command_tx: mpsc::Sender<ApiCommand>,
}
impl ApiServer {
pub fn start(port: u16, state: Arc<SharedApiState>) -> mpsc::Receiver<ApiCommand> {
let (tx, rx) = mpsc::channel();
let server = ApiServer {
port,
state,
command_tx: tx,
};
thread::spawn(move || {
server.run();
});
rx
}
fn run(self) {
let addr = format!("0.0.0.0:{}", self.port);
log::info!("API server starting on http://{}", addr);
let state = self.state;
let tx = self.command_tx;
match rouille::Server::new(&addr, move |request| {
Self::handle_request(request, &state, &tx)
}) {
Ok(server) => {
log::info!("API server listening on http://{}", addr);
server.run();
}
Err(e) => {
log::error!("Failed to start API server on port {}: {}", self.port, e);
log::error!("This may be caused by another instance of playa already running.");
log::error!("API server will not be available in this session.");
}
}
}
fn handle_request(
request: &Request,
state: &Arc<SharedApiState>,
tx: &mpsc::Sender<ApiCommand>,
) -> Response {
if request.method() == "OPTIONS" {
return Response::empty_204().with_additional_header("Access-Control-Allow-Origin", "*")
.with_additional_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.with_additional_header("Access-Control-Allow-Headers", "Content-Type");
}
let path = request.url();
if request.method() == "POST" {
if let Some(frame_str) = path.strip_prefix("/api/player/frame/") {
if let Ok(frame) = frame_str.parse::<i32>() {
return Self::send_command(tx, ApiCommand::SetFrame(frame))
.with_additional_header("Access-Control-Allow-Origin", "*");
} else {
return Response::json(&ApiResponse::err("Invalid frame number"))
.with_status_code(400)
.with_additional_header("Access-Control-Allow-Origin", "*");
}
}
if let Some(fps_str) = path.strip_prefix("/api/player/fps/") {
if let Ok(fps) = fps_str.parse::<f32>() {
return Self::send_command(tx, ApiCommand::SetFps(fps))
.with_additional_header("Access-Control-Allow-Origin", "*");
} else {
return Response::json(&ApiResponse::err("Invalid FPS value"))
.with_status_code(400)
.with_additional_header("Access-Control-Allow-Origin", "*");
}
}
}
let response = rouille::router!(request,
(GET) ["/api/status"] => {
Self::get_status(state)
},
(GET) ["/api/player"] => {
Self::get_player(state)
},
(GET) ["/api/comp"] => {
Self::get_comp(state)
},
(GET) ["/api/cache"] => {
Self::get_cache(state)
},
(POST) ["/api/player/play"] => {
Self::send_command(tx, ApiCommand::Play)
},
(POST) ["/api/player/pause"] => {
Self::send_command(tx, ApiCommand::Pause)
},
(POST) ["/api/player/stop"] => {
Self::send_command(tx, ApiCommand::Stop)
},
(POST) ["/api/player/toggle-loop"] => {
Self::send_command(tx, ApiCommand::ToggleLoop)
},
(POST) ["/api/player/next"] => {
Self::send_command(tx, ApiCommand::NextFrame)
},
(POST) ["/api/player/prev"] => {
Self::send_command(tx, ApiCommand::PrevFrame)
},
(POST) ["/api/app/exit"] => {
Self::send_command(tx, ApiCommand::Exit)
},
(POST) ["/api/player/frame"] => {
Response::json(&ApiResponse::err("Missing frame number")).with_status_code(400)
},
(POST) ["/api/project/load"] => {
Self::handle_load(request, tx)
},
(POST) ["/api/event"] => {
Self::handle_event(request, tx)
},
(GET) ["/api/health"] => {
Response::json(&ApiResponse::ok_msg("playa API server"))
},
(GET) ["/api/screenshot"] => {
Self::handle_screenshot(tx, state, true) },
(GET) ["/api/screenshot/frame"] => {
Self::handle_screenshot(tx, state, false) },
_ => {
Response::json(&ApiResponse::err("Not found")).with_status_code(404)
}
);
response.with_additional_header("Access-Control-Allow-Origin", "*")
}
fn get_status(state: &Arc<SharedApiState>) -> Response {
let player = state.player.read().unwrap().clone();
let comp = state.comp.read().unwrap().clone();
let cache = state.cache.read().unwrap().clone();
Response::json(&StatusResponse { player, comp, cache })
}
fn get_player(state: &Arc<SharedApiState>) -> Response {
let player = state.player.read().unwrap().clone();
Response::json(&player)
}
fn get_comp(state: &Arc<SharedApiState>) -> Response {
let comp = state.comp.read().unwrap().clone();
match comp {
Some(c) => Response::json(&c),
None => Response::json(&ApiResponse::err("No active comp")).with_status_code(404),
}
}
fn get_cache(state: &Arc<SharedApiState>) -> Response {
let cache = state.cache.read().unwrap().clone();
Response::json(&cache)
}
fn send_command(tx: &mpsc::Sender<ApiCommand>, cmd: ApiCommand) -> Response {
match tx.send(cmd) {
Ok(_) => Response::json(&ApiResponse::ok()),
Err(e) => Response::json(&ApiResponse::err(&format!("Failed to send command: {}", e)))
.with_status_code(500),
}
}
fn handle_load(request: &Request, tx: &mpsc::Sender<ApiCommand>) -> Response {
match rouille::input::json_input::<LoadRequest>(request) {
Ok(req) => Self::send_command(tx, ApiCommand::LoadSequence(req.path)),
Err(e) => Response::json(&ApiResponse::err(&format!("Invalid JSON: {}", e)))
.with_status_code(400),
}
}
fn handle_event(request: &Request, tx: &mpsc::Sender<ApiCommand>) -> Response {
match rouille::input::json_input::<EventRequest>(request) {
Ok(req) => {
let payload = serde_json::to_string(&req.payload).unwrap_or_default();
Self::send_command(tx, ApiCommand::EmitEvent {
event_type: req.event_type,
payload,
})
}
Err(e) => Response::json(&ApiResponse::err(&format!("Invalid JSON: {}", e)))
.with_status_code(400),
}
}
fn handle_screenshot(tx: &mpsc::Sender<ApiCommand>, state: &SharedApiState, viewport_only: bool) -> Response {
if let Some(ctx) = state.egui_ctx.read().unwrap().as_ref() {
ctx.request_repaint();
}
let (resp_tx, resp_rx) = crossbeam::bounded(1);
let cmd = ApiCommand::Screenshot {
viewport_only,
response: resp_tx,
};
if let Err(e) = tx.send(cmd) {
return Response::json(&ApiResponse::err(&format!("Failed to send command: {}", e)))
.with_status_code(500);
}
if let Some(ctx) = state.egui_ctx.read().unwrap().as_ref() {
ctx.request_repaint();
}
match resp_rx.recv_timeout(Duration::from_secs(15)) {
Ok(Ok(jpeg_bytes)) => {
Response::from_data("image/jpeg", jpeg_bytes)
}
Ok(Err(err)) => {
Response::json(&ApiResponse::err(&err)).with_status_code(500)
}
Err(_) => {
Response::json(&ApiResponse::err("Screenshot timeout")).with_status_code(504)
}
}
}
}