mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 09:06:07 +02:00
feat: enhance audio playback features and UI, including audio player dialog and improved file size handling
This commit is contained in:
@@ -6,7 +6,10 @@
|
||||
|
||||
- New `Audio` message type — uploaded audio files (`.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.wma`) are automatically detected and categorized
|
||||
- TUI client renders audio messages with `♪ [Audio: filename] (Enter to play)` indicator
|
||||
- Press Enter on an audio message to play it locally via `AudioPlaybackService` (NetCoreAudio)
|
||||
- Press Enter on an audio message to open the **Audio Player dialog** with animated wave visualization, play/pause/stop controls, and volume slider
|
||||
- Audio file upload limit raised to **20 MB** (separate from the 10 MB general file limit)
|
||||
- `AudioPlaybackService` enhanced with pause/resume, volume control, and playback-finished events
|
||||
- Fixed: wrapped audio/file messages now remain clickable on all lines (attachment metadata propagated through word-wrap)
|
||||
- IRC gateway formats audio messages as `♪ [Audio: filename] url`
|
||||
- Server detects audio files by extension via `FileValidationHelper.IsAudioFile()`
|
||||
|
||||
|
||||
@@ -896,9 +896,9 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Playing {fileName}..."));
|
||||
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||
var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||
await _audioPlayback.PlayAsync(tempPath);
|
||||
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
|
||||
}, "Failed to play audio");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@ public class AudioPlaybackService
|
||||
private readonly Player _player = new();
|
||||
|
||||
public bool IsPlaying => _player.Playing;
|
||||
public bool IsPaused => _player.Paused;
|
||||
|
||||
public event EventHandler? PlaybackFinished;
|
||||
|
||||
public AudioPlaybackService()
|
||||
{
|
||||
_player.PlaybackFinished += (s, e) => PlaybackFinished?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task PlayAsync(string filePath)
|
||||
{
|
||||
@@ -24,6 +32,32 @@ public class AudioPlaybackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PauseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_player.Playing && !_player.Paused)
|
||||
await _player.Pause();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to pause audio playback");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ResumeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_player.Paused)
|
||||
await _player.Resume();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to resume audio playback");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
try
|
||||
@@ -36,4 +70,16 @@ public class AudioPlaybackService
|
||||
Log.Warning(ex, "Failed to stop audio playback");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetVolumeAsync(byte volume)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _player.SetVolume(Math.Min(volume, (byte)100));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to set audio volume");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
using EchoHub.Client.Services;
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using Terminal.Gui.Views;
|
||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||
|
||||
namespace EchoHub.Client.UI;
|
||||
|
||||
public sealed class AudioPlayerDialog
|
||||
{
|
||||
// Block characters for wave animation (increasing height)
|
||||
private static readonly string[] WaveBlocks = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
||||
private const int WaveBarCount = 24;
|
||||
private const int AnimationIntervalMs = 150;
|
||||
|
||||
private static readonly Attribute WaveActiveAttr = new(new Color(180, 100, 255), Color.Transparent);
|
||||
private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.Transparent);
|
||||
private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.Transparent);
|
||||
private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.Transparent);
|
||||
private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.Transparent);
|
||||
private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.Transparent);
|
||||
|
||||
public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
|
||||
{
|
||||
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 14 };
|
||||
|
||||
// ── File name ──
|
||||
var fileLabel = new Label
|
||||
{
|
||||
Text = $"\u266a {TruncateFileName(fileName, 44)}",
|
||||
X = 2,
|
||||
Y = 1,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
// ── Wave visualization ──
|
||||
var waveLabel = new Label
|
||||
{
|
||||
X = 2,
|
||||
Y = 3,
|
||||
Width = Dim.Fill(2),
|
||||
Height = 1
|
||||
};
|
||||
|
||||
// ── Status label ──
|
||||
var statusLabel = new Label
|
||||
{
|
||||
Text = "Stopped",
|
||||
X = 2,
|
||||
Y = 5,
|
||||
Width = 20
|
||||
};
|
||||
|
||||
// ── Volume controls ──
|
||||
var volumeHeaderLabel = new Label
|
||||
{
|
||||
Text = "Volume:",
|
||||
X = 2,
|
||||
Y = 7
|
||||
};
|
||||
|
||||
byte currentVolume = 50;
|
||||
var volumeBar = new ProgressBar
|
||||
{
|
||||
X = 14,
|
||||
Y = 7,
|
||||
Width = 20,
|
||||
Height = 1,
|
||||
Fraction = currentVolume / 100f,
|
||||
ProgressBarStyle = ProgressBarStyle.Continuous
|
||||
};
|
||||
|
||||
var volumePercentLabel = new Label
|
||||
{
|
||||
Text = $"{currentVolume}%",
|
||||
X = 35,
|
||||
Y = 7,
|
||||
Width = 5
|
||||
};
|
||||
|
||||
var volDownButton = new Button
|
||||
{
|
||||
Text = "-",
|
||||
X = 10,
|
||||
Y = 7,
|
||||
Width = 3
|
||||
};
|
||||
|
||||
var volUpButton = new Button
|
||||
{
|
||||
Text = "+",
|
||||
X = 41,
|
||||
Y = 7,
|
||||
Width = 3
|
||||
};
|
||||
|
||||
// ── Playback controls ──
|
||||
var playButton = new Button
|
||||
{
|
||||
Text = "\u25b6 Play",
|
||||
X = 2,
|
||||
Y = 10,
|
||||
IsDefault = true
|
||||
};
|
||||
|
||||
var stopButton = new Button
|
||||
{
|
||||
Text = "\u25a0 Stop",
|
||||
X = Pos.Right(playButton) + 2,
|
||||
Y = 10
|
||||
};
|
||||
|
||||
var closeButton = new Button
|
||||
{
|
||||
Text = "Close",
|
||||
X = Pos.Right(stopButton) + 2,
|
||||
Y = 10
|
||||
};
|
||||
|
||||
// ── Animation state ──
|
||||
var animationOffset = 0;
|
||||
var random = new Random();
|
||||
// Pre-generate a repeating wave pattern
|
||||
var wavePattern = new int[WaveBarCount + 8];
|
||||
for (int i = 0; i < wavePattern.Length; i++)
|
||||
wavePattern[i] = random.Next(0, WaveBlocks.Length);
|
||||
|
||||
Timer? animationTimer = null;
|
||||
var isDisposed = false;
|
||||
|
||||
// ── Helper functions ──
|
||||
void UpdateWave(bool isActive)
|
||||
{
|
||||
if (isDisposed) return;
|
||||
|
||||
var bars = new string[WaveBarCount];
|
||||
for (int i = 0; i < WaveBarCount; i++)
|
||||
{
|
||||
if (isActive)
|
||||
{
|
||||
var idx = wavePattern[(i + animationOffset) % wavePattern.Length];
|
||||
bars[i] = WaveBlocks[idx];
|
||||
}
|
||||
else
|
||||
{
|
||||
bars[i] = WaveBlocks[1]; // low idle bars
|
||||
}
|
||||
}
|
||||
waveLabel.Text = string.Join(" ", bars);
|
||||
}
|
||||
|
||||
void UpdateStatus()
|
||||
{
|
||||
if (isDisposed) return;
|
||||
|
||||
if (audioService.IsPlaying && !audioService.IsPaused)
|
||||
{
|
||||
statusLabel.Text = "Playing";
|
||||
playButton.Text = "\u23f8 Pause";
|
||||
}
|
||||
else if (audioService.IsPaused)
|
||||
{
|
||||
statusLabel.Text = "Paused";
|
||||
playButton.Text = "\u25b6 Resume";
|
||||
}
|
||||
else
|
||||
{
|
||||
statusLabel.Text = "Stopped";
|
||||
playButton.Text = "\u25b6 Play";
|
||||
}
|
||||
}
|
||||
|
||||
void StartAnimation()
|
||||
{
|
||||
animationTimer?.Dispose();
|
||||
animationTimer = new Timer(_ =>
|
||||
{
|
||||
if (isDisposed) return;
|
||||
animationOffset++;
|
||||
// Shuffle a few bars each tick for organic movement
|
||||
var idx = random.Next(0, wavePattern.Length);
|
||||
wavePattern[idx] = random.Next(0, WaveBlocks.Length);
|
||||
|
||||
app.Invoke(() =>
|
||||
{
|
||||
if (isDisposed) return;
|
||||
UpdateWave(true);
|
||||
});
|
||||
}, null, 0, AnimationIntervalMs);
|
||||
}
|
||||
|
||||
void StopAnimation()
|
||||
{
|
||||
animationTimer?.Dispose();
|
||||
animationTimer = null;
|
||||
if (!isDisposed)
|
||||
UpdateWave(false);
|
||||
}
|
||||
|
||||
async Task UpdateVolume(byte newVolume)
|
||||
{
|
||||
currentVolume = Math.Clamp(newVolume, (byte)0, (byte)100);
|
||||
await audioService.SetVolumeAsync(currentVolume);
|
||||
if (!isDisposed)
|
||||
{
|
||||
volumeBar.Fraction = currentVolume / 100f;
|
||||
volumePercentLabel.Text = $"{currentVolume}%";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom drawing for colored elements ──
|
||||
fileLabel.DrawingContent += (s, e) =>
|
||||
{
|
||||
var normalAttr = fileLabel.GetAttributeForRole(VisualRole.Normal);
|
||||
var resolvedAttr = FileNameAttr.Background == Color.Transparent
|
||||
? FileNameAttr with { Background = normalAttr.Background }
|
||||
: FileNameAttr;
|
||||
fileLabel.SetAttribute(resolvedAttr);
|
||||
fileLabel.Move(0, 0);
|
||||
var text = fileLabel.Text ?? "";
|
||||
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||
fileLabel.AddStr(g);
|
||||
// Fill remaining width
|
||||
var width = fileLabel.Viewport.Width;
|
||||
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||
for (int i = textCols; i < width; i++)
|
||||
fileLabel.AddStr(" ");
|
||||
e.Cancel = true;
|
||||
};
|
||||
|
||||
waveLabel.DrawingContent += (s, e) =>
|
||||
{
|
||||
var normalAttr = waveLabel.GetAttributeForRole(VisualRole.Normal);
|
||||
var attr = (audioService.IsPlaying && !audioService.IsPaused) ? WaveActiveAttr : WaveIdleAttr;
|
||||
var resolvedAttr = attr.Background == Color.Transparent
|
||||
? attr with { Background = normalAttr.Background }
|
||||
: attr;
|
||||
waveLabel.SetAttribute(resolvedAttr);
|
||||
waveLabel.Move(0, 0);
|
||||
var text = waveLabel.Text ?? "";
|
||||
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||
waveLabel.AddStr(g);
|
||||
var width = waveLabel.Viewport.Width;
|
||||
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||
for (int i = textCols; i < width; i++)
|
||||
waveLabel.AddStr(" ");
|
||||
e.Cancel = true;
|
||||
};
|
||||
|
||||
statusLabel.DrawingContent += (s, e) =>
|
||||
{
|
||||
var normalAttr = statusLabel.GetAttributeForRole(VisualRole.Normal);
|
||||
Attribute attr;
|
||||
if (audioService.IsPlaying && !audioService.IsPaused)
|
||||
attr = StatusPlayingAttr;
|
||||
else if (audioService.IsPaused)
|
||||
attr = StatusPausedAttr;
|
||||
else
|
||||
attr = StatusStoppedAttr;
|
||||
|
||||
var resolvedAttr = attr.Background == Color.Transparent
|
||||
? attr with { Background = normalAttr.Background }
|
||||
: attr;
|
||||
statusLabel.SetAttribute(resolvedAttr);
|
||||
statusLabel.Move(0, 0);
|
||||
var text = statusLabel.Text ?? "";
|
||||
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||
statusLabel.AddStr(g);
|
||||
var width = statusLabel.Viewport.Width;
|
||||
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||
for (int i = textCols; i < width; i++)
|
||||
statusLabel.AddStr(" ");
|
||||
e.Cancel = true;
|
||||
};
|
||||
|
||||
// ── Event handlers ──
|
||||
playButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
if (audioService.IsPaused)
|
||||
{
|
||||
await audioService.ResumeAsync();
|
||||
app.Invoke(() =>
|
||||
{
|
||||
UpdateStatus();
|
||||
StartAnimation();
|
||||
});
|
||||
}
|
||||
else if (audioService.IsPlaying)
|
||||
{
|
||||
await audioService.PauseAsync();
|
||||
app.Invoke(() =>
|
||||
{
|
||||
UpdateStatus();
|
||||
StopAnimation();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await audioService.SetVolumeAsync(currentVolume);
|
||||
await audioService.PlayAsync(filePath);
|
||||
app.Invoke(() =>
|
||||
{
|
||||
UpdateStatus();
|
||||
StartAnimation();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
stopButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await audioService.StopAsync();
|
||||
app.Invoke(() =>
|
||||
{
|
||||
UpdateStatus();
|
||||
StopAnimation();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
closeButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await audioService.StopAsync();
|
||||
app.Invoke(() =>
|
||||
{
|
||||
isDisposed = true;
|
||||
animationTimer?.Dispose();
|
||||
app.RequestStop();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
volDownButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
var newVol = (byte)Math.Max(0, currentVolume - 10);
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await UpdateVolume(newVol);
|
||||
app.Invoke(() =>
|
||||
{
|
||||
volumeBar.SetNeedsDraw();
|
||||
volumePercentLabel.SetNeedsDraw();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
volUpButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
var newVol = (byte)Math.Min(100, currentVolume + 10);
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await UpdateVolume(newVol);
|
||||
app.Invoke(() =>
|
||||
{
|
||||
volumeBar.SetNeedsDraw();
|
||||
volumePercentLabel.SetNeedsDraw();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Handle playback finishing naturally
|
||||
EventHandler? finishedHandler = null;
|
||||
finishedHandler = (s, e) =>
|
||||
{
|
||||
app.Invoke(() =>
|
||||
{
|
||||
if (isDisposed) return;
|
||||
UpdateStatus();
|
||||
StopAnimation();
|
||||
});
|
||||
};
|
||||
audioService.PlaybackFinished += finishedHandler;
|
||||
|
||||
// ── Initial state ──
|
||||
UpdateWave(false);
|
||||
UpdateStatus();
|
||||
|
||||
dialog.Add(fileLabel, waveLabel, statusLabel,
|
||||
volumeHeaderLabel, volDownButton, volumeBar, volumePercentLabel, volUpButton,
|
||||
playButton, stopButton, closeButton);
|
||||
|
||||
playButton.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
// Cleanup
|
||||
isDisposed = true;
|
||||
animationTimer?.Dispose();
|
||||
audioService.PlaybackFinished -= finishedHandler;
|
||||
}
|
||||
|
||||
private static string TruncateFileName(string name, int maxLen)
|
||||
{
|
||||
if (name.Length <= maxLen)
|
||||
return name;
|
||||
|
||||
var ext = Path.GetExtension(name);
|
||||
var stem = Path.GetFileNameWithoutExtension(name);
|
||||
var available = maxLen - ext.Length - 3; // 3 for "..."
|
||||
if (available < 1)
|
||||
return name[..maxLen];
|
||||
|
||||
return stem[..available] + "..." + ext;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,15 @@ public partial class ChatLine
|
||||
if (currentSegments.Count > 0)
|
||||
results.Add(new ChatLine(currentSegments));
|
||||
|
||||
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
|
||||
foreach (var wrapped in results)
|
||||
{
|
||||
wrapped.AttachmentUrl = AttachmentUrl;
|
||||
wrapped.AttachmentFileName = AttachmentFileName;
|
||||
wrapped.Type = Type;
|
||||
wrapped.MessageId = MessageId;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ public static class HubConstants
|
||||
public const int DefaultHistoryCount = 100;
|
||||
public const int MaxMessageLength = 2000;
|
||||
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||||
public const int MaxAudioFileSizeBytes = 20 * 1024 * 1024; // 20 MB
|
||||
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
||||
public const int MaxMessageNewlines = 30;
|
||||
public const int MaxConsecutiveNewlines = 1;
|
||||
|
||||
@@ -129,13 +129,17 @@ public class ChannelsController : ControllerBase
|
||||
|
||||
var file = Request.Form.Files[0];
|
||||
|
||||
if (file.Length > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
// Detect file type early so we can apply the correct size limit
|
||||
var isAudioByExtension = FileValidationHelper.IsAudioFile(file.FileName);
|
||||
var maxSize = isAudioByExtension ? HubConstants.MaxAudioFileSizeBytes : HubConstants.MaxFileSizeBytes;
|
||||
|
||||
if (file.Length > maxSize)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
|
||||
|
||||
// Detect file type: image (magic bytes), audio (extension), or generic file
|
||||
using var stream = file.OpenReadStream();
|
||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||
var isAudio = !isImage && isAudioByExtension;
|
||||
|
||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user