Lots of code cleanup, moved Twitch stuff to services, further improvements on UI

This commit is contained in:
Enrico Ludwig 2024-09-04 02:27:55 +02:00
parent 8a10573fbf
commit 814cb58742
11 changed files with 307 additions and 155 deletions

View File

@ -12,7 +12,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace BetterRaid;
public partial class App : Application
public class App : Application
{
private static readonly ServiceCollection Services = [];
private static ServiceProvider? _serviceProvider;

View File

@ -2,7 +2,7 @@ using System;
namespace BetterRaid.Attributes;
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, Inherited = true)]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)]
public class PubSubAttribute : Attribute
{
public PubSubType Type { get; }

View File

@ -7,5 +7,7 @@ public enum PubSubType
Subscriptions,
ChannelPoints,
Bits,
Raids
Raids,
StreamUp,
StreamDown
}

View File

@ -20,7 +20,7 @@ public static class Tools
if (_oauthListener == null)
{
_oauthListener = new HttpListener();
_oauthListener.Prefixes.Add(Constants.TwitchOAuthRedirectUrl);
_oauthListener.Prefixes.Add(Constants.TwitchOAuthRedirectUrl + "/");
_oauthListener.Start();
Task.Run(() => WaitForCallback(callback, token), token);
@ -110,7 +110,7 @@ public static class Tools
var dataService = App.ServiceProvider?.GetService(typeof(ITwitchDataService));
if (dataService is ITwitchDataService twitchDataService)
{
twitchDataService.ConnectApi(Constants.TwitchClientId, accessToken!);
twitchDataService.ConnectApiAsync(Constants.TwitchClientId, accessToken!);
}
res.StatusCode = 200;

View File

@ -45,6 +45,8 @@ public class TwitchChannel : INotifyPropertyChanged
}
}
[PubSub(PubSubType.StreamUp, nameof(BroadcasterId))]
[PubSub(PubSubType.StreamDown, nameof(BroadcasterId))]
public bool IsLive
{
get => _isLive;

View File

@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Threading.Tasks;
using BetterRaid.Models;
using TwitchLib.Api;
@ -11,11 +12,12 @@ public interface ITwitchDataService
public TwitchAPI TwitchApi { get; }
public bool IsRaidStarted { get; set; }
public void ConnectApi(string clientId, string accessToken);
public Task ConnectApiAsync(string clientId, string accessToken);
public void SaveAccessToken(string token);
public bool TryGetUserChannel(out TwitchChannel? userChannel);
public string GetOAuthUrl();
public void StartRaid(string from, string to);
public bool CanStartRaidCommand(object? arg);
public void StartRaidCommand(object? arg);
public void StopRaid();
public void StopRaidCommand();

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using BetterRaid.Misc;
using BetterRaid.Models;
using TwitchLib.Api;
@ -45,7 +46,7 @@ public class TwitchDataService : ITwitchDataService, INotifyPropertyChanged, INo
if (TryLoadAccessToken(out var token))
{
Console.WriteLine($"[INFO][{nameof(TwitchDataService)}] Found access token.");
ConnectApi(Constants.TwitchClientId, token);
Task.Run(() => ConnectApiAsync(Constants.TwitchClientId, token));
}
else
{
@ -53,7 +54,7 @@ public class TwitchDataService : ITwitchDataService, INotifyPropertyChanged, INo
}
}
public void ConnectApi(string clientId, string accessToken)
public async Task ConnectApiAsync(string clientId, string accessToken)
{
Console.WriteLine($"[INFO][{nameof(TwitchDataService)}] Connecting to Twitch API ...");
@ -74,6 +75,8 @@ public class TwitchDataService : ITwitchDataService, INotifyPropertyChanged, INo
Console.WriteLine($"[ERROR][{nameof(TwitchDataService)}] Could not get user channel.");
Console.WriteLine($"[ERROR][{nameof(TwitchDataService)}] Failed to connect to Twitch API.");
}
await Task.CompletedTask;
}
private bool TryLoadAccessToken(out string token)
@ -129,6 +132,11 @@ public class TwitchDataService : ITwitchDataService, INotifyPropertyChanged, INo
IsRaidStarted = true;
}
public bool CanStartRaidCommand(object? arg)
{
return UserChannel?.IsLive == true && IsRaidStarted == false;
}
public void StartRaidCommand(object? arg)
{
if (arg == null || UserChannel?.BroadcasterId == null)

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using BetterRaid.Attributes;
using BetterRaid.Extensions;
using BetterRaid.Models;
@ -13,45 +14,127 @@ namespace BetterRaid.Services.Implementations;
public class TwitchPubSubService : ITwitchPubSubService
{
private readonly Dictionary<PubSubType, List<PubSubListener>> _targets = new();
private readonly TwitchPubSub _sub;
private readonly ITwitchDataService _dataService;
private TwitchPubSub? _sub;
public TwitchPubSubService(ITwitchDataService dataService)
{
_dataService = dataService;
Task.Run(InitializePubSubAsync);
}
private async Task InitializePubSubAsync()
{
while (_dataService.UserChannel == null)
{
await Task.Delay(100);
}
_sub = new TwitchPubSub();
_sub.OnPubSubServiceConnected += OnSubOnOnPubSubServiceConnected;
_sub.OnPubSubServiceError += OnSubOnOnPubSubServiceError;
_sub.OnPubSubServiceClosed += OnSubOnOnPubSubServiceClosed;
_sub.OnListenResponse += OnSubOnOnListenResponse;
_sub.OnViewCount += OnSubOnOnViewCount;
_sub.OnStreamUp += OnStreamUp;
_sub.OnStreamDown += OnStreamDown;
_sub.Connect();
if (_dataService.UserChannel != null)
{
RegisterReceiver(_dataService.UserChannel);
}
_dataService.PropertyChanging += (_, args) =>
{
if (args.PropertyName != nameof(_dataService.UserChannel))
return;
if (_dataService.UserChannel != null)
UnregisterReceiver(_dataService.UserChannel);
};
_dataService.PropertyChanged += (_, args) =>
{
if (args.PropertyName != nameof(_dataService.UserChannel))
return;
if (_dataService.UserChannel != null)
RegisterReceiver(_dataService.UserChannel);
};
await Task.CompletedTask;
}
private void OnStreamDown(object? sender, OnStreamDownArgs args)
{
var listeners = _targets
.Where(x => x.Key == PubSubType.StreamDown)
.SelectMany(x => x.Value)
.Where(x => x.ChannelId == args.ChannelId)
.ToList();
foreach (var listener in listeners)
{
if (listener.Listener == null || listener.Instance == null)
continue;
try
{
if (listener.Listener.SetValue(listener.Instance, false) == false)
{
Console.WriteLine(
$"[ERROR][{nameof(TwitchPubSubService)}] Failed to set {listener.Instance.GetType().Name}.{listener.Listener.Name} to true");
}
else
{
Console.WriteLine(
$"[DEBUG][{nameof(TwitchPubSubService)}] Setting {listener.Instance.GetType().Name}.{listener.Listener.Name} to true");
}
}
catch (Exception e)
{
Console.WriteLine(
$"[ERROR][{nameof(TwitchPubSubService)}] Exception while setting {listener.Instance?.GetType().Name}.{listener.Listener?.Name}: {e.Message}");
}
}
}
private void OnStreamUp(object? sender, OnStreamUpArgs args)
{
var listeners = _targets
.Where(x => x.Key == PubSubType.StreamUp)
.SelectMany(x => x.Value)
.Where(x => x.ChannelId == args.ChannelId)
.ToList();
foreach (var listener in listeners)
{
if (listener.Listener == null || listener.Instance == null)
continue;
try
{
if (listener.Listener.SetValue(listener.Instance, true) == false)
{
Console.WriteLine(
$"[ERROR][{nameof(TwitchPubSubService)}] Failed to set {listener.Instance.GetType().Name}.{listener.Listener.Name} to true");
}
else
{
Console.WriteLine(
$"[DEBUG][{nameof(TwitchPubSubService)}] Setting {listener.Instance.GetType().Name}.{listener.Listener.Name} to true");
}
}
catch (Exception e)
{
Console.WriteLine(
$"[ERROR][{nameof(TwitchPubSubService)}] Exception while setting {listener.Instance?.GetType().Name}.{listener.Listener?.Name}: {e.Message}");
}
}
}
private void OnSubOnOnViewCount(object? sender, OnViewCountArgs args)
@ -61,26 +144,29 @@ public class TwitchPubSubService : ITwitchPubSubService
.SelectMany(x => x.Value)
.Where(x => x.ChannelId == args.ChannelId)
.ToList();
foreach (var listener in listeners)
{
if (listener.Listener == null || listener.Instance == null)
continue;
try
{
if (listener.Listener.SetValue(listener.Instance, args.Viewers) == false)
{
Console.WriteLine($"[ERROR][{nameof(TwitchPubSubService)}] Failed to set {listener.Instance.GetType().Name}.{listener.Listener.Name} to {args.Viewers}");
Console.WriteLine(
$"[ERROR][{nameof(TwitchPubSubService)}] Failed to set {listener.Instance.GetType().Name}.{listener.Listener.Name} to {args.Viewers}");
}
else
{
Console.WriteLine($"[DEBUG][{nameof(TwitchPubSubService)}] Setting {listener.Instance.GetType().Name}.{listener.Listener.Name} to {args.Viewers}");
Console.WriteLine(
$"[DEBUG][{nameof(TwitchPubSubService)}] Setting {listener.Instance.GetType().Name}.{listener.Listener.Name} to {args.Viewers}");
}
}
catch (Exception e)
{
Console.WriteLine($"[ERROR][{nameof(TwitchPubSubService)}] Exception while setting {listener.Instance?.GetType().Name}.{listener.Listener?.Name}: {e.Message}");
Console.WriteLine(
$"[ERROR][{nameof(TwitchPubSubService)}] Exception while setting {listener.Instance?.GetType().Name}.{listener.Listener?.Name}: {e.Message}");
}
}
}
@ -109,8 +195,14 @@ public class TwitchPubSubService : ITwitchPubSubService
{
ArgumentNullException.ThrowIfNull(receiver, nameof(receiver));
if (_sub == null)
{
Console.WriteLine($"[ERROR][{nameof(TwitchPubSubService)}] PubSub is not initialized");
return;
}
Console.WriteLine($"[DEBUG][{nameof(TwitchPubSubService)}] Registering {receiver.GetType().Name}");
var type = typeof(T);
var publicTargets = type
.GetProperties()
@ -120,93 +212,79 @@ public class TwitchPubSubService : ITwitchPubSubService
foreach (var target in publicTargets)
{
if (target.GetCustomAttribute<PubSubAttribute>() is not { } attr)
if (target.GetCustomAttributes<PubSubAttribute>() is not { } attrs)
{
continue;
}
var channelId =
type.GetProperty(attr.ChannelIdField)?.GetValue(receiver)?.ToString() ??
type.GetField(attr.ChannelIdField)?.GetValue(receiver)?.ToString();
if (string.IsNullOrEmpty(channelId))
foreach (var attr in attrs)
{
Console.WriteLine($"[ERROR][{nameof(TwitchPubSubService)}] {target.Name} is missing ChannelIdField named {attr.ChannelIdField}");
continue;
}
switch (attr.Type)
{
case PubSubType.Bits:
break;
case PubSubType.ChannelPoints:
break;
case PubSubType.Follows:
break;
case PubSubType.Raids:
break;
case PubSubType.Subscriptions:
break;
case PubSubType.VideoPlayback:
Console.WriteLine($"[DEBUG][{nameof(TwitchPubSubService)}] Registering {target.Name} for {attr.Type}");
if (_targets.TryGetValue(PubSubType.VideoPlayback, out var value))
var channelId =
type.GetProperty(attr.ChannelIdField)?.GetValue(receiver)?.ToString() ??
type.GetField(attr.ChannelIdField)?.GetValue(receiver)?.ToString();
if (channelId == null)
{
Console.WriteLine(
$"[ERROR][{nameof(TwitchPubSubService)}] {target.Name} is missing ChannelIdField named {attr.ChannelIdField}");
continue;
}
if (string.IsNullOrWhiteSpace(channelId))
{
Console.WriteLine(
$"[ERROR][{nameof(TwitchPubSubService)}] {target.Name} ChannelIdField named {attr.ChannelIdField} is empty");
continue;
}
Console.WriteLine($"[DEBUG][{nameof(TwitchPubSubService)}] Registering {target.Name} for {attr.Type}");
if (_targets.TryGetValue(attr.Type, out var listeners))
{
listeners.Add(new PubSubListener
{
value.Add(new PubSubListener
ChannelId = channelId,
Instance = receiver,
Listener = target
});
}
else
{
_targets.Add(attr.Type, [
new PubSubListener
{
ChannelId = channelId,
Instance = receiver,
Listener = target
});
}
else
{
_targets.Add(PubSubType.VideoPlayback, [
new PubSubListener
{
ChannelId = channelId,
Instance = receiver,
Listener = target
}
]);
}
_sub.ListenToVideoPlayback(channelId);
_sub.SendTopics(_dataService.AccessToken);
break;
}
]);
}
_sub.ListenToVideoPlayback(channelId);
_sub.SendTopics(_dataService.AccessToken, true);
}
}
}
public void UnregisterReceiver<T>(T receiver) where T : class
{
ArgumentNullException.ThrowIfNull(receiver, nameof(receiver));
if (_sub == null)
{
Console.WriteLine($"[ERROR][{nameof(TwitchPubSubService)}] PubSub is not initialized");
return;
}
foreach (var (topic, listeners) in _targets)
{
var listener = listeners.Where(x => x.Instance == receiver).ToList();
foreach (var l in listener)
{
switch (topic)
{
case PubSubType.Bits:
break;
case PubSubType.ChannelPoints:
break;
case PubSubType.Follows:
break;
case PubSubType.Raids:
break;
case PubSubType.Subscriptions:
break;
case PubSubType.VideoPlayback:
_sub.ListenToVideoPlayback(l.ChannelId);
_sub.SendTopics(_dataService.AccessToken, true);
break;
}
_sub.ListenToVideoPlayback(l.ChannelId);
_sub.SendTopics(_dataService.AccessToken, true);
}
_targets[topic].RemoveAll(x => x.Instance == receiver);
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -12,17 +13,17 @@ using BetterRaid.Views;
namespace BetterRaid.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
public class MainWindowViewModel : ViewModelBase
{
private string? _filter;
private ObservableCollection<TwitchChannel> _channels = [];
private BetterRaidDatabase? _db;
private readonly BetterRaidDatabase? _db;
private readonly ITwitchPubSubService _pubSub;
public BetterRaidDatabase? Database
{
get => _db;
set
private init
{
if (SetProperty(ref _db, value) && _db != null)
{
@ -37,6 +38,8 @@ public partial class MainWindowViewModel : ViewModelBase
set => SetProperty(ref _channels, value);
}
public ObservableCollection<TwitchChannel> FilteredChannels => GetFilteredChannels();
public ITwitchDataService DataService { get; }
public string? Filter
@ -51,10 +54,12 @@ public partial class MainWindowViewModel : ViewModelBase
{
_pubSub = pubSub;
DataService = dataService;
DataService.PropertyChanged += OnDataServicePropertyChanged;
Database = BetterRaidDatabase.LoadFromFile(Constants.DatabaseFilePath);
Database.PropertyChanged += OnDatabasePropertyChanged;
}
public void ExitApplication()
{
//TODO polish later
@ -107,4 +112,40 @@ public partial class MainWindowViewModel : ViewModelBase
Channels.Add(channel);
}
}
private ObservableCollection<TwitchChannel> GetFilteredChannels()
{
var filteredChannels = Channels
.Where(channel => Database?.OnlyOnline == false || channel.IsLive)
.Where(channel => string.IsNullOrWhiteSpace(Filter) || channel.Name?.Contains(Filter, StringComparison.OrdinalIgnoreCase) == true)
.ToList();
return new ObservableCollection<TwitchChannel>(filteredChannels);
}
private void OnDataServicePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(DataService.UserChannel))
return;
OnPropertyChanged(nameof(IsLoggedIn));
}
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
if (e.PropertyName == nameof(Filter))
{
OnPropertyChanged(nameof(FilteredChannels));
}
}
private void OnDatabasePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(BetterRaidDatabase.OnlyOnline))
return;
OnPropertyChanged(nameof(FilteredChannels));
}
}

View File

@ -22,83 +22,85 @@
</Window.Resources>
<Grid HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
VerticalAlignment="Stretch"
ColumnDefinitions="Auto,*"
RowDefinitions="50,*">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Grid.Row="0"
Orientation="Horizontal">
<ai:AdvancedImage CornerRadius="20"
Width="40"
Height="40"
Margin="5"
Source="{Binding DataService.UserChannel.ThumbnailUrl,
FallbackValue={x:Static misc:Constants.ChannelPlaceholderImageUrl},
TargetNullValue={x:Static misc:Constants.ChannelPlaceholderImageUrl}}" />
<TextBlock VerticalAlignment="Center"
Margin="5, 0, 0, 0"
FontWeight="Bold">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} ({1})">
<Binding Path="DataService.UserChannel.DisplayName"
FallbackValue="-" />
<Binding Path="DataService.UserChannel.ViewerCount"
FallbackValue="Offline"
TargetNullValue="Offline" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Menu Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="0">
<MenuItem Header="File">
<MenuItem Header="About"
CommandParameter="{Binding $parent[Window]}"
Command="{Binding ShowAboutWindow}" />
<Separator />
<MenuItem Header="Exit"
Command="{Binding ExitApplication}" />
</MenuItem>
</Menu>
<StackPanel Grid.Column="1"
Grid.Row="0"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="5">
<Button Command="{Binding LoginWithTwitch}"
IsVisible="{Binding !IsLoggedIn, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="#6441a5" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#b9a3e3" />
</Style>
</Button.Styles>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Login"
Foreground="#f1f1f1"
FontSize="14" />
<Image Source="avares://BetterRaid/Assets/glitch_flat_white.png"
Width="16"
Height="16"
Margin="5, 0, 0, 0" />
</StackPanel>
</Button>
<CheckBox Content="Only Online"
IsChecked="{Binding Database.OnlyOnline, Mode=TwoWay}" />
IsChecked="{Binding Database.OnlyOnline, Mode=TwoWay, FallbackValue=False}" />
<TextBox Grid.Column="1"
Grid.Row="0"
Width="200"
Margin="2"
<TextBox Width="200"
Margin="5, 10, 5, 10"
Watermark="Filter Channels"
Text="{Binding Filter, Mode=TwoWay}"
HorizontalAlignment="Right" />
</StackPanel>
<Button Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="200"
Height="50"
Command="{Binding LoginWithTwitch}"
IsVisible="{Binding !IsLoggedIn, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="#6441a5" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#6C4CA5" />
</Style>
</Button.Styles>
<TextBlock Text="Please login first!"
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="1"
IsVisible="{Binding !IsLoggedIn, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
FontSize="24"
Foreground="#f1f1f1" />
<StackPanel Orientation="Horizontal">
<TextBlock Text="Login with Twitch"
Foreground="#f1f1f1"
FontSize="18"
VerticalAlignment="Center" />
<Image Source="avares://BetterRaid/Assets/glitch_flat_white.png"
Width="24"
Height="24"
VerticalAlignment="Center"
Margin="5, 0, 0, 0" />
</StackPanel>
</Button>
<ScrollViewer Grid.Column="0"
Grid.ColumnSpan="2"
@ -113,12 +115,21 @@
IsScrollInertiaEnabled="True" />
</ScrollViewer.GestureRecognizers>
<ListBox ItemsSource="{Binding Channels}">
<ListBox ItemsSource="{Binding FilteredChannels, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
SelectionMode="Single"
SelectionChanged="SelectingItemsControl_OnSelectionChanged">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="100, *, 120"
RowDefinitions="100">
<ai:AdvancedImage Grid.Column="0"
Grid.Row="0"
Source="{Binding ThumbnailUrl, TargetNullValue={x:Static misc:Constants.ChannelPlaceholderImageUrl}}" />

View File

@ -8,4 +8,12 @@ public partial class MainWindow : Window
{
InitializeComponent();
}
private void SelectingItemsControl_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (sender is ListBox listBox)
{
listBox.SelectedItems?.Clear();
}
}
}