ascii-chat 0.8.38
Real-time terminal-based video chat with ASCII art conversion
Loading...
Searching...
No Matches
client_like.c
Go to the documentation of this file.
1
7#include <ascii-chat/session/client_like.h>
8#include <ascii-chat/session/capture.h>
9#include <ascii-chat/session/display.h>
10#include <ascii-chat/session/render.h>
11#include <ascii-chat/session/session_log_buffer.h>
12
13#include <ascii-chat/media/source.h>
14#include <ascii-chat/audio/audio.h>
15#include <ascii-chat/video/webcam/webcam.h>
16#include <ascii-chat/ui/splash.h>
17
18#include <ascii-chat/log/logging.h>
19#include <ascii-chat/options/options.h>
20#include <ascii-chat/platform/abstraction.h>
21#include <ascii-chat/platform/terminal.h>
22#include <ascii-chat/network/tcp/client.h>
23#include <ascii-chat/network/websocket/client.h>
24#include <ascii-chat/util/url.h>
25
26#include <string.h>
27#include <stdatomic.h>
28
29// Forward declarations (implemented below)
30static bool capture_should_exit_adapter(void *user_data);
31static bool display_should_exit_adapter(void *user_data);
32
33// Module-level config for adapter callbacks
34static const session_client_like_config_t *g_current_config = NULL;
35
36// Module-level adapter functions for render loop access
37static bool (*g_render_should_exit)(void *) = NULL;
38
39// Module-level network clients (created by framework, accessed by run_fn)
40static tcp_client_t *g_tcp_client = NULL;
41static websocket_client_t *g_websocket_client = NULL;
42
43// External exit function from src/main.c
44extern bool should_exit(void);
45
46/* ============================================================================
47 * Public Accessors
48 * ============================================================================ */
49
51 return g_render_should_exit;
52}
53
55 return g_tcp_client;
56}
57
59 return g_websocket_client;
60}
61
62/* ============================================================================
63 * Exit Condition Adapters
64 * ============================================================================ */
65
70static bool capture_should_exit_adapter(void *user_data) {
71 (void)user_data;
72 if (should_exit()) {
73 return true;
74 }
75 if (g_current_config && g_current_config->custom_should_exit) {
76 return g_current_config->custom_should_exit(g_current_config->exit_user_data);
77 }
78 return false;
79}
80
85static bool display_should_exit_adapter(void *user_data) {
86 (void)user_data;
87 if (should_exit()) {
88 return true;
89 }
90 if (g_current_config && g_current_config->custom_should_exit) {
91 return g_current_config->custom_should_exit(g_current_config->exit_user_data);
92 }
93 return false;
94}
95
96asciichat_error_t session_client_like_run(const session_client_like_config_t *config) {
97 if (!config || !config->run_fn) {
98 return SET_ERRNO(ERROR_INVALID_PARAM, "config or run_fn is NULL");
99 }
100
101 // Store config globally for adapter callbacks
102 g_current_config = config;
103
104 // Store the render should_exit adapter globally so mode-specific run_fn can access it
105 g_render_should_exit = display_should_exit_adapter;
106
107 asciichat_error_t result = ASCIICHAT_OK;
108
109 // Keep track of what's been initialized for cleanup
110 session_display_ctx_t *temp_display = NULL;
111 session_capture_ctx_t *capture = NULL;
112 session_display_ctx_t *display = NULL;
113 audio_context_t *audio_ctx = NULL;
114 media_source_t *probe_source = NULL;
115 bool audio_available = false;
116
117 // ============================================================================
118 // SETUP: Terminal and Logging
119 // ============================================================================
120
121 log_debug("session_client_like_run(): Setting up terminal and logging");
122
123 // Force stderr when stdout is piped (prevents ASCII corruption in output stream)
124 bool should_force_stderr = terminal_should_force_stderr();
125 log_debug("terminal_should_force_stderr()=%d", should_force_stderr);
126
127 if (should_force_stderr) {
128 // Redirect logs to stderr to prevent corruption of stdout (for pipes)
129 // But keep terminal output enabled so we can see initialization errors
131 }
132
133 // ============================================================================
134 // SETUP: Keepawake System
135 // ============================================================================
136
137 log_debug("session_client_like_run(): Validating keepawake options");
138
139 // Validate mutual exclusivity
140 bool enable_ka = GET_OPTION(enable_keepawake);
141 bool disable_ka = GET_OPTION(disable_keepawake);
142 log_debug("enable_keepawake=%d, disable_keepawake=%d", enable_ka, disable_ka);
143
144 if (enable_ka && disable_ka) {
145 result = SET_ERRNO(ERROR_INVALID_PARAM, "--keepawake and --no-keepawake are mutually exclusive");
146 goto cleanup;
147 }
148
149 log_debug("session_client_like_run(): Enabling keepawake if needed");
150
151 // Enable keepawake unless explicitly disabled
152 if (!disable_ka) {
154 }
155
156 log_debug("session_client_like_run(): Keepawake setup complete");
157
158 // ============================================================================
159 // SETUP: Splash Screen (before media initialization)
160 // ============================================================================
161
162 log_debug("session_client_like_run(): Creating temporary display for splash");
163
164 temp_display = session_display_create(NULL);
165 if (temp_display) {
166 splash_intro_start(temp_display);
167
168 // Detect if we're using media vs webcam (needed for splash timing)
169 const char *media_url = GET_OPTION(media_url);
170 const char *media_file = GET_OPTION(media_file);
171 bool has_media = (media_url && strlen(media_url) > 0) || (media_file && strlen(media_file) > 0);
172
173 // Show splash briefly for webcam, but skip sleep for media (which takes time anyway)
174 if (!has_media && !GET_OPTION(snapshot_mode)) {
176 }
177 }
178
179 // ============================================================================
180 // SETUP: Terminal Logging in Snapshot Mode
181 // ============================================================================
182
183 if (!GET_OPTION(snapshot_mode)) {
185 }
186
187 // ============================================================================
188 // SETUP: Media Source Selection and FPS Probing
189 // ============================================================================
190
191 session_capture_config_t capture_config = {0};
192 capture_config.resize_for_network = false;
193 capture_config.should_exit_callback = capture_should_exit_adapter;
194 capture_config.callback_data = NULL;
195
196 // Determine FPS explicitly set by user
197 int user_fps = GET_OPTION(fps);
198 bool fps_explicitly_set = user_fps > 0;
199
200 // Select media source based on options (priority order)
201 const char *media_url_val = GET_OPTION(media_url);
202 const char *media_file_val = GET_OPTION(media_file);
203
204 if (media_url_val && strlen(media_url_val) > 0) {
205 // Network URL takes priority
206 log_info("Using network URL: %s (webcam disabled)", media_url_val);
207 capture_config.type = MEDIA_SOURCE_FILE;
208 capture_config.path = media_url_val;
209 capture_config.loop = false; // Network URLs cannot be looped
210
211 if (fps_explicitly_set) {
212 capture_config.target_fps = (uint32_t)user_fps;
213 log_info("Using user-specified FPS: %u", capture_config.target_fps);
214 } else {
215 // Probe FPS for HTTP URLs
216 probe_source = media_source_create(MEDIA_SOURCE_FILE, media_url_val);
217 if (probe_source) {
218 double url_fps = media_source_get_video_fps(probe_source);
219 log_info("Detected HTTP stream video FPS: %.1f", url_fps);
220 if (url_fps > 0.0) {
221 capture_config.target_fps = (uint32_t)(url_fps + 0.5);
222 } else {
223 log_warn("FPS detection failed for HTTP stream, using default 60 FPS");
224 capture_config.target_fps = 60;
225 }
226 } else {
227 log_warn("Failed to create probe source for HTTP stream, using default 60 FPS");
228 capture_config.target_fps = 60;
229 }
230 }
231 } else if (media_file_val && strlen(media_file_val) > 0) {
232 // Local file or stdin
233 if (strcmp(media_file_val, "-") == 0) {
234 log_info("Using stdin for media streaming (webcam disabled)");
235 capture_config.type = MEDIA_SOURCE_STDIN;
236 capture_config.path = NULL;
237 capture_config.target_fps = fps_explicitly_set ? (uint32_t)user_fps : 60;
238 capture_config.loop = false;
239 } else {
240 log_info("Using media file: %s (webcam disabled)", media_file_val);
241 capture_config.type = MEDIA_SOURCE_FILE;
242 capture_config.path = media_file_val;
243 capture_config.loop = GET_OPTION(media_loop);
244
245 if (fps_explicitly_set) {
246 capture_config.target_fps = (uint32_t)user_fps;
247 log_info("Using user-specified FPS: %u", capture_config.target_fps);
248 } else {
249 // Probe FPS for local files
250 probe_source = media_source_create(MEDIA_SOURCE_FILE, media_file_val);
251 if (probe_source) {
252 double file_fps = media_source_get_video_fps(probe_source);
253 log_info("Detected file video FPS: %.1f", file_fps);
254 if (file_fps > 0.0) {
255 capture_config.target_fps = (uint32_t)(file_fps + 0.5);
256 } else {
257 log_warn("FPS detection failed, using default 60 FPS");
258 capture_config.target_fps = 60;
259 }
260 } else {
261 log_warn("Failed to create probe source for FPS detection, using default 60 FPS");
262 capture_config.target_fps = 60;
263 }
264 }
265 }
266 } else if (GET_OPTION(test_pattern)) {
267 // Test pattern
268 log_info("Using test pattern");
269 capture_config.type = MEDIA_SOURCE_TEST;
270 capture_config.path = NULL;
271 capture_config.target_fps = fps_explicitly_set ? (uint32_t)user_fps : 60;
272 capture_config.loop = false;
273 } else {
274 // Default: webcam
275 log_info("Using local webcam");
276 capture_config.type = MEDIA_SOURCE_WEBCAM;
277 capture_config.path = NULL;
278 capture_config.target_fps = fps_explicitly_set ? (uint32_t)user_fps : 60;
279 capture_config.loop = false;
280 }
281
282 // Apply initial seek if specified
283 capture_config.initial_seek_timestamp = GET_OPTION(media_seek_timestamp);
284
285 // Clean up probe source (don't reuse for actual capture)
286 if (probe_source) {
287 media_source_destroy(probe_source);
288 probe_source = NULL;
289 }
290
291 // ============================================================================
292 // SETUP: Capture Context
293 // ============================================================================
294
295 // Choose capture type based on mode:
296 // - Mirror mode: needs to capture local media (webcam, file, test pattern)
297 // - Network modes (client/discovery): receive frames from network, no local capture
298 bool is_network_mode = (config->tcp_client != NULL || config->websocket_client != NULL);
299
300 if (is_network_mode) {
301 // Network mode: create minimal capture context without media source
302 log_debug("Network mode detected - using network capture (no local media source)");
303 int fps = GET_OPTION(fps);
304 capture = session_network_capture_create((uint32_t)(fps > 0 ? fps : 60));
305 if (!capture) {
306 log_fatal("Failed to initialize network capture context");
307 result = ERROR_MEDIA_INIT;
308 goto cleanup;
309 }
310 if (fps > 0) {
311 log_debug("Network capture FPS set to %d from options", fps);
312 }
313 } else {
314 // Mirror mode: create capture context with local media source
315 log_debug("Mirror mode detected - using mirror capture with local media source");
316 capture = session_mirror_capture_create(&capture_config);
317 if (!capture) {
318 log_fatal("Failed to initialize mirror capture source");
319 result = ERROR_MEDIA_INIT;
320 goto cleanup;
321 }
322 }
323
324 // ============================================================================
325 // SETUP: Audio Context
326 // ============================================================================
327
328 // Skip audio for immediate snapshots
329 bool should_init_audio = true;
330 if (GET_OPTION(snapshot_mode) && GET_OPTION(snapshot_delay) == 0.0) {
331 should_init_audio = false;
332 log_debug("Skipping audio initialization for immediate snapshot");
333 }
334
335 // Probe for audio
336 if (should_init_audio && capture_config.type == MEDIA_SOURCE_FILE && capture_config.path) {
337 media_source_t *audio_probe_source = session_capture_get_media_source(capture);
338 if (audio_probe_source && media_source_has_audio(audio_probe_source)) {
339 audio_available = true;
340
341 // Allocate and initialize audio context
342 audio_ctx = SAFE_MALLOC(sizeof(audio_context_t), audio_context_t *);
343 if (audio_ctx) {
344 *audio_ctx = (audio_context_t){0};
345 if (audio_init(audio_ctx) == ASCIICHAT_OK) {
346 // Link audio to media source
347 media_source_t *media_source = session_capture_get_media_source(capture);
348 audio_ctx->media_source = media_source;
349
350 if (media_source) {
351 media_source_set_audio_context(media_source, audio_ctx);
352 }
353
354 // Determine if microphone should be enabled
355 bool should_enable_mic = audio_should_enable_microphone(GET_OPTION(audio_source), audio_available);
356 audio_ctx->playback_only = !should_enable_mic;
357
358 // Disable jitter buffering for file playback
359 if (audio_ctx->playback_buffer) {
360 audio_ctx->playback_buffer->jitter_buffer_enabled = false;
361 atomic_store(&audio_ctx->playback_buffer->jitter_buffer_filled, true);
362 }
363
364 // Store in capture for keyboard handler access
365 session_capture_set_audio_context(capture, audio_ctx);
366
367 log_debug("Audio context initialized");
368 } else {
369 log_warn("Failed to initialize audio context");
370 audio_destroy(audio_ctx);
371 SAFE_FREE(audio_ctx);
372 audio_ctx = NULL;
373 audio_available = false;
374 }
375 }
376 }
377 }
378
379 // ============================================================================
380 // SETUP: Display Context
381 // ============================================================================
382
383 session_display_config_t display_config = {0};
384 display_config.snapshot_mode = GET_OPTION(snapshot_mode);
385 display_config.palette_type = GET_OPTION(palette_type);
386 display_config.custom_palette = GET_OPTION(palette_custom_set) ? GET_OPTION(palette_custom) : NULL;
387 display_config.color_mode = TERM_COLOR_AUTO;
388 display_config.enable_audio_playback = audio_available;
389 display_config.audio_ctx = audio_ctx;
390 display_config.should_exit_callback = display_should_exit_adapter;
391 display_config.callback_data = NULL;
392
393 display = session_display_create(&display_config);
394 if (!display) {
395 log_fatal("Failed to initialize display");
396 result = ERROR_DISPLAY;
397 goto cleanup;
398 }
399
400 // ============================================================================
401 // SETUP: End Splash Screen
402 // ============================================================================
403
404 log_debug("About to call splash_intro_done()");
406 log_debug("splash_intro_done() returned");
407
408 if (temp_display) {
409 session_display_destroy(temp_display);
410 temp_display = NULL;
411 }
412
413 // ============================================================================
414 // SETUP: Start Audio Playback
415 // ============================================================================
416
417 log_debug("About to check audio context for duplex");
418 if (audio_ctx && audio_available) {
419 if (audio_start_duplex(audio_ctx) == ASCIICHAT_OK) {
420 log_info("Audio playback started");
421 } else {
422 log_warn("Failed to start audio duplex");
423 audio_destroy(audio_ctx);
424 SAFE_FREE(audio_ctx);
425 audio_ctx = NULL;
426 audio_available = false;
427 }
428 }
429
430 // ============================================================================
431 // SETUP: Network Transports (TCP/WebSocket)
432 // ============================================================================
433
434 log_debug("session_client_like_run(): Setting up network transports");
435
436 // Parse server address to determine TCP vs WebSocket
437 const char *server_address = GET_OPTION(address);
438 bool is_websocket = server_address && url_is_websocket(server_address);
439
440 if (is_websocket) {
441 log_debug("WebSocket URL detected: %s", server_address);
442 g_websocket_client = websocket_client_create();
443 if (!g_websocket_client) {
444 log_error("Failed to create WebSocket client");
445 result = ERROR_NETWORK;
446 goto cleanup;
447 }
448 } else {
449 log_debug("Using TCP client for server: %s:%d", server_address ? server_address : "localhost", GET_OPTION(port));
450 g_tcp_client = tcp_client_create();
451 if (!g_tcp_client) {
452 log_error("Failed to create TCP client");
453 result = ERROR_NETWORK;
454 goto cleanup;
455 }
456 }
457
458 // ============================================================================
459 // RUN: Mode-Specific Main Loop with Reconnection
460 // ============================================================================
461
462 // Connection/attempt loop - wraps run_fn with reconnection logic
463 int attempt = 0;
464 int max_attempts = config->max_reconnect_attempts;
465
466 while (true) {
467 attempt++;
468
469 log_debug("About to call config->run_fn() (attempt %d)", attempt);
470 result = config->run_fn(capture, display, config->run_user_data);
471 log_debug("config->run_fn() returned with result=%d", result);
472
473 // Exit immediately if run_fn succeeded
474 if (result == ASCIICHAT_OK) {
475 break;
476 }
477
478 // Check if we should attempt reconnection
479 bool should_retry = false;
480
481 if (max_attempts != 0) { // max_attempts == 0 means no retries
482 // Check custom reconnect logic if provided
483 if (config->should_reconnect_callback) {
484 should_retry = config->should_reconnect_callback(result, attempt, config->reconnect_user_data);
485 } else {
486 should_retry = true; // Default: always retry
487 }
488
489 // Check retry limits if still planning to retry
490 if (should_retry && max_attempts > 0 && attempt >= max_attempts) {
491 log_debug("Reached maximum reconnection attempts (%d), giving up", max_attempts);
492 should_retry = false;
493 }
494 }
495
496 // If not retrying or shutdown requested, exit loop
497 if (!should_retry || should_exit()) {
498 break;
499 }
500
501 // Log reconnection message
502 if (max_attempts == -1) {
503 log_info("Connection failed, retrying...");
504 } else if (max_attempts > 0) {
505 log_info("Connection failed, retrying (attempt %d/%d)...", attempt + 1, max_attempts);
506 }
507
508 // Apply reconnection delay if configured
509 if (config->reconnect_delay_ms > 0) {
510 platform_sleep_ms(config->reconnect_delay_ms);
511 }
512
513 // Continue loop to retry
514 }
515
516 // ============================================================================
517 // CLEANUP (always runs, even on error)
518 // ============================================================================
519
520cleanup:
521 // Re-enable terminal output for shutdown logs
523
524 // Cleanup network transports (TCP/WebSocket clients)
525 if (g_websocket_client) {
526 log_debug("Destroying WebSocket client");
527 websocket_client_destroy(&g_websocket_client);
528 }
529 if (g_tcp_client) {
530 log_debug("Destroying TCP client");
531 tcp_client_destroy(&g_tcp_client);
532 }
533
534 // CRITICAL: Terminate PortAudio device resources FIRST
535 log_debug("Terminating PortAudio device resources");
537
538 // Stop and destroy audio (after PortAudio is terminated)
539 if (audio_ctx) {
540 audio_stop_duplex(audio_ctx);
541 audio_destroy(audio_ctx);
542 SAFE_FREE(audio_ctx);
543 audio_ctx = NULL;
544 }
545
546 // Destroy display
547 if (display) {
549 display = NULL;
550 }
551
552 // Destroy capture
553 if (capture) {
555 capture = NULL;
556 }
557
558 // Clean up probe source if still allocated (shouldn't happen, but be safe)
559 if (probe_source) {
560 media_source_destroy(probe_source);
561 probe_source = NULL;
562 }
563
564 // Free cached webcam images and test patterns
566
567 // Cleanup session log buffer (used by splash screen)
569
570 // Disable keepawake (re-allow OS to sleep)
571 log_debug("Disabling keepawake");
573
574 // Write newline to separate final frame from prompt (if configured)
575 if (config->print_newline_on_tty_exit && terminal_is_stdout_tty()) {
576 const char newline = '\n';
577 (void)platform_write_all(STDOUT_FILENO, &newline, 1);
578 }
579
581
582 return result;
583}
size_t platform_write_all(int fd, const void *buf, size_t count)
Write all data to file descriptor with automatic retry on transient errors.
Definition abstraction.c:39
websocket_client_t * session_client_like_get_websocket_client(void)
Definition client_like.c:58
asciichat_error_t session_client_like_run(const session_client_like_config_t *config)
Definition client_like.c:96
bool should_exit(void)
Definition main.c:90
tcp_client_t * session_client_like_get_tcp_client(void)
Definition client_like.c:54
bool(*)(void *) session_client_like_get_render_should_exit(void)
Definition client_like.c:50
asciichat_error_t audio_init(audio_context_t *ctx)
asciichat_error_t audio_start_duplex(audio_context_t *ctx)
void audio_destroy(audio_context_t *ctx)
asciichat_error_t audio_stop_duplex(audio_context_t *ctx)
void audio_terminate_portaudio_final(void)
Terminate PortAudio and free all device resources.
bool audio_should_enable_microphone(audio_source_t source, bool has_media_audio)
tcp_client_t * tcp_client_create(void)
Create and initialize TCP client.
void tcp_client_destroy(tcp_client_t **client_ptr)
Destroy TCP client and free resources.
websocket_client_t * websocket_client_create(void)
Create and initialize WebSocket client.
void websocket_client_destroy(websocket_client_t **client_ptr)
Destroy WebSocket client and free resources.
void session_capture_set_audio_context(session_capture_ctx_t *ctx, void *audio_ctx)
session_capture_ctx_t * session_mirror_capture_create(const session_capture_config_t *config)
session_capture_ctx_t * session_network_capture_create(uint32_t target_fps)
void * session_capture_get_media_source(session_capture_ctx_t *ctx)
void session_capture_destroy(session_capture_ctx_t *ctx)
void session_display_destroy(session_display_ctx_t *ctx)
session_display_ctx_t * session_display_create(const session_display_config_t *config)
void platform_disable_keepawake(void)
asciichat_error_t platform_enable_keepawake(void)
void log_set_force_stderr(bool enabled)
void log_set_terminal_output(bool enabled)
bool terminal_is_stdout_tty(void)
bool terminal_should_force_stderr(void)
void platform_sleep_ms(unsigned int ms)
void session_log_buffer_destroy(void)
bool media_source_has_audio(media_source_t *source)
Definition source.c:611
void media_source_destroy(media_source_t *source)
Definition source.c:360
double media_source_get_video_fps(media_source_t *source)
Definition source.c:882
void media_source_set_audio_context(media_source_t *source, void *audio_ctx)
Definition source.c:945
media_source_t * media_source_create(media_source_type_t type, const char *path)
Definition source.c:172
int splash_intro_done(void)
Definition splash.c:526
int splash_intro_start(session_display_ctx_t *ctx)
Definition splash.c:482
#define bool
Definition stdbool.h:22
Media source for video and audio capture.
Definition source.c:31
Internal session display context structure.
bool url_is_websocket(const char *url)
Definition url.c:307
void webcam_destroy(void)