feat: media_async + media_index modules, media_files table, tests
- media_async: thread-per-task async engine with primitives (sha256, sign, copy, uuid, file_size) - media_index: media_files DB table with register_async for indexing chunks - chat_msg: async media submission via register_async, simplified chat_msg_submit fields - voice_recorder/attachment_sender: write single file (no chunk splitting on caller side) - inputbar/messagelist: updated to new simplified submit interface - test_media_async: 4-thread stress test (200K ops / 2 sec, 0 errors) - test_media_index: 9 cases covering commit edge cases + async flow - build: Makefile.am, CMakeLists.txt, utun_sources.cmake updated
This commit is contained in:
1 parent
ce5a84da24
commit
65dbfa6813
43 files changed
+1983
-391
No files matched your search
@@ -9,7 +9,7 @@ data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, va
|
||||
data class Message(val id: Long, val author: String, val text: String, val ts: Long,
|
||||
val isOutgoing: Boolean = false, val contentType: String = "text/plain",
|
||||
val filePath: String = "", val waveform: List<Float> = emptyList(),
|
||||
val voiceDurationMs: Int = 0)
|
||||
val voiceDurationMs: Int = 0, val fileSize: Long = 0)
|
||||
|
||||
class ChatRepository {
|
||||
private val myNodeId: Long = NativeLib.getMyNodeId()
|
||||
@@ -53,6 +53,9 @@ class ChatRepository {
|
||||
val contentType = obj.optString("contentType", "text/plain")
|
||||
val filePath = obj.optString("filePath", "")
|
||||
|
||||
var displayText = text
|
||||
var fileSize = 0L
|
||||
|
||||
/* Parse base64 waveform from voice message text (format: "wf=<100 chars A-Za-z0-9+/>;dur=1234;") */
|
||||
var waveform = emptyList<Float>()
|
||||
var voiceDurationMs = 0
|
||||
@@ -88,16 +91,30 @@ class ChatRepository {
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/* Parse file attachment: "filename.ext|file_size|block_size|num_blocks|sigs" */
|
||||
if (contentType == "application/octet-stream") {
|
||||
try {
|
||||
val pipeIdx = text.indexOf('|')
|
||||
if (pipeIdx > 0) {
|
||||
displayText = text.substring(0, pipeIdx)
|
||||
val metaParts = text.substring(pipeIdx + 1).split("|")
|
||||
if (metaParts.isNotEmpty())
|
||||
fileSize = metaParts[0].toLongOrNull() ?: 0L
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
list.add(Message(
|
||||
id = obj.getLong("id"),
|
||||
author = obj.getString("author"),
|
||||
text = text,
|
||||
text = displayText,
|
||||
ts = obj.getLong("ts"),
|
||||
isOutgoing = obj.optBoolean("isOutgoing", false),
|
||||
contentType = contentType,
|
||||
filePath = filePath,
|
||||
waveform = waveform,
|
||||
voiceDurationMs = voiceDurationMs
|
||||
voiceDurationMs = voiceDurationMs,
|
||||
fileSize = fileSize
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }
|
||||
|
||||
@@ -36,6 +36,9 @@ class ConfigProvider(private val context: Context) {
|
||||
private val logUdpIp = stringPreferencesKey("log_udp_ip")
|
||||
private val logUdpPort = intPreferencesKey("log_udp_port")
|
||||
private val serversJson = stringPreferencesKey("servers")
|
||||
private val storageAutoload = intPreferencesKey("storage_autoload")
|
||||
private val storageAutoloadMaxsizeMb = intPreferencesKey("storage_autoload_maxsize_mb")
|
||||
private val storageMaxsizeGb = intPreferencesKey("storage_maxsize_gb")
|
||||
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
|
||||
|
||||
private val gson = Gson()
|
||||
@@ -130,6 +133,9 @@ class ConfigProvider(private val context: Context) {
|
||||
key == "node.listen_port" -> context.dataStore.data.first()[nodeListenPort] ?: 12345
|
||||
key == "control.port" -> context.dataStore.data.first()[controlPort] ?: 9999
|
||||
key == "log_udp.port" -> context.dataStore.data.first()[logUdpPort] ?: 9999
|
||||
key == "storage_autoload" -> context.dataStore.data.first()[storageAutoload] ?: 1
|
||||
key == "storage_autoload_maxsize_mb" -> context.dataStore.data.first()[storageAutoloadMaxsizeMb] ?: 10
|
||||
key == "storage_maxsize_gb" -> context.dataStore.data.first()[storageMaxsizeGb] ?: 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -149,6 +155,9 @@ class ConfigProvider(private val context: Context) {
|
||||
"debug.categories" -> prefs[debugCategories] = value
|
||||
"log_udp.ip" -> prefs[logUdpIp] = value
|
||||
"log_udp.port" -> prefs[logUdpPort] = value.toIntOrNull() ?: 0
|
||||
"storage_autoload" -> prefs[storageAutoload] = value.toIntOrNull() ?: 1
|
||||
"storage_autoload_maxsize_mb" -> prefs[storageAutoloadMaxsizeMb] = value.toIntOrNull() ?: 10
|
||||
"storage_maxsize_gb" -> prefs[storageMaxsizeGb] = value.toIntOrNull() ?: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -62,7 +62,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
|
||||
when {
|
||||
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice)
|
||||
isMedia -> Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor)
|
||||
isMedia -> FileBubble(message, textColor)
|
||||
else -> {
|
||||
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -167,6 +167,26 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileBubble(message: Message, textColor: Color) {
|
||||
Column {
|
||||
Text(message.text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(formatFileSize(message.fileSize), fontSize = 12.sp, color = textColor.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
if (bytes <= 0) return ""
|
||||
if (bytes < 1024) return "${bytes}B"
|
||||
val kb = bytes / 1024.0
|
||||
if (kb < 1024.0) return if (kb < 10.0) "%.1fKB".format(kb) else "%.0fKB".format(kb)
|
||||
val mb = kb / 1024.0
|
||||
if (mb < 1024.0) return if (mb < 10.0) "%.1fMB".format(mb) else "%.0fMB".format(mb)
|
||||
val gb = mb / 1024.0
|
||||
return "%.1fGB".format(gb)
|
||||
}
|
||||
|
||||
private fun formatDurationMs(ms: Int): String {
|
||||
val secs = ms / 1000
|
||||
return "%d:%02d".format(secs / 60, secs % 60)
|
||||
|
||||
@@ -42,7 +42,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
|
||||
var tabIndex by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("Settings", "Audio")
|
||||
val tabs = listOf("Settings", "Audio", "Storage")
|
||||
|
||||
val handleBack = {
|
||||
if (dirty) {
|
||||
@@ -73,6 +73,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
|
||||
when (tabIndex) {
|
||||
0 -> GeneralSettingsTab(vm, dirty, onDirty = { dirty = it }, onExit)
|
||||
1 -> AudioSettingsTab(vm)
|
||||
2 -> StorageSettingsTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,6 +415,52 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Storage Settings Tab ── */
|
||||
|
||||
@Composable
|
||||
private fun StorageSettingsTab() {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var autoload by remember { mutableStateOf(true) }
|
||||
var autoloadMaxSizeMb by remember { mutableStateOf("10") }
|
||||
var maxStorageGb by remember { mutableStateOf("1") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
autoload = provider.getInt("storage_autoload") != 0
|
||||
autoloadMaxSizeMb = provider.getInt("storage_autoload_maxsize_mb").toString()
|
||||
maxStorageGb = provider.getInt("storage_maxsize_gb").toString()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Media Storage", style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Autoload media", modifier = Modifier.weight(1f))
|
||||
Switch(checked = autoload, onCheckedChange = {
|
||||
autoload = it
|
||||
scope.launch { provider.setValue("storage_autoload", if (it) "1" else "0") }
|
||||
})
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
OutlinedTextField(autoloadMaxSizeMb,
|
||||
{ v -> autoloadMaxSizeMb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..500) scope.launch { provider.setValue("storage_autoload_maxsize_mb", v) } },
|
||||
label = { Text("Autoload max size (MB)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true, modifier = Modifier.padding(top = 4.dp))
|
||||
|
||||
OutlinedTextField(maxStorageGb,
|
||||
{ v -> maxStorageGb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..100) scope.launch { provider.setValue("storage_maxsize_gb", v) } },
|
||||
label = { Text("Max storage size (GB)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Labeled Slider ── */
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -489,8 +489,8 @@ int utun_bridge_voice_get_compressor(void) {
|
||||
}
|
||||
|
||||
void utun_bridge_voice_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
|
||||
float rise_rate_per_500ms, float target_level_db) {
|
||||
voice_recorder_set_compressor_config(max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_500ms, target_level_db);
|
||||
float rise_rate_per_sec, float target_level_db) {
|
||||
voice_recorder_set_compressor_config(max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_sec, target_level_db);
|
||||
}
|
||||
|
||||
float utun_bridge_voice_get_peak_level(void) {
|
||||
|
||||
@@ -86,7 +86,7 @@ int utun_bridge_voice_get_preset(void);
|
||||
void utun_bridge_voice_set_compressor(int enabled);
|
||||
int utun_bridge_voice_get_compressor(void);
|
||||
void utun_bridge_voice_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
|
||||
float rise_rate_per_500ms, float target_level_db);
|
||||
float rise_rate_per_sec, float target_level_db);
|
||||
float utun_bridge_voice_get_peak_level(void);
|
||||
|
||||
/* ── Attachment ── */
|
||||
|
||||
@@ -12,18 +12,6 @@
|
||||
#include <unistd.h>
|
||||
#include <libgen.h>
|
||||
|
||||
#define MEDIA_BLOCK_MIN (10 * 1024 * 1024)
|
||||
#define MEDIA_BLOCK_MAX (25 * 1024 * 1024)
|
||||
#define MEDIA_BLOCK_TARGET 30
|
||||
|
||||
static uint32_t media_calc_block_size(uint64_t file_size) {
|
||||
if (file_size == 0) return 0;
|
||||
uint64_t target = (file_size + MEDIA_BLOCK_TARGET - 1) / MEDIA_BLOCK_TARGET;
|
||||
if (target < MEDIA_BLOCK_MIN) target = MEDIA_BLOCK_MIN;
|
||||
if (target > MEDIA_BLOCK_MAX) target = MEDIA_BLOCK_MAX;
|
||||
return (uint32_t)target;
|
||||
}
|
||||
|
||||
int attachment_send(const char* channel_id, const char* src_file_path, const char* db_path) {
|
||||
if (!channel_id || !src_file_path || !db_path) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: invalid args");
|
||||
@@ -33,27 +21,15 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha
|
||||
struct UASYNC* ua = instance_lite_get_uasync();
|
||||
if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: no uasync"); return -1; }
|
||||
|
||||
FILE* src = fopen(src_file_path, "rb");
|
||||
if (!src) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: cannot open %s", src_file_path); return -1; }
|
||||
|
||||
fseek(src, 0, SEEK_END);
|
||||
uint64_t file_size = (uint64_t)ftell(src);
|
||||
fseek(src, 0, SEEK_SET);
|
||||
|
||||
if (file_size == 0) { DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "attachment_send: empty file %s", src_file_path); fclose(src); return -1; }
|
||||
|
||||
/* extract basename and ext from path */
|
||||
/* extract basename and ext */
|
||||
char path_copy[1024];
|
||||
snprintf(path_copy, sizeof(path_copy), "%s", src_file_path);
|
||||
char* fname = basename(path_copy);
|
||||
char* dot = strrchr(fname, '.');
|
||||
char ext[32] = "";
|
||||
if (dot) {
|
||||
snprintf(ext, sizeof(ext), "%s", dot + 1);
|
||||
*dot = '\0';
|
||||
}
|
||||
if (dot) { snprintf(ext, sizeof(ext), "%s", dot + 1); *dot = '\0'; }
|
||||
|
||||
/* generate dt/suffix */
|
||||
/* generate dt/suffix for unique filename */
|
||||
time_t t = time(NULL);
|
||||
struct tm tm_buf; localtime_r(&t, &tm_buf);
|
||||
char dt[32]; snprintf(dt, sizeof(dt), "%04d%02d%02d-%02d%02d%02d",
|
||||
@@ -62,10 +38,7 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha
|
||||
int rnd = rand() & 0xFFFF;
|
||||
char suffix[8]; snprintf(suffix, sizeof(suffix), "%04x", rnd);
|
||||
|
||||
uint32_t block_size = media_calc_block_size(file_size);
|
||||
int num_blocks = (int)((file_size + block_size - 1) / block_size);
|
||||
|
||||
/* ensure media dir */
|
||||
/* ensure media dir exists */
|
||||
char media_dir[1024];
|
||||
snprintf(media_dir, sizeof(media_dir), "%s/media/%s", db_path, channel_id);
|
||||
{
|
||||
@@ -77,46 +50,26 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha
|
||||
mkdir(tmp, 0755);
|
||||
}
|
||||
|
||||
/* write blocks */
|
||||
for (int n = 0; n < num_blocks; n++) {
|
||||
char block_path[1280];
|
||||
snprintf(block_path, sizeof(block_path), "%s/%s_%d_%s_%s.%s",
|
||||
media_dir, dt, n, fname, suffix, ext[0] ? ext : "bin");
|
||||
FILE* dst = fopen(block_path, "wb");
|
||||
if (!dst) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: cannot create block %s", block_path);
|
||||
continue;
|
||||
}
|
||||
uint8_t buf[65536];
|
||||
uint64_t remaining = block_size;
|
||||
while (remaining > 0) {
|
||||
size_t to_read = remaining < sizeof(buf) ? (size_t)remaining : sizeof(buf);
|
||||
size_t rd = fread(buf, 1, to_read, src);
|
||||
if (rd == 0) break;
|
||||
fwrite(buf, 1, rd, dst);
|
||||
remaining -= rd;
|
||||
}
|
||||
fclose(dst);
|
||||
}
|
||||
fclose(src);
|
||||
/* single file path — no splitting */
|
||||
char media_file[1280];
|
||||
snprintf(media_file, sizeof(media_file), "%s/%s_%s_%s.%s",
|
||||
media_dir, dt, fname, suffix, ext[0] ? ext : "bin");
|
||||
|
||||
/* post chat_msg_submit */
|
||||
/* post chat_msg_submit — media_copy=1 so media_index copies src to dest */
|
||||
struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + 1);
|
||||
if (!req) return -1;
|
||||
|
||||
snprintf(req->channel_id, sizeof(req->channel_id), "%s", channel_id);
|
||||
snprintf(req->content_type, sizeof(req->content_type), "application/octet-stream");
|
||||
snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt);
|
||||
snprintf(req->media_basename, sizeof(req->media_basename), "%s", fname);
|
||||
snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix);
|
||||
snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext[0] ? ext : "bin");
|
||||
req->media_num_blocks = (uint32_t)num_blocks;
|
||||
snprintf(req->media_src, sizeof(req->media_src), "%s", src_file_path);
|
||||
snprintf(req->media_dest, sizeof(req->media_dest), "%s", media_file);
|
||||
req->media_copy = 1;
|
||||
req->data = (uint8_t*)(req + 1);
|
||||
req->data_len = 0;
|
||||
req->timestamp = 0;
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s file=%s blocks=%d size=%llu",
|
||||
channel_id, src_file_path, num_blocks, (unsigned long long)file_size);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s src=%s dst=%s",
|
||||
channel_id, src_file_path, media_file);
|
||||
|
||||
uasync_post(ua, chat_core_submit_trampoline, req);
|
||||
return 0;
|
||||
|
||||
@@ -58,6 +58,8 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
|
||||
${SRC_DIR}/proxy
|
||||
${SRC_DIR}/lwip_tcp
|
||||
${SRC_DIR}/BBR
|
||||
${SRC_DIR}/media_delivery
|
||||
${SRC_DIR}/media_async
|
||||
${TOOLS_DIR}
|
||||
${CONFIG_DIR}
|
||||
PARENT_SCOPE
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
#define MEDIA_BLOCK_MIN (10 * 1024 * 1024)
|
||||
#define MEDIA_BLOCK_MAX (25 * 1024 * 1024)
|
||||
#define MEDIA_BLOCK_TARGET 30
|
||||
#define OPUS_MAGIC 0x5355504F
|
||||
#define FRAME_MS 20
|
||||
#define MAX_PACKET 4000
|
||||
@@ -85,9 +82,9 @@ int voice_recorder_init(const char* db_path) {
|
||||
cfg.block_duration_ms = 20;
|
||||
cfg.lookback_ms = 200;
|
||||
cfg.lookahead_ms = 100;
|
||||
cfg.max_gain_db = 30.0f;
|
||||
cfg.rise_rate_per_500ms = 2.0f;
|
||||
cfg.target_level = 0.25f;
|
||||
cfg.max_gain_db = 25.0f;
|
||||
cfg.rise_rate_per_sec = 10.0f;
|
||||
cfg.target_level = 1.0f;
|
||||
audio_compressor_configure(g_rec->compressor, &cfg);
|
||||
}
|
||||
|
||||
@@ -199,14 +196,6 @@ int voice_recorder_feed(const int16_t* samples, int count) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static uint32_t media_calc_block_size(uint64_t file_size) {
|
||||
if (file_size == 0) return 0;
|
||||
uint64_t target = (file_size + MEDIA_BLOCK_TARGET - 1) / MEDIA_BLOCK_TARGET;
|
||||
if (target < MEDIA_BLOCK_MIN) target = MEDIA_BLOCK_MIN;
|
||||
if (target > MEDIA_BLOCK_MAX) target = MEDIA_BLOCK_MAX;
|
||||
return (uint32_t)target;
|
||||
}
|
||||
|
||||
static void voice_cleanup_locked(struct voice_recorder* rec) {
|
||||
u_free(rec->pcm_buffer); rec->pcm_buffer = NULL;
|
||||
rec->pcm_count = 0; rec->pcm_cap = 0;
|
||||
@@ -364,37 +353,8 @@ int voice_recorder_stop(int* out_duration_ms) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* get file size and calculate blocks */
|
||||
FILE* fsize = fopen(temp_path, "rb"); uint64_t file_size = 0;
|
||||
if (fsize) { fseek(fsize, 0, SEEK_END); file_size = (uint64_t)ftell(fsize); fclose(fsize); }
|
||||
|
||||
uint32_t block_size = media_calc_block_size(file_size);
|
||||
int num_blocks = (int)((file_size + block_size - 1) / block_size);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: file_size=%llu block_size=%u num_blocks=%d",
|
||||
(unsigned long long)file_size, block_size, num_blocks);
|
||||
|
||||
/* split file into blocks */
|
||||
FILE* src = fopen(temp_path, "rb");
|
||||
if (!src) { unlink(temp_path); voice_cleanup_locked(g_rec); pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; }
|
||||
|
||||
for (int n = 0; n < num_blocks; n++) {
|
||||
char block_path[1280];
|
||||
snprintf(block_path, sizeof(block_path), "%s/%s_%d_%s_%s.opus",
|
||||
media_dir, dt, n, basename, suffix);
|
||||
FILE* dst = fopen(block_path, "wb");
|
||||
if (!dst) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: cannot create block %s", block_path); continue; }
|
||||
uint8_t buf[65536];
|
||||
uint64_t remaining = block_size;
|
||||
while (remaining > 0) {
|
||||
size_t rd = fread(buf, 1, remaining < sizeof(buf) ? (size_t)remaining : sizeof(buf), src);
|
||||
if (rd == 0) break;
|
||||
fwrite(buf, 1, rd, dst);
|
||||
remaining -= rd;
|
||||
}
|
||||
fclose(dst);
|
||||
}
|
||||
fclose(src);
|
||||
unlink(temp_path);
|
||||
/* file is already at temp_path in media dir – no splitting needed */
|
||||
/* media_index_register_async will handle block size calculation, signing, and DB */
|
||||
|
||||
/* build compact waveform: 100 bytes base64 (A-Za-z0-9+/), log scale */
|
||||
static const char b64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
@@ -421,18 +381,16 @@ int voice_recorder_stop(int* out_duration_ms) {
|
||||
|
||||
snprintf(req->channel_id, sizeof(req->channel_id), "%s", g_rec->channel_id);
|
||||
snprintf(req->content_type, sizeof(req->content_type), "audio/opus");
|
||||
snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt);
|
||||
snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename);
|
||||
snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix);
|
||||
snprintf(req->media_ext, sizeof(req->media_ext), "opus");
|
||||
req->media_num_blocks = (uint32_t)num_blocks;
|
||||
snprintf(req->media_src, sizeof(req->media_src), "%s", temp_path);
|
||||
snprintf(req->media_dest, sizeof(req->media_dest), "%s", temp_path);
|
||||
req->media_copy = 0;
|
||||
req->data = (uint8_t*)(req + 1);
|
||||
req->data_len = (uint32_t)total_data_len;
|
||||
memcpy(req->data, wf_data, total_data_len);
|
||||
req->timestamp = 0;
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: posting media ch=%s dt=%s stem=%s blocks=%d",
|
||||
req->channel_id, dt, basename, num_blocks);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: posting media ch=%s path=%s",
|
||||
req->channel_id, temp_path);
|
||||
|
||||
uasync_post(ua, chat_core_submit_trampoline, req);
|
||||
|
||||
@@ -505,7 +463,7 @@ int voice_recorder_is_compressor_enabled(void) {
|
||||
}
|
||||
|
||||
void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
|
||||
float rise_rate_per_500ms, float target_level_db) {
|
||||
float rise_rate_per_sec, float target_level_db) {
|
||||
pthread_mutex_lock(&g_init_mtx);
|
||||
if (g_rec && g_rec->compressor) {
|
||||
pthread_mutex_lock(&g_rec->mtx);
|
||||
@@ -516,11 +474,11 @@ void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int
|
||||
cfg.lookback_ms = lookback_ms;
|
||||
cfg.lookahead_ms = lookahead_ms;
|
||||
cfg.max_gain_db = (float)max_gain_db;
|
||||
cfg.rise_rate_per_500ms = rise_rate_per_500ms;
|
||||
cfg.rise_rate_per_sec = rise_rate_per_sec;
|
||||
cfg.target_level = powf(10.0f, target_level_db / 20.0f);
|
||||
audio_compressor_configure(g_rec->compressor, &cfg);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder compressor config: gain=%ddB lookback=%d lookahead=%d rise=%.1f target=%.0fdBFS",
|
||||
max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_500ms, target_level_db);
|
||||
max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_sec, target_level_db);
|
||||
pthread_mutex_unlock(&g_rec->mtx);
|
||||
}
|
||||
pthread_mutex_unlock(&g_init_mtx);
|
||||
|
||||
@@ -24,7 +24,7 @@ int voice_recorder_get_preset(void);
|
||||
void voice_recorder_set_compressor_enabled(int enabled);
|
||||
int voice_recorder_is_compressor_enabled(void);
|
||||
void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
|
||||
float rise_rate_per_500ms, float target_level_db);
|
||||
float rise_rate_per_sec, float target_level_db);
|
||||
float voice_recorder_get_peak_level(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -119,6 +119,7 @@ add_executable(chatgui
|
||||
src/voiceplayback.cpp
|
||||
src/media_blocks.cpp
|
||||
src/audiodevicesettingspage.cpp
|
||||
src/storagesettingspage.cpp
|
||||
transport/utun_node.cpp
|
||||
transport/node_config.cpp
|
||||
transport/config_updater.cpp
|
||||
|
||||
@@ -67,6 +67,8 @@ target_include_directories(utun PUBLIC
|
||||
${SRC_DIR}
|
||||
${SRC_DIR}/transport_layer
|
||||
${SRC_DIR}/routing_layer
|
||||
${SRC_DIR}/media_delivery
|
||||
${SRC_DIR}/media_async
|
||||
${LIB_DIR}
|
||||
${SRC_DIR}/uip
|
||||
${CMAKE_SOURCE_DIR}/db
|
||||
|
||||
@@ -158,11 +158,8 @@ void AudioDeviceSettingsPage::loadFromDb() {
|
||||
m_savedInputIdx = m_db->getUiStateInt("audio_input_device", -1);
|
||||
m_savedCodecPreset = m_db->getUiStateInt("opus_codec_preset", 1);
|
||||
m_savedCompressorEnabled = m_db->getUiStateInt("compressor_enabled", 0);
|
||||
m_savedCompressorMaxGainDb = m_db->getUiStateInt("compressor_max_gain_db", 30);
|
||||
m_savedCompressorLookbackMs = m_db->getUiStateInt("compressor_lookback_ms", 200);
|
||||
m_savedCompressorLookaheadMs = m_db->getUiStateInt("compressor_lookahead_ms", 100);
|
||||
m_savedCompressorRiseRateTenths = m_db->getUiStateInt("compressor_rise_rate_tenths", 20);
|
||||
m_savedCompressorTargetLevelDb = m_db->getUiStateInt("compressor_target_level_db", -12);
|
||||
m_savedCompressorMaxGainDb = m_db->getUiStateInt("compressor_max_gain_db", 25);
|
||||
m_savedCompressorRiseRate = m_db->getUiStateInt("compressor_rise_rate", 10);
|
||||
}
|
||||
|
||||
/* Select saved indices */
|
||||
@@ -181,10 +178,7 @@ void AudioDeviceSettingsPage::loadFromDb() {
|
||||
|
||||
m_compressorEnabledCb->setChecked(m_savedCompressorEnabled != 0);
|
||||
m_compressorMaxGainSlider->setValue(m_savedCompressorMaxGainDb);
|
||||
m_compressorLookbackSlider->setValue(m_savedCompressorLookbackMs);
|
||||
m_compressorLookaheadSlider->setValue(m_savedCompressorLookaheadMs);
|
||||
m_compressorRiseRateSlider->setValue(m_savedCompressorRiseRateTenths);
|
||||
m_compressorTargetLevelSlider->setValue(m_savedCompressorTargetLevelDb);
|
||||
m_compressorRiseRateSlider->setValue(m_savedCompressorRiseRate);
|
||||
updateCompressorLabels();
|
||||
}
|
||||
|
||||
@@ -211,30 +205,20 @@ void AudioDeviceSettingsPage::applyAndSave() {
|
||||
|
||||
int compEnabled = m_compressorEnabledCb->isChecked() ? 1 : 0;
|
||||
int compMaxGain = m_compressorMaxGainSlider->value();
|
||||
int compLookback = m_compressorLookbackSlider->value();
|
||||
int compLookahead = m_compressorLookaheadSlider->value();
|
||||
int compRiseRateTenths = m_compressorRiseRateSlider->value();
|
||||
int compTargetLevel = m_compressorTargetLevelSlider->value();
|
||||
int compRiseRate = m_compressorRiseRateSlider->value();
|
||||
|
||||
saveKey("compressor_enabled", compEnabled);
|
||||
saveKey("compressor_max_gain_db", compMaxGain);
|
||||
saveKey("compressor_lookback_ms", compLookback);
|
||||
saveKey("compressor_lookahead_ms", compLookahead);
|
||||
saveKey("compressor_rise_rate_tenths", compRiseRateTenths);
|
||||
saveKey("compressor_target_level_db", compTargetLevel);
|
||||
saveKey("compressor_rise_rate", compRiseRate);
|
||||
|
||||
m_savedOutputIdx = outIdx;
|
||||
m_savedInputIdx = inIdx;
|
||||
m_savedCodecPreset = codecPreset;
|
||||
m_savedCompressorEnabled = compEnabled;
|
||||
m_savedCompressorMaxGainDb = compMaxGain;
|
||||
m_savedCompressorLookbackMs = compLookback;
|
||||
m_savedCompressorLookaheadMs = compLookahead;
|
||||
m_savedCompressorRiseRateTenths = compRiseRateTenths;
|
||||
m_savedCompressorTargetLevelDb = compTargetLevel;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "saved output=%d input=%d codec=%d compressor=%d gain=%ddB lookback=%d lookahead=%d rise=%.1fx target=%ddB",
|
||||
outIdx, inIdx, codecPreset, compEnabled, compMaxGain, compLookback, compLookahead,
|
||||
(float)compRiseRateTenths / 10.0f, compTargetLevel);
|
||||
m_savedCompressorRiseRate = compRiseRate;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "saved output=%d input=%d codec=%d compressor=%d gain=%ddB rise=%.1fx",
|
||||
outIdx, inIdx, codecPreset, compEnabled, compMaxGain, (float)compRiseRate);
|
||||
|
||||
if (m_recorder) {
|
||||
m_recorder->setCompressorEnabled(compEnabled != 0);
|
||||
@@ -243,10 +227,10 @@ void AudioDeviceSettingsPage::applyAndSave() {
|
||||
compCfg.sample_rate = 48000;
|
||||
compCfg.channels = 1;
|
||||
compCfg.max_gain_db = (float)compMaxGain;
|
||||
compCfg.lookback_ms = compLookback;
|
||||
compCfg.lookahead_ms = compLookahead;
|
||||
compCfg.rise_rate_per_500ms = (float)compRiseRateTenths / 10.0f;
|
||||
compCfg.target_level = powf(10.0f, (float)compTargetLevel / 20.0f);
|
||||
compCfg.lookback_ms = 200;
|
||||
compCfg.lookahead_ms = 100;
|
||||
compCfg.rise_rate_per_sec = (float)compRiseRate;
|
||||
compCfg.target_level = 1.0f;
|
||||
audio_compressor_configure(m_recorder->compressor(), &compCfg);
|
||||
}
|
||||
}
|
||||
@@ -342,37 +326,22 @@ void AudioDeviceSettingsPage::buildCompressorUI(QVBoxLayout* layout) {
|
||||
};
|
||||
|
||||
makeSliderRow("Max gain:", m_compressorMaxGainSlider, m_compressorMaxGainLabel,
|
||||
0, 30, 1);
|
||||
makeSliderRow("Lookback time:", m_compressorLookbackSlider, m_compressorLookbackLabel,
|
||||
50, 500, 10);
|
||||
makeSliderRow("Lookahead time:", m_compressorLookaheadSlider, m_compressorLookaheadLabel,
|
||||
20, 500, 10);
|
||||
makeSliderRow("Rise rate (x/500ms):", m_compressorRiseRateSlider, m_compressorRiseRateLabel,
|
||||
11, 100, 1);
|
||||
makeSliderRow("Target level:", m_compressorTargetLevelSlider, m_compressorTargetLevelLabel,
|
||||
-40, 0, 1);
|
||||
0, 50, 1);
|
||||
makeSliderRow("Rise rate (x/sec):", m_compressorRiseRateSlider, m_compressorRiseRateLabel,
|
||||
2, 10, 1);
|
||||
|
||||
auto connectSlider = [this](QSlider* slider) {
|
||||
connect(slider, &QSlider::valueChanged, this, &AudioDeviceSettingsPage::updateCompressorLabels);
|
||||
};
|
||||
connectSlider(m_compressorMaxGainSlider);
|
||||
connectSlider(m_compressorLookbackSlider);
|
||||
connectSlider(m_compressorLookaheadSlider);
|
||||
connectSlider(m_compressorRiseRateSlider);
|
||||
connectSlider(m_compressorTargetLevelSlider);
|
||||
}
|
||||
|
||||
void AudioDeviceSettingsPage::updateCompressorLabels() {
|
||||
if (m_compressorMaxGainLabel)
|
||||
m_compressorMaxGainLabel->setText(QString::number(m_compressorMaxGainSlider->value()) + " dB");
|
||||
if (m_compressorLookbackLabel)
|
||||
m_compressorLookbackLabel->setText(QString::number(m_compressorLookbackSlider->value()) + " ms");
|
||||
if (m_compressorLookaheadLabel)
|
||||
m_compressorLookaheadLabel->setText(QString::number(m_compressorLookaheadSlider->value()) + " ms");
|
||||
if (m_compressorRiseRateLabel) {
|
||||
float v = (float)m_compressorRiseRateSlider->value() / 10.0f;
|
||||
m_compressorRiseRateLabel->setText(QString::number(v, 'f', 1) + "x");
|
||||
int v = m_compressorRiseRateSlider->value();
|
||||
m_compressorRiseRateLabel->setText(QString::number(v) + "x");
|
||||
}
|
||||
if (m_compressorTargetLevelLabel)
|
||||
m_compressorTargetLevelLabel->setText(QString::number(m_compressorTargetLevelSlider->value()) + " dBFS");
|
||||
}
|
||||
@@ -45,19 +45,10 @@ private:
|
||||
|
||||
QCheckBox* m_compressorEnabledCb;
|
||||
QSlider* m_compressorMaxGainSlider;
|
||||
QSlider* m_compressorLookbackSlider;
|
||||
QSlider* m_compressorLookaheadSlider;
|
||||
QSlider* m_compressorRiseRateSlider;
|
||||
QSlider* m_compressorTargetLevelSlider;
|
||||
QLabel* m_compressorMaxGainLabel;
|
||||
QLabel* m_compressorLookbackLabel;
|
||||
QLabel* m_compressorLookaheadLabel;
|
||||
QLabel* m_compressorRiseRateLabel;
|
||||
QLabel* m_compressorTargetLevelLabel;
|
||||
int m_savedCompressorEnabled = 0;
|
||||
int m_savedCompressorMaxGainDb = 30;
|
||||
int m_savedCompressorLookbackMs = 200;
|
||||
int m_savedCompressorLookaheadMs = 100;
|
||||
int m_savedCompressorRiseRateTenths = 20;
|
||||
int m_savedCompressorTargetLevelDb = -12;
|
||||
int m_savedCompressorMaxGainDb = 25;
|
||||
int m_savedCompressorRiseRate = 10;
|
||||
};
|
||||
@@ -233,21 +233,15 @@ void InputBar::onPttReleased() {
|
||||
qPrintable(mediaPath), QDir(mediaPath).exists(), qPrintable(m_channelIdForRecord));
|
||||
|
||||
QString fileName = VoiceEncoder::generateFileName();
|
||||
QString tempPath = mediaPath + "/" + fileName;
|
||||
QString mediaFile = mediaPath + "/" + fileName;
|
||||
float durationSec = 0;
|
||||
int frames = VoiceEncoder::encodeToFile(pcm, pcmSize, 48000, 1, tempPath, durationSec);
|
||||
int frames = VoiceEncoder::encodeToFile(pcm, pcmSize, 48000, 1, mediaFile, durationSec);
|
||||
if (frames <= 0) {
|
||||
QFile::remove(tempPath);
|
||||
QFile::remove(mediaFile);
|
||||
return;
|
||||
}
|
||||
|
||||
MediaBlockInfo blk = media_split_to_blocks(tempPath, mediaPath, true);
|
||||
if (blk.numBlocks <= 0) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "InputBar: block split failed");
|
||||
return;
|
||||
}
|
||||
|
||||
m_voiceFilePath = QString("%1/%2_%3_%4.%5").arg(mediaPath, blk.dt, blk.basename, blk.suffix, blk.ext);
|
||||
m_voiceFilePath = mediaFile;
|
||||
|
||||
const auto& wf = m_recorder->waveformLevels();
|
||||
QString wfStr = "wf=";
|
||||
@@ -267,10 +261,9 @@ void InputBar::onPttReleased() {
|
||||
wfStr += b64[idx];
|
||||
}
|
||||
wfStr += QString(";dur=%1;").arg(durationMs);
|
||||
GUI_INFO("InputBar: voice message ready: dt=%s stem=%s blocks=%d dur=%.1fs",
|
||||
qPrintable(blk.dt), qPrintable(blk.basename), blk.numBlocks, durationSec);
|
||||
emit sendVoiceMessage(blk.dt, blk.basename, blk.suffix, blk.ext,
|
||||
blk.numBlocks, blk.blockSize, durationMs, wfStr);
|
||||
GUI_INFO("InputBar: voice message ready: file=%s dur=%.1fs",
|
||||
qPrintable(mediaFile), durationSec);
|
||||
emit sendVoiceMessage(mediaFile, mediaFile, durationMs, wfStr);
|
||||
}
|
||||
|
||||
void InputBar::onAttachClicked() {
|
||||
@@ -285,13 +278,15 @@ void InputBar::onAttachClicked() {
|
||||
VoiceEncoder::ensureMediaDir(mediaPath);
|
||||
QDir().mkpath(mediaPath);
|
||||
|
||||
MediaBlockInfo blk = media_split_to_blocks(srcPath, mediaPath, false);
|
||||
if (blk.numBlocks <= 0) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "InputBar::onAttachClicked: split failed"); return; }
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
QString ts = now.toString("yyyyMMdd-HHmmss");
|
||||
int rnd = rand() & 0xFFFF;
|
||||
QString destPath = QString("%1/%2_%3_%4.%5")
|
||||
.arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0')).arg(fi.suffix().isEmpty() ? "bin" : fi.suffix());
|
||||
|
||||
GUI_INFO("InputBar::onAttachClicked: file=%s dt=%s blocks=%d",
|
||||
qPrintable(fi.fileName()), qPrintable(blk.dt), blk.numBlocks);
|
||||
emit sendFileAttachment(blk.dt, blk.basename, blk.suffix, blk.ext,
|
||||
blk.numBlocks, blk.blockSize, fi.fileName());
|
||||
GUI_INFO("InputBar::onAttachClicked: src=%s dest=%s",
|
||||
qPrintable(srcPath), qPrintable(destPath));
|
||||
emit sendFileAttachment(srcPath, destPath, fi.fileName());
|
||||
}
|
||||
|
||||
void InputBar::resizeEvent(QResizeEvent* event) {
|
||||
|
||||
@@ -19,13 +19,9 @@ public:
|
||||
|
||||
signals:
|
||||
void sendMessage(const QString &text);
|
||||
void sendVoiceMessage(const QString& mediaDt, const QString& mediaBasename,
|
||||
const QString& mediaSuffix, const QString& mediaExt,
|
||||
int numBlocks, qint64 blockSize,
|
||||
void sendVoiceMessage(const QString& mediaSrc, const QString& mediaDest,
|
||||
int durationMs, const QString& waveformStr);
|
||||
void sendFileAttachment(const QString& mediaDt, const QString& mediaBasename,
|
||||
const QString& mediaSuffix, const QString& mediaExt,
|
||||
int numBlocks, qint64 blockSize,
|
||||
void sendFileAttachment(const QString& mediaSrc, const QString& mediaDest,
|
||||
const QString& displayName);
|
||||
|
||||
protected:
|
||||
|
||||
@@ -217,11 +217,13 @@ void MainWindow::setupSoundFromConfig() {
|
||||
m_recorder->setCompressorEnabled(compEnabled != 0);
|
||||
if (m_recorder->compressor()) {
|
||||
audio_compressor_config_t compCfg = {0};
|
||||
compCfg.max_gain_db = (float)m_db->getUiStateInt("compressor_max_gain_db", 30);
|
||||
compCfg.lookback_ms = m_db->getUiStateInt("compressor_lookback_ms", 200);
|
||||
compCfg.lookahead_ms = m_db->getUiStateInt("compressor_lookahead_ms", 100);
|
||||
compCfg.rise_rate_per_500ms = (float)m_db->getUiStateInt("compressor_rise_rate_tenths", 20) / 10.0f;
|
||||
compCfg.target_level = powf(10.0f, (float)m_db->getUiStateInt("compressor_target_level_db", -12) / 20.0f);
|
||||
compCfg.sample_rate = 48000;
|
||||
compCfg.channels = 1;
|
||||
compCfg.max_gain_db = (float)m_db->getUiStateInt("compressor_max_gain_db", 25);
|
||||
compCfg.lookback_ms = 200;
|
||||
compCfg.lookahead_ms = 100;
|
||||
compCfg.rise_rate_per_sec = (float)m_db->getUiStateInt("compressor_rise_rate", 10);
|
||||
compCfg.target_level = 1.0f;
|
||||
audio_compressor_configure(m_recorder->compressor(), &compCfg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,16 @@ static int measureTextHeight(const QFont &font, const QString &text, int width)
|
||||
return r.height();
|
||||
}
|
||||
|
||||
static QString formatFileSize(qint64 bytes) {
|
||||
if (bytes < 1024) return QString("%1B").arg(bytes);
|
||||
double kb = bytes / 1024.0;
|
||||
if (kb < 1024.0) return QString("%1KB").arg(kb, 0, 'f', kb < 10.0 ? 1 : 0);
|
||||
double mb = kb / 1024.0;
|
||||
if (mb < 1024.0) return QString("%1MB").arg(mb, 0, 'f', mb < 10.0 ? 1 : 0);
|
||||
double gb = mb / 1024.0;
|
||||
return QString("%1GB").arg(gb, 0, 'f', 1);
|
||||
}
|
||||
|
||||
static int calcMaxLineWidth(const QFont &font, const QString &text, int availW) {
|
||||
int maxW = 0;
|
||||
int pos = 0;
|
||||
@@ -259,6 +269,35 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
|
||||
return L;
|
||||
}
|
||||
|
||||
/* File attachment detection */
|
||||
L.isFile = (contentType == "application/octet-stream");
|
||||
if (L.isFile) {
|
||||
QFontMetrics fm(option.font);
|
||||
int th = fm.height();
|
||||
int ih = 24; /* icon height */
|
||||
L.bubbleWidth = 230;
|
||||
int bubbleH = kPadTop + ih + 2 + th + kPadBot;
|
||||
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
|
||||
|
||||
int contentEdge = L.isOutgoing
|
||||
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
|
||||
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
|
||||
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth) : contentEdge;
|
||||
L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH);
|
||||
|
||||
L.textRect = QRect(bx + kPadH + ih + 8, kPadTop, L.bubbleWidth - 2*kPadH - ih - 8, th);
|
||||
L.statusRect = QRect(bx + kPadH + ih + 8 - 48, kPadTop + th + 2, L.bubbleWidth - 2*kPadH - ih - 8 + 48 - 40, th);
|
||||
|
||||
if (!L.narrow) {
|
||||
int avatarY = L.bubbleRect.bottom() - kAvatar;
|
||||
if (!L.isOutgoing)
|
||||
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
|
||||
else
|
||||
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
|
||||
}
|
||||
return L;
|
||||
}
|
||||
|
||||
L.emojiCount = detectEmojiOnly(text);
|
||||
L.emojiOnly = (L.emojiCount >= 1);
|
||||
|
||||
@@ -758,6 +797,46 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
|
||||
return;
|
||||
}
|
||||
|
||||
if (L.isFile) {
|
||||
drawBubble(painter, L, option);
|
||||
if (!L.narrow) drawAvatar(painter, L, index);
|
||||
|
||||
/* File icon */
|
||||
QFont iconFont = option.font;
|
||||
iconFont.setPointSize(iconFont.pointSize() + 6);
|
||||
painter->setFont(iconFont);
|
||||
painter->setPen(option.palette.text().color());
|
||||
QRect iconRect(L.bubbleRect.left() + kPadH, kPadTop, 24, 24);
|
||||
painter->drawText(iconRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x93\x84"));
|
||||
|
||||
QFont fileFont = option.font;
|
||||
fileFont.setBold(true);
|
||||
painter->setFont(fileFont);
|
||||
painter->setPen(option.palette.text().color());
|
||||
QString displayName = index.data(MsgFileDisplayNameRole).toString();
|
||||
painter->drawText(L.textRect, Qt::AlignLeft | Qt::AlignVCenter, displayName);
|
||||
|
||||
QFont sizeFont = option.font;
|
||||
sizeFont.setPointSize(sizeFont.pointSize() - 2);
|
||||
painter->setFont(sizeFont);
|
||||
painter->setPen(QColor(0x80, 0x80, 0x80));
|
||||
qint64 fsz = index.data(MsgFileSizeRole).toLongLong();
|
||||
QString sizeStr = formatFileSize(fsz);
|
||||
int nameH = QFontMetrics(fileFont).height();
|
||||
QRect sizeRect(L.textRect.left(), L.textRect.top() + nameH, L.textRect.width(), QFontMetrics(sizeFont).height());
|
||||
painter->drawText(sizeRect, Qt::AlignLeft | Qt::AlignVCenter, sizeStr);
|
||||
|
||||
/* Timestamp */
|
||||
QFont tsFont = option.font;
|
||||
tsFont.setPointSize(tsFont.pointSize() - 1);
|
||||
painter->setFont(tsFont);
|
||||
painter->drawText(QRect(L.textRect.right() - 40, L.textRect.top() + nameH, 40, QFontMetrics(sizeFont).height()),
|
||||
Qt::AlignRight | Qt::AlignVCenter, index.data(MsgTimeRole).toString());
|
||||
|
||||
painter->restore();
|
||||
return;
|
||||
}
|
||||
|
||||
auto *cv = qobject_cast<const ChatView *>(option.widget);
|
||||
bool singleTextSel = false;
|
||||
bool multiRowSel = false;
|
||||
|
||||
@@ -26,6 +26,7 @@ enum MessageDataRole {
|
||||
MsgVoiceNumBlocksRole = Qt::UserRole + 21,
|
||||
MsgVoiceSigsRole = Qt::UserRole + 22,
|
||||
MsgFileDisplayNameRole = Qt::UserRole + 23,
|
||||
MsgFileSizeRole = Qt::UserRole + 24,
|
||||
};
|
||||
|
||||
class MessageDelegate : public QStyledItemDelegate {
|
||||
@@ -61,6 +62,7 @@ private:
|
||||
QRect emojiArea;
|
||||
bool isOutgoing = false;
|
||||
bool isVoice = false;
|
||||
bool isFile = false;
|
||||
QRect voicePlayBtnRect;
|
||||
QRect voiceWaveformRect;
|
||||
QRect voiceDurationRect;
|
||||
|
||||
@@ -153,8 +153,17 @@ static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data,
|
||||
}
|
||||
}
|
||||
if (ct == "application/octet-stream") {
|
||||
QString fname = QString::fromUtf8(data);
|
||||
QString raw = QString::fromUtf8(data);
|
||||
int pipePos = raw.indexOf('|');
|
||||
QString fname = (pipePos > 0) ? raw.left(pipePos) : raw;
|
||||
item->setData(fname, MsgFileDisplayNameRole);
|
||||
|
||||
if (pipePos > 0) {
|
||||
QString meta = raw.mid(pipePos + 1);
|
||||
QStringList parts = meta.split('|');
|
||||
if (parts.size() >= 1)
|
||||
item->setData(parts[0].toULongLong(), MsgFileSizeRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,10 +255,8 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
||||
gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req);
|
||||
});
|
||||
|
||||
connect(m_inputBar, &InputBar::sendVoiceMessage, this, [this](const QString& dt, const QString& basename,
|
||||
const QString& suffix, const QString& ext,
|
||||
int numBlocks, qint64 blockSize,
|
||||
int durationMs, const QString& wfStr) {
|
||||
connect(m_inputBar, &InputBar::sendVoiceMessage, this, [this](const QString& mediaSrc, const QString& mediaDest,
|
||||
int durationMs, const QString& wfStr) {
|
||||
if (m_currentChannelId.isEmpty()) return;
|
||||
|
||||
QByteArray textData = wfStr.toUtf8();
|
||||
@@ -261,11 +268,9 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
||||
snprintf(req->channel_id, sizeof(req->channel_id), "%s",
|
||||
m_currentChannelId.toUtf8().constData());
|
||||
snprintf(req->content_type, sizeof(req->content_type), "audio/opus");
|
||||
snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt.toUtf8().constData());
|
||||
snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename.toUtf8().constData());
|
||||
snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix.toUtf8().constData());
|
||||
snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext.toUtf8().constData());
|
||||
req->media_num_blocks = (uint32_t)numBlocks;
|
||||
snprintf(req->media_src, sizeof(req->media_src), "%s", mediaSrc.toUtf8().constData());
|
||||
snprintf(req->media_dest, sizeof(req->media_dest), "%s", mediaDest.toUtf8().constData());
|
||||
req->media_copy = 0;
|
||||
req->data = (uint8_t*)(req + 1);
|
||||
req->data_len = (uint32_t)textData.size();
|
||||
memcpy(req->data, textData.constData(), textData.size());
|
||||
@@ -273,10 +278,8 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
||||
gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req);
|
||||
});
|
||||
|
||||
connect(m_inputBar, &InputBar::sendFileAttachment, this, [this](const QString& dt, const QString& basename,
|
||||
const QString& suffix, const QString& ext,
|
||||
int numBlocks, qint64 blockSize,
|
||||
const QString& displayName) {
|
||||
connect(m_inputBar, &InputBar::sendFileAttachment, this, [this](const QString& mediaSrc, const QString& mediaDest,
|
||||
const QString& displayName) {
|
||||
if (m_currentChannelId.isEmpty()) return;
|
||||
|
||||
QByteArray textData = displayName.toUtf8();
|
||||
@@ -288,11 +291,9 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
||||
snprintf(req->channel_id, sizeof(req->channel_id), "%s",
|
||||
m_currentChannelId.toUtf8().constData());
|
||||
snprintf(req->content_type, sizeof(req->content_type), "application/octet-stream");
|
||||
snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt.toUtf8().constData());
|
||||
snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename.toUtf8().constData());
|
||||
snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix.toUtf8().constData());
|
||||
snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext.toUtf8().constData());
|
||||
req->media_num_blocks = (uint32_t)numBlocks;
|
||||
snprintf(req->media_src, sizeof(req->media_src), "%s", mediaSrc.toUtf8().constData());
|
||||
snprintf(req->media_dest, sizeof(req->media_dest), "%s", mediaDest.toUtf8().constData());
|
||||
req->media_copy = (mediaSrc != mediaDest) ? 1 : 0;
|
||||
req->data = (uint8_t*)(req + 1);
|
||||
req->data_len = (uint32_t)textData.size();
|
||||
memcpy(req->data, textData.constData(), textData.size());
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "nodespage.h"
|
||||
#include "soundsettingspage.h"
|
||||
#include "audiodevicesettingspage.h"
|
||||
#include "storagesettingspage.h"
|
||||
#include "../transport/gui_bridge.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../db/db_manager.h"
|
||||
@@ -45,6 +46,7 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
|
||||
"QListWidget::item { padding: 10px 14px; }"
|
||||
"QListWidget::item:selected { background: #3390EC; color: white; }");
|
||||
m_categoryList->addItem("Profile");
|
||||
m_categoryList->addItem("Storage");
|
||||
m_categoryList->addItem("Network");
|
||||
m_categoryList->addItem("Database");
|
||||
m_categoryList->addItem("Status");
|
||||
@@ -84,6 +86,10 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
|
||||
profLayout->addRow(sortLabel, m_sortCombo);
|
||||
m_pages->addWidget(profilePage);
|
||||
|
||||
// Storage page
|
||||
m_storagePage = new StorageSettingsPage(db, m_pages);
|
||||
m_pages->addWidget(m_storagePage);
|
||||
|
||||
// Network page
|
||||
m_networkPage = new NetworkSettingsPage(m_pages);
|
||||
m_pages->addWidget(m_networkPage);
|
||||
@@ -166,11 +172,12 @@ void SettingsDialog::loadProfileFromConfig(const QString& path) {
|
||||
|
||||
void SettingsDialog::onCategoryChanged(int row) {
|
||||
m_pages->setCurrentIndex(row);
|
||||
if (row == 2) m_databasePage->refreshStatus();
|
||||
if (row == 3) m_statusPage->refreshStatus();
|
||||
if (row == 4) m_nodesPage->refreshNodes();
|
||||
if (row == 5) m_soundPage->loadFromDb();
|
||||
if (row == 6) m_audioDevicePage->loadFromDb();
|
||||
if (row == 1) m_storagePage->loadFromDb();
|
||||
if (row == 3) m_databasePage->refreshStatus();
|
||||
if (row == 4) m_statusPage->refreshStatus();
|
||||
if (row == 5) m_nodesPage->refreshNodes();
|
||||
if (row == 6) m_soundPage->loadFromDb();
|
||||
if (row == 7) m_audioDevicePage->loadFromDb();
|
||||
}
|
||||
|
||||
void SettingsDialog::onSave() {
|
||||
@@ -227,5 +234,6 @@ void SettingsDialog::onSave() {
|
||||
|
||||
m_soundPage->applyAndSave();
|
||||
m_audioDevicePage->applyAndSave();
|
||||
m_storagePage->applyAndSave();
|
||||
accept();
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class StatusPage;
|
||||
class NodesPage;
|
||||
class SoundSettingsPage;
|
||||
class AudioDeviceSettingsPage;
|
||||
class StorageSettingsPage;
|
||||
class DbManager;
|
||||
class UtunNode;
|
||||
class AudioRecorder;
|
||||
@@ -45,4 +46,5 @@ private:
|
||||
NodesPage* m_nodesPage;
|
||||
SoundSettingsPage* m_soundPage;
|
||||
AudioDeviceSettingsPage* m_audioDevicePage;
|
||||
StorageSettingsPage* m_storagePage;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
#include "storagesettingspage.h"
|
||||
#include "../db/db_manager.h"
|
||||
#include "../transport/gui_bridge.h"
|
||||
#include "../../lib/mem.h"
|
||||
extern "C" {
|
||||
#include "chat/chat_core.h"
|
||||
}
|
||||
#include <QVBoxLayout>
|
||||
#include <QFormLayout>
|
||||
#include <QCheckBox>
|
||||
#include <QSpinBox>
|
||||
#include <QLabel>
|
||||
#include <QFrame>
|
||||
|
||||
static void saveUiStateInt(const char* key, int val) {
|
||||
QByteArray v = QByteArray::number(val);
|
||||
size_t klen = strlen(key), vlen = (size_t)v.size(), total = klen + 1 + vlen + 1;
|
||||
if (total > 256) return;
|
||||
void* arg = u_malloc(total);
|
||||
memcpy(arg, key, klen + 1);
|
||||
memcpy((char*)arg + klen + 1, v.constData(), vlen + 1);
|
||||
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
|
||||
}
|
||||
|
||||
StorageSettingsPage::StorageSettingsPage(DbManager* db, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_db(db)
|
||||
{
|
||||
auto* layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(0, 12, 0, 0);
|
||||
layout->setSpacing(14);
|
||||
|
||||
auto* title = new QLabel("Media Storage", this);
|
||||
title->setStyleSheet("font-weight: bold; font-size: 13px;");
|
||||
layout->addWidget(title);
|
||||
|
||||
auto* form = new QFormLayout();
|
||||
form->setSpacing(10);
|
||||
form->setContentsMargins(0, 8, 0, 0);
|
||||
|
||||
m_autoloadCheck = new QCheckBox("Autoload media", this);
|
||||
m_autoloadCheck->setStyleSheet("font-size: 13px;");
|
||||
form->addRow(m_autoloadCheck);
|
||||
|
||||
m_autoloadMaxSize = new QSpinBox(this);
|
||||
m_autoloadMaxSize->setRange(1, 500);
|
||||
m_autoloadMaxSize->setSuffix(" MB");
|
||||
m_autoloadMaxSize->setFixedWidth(110);
|
||||
m_autoloadMaxSize->setStyleSheet(
|
||||
"QSpinBox { border: 1px solid palette(mid); border-radius: 4px;"
|
||||
" padding: 5px 8px; font-size: 13px; background: palette(base); }");
|
||||
form->addRow("Autoload max size:", m_autoloadMaxSize);
|
||||
|
||||
m_maxStorageSize = new QSpinBox(this);
|
||||
m_maxStorageSize->setRange(1, 100);
|
||||
m_maxStorageSize->setSuffix(" GB");
|
||||
m_maxStorageSize->setFixedWidth(110);
|
||||
m_maxStorageSize->setStyleSheet(
|
||||
"QSpinBox { border: 1px solid palette(mid); border-radius: 4px;"
|
||||
" padding: 5px 8px; font-size: 13px; background: palette(base); }");
|
||||
form->addRow("Max storage size:", m_maxStorageSize);
|
||||
|
||||
layout->addLayout(form);
|
||||
|
||||
auto* sep = new QFrame(this);
|
||||
sep->setFrameShape(QFrame::HLine);
|
||||
sep->setStyleSheet("QFrame { color: palette(mid); }");
|
||||
layout->addWidget(sep);
|
||||
|
||||
layout->addStretch();
|
||||
}
|
||||
|
||||
void StorageSettingsPage::loadFromDb() {
|
||||
if (!m_db) return;
|
||||
|
||||
bool autoload = m_db->getUiStateInt("storage_autoload", 1);
|
||||
m_autoloadCheck->setChecked(autoload);
|
||||
|
||||
int maxSizeMb = m_db->getUiStateInt("storage_autoload_maxsize_mb", 10);
|
||||
m_autoloadMaxSize->setValue(maxSizeMb);
|
||||
|
||||
int maxGb = m_db->getUiStateInt("storage_maxsize_gb", 1);
|
||||
m_maxStorageSize->setValue(maxGb);
|
||||
}
|
||||
|
||||
void StorageSettingsPage::applyAndSave() {
|
||||
saveUiStateInt("storage_autoload", m_autoloadCheck->isChecked() ? 1 : 0);
|
||||
saveUiStateInt("storage_autoload_maxsize_mb", m_autoloadMaxSize->value());
|
||||
saveUiStateInt("storage_maxsize_gb", m_maxStorageSize->value());
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class DbManager;
|
||||
class QCheckBox;
|
||||
class QSpinBox;
|
||||
|
||||
class StorageSettingsPage : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit StorageSettingsPage(DbManager* db, QWidget* parent = nullptr);
|
||||
|
||||
void loadFromDb();
|
||||
void applyAndSave();
|
||||
|
||||
private:
|
||||
DbManager* m_db = nullptr;
|
||||
QCheckBox* m_autoloadCheck;
|
||||
QSpinBox* m_autoloadMaxSize;
|
||||
QSpinBox* m_maxStorageSize;
|
||||
};
|
||||
Reference in new issue
Block a user