Compare commits

...
This repository has been archived on 2024-09-13. You can view files and clone it, but cannot push or open issues or pull requests.

23 Commits

Author SHA1 Message Date
f781fe0ff9 Implemented SourceList to observe changes to reflect in UI 2024-09-07 16:30:12 +02:00
8fec3171df Started working on ReactiveUI implementation 2024-09-07 00:04:55 +02:00
6373a47cf6 Oops 2024-09-06 23:39:29 +02:00
4806e62a00 Searching for a solution to sort and / or filter ListBox 2024-09-06 23:39:05 +02:00
791cb531e2 Working on event stuff 2024-09-06 22:51:22 +02:00
6d1959fb4c Lots of rewrites, optimization, logging stuff 2024-09-06 21:59:25 +02:00
8489d0c25c Added some direct listeners for logged in user 2024-09-05 17:39:26 +02:00
5786f65c26 Fixed pub sub events were spread over all channels 2024-09-05 16:55:02 +02:00
1ee2333ead Minor post-pr fixes due to previous api changes 2024-09-05 16:50:11 +02:00
35818c7915
Merge pull request #3 from jahnspohrer/dispatcher-service
idea dispatcher service
2024-09-05 16:47:17 +02:00
b2252557a6
Merge branch 'ui-rework' into dispatcher-service 2024-09-05 16:47:02 +02:00
7281792116 Merged TwitchPubSubService and TwitchDataService to TwitchService 2024-09-05 16:39:26 +02:00
Jahn Spohrer
6b835b1895 reworked start up 2024-09-05 16:09:05 +02:00
Jahn Spohrer
079c63188d added view model factory 2024-09-05 16:08:34 +02:00
Jahn Spohrer
3cda100d82 added dispatcher service 2024-09-05 16:08:21 +02:00
814cb58742 Lots of code cleanup, moved Twitch stuff to services, further improvements on UI 2024-09-04 02:27:55 +02:00
8a10573fbf A 'little' code cleanup 2024-09-04 00:10:18 +02:00
a8a481d4f9 Further work on ui-rework, also implemented services, re-implemented raiding functionality and PubSub 2024-09-03 20:30:29 +02:00
c2309599f2 Some code cleanup, fixes and errors for unsupported OS 2024-09-03 14:55:02 +02:00
4ac3a44cef Further work on ui-redesign and implemented dependency injection 2024-09-02 20:35:36 +02:00
b42d313bff Started working on UI rework 2024-09-02 19:08:26 +02:00
019649b179 Removed Build folder 2024-09-02 17:27:27 +02:00
2fcf63cf86 Updated .gitignore 2024-09-02 17:24:41 +02:00
34 changed files with 1456 additions and 1129 deletions

1
.gitignore vendored
View File

@ -20,6 +20,7 @@
mono_crash.*
# Build results
[Bb]iuld/
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/

View File

@ -1,146 +1,123 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using BetterRaid.Services;
using BetterRaid.Services.Implementations;
using BetterRaid.ViewModels;
using BetterRaid.Views;
using TwitchLib.Api;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace BetterRaid;
public partial class App : Application
public class App : Application
{
internal static TwitchAPI? TwitchApi = null;
internal static int AutoUpdateDelay = 10_000;
internal static bool HasUserZnSubbed = false;
internal static string BetterRaidDataPath = "";
internal static string TwitchBroadcasterId = "";
internal static string TwitchOAuthAccessToken = "";
internal static string TwitchOAuthAccessTokenFilePath = "";
internal static string TokenClientId = "kkxu4jorjrrc5jch1ito5i61hbev2o";
internal static readonly string TwitchOAuthRedirectUrl = "http://localhost:9900";
internal static readonly string TwitchOAuthResponseType = "token";
internal static readonly string[] TwitchOAuthScopes = [
"channel:manage:raids",
"user:read:subscriptions"
];
internal static readonly string TwitchOAuthUrl = $"https://id.twitch.tv/oauth2/authorize"
+ $"?client_id={TokenClientId}"
+ "&redirect_uri=http://localhost:9900"
+ $"&response_type={TwitchOAuthResponseType}"
+ $"&scope={string.Join("+", TwitchOAuthScopes)}";
private ServiceProvider? _serviceProvider;
private ILogger<App>? _logger;
public override void Initialize()
{
var userHomeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
_serviceProvider = InitializeServices();
_logger = _serviceProvider.GetRequiredService<ILogger<App>>();
switch (Environment.OSVersion.Platform)
if (TryLoadDatabase() == false)
{
case PlatformID.Win32NT:
BetterRaidDataPath = Path.Combine(userHomeDir, "AppData", "Roaming", "BetterRaid");
break;
case PlatformID.Unix:
BetterRaidDataPath = Path.Combine(userHomeDir, ".config", "BetterRaid");
break;
case PlatformID.MacOSX:
BetterRaidDataPath = Path.Combine(userHomeDir, "Library", "Application Support", "BetterRaid");
break;
_logger?.LogError("Failed to load or initialize database");
Environment.Exit(1);
}
if (!Directory.Exists(BetterRaidDataPath))
Directory.CreateDirectory(BetterRaidDataPath);
TwitchOAuthAccessTokenFilePath = Path.Combine(BetterRaidDataPath, ".access_token");
if (File.Exists(TwitchOAuthAccessTokenFilePath))
{
TwitchOAuthAccessToken = File.ReadAllText(TwitchOAuthAccessTokenFilePath);
InitTwitchClient();
AvaloniaXamlLoader.Load(_serviceProvider, this);
}
AvaloniaXamlLoader.Load(this);
private bool TryLoadDatabase()
{
if (_serviceProvider == null)
{
throw new FieldAccessException($"\"{nameof(_serviceProvider)}\" was null");
}
public static void InitTwitchClient(bool overrideToken = false)
var db = _serviceProvider.GetRequiredService<IDatabaseService>();
try
{
Console.WriteLine("[INFO] Initializing Twitch Client...");
db.LoadOrCreate();
Task.Run(db.UpdateLoadedChannels);
TwitchApi = new TwitchAPI();
TwitchApi.Settings.ClientId = TokenClientId;
TwitchApi.Settings.AccessToken = TwitchOAuthAccessToken;
Console.WriteLine("[INFO] Testing Twitch API connection...");
var user = TwitchApi.Helix.Users.GetUsersAsync().Result.Users.FirstOrDefault();
if (user == null)
{
TwitchApi = null;
Console.WriteLine("[ERROR] Failed to connect to Twitch API!");
return;
return true;
}
var channel = TwitchApi.Helix.Search
.SearchChannelsAsync(user.Login).Result.Channels
.FirstOrDefault(c => c.BroadcasterLogin == user.Login);
var userSubs = TwitchApi.Helix.Subscriptions.CheckUserSubscriptionAsync(
userId: user.Id,
broadcasterId: "1120558409"
).Result.Data;
if (userSubs.Length > 0 && userSubs.Any(s => s.BroadcasterId == "1120558409"))
catch (Exception e)
{
HasUserZnSubbed = true;
}
if (channel == null)
{
Console.WriteLine("[ERROR] User channel could not be found!");
return;
}
TwitchBroadcasterId = channel.Id;
System.Console.WriteLine(TwitchBroadcasterId);
Console.WriteLine("[INFO] Connected to Twitch API as '{0}'!", user.DisplayName);
if (overrideToken)
{
File.WriteAllText(TwitchOAuthAccessTokenFilePath, TwitchOAuthAccessToken);
switch (Environment.OSVersion.Platform)
{
case PlatformID.Win32NT:
File.SetAttributes(TwitchOAuthAccessTokenFilePath, File.GetAttributes(TwitchOAuthAccessTokenFilePath) | FileAttributes.Hidden);
break;
case PlatformID.Unix:
#pragma warning disable CA1416 // Validate platform compatibility
File.SetUnixFileMode(TwitchOAuthAccessTokenFilePath, UnixFileMode.UserRead);
#pragma warning restore CA1416 // Validate platform compatibility
break;
case PlatformID.MacOSX:
File.SetAttributes(TwitchOAuthAccessTokenFilePath, File.GetAttributes(TwitchOAuthAccessTokenFilePath) | FileAttributes.Hidden);
break;
_logger?.LogError(e, "Failed to load database");
return false;
}
}
private ServiceProvider InitializeServices()
{
var services = new ServiceCollection();
services.AddLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Debug);
logging.AddConsole();
});
services.AddSingleton<ITwitchService, TwitchService>();
services.AddSingleton<IWebToolsService, WebToolsService>();
services.AddSingleton<IDatabaseService, DatabaseService>();
services.AddSingleton<ISynchronizaionService, DispatcherService>(_ => new DispatcherService(Dispatcher.UIThread));
services.AddTransient<MainWindowViewModel>();
return services.BuildServiceProvider();
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Line below is needed to remove Avalonia data validation.
// Without this line you will get duplicate validations from both Avalonia and CT
BindingPlugins.DataValidators.RemoveAt(0);
desktop.MainWindow = new MainWindow
if(_serviceProvider == null)
{
DataContext = new MainWindowViewModel(),
throw new FieldAccessException($"\"{nameof(_serviceProvider)}\" was null");
}
var mainWindow = new MainWindow
{
DataContext = _serviceProvider.GetRequiredService<MainWindowViewModel>()
};
switch (ApplicationLifetime)
{
case IClassicDesktopStyleApplicationLifetime desktop:
desktop.MainWindow = mainWindow;
desktop.Exit += OnDesktopOnExit;
break;
case ISingleViewApplicationLifetime singleViewPlatform:
singleViewPlatform.MainView = mainWindow;
break;
}
base.OnFrameworkInitializationCompleted();
}
private void OnDesktopOnExit(object? o, ControlledApplicationLifetimeExitEventArgs controlledApplicationLifetimeExitEventArgs)
{
if (_serviceProvider == null)
{
throw new FieldAccessException($"\"{nameof(_serviceProvider)}\" was null");
}
try
{
var db = _serviceProvider.GetRequiredService<IDatabaseService>();
db.Save();
}
catch (Exception e)
{
_logger?.LogError(e, "Failed to save database");
}
}
}

View File

@ -14,19 +14,23 @@
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
<PackageReference Include="Avalonia" Version="11.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.1.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.1.0" />
<PackageReference Include="Avalonia" Version="11.1.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.1.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.1.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.1.3" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.1.3" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.1.0" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.1.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="TwitchLib" Version="3.5.3" />
</ItemGroup>
</Project>

View File

@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.002.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BetterRaid", "BetterRaid.csproj", "{77D4100D-424A-4E36-BFF2-14A40F217605}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BetterRaid", "BetterRaid.csproj", "{5E0DA55A-6B6B-4906-ACB9-401AB203D537}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -11,10 +11,10 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{77D4100D-424A-4E36-BFF2-14A40F217605}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77D4100D-424A-4E36-BFF2-14A40F217605}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77D4100D-424A-4E36-BFF2-14A40F217605}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77D4100D-424A-4E36-BFF2-14A40F217605}.Release|Any CPU.Build.0 = Release|Any CPU
{5E0DA55A-6B6B-4906-ACB9-401AB203D537}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E0DA55A-6B6B-4906-ACB9-401AB203D537}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E0DA55A-6B6B-4906-ACB9-401AB203D537}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E0DA55A-6B6B-4906-ACB9-401AB203D537}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

Binary file not shown.

Binary file not shown.

View File

@ -1,95 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ai="using:AsyncImageLoader"
xmlns:vm="using:BetterRaid.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="BetterRaid.RaidButton"
x:DataType="vm:RaidButtonViewModel"
Margin="5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Design.DataContext>
<vm:RaidButtonViewModel />
</Design.DataContext>
<Button HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Command="{Binding RaidChannel}">
<Grid HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="75*" />
<RowDefinition Height="25*" />
</Grid.RowDefinitions>
<ai:AdvancedImage Grid.Column="0"
Grid.Row="0"
Source="{Binding Channel.ThumbnailUrl}" />
<Border IsVisible="{Binding IsAd}"
BorderThickness="1"
BorderBrush="DarkGoldenrod"
CornerRadius="4"
Width="24"
Height="16"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="5">
<TextBlock Text="Ad"
Margin="2"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
Foreground="DarkGoldenrod" />
</Border>
<Button Width="32"
Height="32"
Background="DarkRed"
CornerRadius="16"
Padding="0"
BorderThickness="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="5"
IsVisible="{Binding !HideDeleteButton}"
Command="{Binding RemoveChannel}">
<Image Source="avares://BetterRaid/Assets/icons8-close-32.png"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
</Button>
<StackPanel Grid.Column="0"
Grid.Row="1"
Orientation="Vertical">
<Label HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Content="{Binding Channel.DisplayName, FallbackValue=...}" />
<Label HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Content="{Binding Channel.Category, TargetNullValue=-, FallbackValue=...}" />
<Label HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Foreground="{Binding ViewerCountColor}"
Content="{Binding Channel.ViewerCount, TargetNullValue=(Offline), FallbackValue=...}" />
<Label HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Content="{Binding LastRaided, TargetNullValue=Never Raided, FallbackValue=...}" />
</StackPanel>
</Grid>
</Button>
</UserControl>

View File

@ -1,13 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace BetterRaid;
public partial class RaidButton : UserControl
{
public RaidButton()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,29 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace BetterRaid.Converters;
public class ChannelOnlineColorConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isOnline)
{
return isOnline ? new SolidColorBrush(Colors.GreenYellow) : new SolidColorBrush(Colors.OrangeRed);
}
return new SolidColorBrush(Colors.OrangeRed);
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is SolidColorBrush brush)
{
return brush.Color == Colors.GreenYellow;
}
return false;
}
}

View File

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace BetterRaid.Extensions;
public static class DataContextExtensions
{
public static T? GetDataContextAs<T>(this T obj) where T : Window
{
return obj.DataContext as T;
}
}

View File

@ -0,0 +1,55 @@
using System.Reflection;
namespace BetterRaid.Extensions;
public static class MemberInfoExtensions
{
public static bool SetValue<T>(this MemberInfo member, object instance, T value)
{
var targetType = member switch
{
PropertyInfo p => p.PropertyType,
FieldInfo f => f.FieldType,
_ => null
};
if (targetType == null)
return false;
if (member is PropertyInfo property)
{
if (targetType == typeof(T) || targetType.IsAssignableFrom(typeof(T)))
{
property.SetValue(instance, value);
return true;
}
if (targetType == typeof(string))
{
property.SetValue(instance, value?.ToString());
return true;
}
return false;
}
if (member is FieldInfo field)
{
if (targetType == typeof(T) || targetType.IsAssignableFrom(typeof(T)))
{
field.SetValue(instance, value);
return true;
}
if (targetType == typeof(string))
{
field.SetValue(instance, value?.ToString());
return true;
}
return false;
}
return false;
}
}

30
Misc/Constants.cs Normal file
View File

@ -0,0 +1,30 @@
using System;
using System.IO;
namespace BetterRaid.Misc;
public static class Constants
{
// General
public const string ChannelPlaceholderImageUrl = "https://cdn.pixabay.com/photo/2018/11/13/22/01/avatar-3814081_1280.png";
// Paths
public static string BetterRaidDataPath => Environment.OSVersion.Platform switch
{
PlatformID.Win32NT => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "Roaming", "BetterRaid"),
PlatformID.Unix => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "BetterRaid"),
PlatformID.MacOSX => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "BetterRaid"),
_ => throw new PlatformNotSupportedException($"Your platform '{Environment.OSVersion.Platform}' is not supported. Please report this issue here: https://www.github.com/zion-networks/BetterRaid/issues")
};
public static string TwitchOAuthAccessTokenFilePath => Path.Combine(BetterRaidDataPath, ".access_token");
public static string DatabaseFilePath => Path.Combine(BetterRaidDataPath, "brdb.json");
// Twitch API
public const string TwitchClientId = "kkxu4jorjrrc5jch1ito5i61hbev2o";
public const string TwitchOAuthRedirectUrl = "http://localhost:9900";
public const string TwitchOAuthResponseType = "token";
public static readonly string[] TwitchOAuthScopes = [
"channel:manage:raids", // Allows the application to start and cancel raids on the broadcaster's channel
"user:read:subscriptions" // Allows the application to check, if the user has subscribed to the developer's channel
];
}

View File

@ -1,149 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using Newtonsoft.Json;
namespace BetterRaid.Models;
[JsonObject]
public class BetterRaidDatabase : INotifyPropertyChanged
{
[JsonIgnore]
private string? _databaseFilePath;
private bool _onlyOnline;
public event PropertyChangedEventHandler? PropertyChanged;
public bool OnlyOnline
{
get => _onlyOnline;
set
{
if (value == _onlyOnline)
return;
_onlyOnline = value;
OnPropertyChanged();
}
}
public List<string> Channels { get; set; } = [];
public Dictionary<string, DateTime?> LastRaided = [];
public bool AutoSave { get; set; }
public static BetterRaidDatabase LoadFromFile(string path)
{
ArgumentNullException.ThrowIfNullOrEmpty(path);
path = Path.Combine(Environment.CurrentDirectory, path);
if (File.Exists(path) == false)
{
throw new FileNotFoundException("Database file not found", path);
}
var dbStr = File.ReadAllText(path);
var dbObj = JsonConvert.DeserializeObject<BetterRaidDatabase>(dbStr);
if (dbObj == null)
{
throw new JsonException("Failed to read database file");
}
dbObj._databaseFilePath = path;
foreach (var channel in dbObj.Channels)
{
if (dbObj.LastRaided.ContainsKey(channel) == false)
{
dbObj.LastRaided.Add(channel, null);
}
}
Console.WriteLine("[DEBUG] Loaded database from {0}", path);
return dbObj;
}
public void Save(string? path = null)
{
if (string.IsNullOrEmpty(_databaseFilePath) && string.IsNullOrEmpty(path))
{
throw new ArgumentException("No target path given to save database at");
}
if (string.IsNullOrEmpty(path) == false && string.IsNullOrEmpty(_databaseFilePath))
{
_databaseFilePath = path;
}
var dbStr = JsonConvert.SerializeObject(this);
var targetPath = (path ?? _databaseFilePath)!;
File.WriteAllText(targetPath, dbStr);
Console.WriteLine("[DEBUG] Saved database to {0}", targetPath);
}
public void AddChannel(string channel)
{
ArgumentNullException.ThrowIfNull(channel);
if (Channels.Contains(channel))
return;
Channels.Add(channel);
OnPropertyChanged(nameof(Channels));
}
public void RemoveChannel(string channel)
{
ArgumentNullException.ThrowIfNull(channel);
if (Channels.Contains(channel) == false)
return;
Channels.Remove(channel);
OnPropertyChanged(nameof(Channels));
}
public void SetRaided(string channel, DateTime dateTime)
{
ArgumentNullException.ThrowIfNull(channel);
if (LastRaided.ContainsKey(channel))
{
LastRaided[channel] = dateTime;
}
else
{
LastRaided.Add(channel, dateTime);
}
OnPropertyChanged(nameof(LastRaided));
}
public DateTime? GetLastRaided(string channel)
{
ArgumentNullException.ThrowIfNull(channel);
if (LastRaided.ContainsKey(channel))
{
return LastRaided[channel];
}
else
{
return null;
}
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
if (AutoSave && _databaseFilePath != null)
{
Save();
}
}
}

View File

@ -0,0 +1,11 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BetterRaid.Models.Database;
[JsonObject]
public class BetterRaidDatabase
{
public bool OnlyOnline { get; set; }
public List<TwitchChannel> Channels { get; set; } = [];
}

View File

@ -1,24 +1,54 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using Avalonia.Threading;
using BetterRaid.Services;
using Newtonsoft.Json;
using TwitchLib.PubSub.Events;
namespace BetterRaid.Models;
[JsonObject]
public class TwitchChannel : INotifyPropertyChanged
{
private string? _id;
private string _name;
private string? _broadcasterId;
private string? _viewerCount;
private bool _isLive;
private string? _name;
private string? _displayName;
private string? _thumbnailUrl;
private string? _category;
private string? _title;
private DateTime? _lastRaided;
public string? Id
{
get => _id;
set
{
if (value == _id)
return;
_id = value;
OnPropertyChanged();
}
}
public string? BroadcasterId
{
get;
set;
get => _broadcasterId;
set
{
if (value == _broadcasterId)
return;
_broadcasterId = value;
OnPropertyChanged();
}
public string? Name
}
public string Name
{
get => _name;
set
@ -30,6 +60,8 @@ public class TwitchChannel : INotifyPropertyChanged
OnPropertyChanged();
}
}
[JsonIgnore]
public bool IsLive
{
get => _isLive;
@ -42,6 +74,8 @@ public class TwitchChannel : INotifyPropertyChanged
OnPropertyChanged();
}
}
[JsonIgnore]
public string? ViewerCount
{
get => _viewerCount;
@ -81,6 +115,7 @@ public class TwitchChannel : INotifyPropertyChanged
}
}
[JsonIgnore]
public string? Category
{
get => _category;
@ -94,9 +129,83 @@ public class TwitchChannel : INotifyPropertyChanged
}
}
public TwitchChannel(string channelName)
[JsonIgnore]
public string? Title
{
Name = channelName;
get => _title;
set
{
if (value == _title)
return;
_title = value;
OnPropertyChanged();
}
}
public DateTime? LastRaided
{
get => _lastRaided;
set
{
if (value == _lastRaided)
return;
_lastRaided = value;
OnPropertyChanged();
}
}
public TwitchChannel(string? channelName)
{
_name = channelName ?? string.Empty;
}
public void UpdateChannelData(ITwitchService service)
{
var channel = service.TwitchApi.Helix.Search.SearchChannelsAsync(Name).Result.Channels
.FirstOrDefault(c => c.BroadcasterLogin.Equals(Name, StringComparison.CurrentCultureIgnoreCase));
if (channel == null)
return;
var stream = service.TwitchApi.Helix.Streams.GetStreamsAsync(userLogins: [ Name ]).Result.Streams
.FirstOrDefault(s => s.UserLogin.Equals(Name, StringComparison.CurrentCultureIgnoreCase));
Id = channel.Id;
BroadcasterId = channel.Id;
DisplayName = channel.DisplayName;
ThumbnailUrl = channel.ThumbnailUrl;
Category = channel.GameName;
Title = channel.Title;
IsLive = channel.IsLive;
ViewerCount = stream?.ViewerCount == null
? null
: $"{stream.ViewerCount}";
}
public void OnStreamUp(object? sender, OnStreamUpArgs args)
{
if (args.ChannelId != BroadcasterId)
return;
IsLive = true;
}
public void OnStreamDown(object? sender, OnStreamDownArgs args)
{
if (args.ChannelId != BroadcasterId)
return;
IsLive = false;
}
public void OnViewCount(object? sender, OnViewCountArgs args)
{
if (args.ChannelId != BroadcasterId)
return;
ViewerCount = $"{args.Viewers}";
}
public event PropertyChangedEventHandler? PropertyChanged;

View File

@ -1,5 +1,6 @@
using Avalonia;
using System;
using Avalonia.ReactiveUI;
namespace BetterRaid;
@ -9,7 +10,8 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
public static void Main(string[] args) =>
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
@ -17,5 +19,6 @@ sealed class Program
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
.LogToTrace()
.UseReactiveUI();
}

View File

@ -1,2 +1 @@
# BetterRaid

162
Services/DatabaseService.cs Normal file
View File

@ -0,0 +1,162 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BetterRaid.Misc;
using BetterRaid.Models;
using BetterRaid.Models.Database;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace BetterRaid.Services;
public interface IDatabaseService
{
bool OnlyOnline { get; set; }
bool AutoSave { get; set; }
BetterRaidDatabase? Database { get; set; }
void LoadOrCreate();
void LoadFromFile(string path, bool createIfNotExist = false);
Task UpdateLoadedChannels();
void Save(string? path = null);
bool TrySetRaided(TwitchChannel channel, DateTime dateTime);
}
public class DatabaseService : IDatabaseService
{
private string? _databaseFilePath;
private readonly ILogger<DatabaseService> _logger;
private readonly ITwitchService _twitch;
public bool OnlyOnline { get; set; }
public bool AutoSave { get; set; }
public BetterRaidDatabase? Database { get; set; }
public DatabaseService(ILogger<DatabaseService> logger, ITwitchService twitch)
{
_logger = logger;
_twitch = twitch;
}
public void LoadOrCreate()
{
LoadFromFile(Constants.DatabaseFilePath, true);
}
public void LoadFromFile(string path, bool createIfNotExist = false)
{
ArgumentException.ThrowIfNullOrEmpty(path);
path = Path.Combine(Environment.CurrentDirectory, path);
var exists = File.Exists(path);
switch (exists)
{
case false when createIfNotExist == false:
throw new FileNotFoundException("Database file not found", path);
case false when createIfNotExist:
_logger.LogWarning("Database file not found, creating new database");
Database = new BetterRaidDatabase();
Save(path);
_logger.LogDebug("Created new database at {path}", path);
return;
case true:
var dbStr = File.ReadAllText(path);
var dbObj = JsonConvert.DeserializeObject<BetterRaidDatabase>(dbStr);
_databaseFilePath = path;
Database = dbObj ?? throw new JsonException("Failed to read database file");
_logger.LogDebug("Loaded database from {path}", path);
return;
}
}
public async Task UpdateLoadedChannels()
{
if (Database == null || Database.Channels.Count == 0)
return;
await Parallel.ForAsync(0, Database.Channels.Count, (i, c) =>
{
if (c.IsCancellationRequested)
return ValueTask.FromCanceled(c);
var channel = Database.Channels[i];
channel.UpdateChannelData(_twitch);
return ValueTask.CompletedTask;
});
}
public void Save(string? path = null)
{
if (string.IsNullOrEmpty(_databaseFilePath) && string.IsNullOrEmpty(path))
{
throw new ArgumentException("No target path given to save database at");
}
if (string.IsNullOrEmpty(path) == false && string.IsNullOrEmpty(_databaseFilePath))
{
_databaseFilePath = path;
}
var dbStr = JsonConvert.SerializeObject(Database, Formatting.Indented);
var targetPath = _databaseFilePath!;
File.WriteAllText(targetPath, dbStr);
_logger.LogDebug("Saved database to {targetPath}", targetPath);
}
public void AddChannel(TwitchChannel channel)
{
ArgumentNullException.ThrowIfNull(channel);
if (Database == null)
throw new InvalidOperationException("Database is not loaded");
if (Database.Channels.Any(c => c.Name?.Equals(c.Name, StringComparison.CurrentCultureIgnoreCase) == true))
return;
Database.Channels.Add(channel);
}
public void RemoveChannel(TwitchChannel channel)
{
ArgumentNullException.ThrowIfNull(channel);
if (Database == null)
throw new InvalidOperationException("Database is not loaded");
var index = Database.Channels.FindIndex(c => c.Name?.Equals(c.Name, StringComparison.CurrentCultureIgnoreCase) == true);
if (index == -1)
return;
Database.Channels.RemoveAt(index);
}
public bool TrySetRaided(TwitchChannel channel, DateTime dateTime)
{
ArgumentNullException.ThrowIfNull(channel);
if (Database == null)
throw new InvalidOperationException("Database is not loaded");
var twitchChannel = Database.Channels.FirstOrDefault(c => c.Name?.Equals(channel.Name, StringComparison.CurrentCultureIgnoreCase) == true);
if (twitchChannel == null)
return false;
twitchChannel.LastRaided = dateTime;
return true;
}
}

View File

@ -0,0 +1,8 @@
using BetterRaid.ViewModels;
namespace BetterRaid.Services;
public interface IMainViewModelFactory
{
MainWindowViewModel CreateMainWindowViewModel();
}

View File

@ -0,0 +1,8 @@
using System;
namespace BetterRaid.Services;
public interface ISynchronizaionService
{
void Invoke(Action action);
}

View File

@ -0,0 +1,17 @@
using Avalonia.Threading;
using System;
namespace BetterRaid.Services.Implementations;
public class DispatcherService : ISynchronizaionService
{
private readonly Dispatcher dispatcher;
public DispatcherService(Dispatcher dispatcher)
{
this.dispatcher = dispatcher;
}
public void Invoke(Action action)
{
dispatcher.Invoke(action);
}
}

View File

@ -0,0 +1,20 @@
using BetterRaid.ViewModels;
namespace BetterRaid.Services.Implementations;
public class MainWindowViewModelFactory// : IMainViewModelFactory
{
private readonly ITwitchService _twitchService;
private readonly ISynchronizaionService _synchronizaionService;
public MainWindowViewModelFactory(ITwitchService twitchService, ISynchronizaionService synchronizaionService)
{
_twitchService = twitchService;
_synchronizaionService = synchronizaionService;
}
//public MainWindowViewModel CreateMainWindowViewModel()
//{
// return new MainWindowViewModel(_twitchService, _synchronizaionService);
//}
}

453
Services/TwitchService.cs Normal file
View File

@ -0,0 +1,453 @@
using System;
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 Microsoft.Extensions.Logging;
using TwitchLib.Api;
using TwitchLib.Api.Helix.Models.Users.GetUsers;
using TwitchLib.PubSub;
using TwitchLib.PubSub.Events;
namespace BetterRaid.Services;
public interface ITwitchService
{
public string? AccessToken { get; }
public TwitchChannel? UserChannel { get; set; }
public TwitchAPI TwitchApi { get; }
public bool IsRaidStarted { get; set; }
public Task ConnectApiAsync(string clientId, string accessToken);
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();
public void OpenChannelCommand(object? arg);
public void RegisterForEvents(TwitchChannel channel);
public void UnregisterFromEvents(TwitchChannel channel);
public event EventHandler<EventArgs>? UserLoginChanged;
public event EventHandler<TwitchChannel>? TwitchChannelUpdated;
public event PropertyChangingEventHandler? PropertyChanging;
public event PropertyChangedEventHandler? PropertyChanged;
}
public sealed class TwitchService : ITwitchService, INotifyPropertyChanged, INotifyPropertyChanging
{
private bool _isRaidStarted;
private int _raidParticipants;
private TwitchChannel? _userChannel;
private User? _user;
private readonly ILogger<TwitchService> _logger;
private readonly IWebToolsService _webTools;
public string AccessToken { get; private set; } = string.Empty;
public bool IsRaidStarted
{
get => _isRaidStarted;
set => SetField(ref _isRaidStarted, value);
}
public User? User
{
get => _user;
set => SetField(ref _user, value);
}
public TwitchChannel? UserChannel
{
get => _userChannel;
set
{
if (_userChannel != null && _userChannel.Name?.Equals(value?.Name) == true)
return;
SetField(ref _userChannel, value);
_userChannel?.UpdateChannelData(this);
OnOnUserLoginChanged();
}
}
public TwitchAPI TwitchApi { get; }
public TwitchPubSub TwitchEvents { get; }
public int RaidParticipants
{
get => _raidParticipants;
set => SetField(ref _raidParticipants, value);
}
public event EventHandler<EventArgs>? UserLoginChanged;
public event EventHandler<TwitchChannel>? TwitchChannelUpdated;
public event EventHandler<OnStreamDownArgs> OnStreamDown
{
add => TwitchEvents.OnStreamDown += value;
remove => TwitchEvents.OnStreamDown -= value;
}
public event EventHandler<OnStreamUpArgs> OnStreamUp
{
add => TwitchEvents.OnStreamUp += value;
remove => TwitchEvents.OnStreamUp -= value;
}
public TwitchService(ILogger<TwitchService> logger, IWebToolsService webTools)
{
_logger = logger;
_webTools = webTools;
TwitchApi = new TwitchAPI();
TwitchEvents = new TwitchPubSub();
if (TryLoadAccessToken(out var token))
{
_logger.LogInformation("Found access token.");
Task.Run(() => ConnectApiAsync(Constants.TwitchClientId, token))
.ContinueWith(_ => ConnectTwitchEvents());
}
else
{
_logger.LogInformation("No access token found.");
}
}
private async Task ConnectTwitchEvents()
{
if (UserChannel == null || User == null)
return;
_logger.LogInformation("Connecting to Twitch Events ...");
TwitchEvents.OnRaidGo += OnUserRaidGo;
TwitchEvents.OnRaidUpdate += OnUserRaidUpdate;
TwitchEvents.OnStreamUp += OnUserStreamUp;
TwitchEvents.OnStreamDown += OnUserStreamDown;
TwitchEvents.OnViewCount += OnViewCount;
TwitchEvents.OnLog += OnPubSubLog;
TwitchEvents.OnPubSubServiceError += OnPubSubServiceError;
TwitchEvents.OnPubSubServiceConnected += OnPubSubServiceConnected;
TwitchEvents.OnPubSubServiceClosed += OnPubSubServiceClosed;
TwitchEvents.ListenToVideoPlayback(UserChannel.BroadcasterId);
TwitchEvents.ListenToRaid(UserChannel.BroadcasterId);
TwitchEvents.Connect();
await Task.CompletedTask;
}
public async Task ConnectApiAsync(string clientId, string accessToken)
{
_logger.LogInformation("Connecting to Twitch API ...");
AccessToken = accessToken;
TwitchApi.Settings.ClientId = clientId;
TwitchApi.Settings.AccessToken = accessToken;
if (TryGetUser(out var user))
{
User = user;
}
else
{
User = null;
_logger.LogError("Could not get user with client id {clientId} - please check your clientId and accessToken", clientId);
}
if (TryGetUserChannel(out var channel))
{
UserChannel = channel;
_logger.LogInformation("Connected to Twitch API as {channelName} with broadcaster id {channelBroadcasterId}.", channel?.Name, channel?.BroadcasterId);
}
else
{
UserChannel = null;
_logger.LogError("Could not get user channel.");
}
if (User == null || UserChannel == null)
{
_logger.LogError("Could not connect to Twitch API.");
}
await Task.CompletedTask;
}
private bool TryLoadAccessToken(out string token)
{
token = string.Empty;
if (!File.Exists(Constants.TwitchOAuthAccessTokenFilePath))
return false;
token = File.ReadAllText(Constants.TwitchOAuthAccessTokenFilePath);
return true;
}
public void SaveAccessToken(string token)
{
File.WriteAllText(Constants.TwitchOAuthAccessTokenFilePath, token);
}
public bool TryGetUser(out User? user)
{
user = null;
try
{
var userResult = TwitchApi.Helix.Users.GetUsersAsync().Result.Users[0];
if (userResult == null)
{
return false;
}
user = userResult;
return true;
}
catch (Exception e)
{
_logger.LogError(e, "Could not get user.");
return false;
}
}
public bool TryGetUserChannel(out TwitchChannel? channel)
{
channel = null;
if (User == null)
return false;
channel = new TwitchChannel(User.Login);
return true;
}
public void RegisterForEvents(TwitchChannel channel)
{
_logger.LogDebug("Registering for events for {channelName} with broadcaster id {channelBroadcasterId} ...", channel.Name, channel.BroadcasterId);
channel.PropertyChanged += OnTwitchChannelUpdated;
TwitchEvents.OnStreamUp += channel.OnStreamUp;
TwitchEvents.OnStreamDown += channel.OnStreamDown;
TwitchEvents.OnViewCount += channel.OnViewCount;
TwitchEvents.ListenToVideoPlayback(channel.Id);
TwitchEvents.SendTopics(AccessToken);
}
public void UnregisterFromEvents(TwitchChannel channel)
{
_logger.LogDebug("Unregistering from events for {channelName} with broadcaster id {channelBroadcasterId} ...", channel.Name, channel.BroadcasterId);
channel.PropertyChanged -= OnTwitchChannelUpdated;
TwitchEvents.OnStreamUp -= channel.OnStreamUp;
TwitchEvents.OnStreamDown -= channel.OnStreamDown;
TwitchEvents.OnViewCount -= channel.OnViewCount;
TwitchEvents.ListenToVideoPlayback(channel.Id);
TwitchEvents.SendTopics(AccessToken, true);
}
public string GetOAuthUrl()
{
var scopes = string.Join("+", Constants.TwitchOAuthScopes);
return $"https://id.twitch.tv/oauth2/authorize"
+ $"?client_id={Constants.TwitchClientId}"
+ $"&redirect_uri={Constants.TwitchOAuthRedirectUrl}"
+ $"&response_type={Constants.TwitchOAuthResponseType}"
+ $"&scope={scopes}";
}
public void StartRaid(string from, string to)
{
TwitchApi.Helix.Raids.StartRaidAsync(from, to);
IsRaidStarted = true;
}
public bool CanStartRaidCommand(object? arg)
{
return UserChannel?.IsLive == true && IsRaidStarted == false;
}
public void StartRaidCommand(object? arg)
{
if (arg == null || UserChannel?.BroadcasterId == null)
{
return;
}
var from = UserChannel.BroadcasterId!;
var to = arg.ToString()!;
StartRaid(from, to);
}
public void StopRaid()
{
if (UserChannel?.BroadcasterId == null)
return;
if (IsRaidStarted == false)
return;
TwitchApi.Helix.Raids.CancelRaidAsync(UserChannel.BroadcasterId);
IsRaidStarted = false;
}
public void StopRaidCommand()
{
StopRaid();
}
public void OpenChannelCommand(object? arg)
{
var channelName = arg?.ToString();
if (string.IsNullOrEmpty(channelName))
return;
var url = $"https://twitch.tv/{channelName}";
_webTools.OpenUrl(url);
}
private void OnPubSubServiceClosed(object? sender, EventArgs e)
{
_logger.LogWarning("PubSub: Connection closed.");
}
private void OnPubSubServiceError(object? sender, OnPubSubServiceErrorArgs e)
{
_logger.LogError(e.Exception, "PubSub: {exception}", e.Exception);
}
private void OnPubSubLog(object? sender, OnLogArgs e)
{
_logger.LogInformation("PubSub: {data}", e.Data);
}
private void OnPubSubServiceConnected(object? sender, EventArgs e)
{
TwitchEvents.SendTopics(AccessToken);
_logger.LogInformation("PubSub: Connected.");
}
// TODO Not called while raid is ongoing
private void OnUserRaidUpdate(object? sender, OnRaidUpdateArgs e)
{
//if (e.ChannelId != UserChannel?.BroadcasterId)
// return;
RaidParticipants = e.ViewerCount;
_logger.LogInformation("Raid participants: {participants}", RaidParticipants);
}
private void OnViewCount(object? sender, OnViewCountArgs e)
{
if (UserChannel == null)
return;
if (e.ChannelId != UserChannel.Id)
return;
UserChannel.OnViewCount(sender, e);
}
private void OnUserRaidGo(object? sender, OnRaidGoArgs e)
{
if (e.ChannelId != UserChannel?.Id)
return;
_logger.LogInformation("Raid started.");
IsRaidStarted = false;
}
private void OnUserStreamDown(object? sender, OnStreamDownArgs e)
{
if (UserChannel == null)
return;
if (e.ChannelId != UserChannel?.Id)
return;
_logger.LogInformation("Stream down.");
IsRaidStarted = false;
UserChannel.IsLive = false;
}
private void OnUserStreamUp(object? sender, OnStreamUpArgs e)
{
if (UserChannel == null)
return;
if (e.ChannelId != UserChannel?.Id)
return;
_logger.LogInformation("Stream up.");
IsRaidStarted = false;
UserChannel.IsLive = true;
}
private void OnTwitchChannelUpdated(object? sender, PropertyChangedEventArgs e)
{
if (sender is not TwitchChannel channel)
return;
if (e.PropertyName != nameof(TwitchChannel.IsLive))
return;
TwitchChannelUpdated?.Invoke(this, channel);
}
public event PropertyChangingEventHandler? PropertyChanging;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void OnPropertyChanging([CallerMemberName] string? propertyName = null)
{
PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName));
}
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
OnPropertyChanging(propertyName);
field = value;
OnPropertyChanged(propertyName);
return true;
}
private void OnOnUserLoginChanged()
{
UserLoginChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@ -6,28 +6,137 @@ using System.Text;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using BetterRaid.Misc;
using Microsoft.Extensions.Logging;
namespace BetterRaid.Misc;
namespace BetterRaid.Services;
public static class Tools
public interface IWebToolsService
{
private static HttpListener? _oauthListener;
private static Task? _oauthWaiterTask;
void StartOAuthLogin(ITwitchService twitch, Action? callback = null, CancellationToken token = default);
void OpenUrl(string url);
}
public class WebToolsService : IWebToolsService
{
private HttpListener? _oauthListener;
private readonly ILogger<WebToolsService> _logger;
public WebToolsService(ILogger<WebToolsService> logger)
{
_logger = logger;
}
// Source: https://stackoverflow.com/a/43232486
public static void StartOAuthLogin(string url, Action? callback = null, CancellationToken? token = null)
public void StartOAuthLogin(ITwitchService twitch, Action? callback = null, CancellationToken token = default)
{
if (_oauthListener == null)
{
if (_oauthListener != null)
return;
var _token = token ?? CancellationToken.None;
_oauthListener = new HttpListener();
_oauthListener.Prefixes.Add("http://localhost:9900/");
_oauthListener.Prefixes.Add(Constants.TwitchOAuthRedirectUrl + "/");
_oauthListener.Start();
_oauthWaiterTask = WaitForCallback(callback, _token);
Task.Run(() => WaitForCallback(twitch, callback, token), token);
}
OpenUrl(twitch.GetOAuthUrl());
}
private async Task WaitForCallback(ITwitchService twitch, Action? callback, CancellationToken token)
{
if (_oauthListener == null)
return;
if (token.IsCancellationRequested)
return;
_logger.LogDebug("Starting token listener");
while (!token.IsCancellationRequested)
{
var ctx = await _oauthListener.GetContextAsync();
var req = ctx.Request;
var res = ctx.Response;
if (req.Url == null)
continue;
_logger.LogDebug("{method} {url}", req.HttpMethod, req.Url);
// Response, that may contain the access token as fragment
// It must be extracted client-side in browser
if (req.Url.LocalPath == "/")
{
var buf = new byte[1024];
var data = new StringBuilder();
int bytesRead;
while ((bytesRead = await req.InputStream.ReadAsync(buf, token)) > 0)
{
data.Append(Encoding.UTF8.GetString(buf, 0, bytesRead));
}
req.InputStream.Close();
_logger.LogTrace("{data}", data);
res.StatusCode = 200;
await res.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(OAUTH_CLIENT_DOCUMENT).AsMemory(0, OAUTH_CLIENT_DOCUMENT.Length), token);
res.Close();
}
if (req.Url.LocalPath == "/login")
{
var buf = new byte[1024];
var data = new StringBuilder();
int bytesRead;
while ((bytesRead = await req.InputStream.ReadAsync(buf, token)) > 0)
{
data.Append(Encoding.UTF8.GetString(buf, 0, bytesRead));
}
req.InputStream.Close();
var json = data.ToString();
var jsonData = JsonNode.Parse(json);
if (jsonData == null)
{
_logger.LogError("Failed to parse JSON data:");
_logger.LogError("{json}", json);
res.StatusCode = 400;
res.Close();
continue;
}
if (jsonData["access_token"] == null)
{
_logger.LogError("Missing access_token in JSON data.");
res.StatusCode = 400;
res.Close();
continue;
}
var accessToken = jsonData["access_token"]?.ToString()!;
await twitch.ConnectApiAsync(Constants.TwitchClientId, accessToken);
res.StatusCode = 200;
res.Close();
_logger.LogInformation("Received access token!");
callback?.Invoke();
_oauthListener.Stop();
return;
}
}
}
public void OpenUrl(string url)
{
try
{
Process.Start(url);
@ -55,98 +164,6 @@ public static class Tools
}
}
private static async Task WaitForCallback(Action? callback, CancellationToken token)
{
if (_oauthListener == null)
return;
if (token.IsCancellationRequested)
return;
Console.WriteLine("Starting token listener");
while (!token.IsCancellationRequested)
{
var ctx = await _oauthListener.GetContextAsync();
var req = ctx.Request;
var res = ctx.Response;
if (req.Url == null)
continue;
Console.WriteLine("{0} {1}", req.HttpMethod, req.Url);
// Response, that may contain the access token as fragment
// It must be extracted client-side in browser
if (req.Url.LocalPath == "/")
{
var buf = new byte[1024];
var data = new StringBuilder();
int bytesRead;
while ((bytesRead = await req.InputStream.ReadAsync(buf, token)) > 0)
{
data.Append(Encoding.UTF8.GetString(buf, 0, bytesRead));
}
req.InputStream.Close();
Console.WriteLine(data.ToString());
res.StatusCode = 200;
await res.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(OAUTH_CLIENT_DOCUMENT).AsMemory(0, OAUTH_CLIENT_DOCUMENT.Length), token);
res.Close();
}
if (req.Url.LocalPath == "/login")
{
var buf = new byte[1024];
var data = new StringBuilder();
int bytesRead;
while ((bytesRead = await req.InputStream.ReadAsync(buf, token)) > 0)
{
data.Append(Encoding.UTF8.GetString(buf, 0, bytesRead));
}
req.InputStream.Close();
var json = data.ToString();
var jsonData = JsonObject.Parse(json);
if (jsonData == null)
{
Console.WriteLine("[ERROR] Failed to parse JSON data:");
Console.WriteLine(json);
res.StatusCode = 400;
res.Close();
continue;
}
if (jsonData["access_token"] == null)
{
Console.WriteLine("[ERROR] Missing access_token in JSON data.");
res.StatusCode = 400;
res.Close();
continue;
}
var accessToken = jsonData["access_token"]?.ToString();
App.TwitchOAuthAccessToken = accessToken!;
res.StatusCode = 200;
res.Close();
Console.WriteLine("[INFO] Received access token!");
callback?.Invoke();
_oauthListener.Stop();
return;
}
}
}
private const string OAUTH_CLIENT_DOCUMENT =
@"
<!DOCTYPE html>

View File

@ -1,6 +0,0 @@
namespace BetterRaid.ViewModels;
public class AddChannelWindowViewModel : ViewModelBase
{
}

View File

@ -1,32 +1,103 @@
using System;
using System.Collections.ObjectModel;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using BetterRaid.Extensions;
using BetterRaid.Misc;
using BetterRaid.Models;
using BetterRaid.Services;
using BetterRaid.Views;
using DynamicData;
using DynamicData.Binding;
using Microsoft.Extensions.Logging;
using ReactiveUI;
namespace BetterRaid.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
public class MainWindowViewModel : ViewModelBase
{
private string? _filter;
private readonly SourceList<TwitchChannel> _sourceList;
private BetterRaidDatabase? _db;
private readonly ISynchronizaionService _synchronizationService;
private readonly ILogger<MainWindowViewModel> _logger;
private readonly IWebToolsService _webTools;
private readonly IDatabaseService _db;
private readonly ITwitchService _twitch;
public BetterRaidDatabase? Database
{
get => _db;
set => SetProperty(ref _db, value);
}
private string _filter;
private bool _onlyOnline;
private readonly ReadOnlyObservableCollection<TwitchChannel> _filteredChannels;
public string? Filter
public ITwitchService Twitch => _twitch;
public ReadOnlyObservableCollection<TwitchChannel> FilteredChannels => _filteredChannels;
public string Filter
{
get => _filter;
set => SetProperty(ref _filter, value);
set
{
this.RaiseAndSetIfChanged(ref _filter, value);
_sourceList.Edit(innerList =>
{
if (_db.Database == null)
return;
innerList.Clear();
innerList.AddRange(_db.Database.Channels);
});
}
}
public bool IsLoggedIn => App.TwitchApi != null;
public bool OnlyOnline
{
get => _onlyOnline;
set
{
this.RaiseAndSetIfChanged(ref _onlyOnline, value);
_sourceList.Edit(innerList =>
{
if (_db.Database == null)
return;
innerList.Clear();
innerList.AddRange(_db.Database.Channels);
});
}
}
public bool IsLoggedIn => _twitch.UserChannel != null;
public MainWindowViewModel(
ILogger<MainWindowViewModel> logger,
ITwitchService twitch,
IWebToolsService webTools,
IDatabaseService db,
ISynchronizaionService synchronizationService)
{
_logger = logger;
_twitch = twitch;
_webTools = webTools;
_db = db;
_synchronizationService = synchronizationService;
_filter = string.Empty;
_twitch.UserLoginChanged += OnUserLoginChanged;
_sourceList = new SourceList<TwitchChannel>();
_sourceList.Connect()
.Filter(channel => channel.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase))
.Filter(channel => !OnlyOnline || channel.IsLive)
.Sort(SortExpressionComparer<TwitchChannel>.Descending(channel => channel.IsLive))
.ObserveOn(RxApp.MainThreadScheduler)
.Bind(out _filteredChannels)
.Subscribe();
LoadChannelsFromDb();
}
public void ExitApplication()
{
@ -43,13 +114,36 @@ public partial class MainWindowViewModel : ViewModelBase
public void LoginWithTwitch()
{
Tools.StartOAuthLogin(App.TwitchOAuthUrl, OnTwitchLoginCallback, CancellationToken.None);
_webTools.StartOAuthLogin(_twitch, OnTwitchLoginCallback, CancellationToken.None);
}
public void OnTwitchLoginCallback()
private void OnTwitchLoginCallback()
{
App.InitTwitchClient(overrideToken: true);
this.RaisePropertyChanged(nameof(IsLoggedIn));
}
OnPropertyChanged(nameof(IsLoggedIn));
private void LoadChannelsFromDb()
{
if (_db.Database == null)
{
_logger.LogError("Database is null");
return;
}
foreach (var channel in _db.Database.Channels)
{
Task.Run(() =>
{
channel.UpdateChannelData(_twitch);
_twitch.RegisterForEvents(channel);
});
}
_sourceList.Edit(innerList => innerList.AddRange(_db.Database.Channels));
}
private void OnUserLoginChanged(object? sender, EventArgs e)
{
this.RaisePropertyChanged(nameof(IsLoggedIn));
}
}

View File

@ -1,195 +0,0 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media;
using Avalonia.Threading;
using BetterRaid.Events;
using BetterRaid.Models;
using TwitchLib.Api.Helix.Models.Raids.StartRaid;
using TwitchLib.Api.Helix.Models.Search;
using TwitchLib.Api.Helix.Models.Streams.GetStreams;
namespace BetterRaid.ViewModels;
public class RaidButtonViewModel : ViewModelBase
{
private TwitchChannel? _channel;
private SolidColorBrush _viewerCountColor = new SolidColorBrush(Color.FromRgb(byte.MaxValue, byte.MaxValue, byte.MaxValue));
private bool _hideDeleteButton;
private bool _isAd;
public string ChannelName
{
get;
set;
}
public bool HideDeleteButton
{
get => _hideDeleteButton;
set => SetProperty(ref _hideDeleteButton, value);
}
public bool IsAd
{
get => _isAd;
set => SetProperty(ref _isAd, value);
}
public TwitchChannel? Channel => _channel ?? new TwitchChannel(ChannelName);
public SolidColorBrush ViewerCountColor
{
get => _viewerCountColor;
set => SetProperty(ref _viewerCountColor, value);
}
public MainWindowViewModel? MainVm { get; set; }
public DateTime? LastRaided => MainVm?.Database?.GetLastRaided(ChannelName);
public event EventHandler<ChannelDataChangedEventArgs>? ChannelDataChanged;
public RaidButtonViewModel(string channelName)
{
ChannelName = channelName;
}
public async Task<bool> GetOrUpdateChannelAsync()
{
Console.WriteLine("[DEBUG] Updating channel '{0}' ...", ChannelName);
var currentChannelData = await GetChannelAsync(ChannelName);
if (currentChannelData == null)
return false;
var currentStreamData = await GetStreamAsync(currentChannelData);
var swapChannel = new TwitchChannel(ChannelName)
{
BroadcasterId = currentChannelData.Id,
Name = ChannelName,
DisplayName = currentChannelData.DisplayName,
IsLive = currentChannelData.IsLive,
ThumbnailUrl = currentChannelData.ThumbnailUrl,
ViewerCount = currentStreamData?.ViewerCount == null
? "(Offline)"
: $"{currentStreamData?.ViewerCount} Viewers",
Category = currentStreamData?.GameName
};
if (_channel != null)
{
_channel.PropertyChanged -= OnChannelDataChanged;
}
Dispatcher.UIThread.Invoke(() => {
ViewerCountColor = new SolidColorBrush(Color.FromRgb(
r: swapChannel.IsLive ? (byte) 0 : byte.MaxValue,
g: swapChannel.IsLive ? byte.MaxValue : (byte) 0,
b: 0)
);
_channel = swapChannel;
OnPropertyChanged(nameof(Channel));
});
if (_channel != null)
{
_channel.PropertyChanged += OnChannelDataChanged;
}
Console.WriteLine("[DEBUG] DONE Updating channel '{0}'", ChannelName);
return true;
}
private async Task<Channel?> GetChannelAsync(string channelName)
{
if (App.TwitchApi == null)
return null;
if (string.IsNullOrEmpty(channelName))
return null;
var channels = await App.TwitchApi.Helix.Search.SearchChannelsAsync(channelName);
var exactChannel = channels.Channels.FirstOrDefault(c => c.BroadcasterLogin.Equals(channelName, StringComparison.CurrentCultureIgnoreCase));
return exactChannel;
}
private async Task<Stream?> GetStreamAsync(Channel currentChannelData)
{
if (App.TwitchApi == null)
return null;
if (currentChannelData == null)
return null;
var streams = await App.TwitchApi.Helix.Streams.GetStreamsAsync(userLogins: [currentChannelData.BroadcasterLogin]);
var exactStream = streams.Streams.FirstOrDefault(s => s.UserLogin == currentChannelData.BroadcasterLogin);
return exactStream;
}
public async Task RaidChannel()
{
if (App.TwitchApi == null)
return;
if (Channel == null)
return;
if (string.IsNullOrWhiteSpace(App.TwitchBroadcasterId))
return;
if (App.TwitchBroadcasterId == Channel.BroadcasterId)
return;
try
{
await App.TwitchApi.Helix.Raids.StartRaidAsync(App.TwitchBroadcasterId, Channel.BroadcasterId);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
Console.WriteLine(e.StackTrace);
return;
}
if (MainVm?.Database != null)
{
MainVm.Database.SetRaided(ChannelName, DateTime.Now);
}
}
public void RemoveChannel()
{
if (MainVm?.Database == null)
return;
MainVm.Database.RemoveChannel(ChannelName);
}
private void OnChannelDataChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case "IsLive":
OnChannelDataChanged(ChannelDataChangedEventArgs.FromIsLive(false, true));
break;
case "ViewerCount":
OnChannelDataChanged(ChannelDataChangedEventArgs.FromViewerCount(0, 10));
break;
}
}
private void OnChannelDataChanged(ChannelDataChangedEventArgs args)
{
ChannelDataChanged?.Invoke(this, args);
}
}

View File

@ -1,7 +1,9 @@
using CommunityToolkit.Mvvm.ComponentModel;
using ReactiveUI;
namespace BetterRaid.ViewModels;
public class ViewModelBase : ObservableObject
public class ViewModelBase : ReactiveObject
{
}

View File

@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:BetterRaid.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="BetterRaid.Views.AboutWindow"
Title="About"

View File

@ -1,6 +1,4 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace BetterRaid.Views;

View File

@ -1,38 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:BetterRaid.ViewModels"
mc:Ignorable="d" d:DesignWidth="100" d:DesignHeight="50"
x:Class="BetterRaid.Views.AddChannelWindow"
x:DataType="vm:AddChannelWindowViewModel"
Icon="/Assets/logo.png"
Width="200"
Height="80"
MaxWidth="200"
MaxHeight="80"
Title="Add Channel">
<Design.DataContext>
<vm:AddChannelWindowViewModel/>
</Design.DataContext>
<StackPanel Orientation="Vertical"
Margin="5"
Spacing="5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<TextBox HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
x:Name="channelNameTxt"
Watermark="Enter Channelname" />
<Button HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="OK"
x:Name="okBtn"
IsEnabled="True" />
</StackPanel>
</Window>

View File

@ -1,13 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace BetterRaid.Views;
public partial class AddChannelWindow : Window
{
public AddChannelWindow()
{
InitializeComponent();
}
}

View File

@ -1,9 +1,14 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:BetterRaid.ViewModels"
xmlns:con="using:BetterRaid.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="450"
xmlns:ai="using:AsyncImageLoader"
xmlns:misc="using:BetterRaid.Misc"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignHeight="800"
Width="600"
Height="800"
x:Class="BetterRaid.Views.MainWindow"
@ -12,43 +17,66 @@
Title="BetterRaid"
Background="DarkSlateGray">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Window.Resources>
<con:ChannelOnlineColorConverter x:Key="ChannelOnlineColorConverter" />
</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 Twitch.UserChannel.ThumbnailUrl,
FallbackValue={x:Static misc:Constants.ChannelPlaceholderImageUrl},
TargetNullValue={x:Static misc:Constants.ChannelPlaceholderImageUrl}}" />
<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>
<TextBlock VerticalAlignment="Center"
Margin="5, 0, 0, 0"
FontWeight="Bold">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} ({1})">
<Binding Path="Twitch.UserChannel.DisplayName"
FallbackValue="-" />
<Binding Path="Twitch.UserChannel.ViewerCount"
FallbackValue="Offline"
TargetNullValue="Offline" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<StackPanel Grid.Column="1"
Grid.Row="0"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="5">
<Button Command="{Binding LoginWithTwitch}"
<CheckBox Content="Only Online"
IsChecked="{Binding OnlyOnline, Mode=TwoWay, FallbackValue=False}" />
<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>
@ -56,45 +84,24 @@
<Setter Property="Background" Value="#6441a5" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#b9a3e3" />
<Setter Property="Background" Value="#6C4CA5" />
</Style>
</Button.Styles>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Login"
<TextBlock Text="Login with Twitch"
Foreground="#f1f1f1"
FontSize="14" />
FontSize="18"
VerticalAlignment="Center" />
<Image Source="avares://BetterRaid/Assets/glitch_flat_white.png"
Width="16"
Height="16"
Width="24"
Height="24"
VerticalAlignment="Center"
Margin="5, 0, 0, 0" />
</StackPanel>
</Button>
<CheckBox Content="Only Online"
IsChecked="{Binding Database.OnlyOnline, Mode=TwoWay}" />
<TextBox Grid.Column="1"
Grid.Row="0"
Width="200"
Margin="2"
Watermark="Filter Channels"
Text="{Binding Filter, Mode=TwoWay}"
HorizontalAlignment="Right" />
</StackPanel>
<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" />
<ScrollViewer Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="1"
@ -108,17 +115,133 @@
IsScrollInertiaEnabled="True" />
</ScrollViewer.GestureRecognizers>
<Grid HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
x:Name="raidGrid">
<ListBox ItemsSource="{Binding FilteredChannels, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
SelectionMode="Single"
SelectionChanged="SelectingItemsControl_OnSelectionChanged">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<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}}" />
<Border Grid.Column="0"
Grid.Row="0"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Height="25"
MinWidth="25"
CornerRadius="12.5"
Background="{Binding IsLive, Converter={StaticResource ChannelOnlineColorConverter}}"
Padding="0"
Margin="0, 0, 5, 5">
<TextBlock Text="{Binding ViewerCount, TargetNullValue='-', Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
FontSize="12"
TextAlignment="Center"
FontWeight="SemiBold"
Padding="0"
Margin="5"
Foreground="Black"/>
</Border>
<Grid Grid.Column="1"
Grid.Row="0"
Margin="10, 0, 0, 0"
ColumnDefinitions="100, *"
RowDefinitions="20, 20, 40, 20">
<TextBlock Grid.Column="0"
Grid.Row="0"
Grid.ColumnSpan="2"
FontWeight="Bold"
TextDecorations="Underline"
Text="{Binding Name, TargetNullValue='???'}" />
<TextBlock Grid.Column="0"
Grid.Row="1"
Text="Category:"
FontWeight="SemiBold" />
<TextBlock Grid.Column="0"
Grid.Row="2"
Text="Title:"
FontWeight="SemiBold" />
<TextBlock Grid.Column="0"
Grid.Row="3"
Text="Last Raided:"
FontWeight="SemiBold" />
<TextBlock Grid.Column="1"
Grid.Row="1"
Text="{Binding Category, TargetNullValue='-'}" />
<TextBlock Grid.Column="1"
Grid.Row="2"
TextWrapping="Wrap"
Text="{Binding Title, TargetNullValue='-'}" />
<TextBlock Grid.Column="1"
Grid.Row="3"
Text="{Binding LastRaided, TargetNullValue='Never Raided'}" />
</Grid>
<StackPanel Grid.Column="2"
Grid.Row="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Button Content="Start Raid"
Height="50"
Margin="0"
CornerRadius="0"
Background="ForestGreen"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
IsEnabled="{Binding IsLive}"
IsVisible="{Binding !$parent[Window].((vm:MainWindowViewModel)DataContext).Twitch.IsRaidStarted}"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).Twitch.StartRaidCommand}"
CommandParameter="{Binding BroadcasterId}" />
<Button Content="Cancel Raid"
Height="50"
Margin="0"
CornerRadius="0"
Background="DarkRed"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
IsVisible="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).Twitch.IsRaidStarted}"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).Twitch.StopRaidCommand}" />
<Button Content="View Channel"
Height="50"
CornerRadius="0"
Margin="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).Twitch.OpenChannelCommand}"
CommandParameter="{Binding Name}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Grid>

View File

@ -1,293 +1,19 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using BetterRaid.Extensions;
using BetterRaid.Models;
using BetterRaid.ViewModels;
namespace BetterRaid.Views;
public partial class MainWindow : Window
{
private ObservableCollection<RaidButtonViewModel> _raidButtonVMs;
private RaidButtonViewModel? _znButtonVm;
private BackgroundWorker _autoUpdater;
public MainWindow()
{
_raidButtonVMs = [];
_znButtonVm = null;
_autoUpdater = new();
DataContextChanged += OnDataContextChanged;
InitializeComponent();
_autoUpdater.WorkerSupportsCancellation = true;
_autoUpdater.DoWork += UpdateAllTiles;
}
private void OnDatabaseChanged(object? sender, PropertyChangedEventArgs e)
private void SelectingItemsControl_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
// TODO: Only if new channel was added or existing were removed
// InitializeRaidChannels();
GenerateRaidGrid();
}
private void OnDataContextChanged(object? sender, EventArgs e)
if (sender is ListBox listBox)
{
if (DataContext is MainWindowViewModel vm)
{
var dbPath = Path.Combine(App.BetterRaidDataPath, "db.json");
try
{
vm.Database = BetterRaidDatabase.LoadFromFile(dbPath);
}
catch (FileNotFoundException)
{
var db = new BetterRaidDatabase();
db.Save(dbPath);
vm.Database = db;
}
vm.Database.AutoSave = true;
vm.Database.PropertyChanged += OnDatabaseChanged;
vm.PropertyChanged += OnViewModelChanged;
InitializeRaidChannels();
GenerateRaidGrid();
}
}
private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainWindowViewModel.Filter))
{
GenerateRaidGrid();
}
if (e.PropertyName == nameof(MainWindowViewModel.IsLoggedIn) && DataContext is MainWindowViewModel { IsLoggedIn: true })
{
InitializeRaidChannels();
GenerateRaidGrid();
}
}
private void InitializeRaidChannels()
{
if (DataContext is MainWindowViewModel { IsLoggedIn: false })
return;
if (_autoUpdater?.IsBusy == false)
{
_autoUpdater?.CancelAsync();
}
_raidButtonVMs.Clear();
var vm = DataContext as MainWindowViewModel;
if (vm?.Database == null)
return;
foreach (var channel in vm.Database.Channels)
{
if (string.IsNullOrEmpty(channel))
continue;
var rbvm = new RaidButtonViewModel(channel)
{
MainVm = vm
};
_raidButtonVMs.Add(rbvm);
}
if (App.HasUserZnSubbed)
{
_znButtonVm = null;
}
else
{
_znButtonVm = new RaidButtonViewModel("zionnetworks")
{
MainVm = vm,
HideDeleteButton = true,
IsAd = true
};
}
if (_autoUpdater?.IsBusy == false)
{
_autoUpdater?.RunWorkerAsync();
}
}
private void GenerateRaidGrid()
{
if (DataContext is MainWindowViewModel { IsLoggedIn: false })
return;
foreach (var child in raidGrid.Children)
{
if (child is Button btn)
{
btn.Click -= OnAddChannelButtonClicked;
}
}
raidGrid.Children.Clear();
var vm = DataContext as MainWindowViewModel;
if (vm?.Database == null)
{
return;
}
var visibleChannels = _raidButtonVMs.Where(channel =>
{
var visible = true;
if (string.IsNullOrWhiteSpace(vm.Filter) == false)
{
if (channel.ChannelName.Contains(vm.Filter, StringComparison.OrdinalIgnoreCase) == false)
{
visible = false;
}
}
if (vm.Database.OnlyOnline && channel.Channel?.IsLive == false)
{
visible = false;
}
return visible;
}).OrderByDescending(c => c.Channel?.IsLive).ToList();
var rows = (int)Math.Ceiling((visibleChannels.Count + (App.HasUserZnSubbed ? 1 : 2)) / 3.0);
for (var i = 0; i < rows; i++)
{
raidGrid.RowDefinitions.Add(new RowDefinition(GridLength.Parse("Auto")));
}
var colIndex = App.HasUserZnSubbed ? 0 : 1;
var rowIndex = 0;
foreach (var channel in visibleChannels)
{
var btn = new RaidButton
{
DataContext = channel
};
Grid.SetColumn(btn, colIndex);
Grid.SetRow(btn, rowIndex);
raidGrid.Children.Add(btn);
colIndex++;
if (colIndex % 3 == 0)
{
colIndex = 0;
rowIndex++;
}
}
var addButton = new Button
{
Content = "+",
FontSize = 72,
Margin = new Avalonia.Thickness(5),
MinHeight = 250,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Stretch,
HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center
};
addButton.Click += OnAddChannelButtonClicked;
Grid.SetColumn(addButton, colIndex);
Grid.SetRow(addButton, rowIndex);
raidGrid.Children.Add(addButton);
if (App.HasUserZnSubbed == false)
{
var znButton = new RaidButton
{
DataContext = _znButtonVm
};
Grid.SetColumn(znButton, 0);
Grid.SetRow(znButton, 0);
raidGrid.Children.Add(znButton);
}
}
private void OnAddChannelButtonClicked(object? sender, RoutedEventArgs e)
{
var dialog = new AddChannelWindow();
dialog.CenterToOwner();
var vm = DataContext as MainWindowViewModel;
if (vm?.Database == null)
return;
// TODO Button Command not working, Button remains disabled
// This is a dirty workaround
dialog.okBtn.Click += (sender, args) => {
if (string.IsNullOrWhiteSpace(dialog?.channelNameTxt.Text) == false)
{
vm.Database.AddChannel(dialog.channelNameTxt.Text);
vm.Database.Save();
}
dialog?.Close();
InitializeRaidChannels();
GenerateRaidGrid();
};
dialog.ShowDialog(this);
}
public void UpdateChannelData()
{
var loggedIn = Dispatcher.UIThread.Invoke(() => {
return (DataContext as MainWindowViewModel)?.IsLoggedIn ?? false;
});
if (loggedIn == false)
return;
foreach (var vm in _raidButtonVMs)
{
Task.Run(vm.GetOrUpdateChannelAsync);
}
if (_znButtonVm != null)
{
Task.Run(_znButtonVm.GetOrUpdateChannelAsync);
}
}
private void UpdateAllTiles(object? sender, DoWorkEventArgs e)
{
while (e.Cancel == false)
{
UpdateChannelData();
Task.Delay(App.AutoUpdateDelay).Wait();
listBox.SelectedItems?.Clear();
}
}
}