TASK 9.4: live chat wiring — right panel with Super Chat + membership badges
- YouTubeStreamService: added GetBroadcastLiveChatIdAsync to fetch liveChatId from broadcast contentDetails - YouTubeChatService.Poll(): parse snippet.type to handle textMessageEvent, superChatEvent, newSponsorEvent - MainViewModel: wire _youtubeChat.Start(liveChatId) in PrepareAndStartLiveAsync after broadcast creation - MainViewModel: wire _youtubeChat.Stop() in StopStream before encoder stop - MainViewModel: wire _youtubeChat.Dispose() in Shutdown - MainWindow.xaml: updated chat DataTemplate with member badge (⭐), Super Chat highlight (💰 + gold border), timestamps (HH:mm) - MainWindow.xaml.cs: auto-scroll ChatListBox on new messages via CollectionChanged - Chat lifecycle is non-fatal: failures log and the stream continues without on-panel chat
This commit is contained in:
+80
-6
@@ -980,16 +980,90 @@
|
|||||||
ItemsSource="{Binding ChatMessages}"
|
ItemsSource="{Binding ChatMessages}"
|
||||||
ItemContainerStyle="{StaticResource YtListBoxItem}"
|
ItemContainerStyle="{StaticResource YtListBoxItem}"
|
||||||
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
|
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
|
||||||
Foreground="#e0e0e0" FontSize="12">
|
Foreground="#e0e0e0" FontSize="12"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
x:Name="ChatListBox">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Margin="0,2">
|
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4" Margin="0,1">
|
||||||
<TextBlock>
|
<Border.Style>
|
||||||
<Run Text="{Binding AuthorName, Mode=OneWay}" FontWeight="Bold" Foreground="#e94560"/>
|
<Style TargetType="Border">
|
||||||
<Run Text=": "/>
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
<Run Text="{Binding Message, Mode=OneWay}" Foreground="#e0e0e0"/>
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
|
||||||
|
<Setter Property="Background" Value="#2a1a0a"/>
|
||||||
|
<Setter Property="BorderBrush" Value="#eab308"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1,0,1,0"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<!-- Member badge -->
|
||||||
|
<TextBlock Text="⭐" FontSize="10" Margin="0,0,3,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsMember}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<!-- Author name -->
|
||||||
|
<TextBlock FontWeight="Bold" Foreground="#e94560"
|
||||||
|
Text="{Binding AuthorName, Mode=OneWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
<!-- Super Chat amount -->
|
||||||
|
<TextBlock Foreground="#eab308" FontWeight="Bold"
|
||||||
|
FontSize="11" Margin="6,0,0,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
<Run Text="💰 $"/>
|
||||||
|
<Run Text="{Binding SuperChatAmount, Mode=OneWay, StringFormat=F2}"/>
|
||||||
|
<Run Text="{Binding SuperChatCurrency, Mode=OneWay}"/>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<!-- Timestamp -->
|
||||||
|
<TextBlock Foreground="#555" FontSize="10"
|
||||||
|
Margin="6,0,0,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Timestamp}" Value="{x:Null}">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
<TextBlock.Text>
|
||||||
|
<Binding Path="Timestamp" StringFormat="{}{0:HH:mm}"/>
|
||||||
|
</TextBlock.Text>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<TextBlock Text="{Binding Message, Mode=OneWay}"
|
||||||
|
Foreground="#e0e0e0" TextWrapping="Wrap"
|
||||||
|
Margin="0,2,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public partial class MainWindow : Window
|
|||||||
AppLog.Write("MainWindow ctor: after InitializeComponent");
|
AppLog.Write("MainWindow ctor: after InitializeComponent");
|
||||||
_viewModel = (MainViewModel)DataContext;
|
_viewModel = (MainViewModel)DataContext;
|
||||||
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
|
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
|
||||||
// When a focused scene is deleted, never land focus on a hidden scene.
|
// When a focused scene is deleted, never land focus on a hidden scene.
|
||||||
SceneList.IsSelectable = item => item is Scene { IsHidden: false };
|
SceneList.IsSelectable = item => item is Scene { IsHidden: false };
|
||||||
UpdateTaskbarOverlay();
|
UpdateTaskbarOverlay();
|
||||||
@@ -232,6 +233,14 @@ public partial class MainWindow : Window
|
|||||||
TakeSnapshot();
|
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.
|
// Static images and the webcam share the move/resize/selection behavior.
|
||||||
private static bool IsDraggableElement(SceneElement element)
|
private static bool IsDraggableElement(SceneElement element)
|
||||||
=> element.IsDraggable;
|
=> element.IsDraggable;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public class YouTubeChatService : IDisposable
|
|||||||
{
|
{
|
||||||
var snippet = item.GetProperty("snippet");
|
var snippet = item.GetProperty("snippet");
|
||||||
var author = item.GetProperty("authorDetails");
|
var author = item.GetProperty("authorDetails");
|
||||||
|
var type = snippet.GetProperty("type").GetString() ?? "textMessageEvent";
|
||||||
|
|
||||||
var message = new ChatMessage
|
var message = new ChatMessage
|
||||||
{
|
{
|
||||||
@@ -80,11 +81,50 @@ public class YouTubeChatService : IDisposable
|
|||||||
AuthorName = author.GetProperty("displayName").GetString()!,
|
AuthorName = author.GetProperty("displayName").GetString()!,
|
||||||
AuthorChannelId = author.GetProperty("channelId").GetString()!,
|
AuthorChannelId = author.GetProperty("channelId").GetString()!,
|
||||||
AuthorImageUrl = author.GetProperty("profileImageUrl").GetString()!,
|
AuthorImageUrl = author.GetProperty("profileImageUrl").GetString()!,
|
||||||
Message = snippet.GetProperty("textMessageDetails").GetProperty("messageText").GetString()!,
|
|
||||||
Timestamp = DateTime.Parse(snippet.GetProperty("publishedAt").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);
|
MessageReceived?.Invoke(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,4 +190,28 @@ public class YouTubeStreamService
|
|||||||
}
|
}
|
||||||
return health;
|
return health;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the liveChatId for a broadcast by fetching its contentDetails.
|
||||||
|
/// The liveChatId is needed to poll chat messages. Called after broadcast creation
|
||||||
|
/// in PrepareAndStartLiveAsync.</summary>
|
||||||
|
public async Task<string?> 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<JsonElement>(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1605,6 +1605,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
_screenCaptureManager.Dispose();
|
_screenCaptureManager.Dispose();
|
||||||
_layoutStore.Dispose();
|
_layoutStore.Dispose();
|
||||||
_musicPlayer.Dispose();
|
_musicPlayer.Dispose();
|
||||||
|
_youtubeChat.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SaveLayoutNow()
|
public void SaveLayoutNow()
|
||||||
@@ -2236,6 +2237,27 @@ public class MainViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -2302,6 +2324,9 @@ public class MainViewModel : ViewModelBase
|
|||||||
StreamStatus = StreamStatus.Offline;
|
StreamStatus = StreamStatus.Offline;
|
||||||
WindowTitle = "ytLlive";
|
WindowTitle = "ytLlive";
|
||||||
ResetHealth(StreamStatus.Offline);
|
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,
|
// 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
|
// then stop the pump (stdin EOF) — both inputs end and the encoder
|
||||||
// finalizes the FLV. Audio capture itself is always-on (preview
|
// finalizes the FLV. Audio capture itself is always-on (preview
|
||||||
|
|||||||
Reference in New Issue
Block a user