Compare commits
No commits in common. "reactive-ui" and "main" have entirely different histories.
reactive-u
...
main
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,7 +20,6 @@
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Bb]iuld/
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
|
193
App.axaml.cs
193
App.axaml.cs
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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
BIN
Build/BetterRaid-0.0.1-alpha.exe
Executable file
Binary file not shown.
BIN
Build/BetterRaid-0.0.1-alpha.x86_64
Executable file
BIN
Build/BetterRaid-0.0.1-alpha.x86_64
Executable file
Binary file not shown.
95
Controls/RaidButton.axaml
Normal file
95
Controls/RaidButton.axaml
Normal 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>
|
13
Controls/RaidButton.axaml.cs
Normal file
13
Controls/RaidButton.axaml.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace BetterRaid;
|
||||
|
||||
public partial class RaidButton : UserControl
|
||||
{
|
||||
public RaidButton()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
11
Extensions/DataContextExtensions.cs
Normal file
11
Extensions/DataContextExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
];
|
||||
}
|
@ -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>
|
149
Models/BetterRaidDatabase.cs
Normal file
149
Models/BetterRaidDatabase.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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; } = [];
|
||||
}
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
using BetterRaid.ViewModels;
|
||||
|
||||
namespace BetterRaid.Services;
|
||||
|
||||
public interface IMainViewModelFactory
|
||||
{
|
||||
MainWindowViewModel CreateMainWindowViewModel();
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace BetterRaid.Services;
|
||||
|
||||
public interface ISynchronizaionService
|
||||
{
|
||||
void Invoke(Action action);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
//}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
6
ViewModels/AddChannelWindowViewModel.cs
Normal file
6
ViewModels/AddChannelWindowViewModel.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace BetterRaid.ViewModels;
|
||||
|
||||
public class AddChannelWindowViewModel : ViewModelBase
|
||||
{
|
||||
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
195
ViewModels/RaidButtonViewModel.cs
Normal file
195
ViewModels/RaidButtonViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
@ -1,9 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace BetterRaid.ViewModels;
|
||||
|
||||
public class ViewModelBase : ReactiveObject
|
||||
public class ViewModelBase : ObservableObject
|
||||
{
|
||||
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -1,4 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace BetterRaid.Views;
|
||||
|
||||
|
38
Views/AddChannelWindow.axaml
Normal file
38
Views/AddChannelWindow.axaml
Normal 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>
|
13
Views/AddChannelWindow.axaml.cs
Normal file
13
Views/AddChannelWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user