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