Compare commits

..

No commits in common. "reactive-ui" and "main" have entirely different histories.

34 changed files with 1129 additions and 1456 deletions

1
.gitignore vendored
View File

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

View File

@ -1,123 +1,146 @@
using System;
using System.Threading.Tasks;
using System.IO;
using System.Linq;
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 Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TwitchLib.Api;
namespace BetterRaid;
public class App : Application
public partial class App : Application
{
private ServiceProvider? _serviceProvider;
private ILogger<App>? _logger;
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)}";
public override void Initialize()
{
_serviceProvider = InitializeServices();
_logger = _serviceProvider.GetRequiredService<ILogger<App>>();
var userHomeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (TryLoadDatabase() == false)
switch (Environment.OSVersion.Platform)
{
_logger?.LogError("Failed to load or initialize database");
Environment.Exit(1);
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;
}
AvaloniaXamlLoader.Load(_serviceProvider, this);
if (!Directory.Exists(BetterRaidDataPath))
Directory.CreateDirectory(BetterRaidDataPath);
TwitchOAuthAccessTokenFilePath = Path.Combine(BetterRaidDataPath, ".access_token");
if (File.Exists(TwitchOAuthAccessTokenFilePath))
{
TwitchOAuthAccessToken = File.ReadAllText(TwitchOAuthAccessTokenFilePath);
InitTwitchClient();
}
AvaloniaXamlLoader.Load(this);
}
private bool TryLoadDatabase()
public static void InitTwitchClient(bool overrideToken = false)
{
if (_serviceProvider == null)
Console.WriteLine("[INFO] Initializing Twitch Client...");
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)
{
throw new FieldAccessException($"\"{nameof(_serviceProvider)}\" was null");
TwitchApi = null;
Console.WriteLine("[ERROR] Failed to connect to Twitch API!");
return;
}
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"))
{
HasUserZnSubbed = true;
}
var db = _serviceProvider.GetRequiredService<IDatabaseService>();
try
if (channel == null)
{
db.LoadOrCreate();
Task.Run(db.UpdateLoadedChannels);
return true;
Console.WriteLine("[ERROR] User channel could not be found!");
return;
}
catch (Exception e)
TwitchBroadcasterId = channel.Id;
System.Console.WriteLine(TwitchBroadcasterId);
Console.WriteLine("[INFO] Connected to Twitch API as '{0}'!", user.DisplayName);
if (overrideToken)
{
_logger?.LogError(e, "Failed to load database");
return false;
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;
}
}
}
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()
{
BindingPlugins.DataValidators.RemoveAt(0);
if(_serviceProvider == null)
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
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;
// 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
{
DataContext = new MainWindowViewModel(),
};
}
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,23 +14,19 @@
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.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" />
<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" />
<!--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.3" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.1.0" />
<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", "{5E0DA55A-6B6B-4906-ACB9-401AB203D537}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BetterRaid", "BetterRaid.csproj", "{77D4100D-424A-4E36-BFF2-14A40F217605}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -11,10 +11,10 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{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
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

BIN
Build/BetterRaid-0.0.1-alpha.exe Executable file

Binary file not shown.

Binary file not shown.

95
Controls/RaidButton.axaml Normal file
View File

@ -0,0 +1,95 @@
<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

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

View File

@ -1,29 +0,0 @@
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

@ -0,0 +1,11 @@
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

@ -1,55 +0,0 @@
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;
}
}

View File

@ -1,30 +0,0 @@
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

@ -6,137 +6,28 @@ using System.Text;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using BetterRaid.Misc;
using Microsoft.Extensions.Logging;
namespace BetterRaid.Services;
namespace BetterRaid.Misc;
public interface IWebToolsService
public static class Tools
{
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;
}
private static HttpListener? _oauthListener;
private static Task? _oauthWaiterTask;
// Source: https://stackoverflow.com/a/43232486
public void StartOAuthLogin(ITwitchService twitch, Action? callback = null, CancellationToken token = default)
public static void StartOAuthLogin(string url, Action? callback = null, CancellationToken? token = null)
{
if (_oauthListener == null)
{
_oauthListener = new HttpListener();
_oauthListener.Prefixes.Add(Constants.TwitchOAuthRedirectUrl + "/");
_oauthListener.Start();
Task.Run(() => WaitForCallback(twitch, callback, token), token);
}
OpenUrl(twitch.GetOAuthUrl());
}
private async Task WaitForCallback(ITwitchService twitch, Action? callback, CancellationToken token)
{
if (_oauthListener == null)
if (_oauthListener != null)
return;
if (token.IsCancellationRequested)
return;
var _token = token ?? CancellationToken.None;
_logger.LogDebug("Starting token listener");
_oauthListener = new HttpListener();
_oauthListener.Prefixes.Add("http://localhost:9900/");
_oauthListener.Start();
while (!token.IsCancellationRequested)
{
var ctx = await _oauthListener.GetContextAsync();
var req = ctx.Request;
var res = ctx.Response;
_oauthWaiterTask = WaitForCallback(callback, _token);
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);
@ -164,6 +55,98 @@ public class WebToolsService : IWebToolsService
}
}
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

@ -0,0 +1,149 @@
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

@ -1,11 +0,0 @@
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,54 +1,24 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using BetterRaid.Services;
using Newtonsoft.Json;
using TwitchLib.PubSub.Events;
using Avalonia.Threading;
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 => _broadcasterId;
set
{
if (value == _broadcasterId)
return;
_broadcasterId = value;
OnPropertyChanged();
}
get;
set;
}
public string Name
public string? Name
{
get => _name;
set
@ -60,8 +30,6 @@ public class TwitchChannel : INotifyPropertyChanged
OnPropertyChanged();
}
}
[JsonIgnore]
public bool IsLive
{
get => _isLive;
@ -74,8 +42,6 @@ public class TwitchChannel : INotifyPropertyChanged
OnPropertyChanged();
}
}
[JsonIgnore]
public string? ViewerCount
{
get => _viewerCount;
@ -115,7 +81,6 @@ public class TwitchChannel : INotifyPropertyChanged
}
}
[JsonIgnore]
public string? Category
{
get => _category;
@ -128,84 +93,10 @@ public class TwitchChannel : INotifyPropertyChanged
OnPropertyChanged();
}
}
[JsonIgnore]
public string? Title
public TwitchChannel(string 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}";
Name = channelName;
}
public event PropertyChangedEventHandler? PropertyChanged;

View File

@ -1,6 +1,5 @@
using Avalonia;
using System;
using Avalonia.ReactiveUI;
namespace BetterRaid;
@ -10,15 +9,13 @@ 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()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI();
.LogToTrace();
}

View File

@ -1 +1,2 @@
# BetterRaid

View File

@ -1,162 +0,0 @@
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

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

View File

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

View File

@ -1,17 +0,0 @@
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

@ -1,20 +0,0 @@
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);
//}
}

View File

@ -1,453 +0,0 @@
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

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

View File

@ -1,103 +1,32 @@
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 class MainWindowViewModel : ViewModelBase
public partial class MainWindowViewModel : ViewModelBase
{
private readonly SourceList<TwitchChannel> _sourceList;
private string? _filter;
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 ITwitchService Twitch => _twitch;
public ReadOnlyObservableCollection<TwitchChannel> FilteredChannels => _filteredChannels;
public string Filter
public string? Filter
{
get => _filter;
set
{
this.RaiseAndSetIfChanged(ref _filter, value);
_sourceList.Edit(innerList =>
{
if (_db.Database == null)
return;
innerList.Clear();
innerList.AddRange(_db.Database.Channels);
});
}
set => SetProperty(ref _filter, value);
}
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 bool IsLoggedIn => App.TwitchApi != null;
public void ExitApplication()
{
@ -114,36 +43,13 @@ public class MainWindowViewModel : ViewModelBase
public void LoginWithTwitch()
{
_webTools.StartOAuthLogin(_twitch, OnTwitchLoginCallback, CancellationToken.None);
Tools.StartOAuthLogin(App.TwitchOAuthUrl, OnTwitchLoginCallback, CancellationToken.None);
}
private void OnTwitchLoginCallback()
public void OnTwitchLoginCallback()
{
this.RaisePropertyChanged(nameof(IsLoggedIn));
}
App.InitTwitchClient(overrideToken: true);
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));
OnPropertyChanged(nameof(IsLoggedIn));
}
private void OnUserLoginChanged(object? sender, EventArgs e)
{
this.RaisePropertyChanged(nameof(IsLoggedIn));
}
}
}

View File

@ -0,0 +1,195 @@
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,9 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using ReactiveUI;
namespace BetterRaid.ViewModels;
public class ViewModelBase : ReactiveObject
public class ViewModelBase : ObservableObject
{
}

View File

@ -2,7 +2,6 @@
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,4 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace BetterRaid.Views;

View File

@ -0,0 +1,38 @@
<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

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

View File

@ -1,14 +1,9 @@
<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"
xmlns:ai="using:AsyncImageLoader"
xmlns:misc="using:BetterRaid.Misc"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignHeight="800"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="450"
Width="600"
Height="800"
x:Class="BetterRaid.Views.MainWindow"
@ -16,91 +11,89 @@
Icon="/Assets/logo.png"
Title="BetterRaid"
Background="DarkSlateGray">
<Window.Resources>
<con:ChannelOnlineColorConverter x:Key="ChannelOnlineColorConverter" />
</Window.Resources>
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ColumnDefinitions="Auto,*"
RowDefinitions="50,*">
VerticalAlignment="Stretch">
<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}}" />
<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>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<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">
<CheckBox Content="Only Online"
IsChecked="{Binding OnlyOnline, Mode=TwoWay, FallbackValue=False}" />
<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>
<TextBox Width="200"
Margin="5, 10, 5, 10"
<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}" />
<TextBox Grid.Column="1"
Grid.Row="0"
Width="200"
Margin="2"
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>
<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>
<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"
@ -115,133 +108,17 @@
IsScrollInertiaEnabled="True" />
</ScrollViewer.GestureRecognizers>
<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">
<Grid HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
x:Name="raidGrid">
<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>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
</Grid>
</ScrollViewer>
</Grid>

View File

@ -1,19 +1,293 @@
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 SelectingItemsControl_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
private void OnDatabaseChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is ListBox listBox)
// TODO: Only if new channel was added or existing were removed
// InitializeRaidChannels();
GenerateRaidGrid();
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (DataContext is MainWindowViewModel vm)
{
listBox.SelectedItems?.Clear();
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();
}
}
}