diff --git a/MainWindow.xaml b/MainWindow.xaml
index e0e4173..c9ed9de 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -980,16 +980,90 @@
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
- Foreground="#e0e0e0" FontSize="12">
+ Foreground="#e0e0e0" FontSize="12"
+ ScrollViewer.HorizontalScrollBarVisibility="Disabled"
+ x:Name="ChatListBox">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index d9d4654..5e66e1f 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -21,6 +21,7 @@ public partial class MainWindow : Window
AppLog.Write("MainWindow ctor: after InitializeComponent");
_viewModel = (MainViewModel)DataContext;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
+ _viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
// When a focused scene is deleted, never land focus on a hidden scene.
SceneList.IsSelectable = item => item is Scene { IsHidden: false };
UpdateTaskbarOverlay();
@@ -232,6 +233,14 @@ public partial class MainWindow : Window
TakeSnapshot();
}
+ private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+ {
+ if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
+ {
+ ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
+ }
+ }
+
// Static images and the webcam share the move/resize/selection behavior.
private static bool IsDraggableElement(SceneElement element)
=> element.IsDraggable;
diff --git a/Services/YouTubeChatService.cs b/Services/YouTubeChatService.cs
index 8f1a099..682ec99 100644
--- a/Services/YouTubeChatService.cs
+++ b/Services/YouTubeChatService.cs
@@ -73,6 +73,7 @@ public class YouTubeChatService : IDisposable
{
var snippet = item.GetProperty("snippet");
var author = item.GetProperty("authorDetails");
+ var type = snippet.GetProperty("type").GetString() ?? "textMessageEvent";
var message = new ChatMessage
{
@@ -80,11 +81,50 @@ public class YouTubeChatService : IDisposable
AuthorName = author.GetProperty("displayName").GetString()!,
AuthorChannelId = author.GetProperty("channelId").GetString()!,
AuthorImageUrl = author.GetProperty("profileImageUrl").GetString()!,
- Message = snippet.GetProperty("textMessageDetails").GetProperty("messageText").GetString()!,
Timestamp = DateTime.Parse(snippet.GetProperty("publishedAt").GetString()!),
- IsMember = author.GetProperty("isChatSponsor").GetBoolean()
+ IsMember = author.GetProperty("isChatSponsor").GetBoolean(),
+ MembershipLevel = author.TryGetProperty("badgeText", out var badgeText)
+ ? badgeText.GetString()
+ : null
};
+ switch (type)
+ {
+ case "superChatEvent":
+ message.IsSuperChat = true;
+ if (snippet.TryGetProperty("superChatDetails", out var scDetails))
+ {
+ message.SuperChatAmount = scDetails.TryGetProperty("amountMicros", out var amt)
+ ? amt.GetDouble() / 1_000_000.0
+ : 0;
+ message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
+ ? cur.GetString()
+ : null;
+ message.Message = scDetails.TryGetProperty("userComment", out var comment)
+ ? comment.GetString() ?? string.Empty
+ : string.Empty;
+ }
+ break;
+
+ case "newSponsorEvent":
+ message.Message = $"🎉 {message.AuthorName} became a member!";
+ if (snippet.TryGetProperty("membershipDetails", out var memDetails))
+ {
+ message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level)
+ ? level.GetString()
+ : message.MembershipLevel;
+ }
+ break;
+
+ default: // textMessageEvent
+ message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
+ ? textDetails.TryGetProperty("messageText", out var text)
+ ? text.GetString() ?? string.Empty
+ : string.Empty
+ : string.Empty;
+ break;
+ }
+
MessageReceived?.Invoke(message);
}
}
diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs
index 100eeaa..f30d9d8 100644
--- a/Services/YouTubeStreamService.cs
+++ b/Services/YouTubeStreamService.cs
@@ -190,4 +190,28 @@ public class YouTubeStreamService
}
return health;
}
+
+ /// Returns the liveChatId for a broadcast by fetching its contentDetails.
+ /// The liveChatId is needed to poll chat messages. Called after broadcast creation
+ /// in PrepareAndStartLiveAsync.
+ public async Task GetBroadcastLiveChatIdAsync(string broadcastId)
+ {
+ if (!await EnsureToken()) return null;
+
+ _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
+ var response = await _http.GetAsync(
+ $"{ApiBase}/liveBroadcasts?part=contentDetails&id={broadcastId}");
+ if (!response.IsSuccessStatusCode) return null;
+
+ var json = await response.Content.ReadAsStringAsync();
+ var data = JsonSerializer.Deserialize(json);
+ var items = data.GetProperty("items");
+ if (items.GetArrayLength() == 0) return null;
+
+ var contentDetails = items[0].GetProperty("contentDetails");
+ if (contentDetails.TryGetProperty("liveChatId", out var liveChatId))
+ return liveChatId.GetString();
+
+ return null;
+ }
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index b367556..1b766b9 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -1605,6 +1605,7 @@ public class MainViewModel : ViewModelBase
_screenCaptureManager.Dispose();
_layoutStore.Dispose();
_musicPlayer.Dispose();
+ _youtubeChat.Dispose();
}
public void SaveLayoutNow()
@@ -2236,6 +2237,27 @@ public class MainViewModel : ViewModelBase
return;
}
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
+
+ // Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
+ // contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
+ // the stream continues without on-panel chat.
+ try
+ {
+ var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
+ if (liveChatId != null)
+ {
+ _youtubeChat.Start(liveChatId);
+ AppLog.Write($"Chat polling started: {liveChatId}");
+ }
+ else
+ {
+ AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
+ }
+ }
+ catch (Exception chatEx)
+ {
+ AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
+ }
}
catch (Exception ex)
{
@@ -2302,6 +2324,9 @@ public class MainViewModel : ViewModelBase
StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLlive";
ResetHealth(StreamStatus.Offline);
+ // Stop chat polling first (TASK 9.4) — no more messages arriving while
+ // we're tearing down the stream.
+ _youtubeChat.Stop();
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
// then stop the pump (stdin EOF) — both inputs end and the encoder
// finalizes the FLV. Audio capture itself is always-on (preview