Add Windows desktop version
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -134,3 +134,9 @@ app/debug/
|
|||||||
dashboard/node_modules/
|
dashboard/node_modules/
|
||||||
dashboard/server.log
|
dashboard/server.log
|
||||||
dashboard/config.json
|
dashboard/config.json
|
||||||
|
|
||||||
|
# Windows desktop project artifacts
|
||||||
|
windows/StreamPlayer.Desktop/.vs/
|
||||||
|
windows/StreamPlayer.Desktop/bin/
|
||||||
|
windows/StreamPlayer.Desktop/obj/
|
||||||
|
windows/StreamPlayer.Desktop/ResolverTest/
|
||||||
|
|||||||
23
windows/StreamPlayer.Desktop/App.axaml
Normal file
23
windows/StreamPlayer.Desktop/App.axaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="StreamPlayer.Desktop.App"
|
||||||
|
xmlns:local="using:StreamPlayer.Desktop"
|
||||||
|
xmlns:converters="using:StreamPlayer.Desktop.Converters"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||||
|
|
||||||
|
<Application.DataTemplates>
|
||||||
|
<local:ViewLocator/>
|
||||||
|
</Application.DataTemplates>
|
||||||
|
|
||||||
|
<Application.Resources>
|
||||||
|
<converters:InverseBooleanConverter x:Key="InverseBooleanConverter" />
|
||||||
|
<converters:BooleanToBrushConverter x:Key="LiveStatusBrushConverter"
|
||||||
|
TrueBrush="#27AE60"
|
||||||
|
FalseBrush="#444" />
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
49
windows/StreamPlayer.Desktop/App.axaml.cs
Normal file
49
windows/StreamPlayer.Desktop/App.axaml.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Data.Core;
|
||||||
|
using Avalonia.Data.Core.Plugins;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using LibVLCSharp.Shared;
|
||||||
|
using StreamPlayer.Desktop.ViewModels;
|
||||||
|
using StreamPlayer.Desktop.Views;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
Core.Initialize();
|
||||||
|
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||||
|
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||||
|
DisableAvaloniaDataAnnotationValidation();
|
||||||
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = new MainWindowViewModel(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisableAvaloniaDataAnnotationValidation()
|
||||||
|
{
|
||||||
|
// Get an array of plugins to remove
|
||||||
|
var dataValidationPluginsToRemove =
|
||||||
|
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
||||||
|
|
||||||
|
// remove each entry found
|
||||||
|
foreach (var plugin in dataValidationPluginsToRemove)
|
||||||
|
{
|
||||||
|
BindingPlugins.DataValidators.Remove(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
windows/StreamPlayer.Desktop/AppVersion.cs
Normal file
14
windows/StreamPlayer.Desktop/AppVersion.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace StreamPlayer.Desktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Centraliza los metadatos de versión y endpoints compartidos con la app Android original.
|
||||||
|
/// </summary>
|
||||||
|
public static class AppVersion
|
||||||
|
{
|
||||||
|
public const string VersionName = "9.4.6";
|
||||||
|
public const int VersionCode = 94600;
|
||||||
|
|
||||||
|
public const string DeviceRegistryUrl = "http://194.163.191.200:4000";
|
||||||
|
public const string LatestReleaseApi =
|
||||||
|
"https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest";
|
||||||
|
}
|
||||||
BIN
windows/StreamPlayer.Desktop/Assets/avalonia-logo.ico
Normal file
BIN
windows/StreamPlayer.Desktop/Assets/avalonia-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Converters;
|
||||||
|
|
||||||
|
public sealed class BooleanToBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public IBrush TrueBrush { get; set; } = Brushes.White;
|
||||||
|
public IBrush FalseBrush { get; set; } = Brushes.Gray;
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is bool boolean)
|
||||||
|
{
|
||||||
|
return boolean ? TrueBrush : FalseBrush;
|
||||||
|
}
|
||||||
|
return Avalonia.AvaloniaProperty.UnsetValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) =>
|
||||||
|
Avalonia.AvaloniaProperty.UnsetValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Converters;
|
||||||
|
|
||||||
|
public sealed class InverseBooleanConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static readonly InverseBooleanConverter Instance = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is bool boolean)
|
||||||
|
{
|
||||||
|
return !boolean;
|
||||||
|
}
|
||||||
|
return Avalonia.AvaloniaProperty.UnsetValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is bool boolean)
|
||||||
|
{
|
||||||
|
return !boolean;
|
||||||
|
}
|
||||||
|
return Avalonia.AvaloniaProperty.UnsetValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
windows/StreamPlayer.Desktop/JsonExtensions.cs
Normal file
63
windows/StreamPlayer.Desktop/JsonExtensions.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop;
|
||||||
|
|
||||||
|
public static class JsonExtensions
|
||||||
|
{
|
||||||
|
public static string GetPropertyOrDefault(this JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
|
||||||
|
{
|
||||||
|
return property.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => property.GetString() ?? string.Empty,
|
||||||
|
JsonValueKind.Number => property.GetRawText(),
|
||||||
|
JsonValueKind.True => "true",
|
||||||
|
JsonValueKind.False => "false",
|
||||||
|
_ => property.GetRawText()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetPropertyOrDefaultInt(this JsonElement element, string propertyName, int fallback = 0)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
|
||||||
|
{
|
||||||
|
if (property.TryGetInt32(out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long GetPropertyOrDefaultLong(this JsonElement element, string propertyName, long fallback = 0)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
|
||||||
|
{
|
||||||
|
if (property.TryGetInt64(out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool GetPropertyOrDefaultBool(this JsonElement element, string propertyName, bool fallback = false)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
|
||||||
|
{
|
||||||
|
if (property.ValueKind == JsonValueKind.True)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (property.ValueKind == JsonValueKind.False)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
windows/StreamPlayer.Desktop/Models/ChannelSection.cs
Normal file
14
windows/StreamPlayer.Desktop/Models/ChannelSection.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
public enum SectionType
|
||||||
|
{
|
||||||
|
Events,
|
||||||
|
Channels
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ChannelSection(string Title, SectionType Type, IReadOnlyList<StreamChannel> Channels)
|
||||||
|
{
|
||||||
|
public bool IsEvents => Type == SectionType.Events;
|
||||||
|
}
|
||||||
6
windows/StreamPlayer.Desktop/Models/DeviceStatus.cs
Normal file
6
windows/StreamPlayer.Desktop/Models/DeviceStatus.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
public sealed record DeviceStatus(bool IsBlocked, string Reason, string TokenPart)
|
||||||
|
{
|
||||||
|
public static DeviceStatus Allowed() => new(false, string.Empty, string.Empty);
|
||||||
|
}
|
||||||
37
windows/StreamPlayer.Desktop/Models/LiveEvent.cs
Normal file
37
windows/StreamPlayer.Desktop/Models/LiveEvent.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
public sealed record LiveEvent(
|
||||||
|
string Title,
|
||||||
|
string DisplayTime,
|
||||||
|
string Category,
|
||||||
|
string Status,
|
||||||
|
string PageUrl,
|
||||||
|
string ChannelName,
|
||||||
|
long StartTimestamp)
|
||||||
|
{
|
||||||
|
public bool IsLive =>
|
||||||
|
!string.IsNullOrWhiteSpace(Status) &&
|
||||||
|
Status.Contains("live", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public string Subtitle
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(DisplayTime) && string.IsNullOrWhiteSpace(Category))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(Category))
|
||||||
|
{
|
||||||
|
return DisplayTime;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(DisplayTime))
|
||||||
|
{
|
||||||
|
return Category;
|
||||||
|
}
|
||||||
|
return $"{DisplayTime} · {Category}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
windows/StreamPlayer.Desktop/Models/StreamChannel.cs
Normal file
3
windows/StreamPlayer.Desktop/Models/StreamChannel.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
public sealed record StreamChannel(string Name, string PageUrl);
|
||||||
52
windows/StreamPlayer.Desktop/Models/UpdateInfo.cs
Normal file
52
windows/StreamPlayer.Desktop/Models/UpdateInfo.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
public sealed record UpdateInfo(
|
||||||
|
int VersionCode,
|
||||||
|
string VersionName,
|
||||||
|
string ReleaseNotes,
|
||||||
|
string DownloadUrl,
|
||||||
|
string DownloadFileName,
|
||||||
|
long DownloadSizeBytes,
|
||||||
|
int DownloadCount,
|
||||||
|
string ReleasePageUrl,
|
||||||
|
int MinSupportedVersionCode,
|
||||||
|
bool ForceUpdate)
|
||||||
|
{
|
||||||
|
public bool IsUpdateAvailable(int currentVersionCode) => VersionCode > currentVersionCode;
|
||||||
|
|
||||||
|
public bool IsMandatory(int currentVersionCode) =>
|
||||||
|
ForceUpdate || (MinSupportedVersionCode > 0 && currentVersionCode < MinSupportedVersionCode);
|
||||||
|
|
||||||
|
public string GetReleaseNotesPreview(int maxLength = 900)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ReleaseNotes))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
if (ReleaseNotes.Length <= maxLength)
|
||||||
|
{
|
||||||
|
return ReleaseNotes.Trim();
|
||||||
|
}
|
||||||
|
return ReleaseNotes[..maxLength].TrimEnd() + Environment.NewLine + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FormatSize()
|
||||||
|
{
|
||||||
|
if (DownloadSizeBytes <= 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
string[] suffixes = { "B", "KB", "MB", "GB" };
|
||||||
|
double size = DownloadSizeBytes;
|
||||||
|
int index = 0;
|
||||||
|
while (size >= 1024 && index < suffixes.Length - 1)
|
||||||
|
{
|
||||||
|
size /= 1024;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return $"{size:0.##} {suffixes[index]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
windows/StreamPlayer.Desktop/Program.cs
Normal file
21
windows/StreamPlayer.Desktop/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop;
|
||||||
|
|
||||||
|
sealed class Program
|
||||||
|
{
|
||||||
|
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
90
windows/StreamPlayer.Desktop/Services/ChannelRepository.cs
Normal file
90
windows/StreamPlayer.Desktop/Services/ChannelRepository.cs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
public static class ChannelRepository
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<StreamChannel> Channels = BuildChannels();
|
||||||
|
|
||||||
|
public static IReadOnlyList<StreamChannel> GetChannels() => Channels;
|
||||||
|
|
||||||
|
private static IReadOnlyList<StreamChannel> BuildChannels()
|
||||||
|
{
|
||||||
|
var list = new List<StreamChannel>
|
||||||
|
{
|
||||||
|
new("ESPN", "https://streamtpmedia.com/global2.php?stream=espn"),
|
||||||
|
new("ESPN 2", "https://streamtpmedia.com/global2.php?stream=espn2"),
|
||||||
|
new("ESPN 3", "https://streamtpmedia.com/global2.php?stream=espn3"),
|
||||||
|
new("ESPN 4", "https://streamtpmedia.com/global2.php?stream=espn4"),
|
||||||
|
new("ESPN 3 MX", "https://streamtpmedia.com/global2.php?stream=espn3mx"),
|
||||||
|
new("ESPN 5", "https://streamtpmedia.com/global2.php?stream=espn5"),
|
||||||
|
new("Fox Sports 3 MX", "https://streamtpmedia.com/global2.php?stream=foxsports3mx"),
|
||||||
|
new("ESPN 6", "https://streamtpmedia.com/global2.php?stream=espn6"),
|
||||||
|
new("Fox Sports MX", "https://streamtpmedia.com/global2.php?stream=foxsportsmx"),
|
||||||
|
new("ESPN 7", "https://streamtpmedia.com/global2.php?stream=espn7"),
|
||||||
|
new("Azteca Deportes", "https://streamtpmedia.com/global2.php?stream=azteca_deportes"),
|
||||||
|
new("Win Plus", "https://streamtpmedia.com/global2.php?stream=winplus"),
|
||||||
|
new("DAZN 1", "https://streamtpmedia.com/global2.php?stream=dazn1"),
|
||||||
|
new("Win Plus 2", "https://streamtpmedia.com/global2.php?stream=winplus2"),
|
||||||
|
new("DAZN 2", "https://streamtpmedia.com/global2.php?stream=dazn2"),
|
||||||
|
new("Win Sports", "https://streamtpmedia.com/global2.php?stream=winsports"),
|
||||||
|
new("DAZN LaLiga", "https://streamtpmedia.com/global2.php?stream=dazn_laliga"),
|
||||||
|
new("Win Plus Online 1", "https://streamtpmedia.com/global2.php?stream=winplusonline1"),
|
||||||
|
new("Caracol TV", "https://streamtpmedia.com/global2.php?stream=caracoltv"),
|
||||||
|
new("Fox 1 AR", "https://streamtpmedia.com/global2.php?stream=fox1ar"),
|
||||||
|
new("Fox 2 USA", "https://streamtpmedia.com/global2.php?stream=fox_2_usa"),
|
||||||
|
new("Fox 2 AR", "https://streamtpmedia.com/global2.php?stream=fox2ar"),
|
||||||
|
new("TNT 1 GB", "https://streamtpmedia.com/global2.php?stream=tnt_1_gb"),
|
||||||
|
new("TNT 2 GB", "https://streamtpmedia.com/global2.php?stream=tnt_2_gb"),
|
||||||
|
new("Fox 3 AR", "https://streamtpmedia.com/global2.php?stream=fox3ar"),
|
||||||
|
new("Universo USA", "https://streamtpmedia.com/global2.php?stream=universo_usa"),
|
||||||
|
new("DSports", "https://streamtpmedia.com/global2.php?stream=dsports"),
|
||||||
|
new("Univision USA", "https://streamtpmedia.com/global2.php?stream=univision_usa"),
|
||||||
|
new("DSports 2", "https://streamtpmedia.com/global2.php?stream=dsports2"),
|
||||||
|
new("Fox Deportes USA", "https://streamtpmedia.com/global2.php?stream=fox_deportes_usa"),
|
||||||
|
new("DSports Plus", "https://streamtpmedia.com/global2.php?stream=dsportsplus"),
|
||||||
|
new("Fox Sports 2 MX", "https://streamtpmedia.com/global2.php?stream=foxsports2mx"),
|
||||||
|
new("TNT Sports Chile", "https://streamtpmedia.com/global2.php?stream=tntsportschile"),
|
||||||
|
new("Fox Sports Premium", "https://streamtpmedia.com/global2.php?stream=foxsportspremium"),
|
||||||
|
new("TNT Sports", "https://streamtpmedia.com/global2.php?stream=tntsports"),
|
||||||
|
new("ESPN MX", "https://streamtpmedia.com/global2.php?stream=espnmx"),
|
||||||
|
new("ESPN Premium", "https://streamtpmedia.com/global2.php?stream=espnpremium"),
|
||||||
|
new("ESPN 2 MX", "https://streamtpmedia.com/global2.php?stream=espn2mx"),
|
||||||
|
new("TyC Sports", "https://streamtpmedia.com/global2.php?stream=tycsports"),
|
||||||
|
new("TUDN USA", "https://streamtpmedia.com/global2.php?stream=tudn_usa"),
|
||||||
|
new("Telefe", "https://streamtpmedia.com/global2.php?stream=telefe"),
|
||||||
|
new("TNT 3 GB", "https://streamtpmedia.com/global2.php?stream=tnt_3_gb"),
|
||||||
|
new("TV Pública", "https://streamtpmedia.com/global2.php?stream=tv_publica"),
|
||||||
|
new("Fox 1 USA", "https://streamtpmedia.com/global2.php?stream=fox_1_usa"),
|
||||||
|
new("Liga 1 Max", "https://streamtpmedia.com/global2.php?stream=liga1max"),
|
||||||
|
new("Gol TV", "https://streamtpmedia.com/global2.php?stream=goltv"),
|
||||||
|
new("VTV Plus", "https://streamtpmedia.com/global2.php?stream=vtvplus"),
|
||||||
|
new("ESPN Deportes", "https://streamtpmedia.com/global2.php?stream=espndeportes"),
|
||||||
|
new("Gol Perú", "https://streamtpmedia.com/global2.php?stream=golperu"),
|
||||||
|
new("TNT 4 GB", "https://streamtpmedia.com/global2.php?stream=tnt_4_gb"),
|
||||||
|
new("SportTV BR 1", "https://streamtpmedia.com/global2.php?stream=sporttvbr1"),
|
||||||
|
new("SportTV BR 2", "https://streamtpmedia.com/global2.php?stream=sporttvbr2"),
|
||||||
|
new("SportTV BR 3", "https://streamtpmedia.com/global2.php?stream=sporttvbr3"),
|
||||||
|
new("Premiere 1", "https://streamtpmedia.com/global2.php?stream=premiere1"),
|
||||||
|
new("Premiere 2", "https://streamtpmedia.com/global2.php?stream=premiere2"),
|
||||||
|
new("Premiere 3", "https://streamtpmedia.com/global2.php?stream=premiere3"),
|
||||||
|
new("ESPN NL 1", "https://streamtpmedia.com/global2.php?stream=espn_nl1"),
|
||||||
|
new("ESPN NL 2", "https://streamtpmedia.com/global2.php?stream=espn_nl2"),
|
||||||
|
new("ESPN NL 3", "https://streamtpmedia.com/global2.php?stream=espn_nl3"),
|
||||||
|
new("Caliente TV MX", "https://streamtpmedia.com/global2.php?stream=calientetvmx"),
|
||||||
|
new("USA Network", "https://streamtpmedia.com/global2.php?stream=usa_network"),
|
||||||
|
new("TyC Internacional", "https://streamtpmedia.com/global2.php?stream=tycinternacional"),
|
||||||
|
new("Canal 5 MX", "https://streamtpmedia.com/global2.php?stream=canal5mx"),
|
||||||
|
new("TUDN MX", "https://streamtpmedia.com/global2.php?stream=TUDNMX"),
|
||||||
|
new("FUTV", "https://streamtpmedia.com/global2.php?stream=futv"),
|
||||||
|
new("LaLiga Hypermotion", "https://streamtpmedia.com/global2.php?stream=laligahypermotion")
|
||||||
|
};
|
||||||
|
|
||||||
|
return list
|
||||||
|
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
public sealed class DeviceRegistryService
|
||||||
|
{
|
||||||
|
private static readonly HttpClient HttpClient = new(new HttpClientHandler
|
||||||
|
{
|
||||||
|
AutomaticDecompression = DecompressionMethods.All
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(20)
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _deviceId = CreateDeviceId();
|
||||||
|
|
||||||
|
public async Task<DeviceStatus> SyncAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(AppVersion.DeviceRegistryUrl))
|
||||||
|
{
|
||||||
|
return DeviceStatus.Allowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
deviceId = _deviceId,
|
||||||
|
deviceName = Environment.MachineName,
|
||||||
|
model = RuntimeInformation.OSDescription,
|
||||||
|
manufacturer = "Microsoft",
|
||||||
|
osVersion = Environment.OSVersion.VersionString,
|
||||||
|
appVersionName = AppVersion.VersionName,
|
||||||
|
appVersionCode = AppVersion.VersionCode
|
||||||
|
};
|
||||||
|
|
||||||
|
string endpoint = $"{SanitizeBaseUrl(AppVersion.DeviceRegistryUrl)}/api/devices/register";
|
||||||
|
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||||
|
{
|
||||||
|
Content = content
|
||||||
|
};
|
||||||
|
using var response = await HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
using var document = JsonDocument.Parse(body);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
bool blocked = root.GetPropertyOrDefaultBool("blocked", false);
|
||||||
|
string reason = root.GetPropertyOrDefault("message");
|
||||||
|
if (root.TryGetProperty("device", out var deviceElement))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reason))
|
||||||
|
{
|
||||||
|
reason = deviceElement.GetPropertyOrDefault("notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
string tokenPart = string.Empty;
|
||||||
|
if (root.TryGetProperty("verification", out var verificationElement))
|
||||||
|
{
|
||||||
|
bool verificationRequired = verificationElement.GetPropertyOrDefaultBool("required", false);
|
||||||
|
blocked = blocked || verificationRequired;
|
||||||
|
tokenPart = verificationElement.GetPropertyOrDefault("clientTokenPart");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeviceStatus(blocked, reason, tokenPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeBaseUrl(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
return value.EndsWith("/", StringComparison.Ordinal) ? value.TrimEnd('/') : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateDeviceId()
|
||||||
|
{
|
||||||
|
string raw = $"{Environment.MachineName}|{Environment.UserName}|{RuntimeInformation.OSDescription}";
|
||||||
|
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||||
|
return Convert.ToHexString(hash)[..24].ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
windows/StreamPlayer.Desktop/Services/DnsHelper.cs
Normal file
34
windows/StreamPlayer.Desktop/Services/DnsHelper.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
public static class DnsHelper
|
||||||
|
{
|
||||||
|
private static readonly string[] DomainsToPrefetch =
|
||||||
|
{
|
||||||
|
"streamtpmedia.com",
|
||||||
|
"google.com",
|
||||||
|
"doubleclick.net"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void WarmUp()
|
||||||
|
{
|
||||||
|
ServicePointManager.DnsRefreshTimeout = (int)TimeSpan.FromMinutes(5).TotalMilliseconds;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
foreach (var domain in DomainsToPrefetch)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Dns.GetHostAddressesAsync(domain).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore individual failures, this is best-effort caching.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
198
windows/StreamPlayer.Desktop/Services/EventService.cs
Normal file
198
windows/StreamPlayer.Desktop/Services/EventService.cs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
public sealed class EventService
|
||||||
|
{
|
||||||
|
private static readonly Uri EventsUri = new("https://streamtpmedia.com/eventos.json");
|
||||||
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24);
|
||||||
|
private static readonly string CachePath = Path.Combine(GetDataDirectory(), "events-cache.json");
|
||||||
|
private static readonly TimeZoneInfo EventZone = ResolveEventZone();
|
||||||
|
private static readonly HttpClient HttpClient = CreateHttpClient();
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<LiveEvent>> GetEventsAsync(bool forceRefresh, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!forceRefresh && TryLoadFromCache(out var cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string json = await DownloadJsonAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var events = ParseEvents(json);
|
||||||
|
SaveCache(json);
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (TryLoadFromCache(out var cachedEvents))
|
||||||
|
{
|
||||||
|
return cachedEvents;
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryLoadFromCache(out IReadOnlyList<LiveEvent> events)
|
||||||
|
{
|
||||||
|
events = Array.Empty<LiveEvent>();
|
||||||
|
if (!File.Exists(CachePath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var age = DateTimeOffset.UtcNow - File.GetLastWriteTimeUtc(CachePath);
|
||||||
|
if (age > CacheDuration)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string json = File.ReadAllText(CachePath, Encoding.UTF8);
|
||||||
|
events = ParseEvents(json);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveCache(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(CachePath)!);
|
||||||
|
File.WriteAllText(CachePath, json, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Not critical if the cache cannot be persisted.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> DownloadJsonAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await HttpClient.GetAsync(EventsUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<LiveEvent> ParseEvents(string json)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var results = new List<LiveEvent>();
|
||||||
|
foreach (var item in document.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
string title = item.GetPropertyOrDefault("title");
|
||||||
|
string time = item.GetPropertyOrDefault("time");
|
||||||
|
string category = item.GetPropertyOrDefault("category");
|
||||||
|
string status = item.GetPropertyOrDefault("status");
|
||||||
|
string link = NormalizeLink(item.GetPropertyOrDefault("link"));
|
||||||
|
string channelName = ExtractChannelName(link);
|
||||||
|
long startMillis = ParseEventTime(time);
|
||||||
|
|
||||||
|
results.Add(new LiveEvent(title, time, category, status, link, channelName, startMillis));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLink(string link)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(link))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
return link.Replace("global1.php", "global2.php", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractChannelName(string link)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(link))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
int index = link.IndexOf("stream=", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (index < 0 || index + 7 >= link.Length)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
return link[(index + 7)..].Replace("_", " ").ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ParseEventTime(string time)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(time))
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = DateTime.ParseExact(time.Trim(), "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None);
|
||||||
|
var today = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, EventZone).Date;
|
||||||
|
var localCandidate = new DateTime(today.Year, today.Month, today.Day, parsed.Hour, parsed.Minute, 0, DateTimeKind.Unspecified);
|
||||||
|
var utcDateTime = TimeZoneInfo.ConvertTimeToUtc(localCandidate, EventZone);
|
||||||
|
var start = new DateTimeOffset(utcDateTime, TimeSpan.Zero);
|
||||||
|
if (start < DateTimeOffset.UtcNow.AddHours(-12))
|
||||||
|
{
|
||||||
|
start = start.AddDays(1);
|
||||||
|
}
|
||||||
|
return start.ToUnixTimeMilliseconds();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDataDirectory()
|
||||||
|
{
|
||||||
|
var folder = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"StreamPlayerDesktop");
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient()
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||||
|
};
|
||||||
|
var client = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(15)
|
||||||
|
};
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("StreamPlayerDesktop/1.0");
|
||||||
|
client.DefaultRequestHeaders.ConnectionClose = false;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeZoneInfo ResolveEventZone()
|
||||||
|
{
|
||||||
|
string[] candidates = { "America/Argentina/Buenos_Aires", "Argentina Standard Time" };
|
||||||
|
foreach (var id in candidates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TimeZoneInfo.Local;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
windows/StreamPlayer.Desktop/Services/SectionBuilder.cs
Normal file
83
windows/StreamPlayer.Desktop/Services/SectionBuilder.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
public static class SectionBuilder
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ChannelSection> BuildSections()
|
||||||
|
{
|
||||||
|
var sections = new List<ChannelSection>
|
||||||
|
{
|
||||||
|
new("Eventos en vivo", SectionType.Events, Array.Empty<StreamChannel>())
|
||||||
|
};
|
||||||
|
|
||||||
|
var grouped = ChannelRepository.GetChannels()
|
||||||
|
.GroupBy(channel => DeriveGroupName(channel.Name))
|
||||||
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<StreamChannel>)group.ToList());
|
||||||
|
|
||||||
|
if (grouped.TryGetValue("ESPN", out var espnGroup))
|
||||||
|
{
|
||||||
|
sections.Add(new ChannelSection("ESPN", SectionType.Channels, espnGroup));
|
||||||
|
grouped.Remove("ESPN");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in grouped.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var channels = grouped[key];
|
||||||
|
if (channels.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sections.Add(new ChannelSection(key, SectionType.Channels, channels));
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.Add(new ChannelSection("Todos los canales", SectionType.Channels, ChannelRepository.GetChannels()));
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DeriveGroupName(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return "General";
|
||||||
|
}
|
||||||
|
string upper = name.ToUpperInvariant();
|
||||||
|
if (upper.StartsWith("ESPN", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "ESPN";
|
||||||
|
}
|
||||||
|
if (upper.Contains("FOX SPORTS", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "Fox Sports";
|
||||||
|
}
|
||||||
|
if (upper.Contains("FOX", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "Fox";
|
||||||
|
}
|
||||||
|
if (upper.Contains("TNT", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "TNT";
|
||||||
|
}
|
||||||
|
if (upper.Contains("DAZN", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "DAZN";
|
||||||
|
}
|
||||||
|
if (upper.Contains("TUDN", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "TUDN";
|
||||||
|
}
|
||||||
|
if (upper.Contains("TYC", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "TyC";
|
||||||
|
}
|
||||||
|
if (upper.Contains("GOL", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "Gol";
|
||||||
|
}
|
||||||
|
int spaceIndex = upper.IndexOf(' ');
|
||||||
|
return spaceIndex > 0 ? upper[..spaceIndex] : upper;
|
||||||
|
}
|
||||||
|
}
|
||||||
149
windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs
Normal file
149
windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replica el resolvedor ofuscado que utiliza la app Android para reconstruir la URL real del stream.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StreamUrlResolver
|
||||||
|
{
|
||||||
|
private static readonly Regex ArrayNameRegex =
|
||||||
|
new(@"var\s+playbackURL\s*=\s*""""\s*,\s*([A-Za-z0-9]+)\s*=\s*\[\]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex EntryRegex =
|
||||||
|
new(@"\[(\d+),""([A-Za-z0-9+/=]+)""\]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex KeyFunctionsRegex =
|
||||||
|
new(@"var\s+k=(\w+)\(\)\+(\w+)\(\);", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private const string FunctionTemplate = @"function\s+{0}\(\)\s*\{{\s*return\s+(\d+);\s*\}}";
|
||||||
|
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) StreamPlayerResolver/1.0";
|
||||||
|
|
||||||
|
private static readonly HttpClient HttpClient = CreateHttpClient();
|
||||||
|
|
||||||
|
public async Task<string> ResolveAsync(string pageUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pageUrl))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("URL inválida", nameof(pageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
string html = await DownloadPageAsync(pageUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
long keyOffset = ExtractKeyOffset(html);
|
||||||
|
var entries = ExtractEntries(html);
|
||||||
|
if (entries.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No se pudieron obtener los fragmentos del stream.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(entry.Encoded));
|
||||||
|
string numeric = new string(decoded.Where(char.IsDigit).ToArray());
|
||||||
|
if (string.IsNullOrEmpty(numeric))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!long.TryParse(numeric, out long value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
builder.Append((char)(value - keyOffset));
|
||||||
|
}
|
||||||
|
|
||||||
|
string url = builder.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No se pudo reconstruir la URL de reproducción.");
|
||||||
|
}
|
||||||
|
return url.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> DownloadPageAsync(string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.UserAgent.ParseAdd(UserAgent);
|
||||||
|
request.Headers.Referrer = new Uri("https://streamtpmedia.com/");
|
||||||
|
using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ExtractKeyOffset(string html)
|
||||||
|
{
|
||||||
|
var match = KeyFunctionsRegex.Match(html);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No se encontró la clave para el stream.");
|
||||||
|
}
|
||||||
|
string first = match.Groups[1].Value;
|
||||||
|
string second = match.Groups[2].Value;
|
||||||
|
long firstVal = ExtractReturnValue(html, first);
|
||||||
|
long secondVal = ExtractReturnValue(html, second);
|
||||||
|
return firstVal + secondVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ExtractReturnValue(string html, string functionName)
|
||||||
|
{
|
||||||
|
var pattern = string.Format(CultureInfo.InvariantCulture, FunctionTemplate, Regex.Escape(functionName));
|
||||||
|
var regex = new Regex(pattern);
|
||||||
|
var match = regex.Match(html);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"No se encontró el valor de la función {functionName}.");
|
||||||
|
}
|
||||||
|
return long.Parse(match.Groups[1].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entry> ExtractEntries(string html)
|
||||||
|
{
|
||||||
|
var matcher = ArrayNameRegex.Match(html);
|
||||||
|
if (!matcher.Success)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No se detectó la variable de fragmentos.");
|
||||||
|
}
|
||||||
|
string arrayName = matcher.Groups[1].Value;
|
||||||
|
var arrayRegex = new Regex($"{Regex.Escape(arrayName)}=\\[(.*?)\\];", RegexOptions.Singleline);
|
||||||
|
var arrayMatch = arrayRegex.Match(html);
|
||||||
|
if (!arrayMatch.Success)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No se encontró el arreglo de fragmentos.");
|
||||||
|
}
|
||||||
|
string rawEntries = arrayMatch.Groups[1].Value;
|
||||||
|
var entries = new List<Entry>();
|
||||||
|
foreach (Match match in EntryRegex.Matches(rawEntries))
|
||||||
|
{
|
||||||
|
if (int.TryParse(match.Groups[1].Value, out int index))
|
||||||
|
{
|
||||||
|
entries.Add(new Entry(index, match.Groups[2].Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.OrderBy(e => e.Index).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient()
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
AutomaticDecompression = DecompressionMethods.All,
|
||||||
|
AllowAutoRedirect = true
|
||||||
|
};
|
||||||
|
return new HttpClient(handler)
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(20)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Entry(int Index, string Encoded);
|
||||||
|
}
|
||||||
236
windows/StreamPlayer.Desktop/Services/UpdateService.cs
Normal file
236
windows/StreamPlayer.Desktop/Services/UpdateService.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
public sealed class UpdateService
|
||||||
|
{
|
||||||
|
private static readonly HttpClient HttpClient = new(new HttpClientHandler
|
||||||
|
{
|
||||||
|
AutomaticDecompression = DecompressionMethods.All
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(20)
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<UpdateInfo?> CheckForUpdatesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, AppVersion.LatestReleaseApi);
|
||||||
|
using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
string tagName = root.GetPropertyOrDefault("tag_name");
|
||||||
|
string versionName = DeriveVersionName(tagName, root.GetPropertyOrDefault("name"));
|
||||||
|
int versionCode = ParseVersionCode(versionName);
|
||||||
|
string releaseNotes = root.GetPropertyOrDefault("body");
|
||||||
|
string releasePageUrl = root.GetPropertyOrDefault("html_url");
|
||||||
|
|
||||||
|
string downloadUrl = string.Empty;
|
||||||
|
string downloadFileName = string.Empty;
|
||||||
|
long sizeBytes = 0;
|
||||||
|
int downloadCount = 0;
|
||||||
|
JsonElement assetsElement = default;
|
||||||
|
bool hasAssets = root.TryGetProperty("assets", out assetsElement) && assetsElement.ValueKind == JsonValueKind.Array;
|
||||||
|
if (hasAssets && (TryFindAsset(assetsElement, IsApkAsset, out var apkAsset) ||
|
||||||
|
TryGetFirstAsset(assetsElement, out apkAsset)))
|
||||||
|
{
|
||||||
|
downloadUrl = apkAsset.GetPropertyOrDefault("browser_download_url");
|
||||||
|
downloadFileName = apkAsset.GetPropertyOrDefault("name");
|
||||||
|
long.TryParse(apkAsset.GetPropertyOrDefault("size"), out sizeBytes);
|
||||||
|
int.TryParse(apkAsset.GetPropertyOrDefault("download_count"), out downloadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = hasAssets
|
||||||
|
? await TryFetchManifestAsync(assetsElement, cancellationToken).ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
if (manifest is not null)
|
||||||
|
{
|
||||||
|
versionCode = manifest.Value.GetPropertyOrDefaultInt("versionCode", versionCode);
|
||||||
|
var manifestVersionName = manifest.Value.GetPropertyOrDefault("versionName");
|
||||||
|
if (!string.IsNullOrWhiteSpace(manifestVersionName))
|
||||||
|
{
|
||||||
|
versionName = manifestVersionName;
|
||||||
|
}
|
||||||
|
int minSupported = manifest.Value.GetPropertyOrDefaultInt("minSupportedVersionCode", 0);
|
||||||
|
bool forceUpdate = manifest.Value.GetPropertyOrDefaultBool("forceUpdate", false);
|
||||||
|
string manifestUrl = manifest.Value.GetPropertyOrDefault("downloadUrl");
|
||||||
|
if (!string.IsNullOrWhiteSpace(manifestUrl))
|
||||||
|
{
|
||||||
|
downloadUrl = manifestUrl;
|
||||||
|
}
|
||||||
|
string manifestFileName = manifest.Value.GetPropertyOrDefault("fileName");
|
||||||
|
if (!string.IsNullOrWhiteSpace(manifestFileName))
|
||||||
|
{
|
||||||
|
downloadFileName = manifestFileName;
|
||||||
|
}
|
||||||
|
long manifestSize = manifest.Value.GetPropertyOrDefaultLong("sizeBytes", sizeBytes);
|
||||||
|
if (manifestSize > 0)
|
||||||
|
{
|
||||||
|
sizeBytes = manifestSize;
|
||||||
|
}
|
||||||
|
string manifestNotes = manifest.Value.GetPropertyOrDefault("notes");
|
||||||
|
if (!string.IsNullOrWhiteSpace(manifestNotes) && string.IsNullOrWhiteSpace(releaseNotes))
|
||||||
|
{
|
||||||
|
releaseNotes = manifestNotes;
|
||||||
|
}
|
||||||
|
return BuildInfo(versionCode, versionName, releaseNotes, downloadUrl, downloadFileName,
|
||||||
|
sizeBytes, downloadCount, releasePageUrl, minSupported, forceUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildInfo(versionCode, versionName, releaseNotes, downloadUrl, downloadFileName,
|
||||||
|
sizeBytes, downloadCount, releasePageUrl, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateInfo? BuildInfo(
|
||||||
|
int versionCode,
|
||||||
|
string versionName,
|
||||||
|
string releaseNotes,
|
||||||
|
string downloadUrl,
|
||||||
|
string downloadFileName,
|
||||||
|
long sizeBytes,
|
||||||
|
int downloadCount,
|
||||||
|
string releasePageUrl,
|
||||||
|
int minSupported,
|
||||||
|
bool forceUpdate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(downloadUrl))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UpdateInfo(
|
||||||
|
versionCode,
|
||||||
|
versionName,
|
||||||
|
releaseNotes,
|
||||||
|
downloadUrl,
|
||||||
|
downloadFileName,
|
||||||
|
sizeBytes,
|
||||||
|
downloadCount,
|
||||||
|
releasePageUrl,
|
||||||
|
minSupported,
|
||||||
|
forceUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement?> TryFetchManifestAsync(JsonElement assets, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var asset in assets.EnumerateArray())
|
||||||
|
{
|
||||||
|
string name = asset.GetPropertyOrDefault("name").ToLowerInvariant();
|
||||||
|
if (!name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!name.Contains("update", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!name.Contains("manifest", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string url = asset.GetPropertyOrDefault("browser_download_url");
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var manifestResponse = await HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
|
manifestResponse.EnsureSuccessStatusCode();
|
||||||
|
await using var stream = await manifestResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Try next manifest candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryFindAsset(JsonElement assets, Func<JsonElement, bool> predicate, out JsonElement asset)
|
||||||
|
{
|
||||||
|
if (assets.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
asset = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (var candidate in assets.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (predicate(candidate))
|
||||||
|
{
|
||||||
|
asset = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
asset = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsApkAsset(JsonElement asset)
|
||||||
|
{
|
||||||
|
string name = asset.GetPropertyOrDefault("name").ToLowerInvariant();
|
||||||
|
return name.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetFirstAsset(JsonElement assets, out JsonElement asset)
|
||||||
|
{
|
||||||
|
if (assets.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
using var enumerator = assets.EnumerateArray().GetEnumerator();
|
||||||
|
if (enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
asset = enumerator.Current;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
asset = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DeriveVersionName(string tagName, string fallback)
|
||||||
|
{
|
||||||
|
string baseName = string.IsNullOrWhiteSpace(tagName) ? fallback : tagName;
|
||||||
|
if (string.IsNullOrWhiteSpace(baseName))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
return Regex.Replace(baseName, @"^[Vv]", string.Empty).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseVersionCode(string versionName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(versionName))
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
var parts = versionName.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
int major = ParsePart(parts, 0);
|
||||||
|
int minor = ParsePart(parts, 1);
|
||||||
|
int patch = ParsePart(parts, 2);
|
||||||
|
return major * 10000 + minor * 100 + patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParsePart(IReadOnlyList<string> parts, int index)
|
||||||
|
{
|
||||||
|
if (index >= parts.Count)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (int.TryParse(Regex.Replace(parts[index], @"[^\d]", string.Empty), out int value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
169
windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs
Normal file
169
windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Principal;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
public sealed class WindowsDnsService
|
||||||
|
{
|
||||||
|
private static readonly string[] PreferredDns = { "8.8.8.8", "8.8.4.4" };
|
||||||
|
private bool _attempted;
|
||||||
|
|
||||||
|
public async Task<DnsSetupResult> EnsureGoogleDnsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return DnsSetupResult.CreateSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_attempted)
|
||||||
|
{
|
||||||
|
return DnsSetupResult.CreateSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
_attempted = true;
|
||||||
|
|
||||||
|
bool needsElevation = !IsRunningAsAdministrator();
|
||||||
|
if (needsElevation)
|
||||||
|
{
|
||||||
|
var consent = PromptForElevation();
|
||||||
|
if (!consent)
|
||||||
|
{
|
||||||
|
return DnsSetupResult.CreateFailure("Se canceló la solicitud de permisos. Ejecuta la app como administrador o configura los DNS manualmente (8.8.8.8 y 8.8.4.4).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var interfaces = GetEligibleInterfaces().ToList();
|
||||||
|
if (interfaces.Count == 0)
|
||||||
|
{
|
||||||
|
return DnsSetupResult.CreateSuccess("No se detectaron adaptadores de red activos para forzar DNS.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var adapter in interfaces)
|
||||||
|
{
|
||||||
|
bool primary = await RunNetshAsync(
|
||||||
|
$"interface ipv4 set dns name=\"{adapter}\" static {PreferredDns[0]} primary",
|
||||||
|
cancellationToken,
|
||||||
|
needsElevation);
|
||||||
|
bool secondary = await RunNetshAsync(
|
||||||
|
$"interface ipv4 add dns name=\"{adapter}\" {PreferredDns[1]} index=2",
|
||||||
|
cancellationToken,
|
||||||
|
needsElevation);
|
||||||
|
if (!primary || !secondary)
|
||||||
|
{
|
||||||
|
return DnsSetupResult.CreateFailure($"No se pudo configurar DNS para el adaptador \"{adapter}\". Verifica permisos de administrador o configura manualmente los DNS de Google.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DnsSetupResult.CreateSuccess("DNS de Google aplicados correctamente a los adaptadores de red activos.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRunningAsAdministrator()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
var principal = new WindowsPrincipal(identity);
|
||||||
|
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetEligibleInterfaces()
|
||||||
|
{
|
||||||
|
return NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.Where(ni =>
|
||||||
|
ni.OperationalStatus == OperationalStatus.Up &&
|
||||||
|
ni.NetworkInterfaceType != NetworkInterfaceType.Loopback &&
|
||||||
|
ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel &&
|
||||||
|
ni.Supports(NetworkInterfaceComponent.IPv4))
|
||||||
|
.Select(ni => ni.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PromptForElevation()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "netsh",
|
||||||
|
Arguments = "advfirewall show currentprofile",
|
||||||
|
UseShellExecute = true,
|
||||||
|
Verb = "runas",
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
process.WaitForExit();
|
||||||
|
return process.ExitCode == 0;
|
||||||
|
}
|
||||||
|
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||||
|
{
|
||||||
|
// User cancelled UAC prompt.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> RunNetshAsync(string arguments, CancellationToken cancellationToken, bool elevate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "netsh",
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = elevate,
|
||||||
|
RedirectStandardOutput = !elevate,
|
||||||
|
RedirectStandardError = !elevate,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
if (elevate)
|
||||||
|
{
|
||||||
|
psi.Verb = "runas";
|
||||||
|
}
|
||||||
|
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await process.WaitForExitAsync(cancellationToken);
|
||||||
|
return process.ExitCode == 0;
|
||||||
|
}
|
||||||
|
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DnsSetupResult(bool Success, string Message)
|
||||||
|
{
|
||||||
|
public static DnsSetupResult CreateSuccess(string message = "") => new(true, message);
|
||||||
|
public static DnsSetupResult CreateFailure(string message) => new(false, message);
|
||||||
|
}
|
||||||
36
windows/StreamPlayer.Desktop/StreamPlayer.Desktop.csproj
Normal file
36
windows/StreamPlayer.Desktop/StreamPlayer.Desktop.csproj
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Version>9.4.6</Version>
|
||||||
|
<AssemblyVersion>9.4.6.0</AssemblyVersion>
|
||||||
|
<FileVersion>9.4.6.0</FileVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Models\" />
|
||||||
|
<Folder Include="Services\" />
|
||||||
|
<Folder Include="Converters\" />
|
||||||
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.9" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.9" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.9" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.9" />
|
||||||
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.9">
|
||||||
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||||
|
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||||
|
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.21" />
|
||||||
|
<PackageReference Include="System.Security.Principal.Windows" Version="5.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
34
windows/StreamPlayer.Desktop/StreamPlayer.Desktop.sln
Normal file
34
windows/StreamPlayer.Desktop/StreamPlayer.Desktop.sln
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 18
|
||||||
|
VisualStudioVersion = 18.1.11312.151 d18.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StreamPlayer.Desktop", "StreamPlayer.Desktop.csproj", "{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x64.ActiveCfg = Debug|x64
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x64.Build.0 = Debug|x64
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x86.ActiveCfg = Debug|x86
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x64.ActiveCfg = Release|x64
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x64.Build.0 = Release|x64
|
||||||
|
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {78A0B5E4-153D-4093-85ED-127C1CDFB1B1}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
37
windows/StreamPlayer.Desktop/ViewLocator.cs
Normal file
37
windows/StreamPlayer.Desktop/ViewLocator.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Templates;
|
||||||
|
using StreamPlayer.Desktop.ViewModels;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Given a view model, returns the corresponding view if possible.
|
||||||
|
/// </summary>
|
||||||
|
[RequiresUnreferencedCode(
|
||||||
|
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||||
|
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
|
||||||
|
public class ViewLocator : IDataTemplate
|
||||||
|
{
|
||||||
|
public Control? Build(object? param)
|
||||||
|
{
|
||||||
|
if (param is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||||
|
var type = Type.GetType(name);
|
||||||
|
|
||||||
|
if (type != null)
|
||||||
|
{
|
||||||
|
return (Control)Activator.CreateInstance(type)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextBlock { Text = "Not Found: " + name };
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Match(object? data)
|
||||||
|
{
|
||||||
|
return data is ViewModelBase;
|
||||||
|
}
|
||||||
|
}
|
||||||
301
windows/StreamPlayer.Desktop/ViewModels/MainWindowViewModel.cs
Normal file
301
windows/StreamPlayer.Desktop/ViewModels/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
using StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainWindowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly EventService _eventService = new();
|
||||||
|
private readonly UpdateService _updateService = new();
|
||||||
|
private readonly DeviceRegistryService _deviceRegistryService = new();
|
||||||
|
private readonly WindowsDnsService _dnsService = new();
|
||||||
|
private readonly ReadOnlyCollection<ChannelSection> _sections;
|
||||||
|
private readonly AsyncRelayCommand _refreshEventsCommand;
|
||||||
|
private readonly RelayCommand<StreamChannel> _openChannelCommand;
|
||||||
|
private readonly RelayCommand<LiveEvent> _openEventCommand;
|
||||||
|
|
||||||
|
private bool _isInitialized;
|
||||||
|
private CancellationTokenSource? _eventsCts;
|
||||||
|
|
||||||
|
public ObservableCollection<StreamChannel> VisibleChannels { get; } = new();
|
||||||
|
public ObservableCollection<LiveEvent> VisibleEvents { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ChannelSection? selectedSection;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isLoading;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isShowingEvents;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isRefreshingEvents;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isDeviceCheckInProgress = true;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isDeviceAllowed;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string statusMessage = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string deviceStatusMessage = "Verificando dispositivo…";
|
||||||
|
|
||||||
|
public ReadOnlyCollection<ChannelSection> Sections => _sections;
|
||||||
|
|
||||||
|
public string RefreshButtonLabel => IsRefreshingEvents
|
||||||
|
? "Actualizando..."
|
||||||
|
: "Actualizar eventos";
|
||||||
|
|
||||||
|
public bool IsInteractionLocked => IsDeviceCheckInProgress || !IsDeviceAllowed;
|
||||||
|
|
||||||
|
public IAsyncRelayCommand RefreshEventsCommand => _refreshEventsCommand;
|
||||||
|
public IRelayCommand<StreamChannel> OpenChannelCommand => _openChannelCommand;
|
||||||
|
public IRelayCommand<LiveEvent> OpenEventCommand => _openEventCommand;
|
||||||
|
|
||||||
|
public event EventHandler<StreamChannel>? ChannelRequested;
|
||||||
|
public event EventHandler<string>? ErrorRaised;
|
||||||
|
public event EventHandler<UpdateInfo>? UpdateAvailable;
|
||||||
|
public event EventHandler<DeviceStatus>? DeviceStatusEvaluated;
|
||||||
|
|
||||||
|
public MainWindowViewModel()
|
||||||
|
{
|
||||||
|
var sections = SectionBuilder.BuildSections().ToList();
|
||||||
|
_sections = new ReadOnlyCollection<ChannelSection>(sections);
|
||||||
|
_refreshEventsCommand = new AsyncRelayCommand(
|
||||||
|
() => LoadEventsAsync(forceRefresh: true, CancellationToken.None),
|
||||||
|
() => IsShowingEvents && !IsRefreshingEvents && CanInteract());
|
||||||
|
|
||||||
|
_openChannelCommand = new RelayCommand<StreamChannel>(
|
||||||
|
channel =>
|
||||||
|
{
|
||||||
|
if (channel != null)
|
||||||
|
{
|
||||||
|
ChannelRequested?.Invoke(this, channel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => CanInteract());
|
||||||
|
|
||||||
|
_openEventCommand = new RelayCommand<LiveEvent>(
|
||||||
|
evt =>
|
||||||
|
{
|
||||||
|
if (evt == null || string.IsNullOrWhiteSpace(evt.PageUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChannelRequested?.Invoke(this, new StreamChannel(evt.Title, evt.PageUrl));
|
||||||
|
},
|
||||||
|
_ => CanInteract());
|
||||||
|
|
||||||
|
SelectedSection = _sections.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dnsResult = await _dnsService.EnsureGoogleDnsAsync(cancellationToken);
|
||||||
|
if (!dnsResult.Success)
|
||||||
|
{
|
||||||
|
ErrorRaised?.Invoke(this, dnsResult.Message);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(dnsResult.Message))
|
||||||
|
{
|
||||||
|
StatusMessage = dnsResult.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
DnsHelper.WarmUp();
|
||||||
|
_isInitialized = true;
|
||||||
|
if (SelectedSection != null)
|
||||||
|
{
|
||||||
|
await LoadSectionAsync(SelectedSection, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = await _updateService.CheckForUpdatesAsync(cancellationToken);
|
||||||
|
if (info != null && info.IsUpdateAvailable(AppVersion.VersionCode))
|
||||||
|
{
|
||||||
|
UpdateAvailable?.Invoke(this, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorRaised?.Invoke(this, $"No se pudo verificar actualizaciones: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task VerifyDeviceAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IsDeviceCheckInProgress = true;
|
||||||
|
DeviceStatusMessage = "Verificando dispositivo…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var status = await _deviceRegistryService.SyncAsync(cancellationToken);
|
||||||
|
IsDeviceAllowed = !status.IsBlocked;
|
||||||
|
DeviceStatusMessage = status.IsBlocked ? "Dispositivo bloqueado" : string.Empty;
|
||||||
|
DeviceStatusEvaluated?.Invoke(this, status);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorRaised?.Invoke(this, $"Error sincronizando dispositivo: {ex.Message}");
|
||||||
|
IsDeviceAllowed = true;
|
||||||
|
DeviceStatusMessage = string.Empty;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsDeviceCheckInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedSectionChanged(ChannelSection? value)
|
||||||
|
{
|
||||||
|
IsShowingEvents = value?.IsEvents == true;
|
||||||
|
_refreshEventsCommand.NotifyCanExecuteChanged();
|
||||||
|
if (!_isInitialized || value == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ = LoadSectionSafeAsync(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadSectionAsync(ChannelSection section, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (section.IsEvents)
|
||||||
|
{
|
||||||
|
await LoadEventsAsync(false, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CancelPendingEvents();
|
||||||
|
SetLoading(false);
|
||||||
|
VisibleEvents.Clear();
|
||||||
|
VisibleChannels.Clear();
|
||||||
|
foreach (var channel in section.Channels)
|
||||||
|
{
|
||||||
|
VisibleChannels.Add(channel);
|
||||||
|
}
|
||||||
|
StatusMessage = VisibleChannels.Count == 0
|
||||||
|
? "No hay canales en esta sección."
|
||||||
|
: string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadEventsAsync(bool forceRefresh, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (SelectedSection?.IsEvents != true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CancelPendingEvents();
|
||||||
|
_eventsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
var token = _eventsCts.Token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SetLoading(true);
|
||||||
|
IsRefreshingEvents = true;
|
||||||
|
StatusMessage = string.Empty;
|
||||||
|
var events = await _eventService.GetEventsAsync(forceRefresh, token);
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
VisibleChannels.Clear();
|
||||||
|
VisibleEvents.Clear();
|
||||||
|
foreach (var evt in events.OrderBy(e => e.StartTimestamp <= 0 ? long.MaxValue : e.StartTimestamp))
|
||||||
|
{
|
||||||
|
VisibleEvents.Add(evt);
|
||||||
|
}
|
||||||
|
if (VisibleEvents.Count == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = "No hay eventos próximos.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Ignore cancellation.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = "No se pudieron cargar los eventos.";
|
||||||
|
ErrorRaised?.Invoke(this, $"No se pudieron cargar los eventos: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
SetLoading(false);
|
||||||
|
}
|
||||||
|
IsRefreshingEvents = false;
|
||||||
|
_refreshEventsCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadSectionSafeAsync(ChannelSection section)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await LoadSectionAsync(section, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorRaised?.Invoke(this, $"Error al actualizar la sección: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetLoading(bool value)
|
||||||
|
{
|
||||||
|
IsLoading = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelPendingEvents()
|
||||||
|
{
|
||||||
|
if (_eventsCts != null)
|
||||||
|
{
|
||||||
|
_eventsCts.Cancel();
|
||||||
|
_eventsCts.Dispose();
|
||||||
|
_eventsCts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanInteract() => !IsInteractionLocked;
|
||||||
|
|
||||||
|
private void NotifyInteractionChanged()
|
||||||
|
{
|
||||||
|
_openChannelCommand.NotifyCanExecuteChanged();
|
||||||
|
_openEventCommand.NotifyCanExecuteChanged();
|
||||||
|
_refreshEventsCommand.NotifyCanExecuteChanged();
|
||||||
|
OnPropertyChanged(nameof(IsInteractionLocked));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsDeviceCheckInProgressChanged(bool value)
|
||||||
|
{
|
||||||
|
NotifyInteractionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsDeviceAllowedChanged(bool value)
|
||||||
|
{
|
||||||
|
NotifyInteractionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsRefreshingEventsChanged(bool value)
|
||||||
|
{
|
||||||
|
_refreshEventsCommand.NotifyCanExecuteChanged();
|
||||||
|
OnPropertyChanged(nameof(RefreshButtonLabel));
|
||||||
|
}
|
||||||
|
}
|
||||||
7
windows/StreamPlayer.Desktop/ViewModels/ViewModelBase.cs
Normal file
7
windows/StreamPlayer.Desktop/ViewModels/ViewModelBase.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.ViewModels;
|
||||||
|
|
||||||
|
public abstract class ViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
35
windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml
Normal file
35
windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="StreamPlayer.Desktop.Views.BlockedDialog"
|
||||||
|
Width="420"
|
||||||
|
SizeToContent="Height"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="False"
|
||||||
|
Title="Dispositivo bloqueado">
|
||||||
|
<Border Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="Acceso bloqueado"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"/>
|
||||||
|
<TextBlock x:Name="ReasonText"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<StackPanel x:Name="TokenPanel"
|
||||||
|
Spacing="6"
|
||||||
|
IsVisible="False">
|
||||||
|
<TextBlock Text="Token para soporte:"/>
|
||||||
|
<TextBox x:Name="TokenText"
|
||||||
|
IsReadOnly="True"
|
||||||
|
Background="#111"
|
||||||
|
Foreground="White"
|
||||||
|
BorderBrush="#555"/>
|
||||||
|
<Button Content="Copiar token"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Click="OnCopyClicked"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Content="Cerrar aplicación"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Width="180"
|
||||||
|
Click="OnCloseClicked"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
67
windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml.cs
Normal file
67
windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Views;
|
||||||
|
|
||||||
|
public partial class BlockedDialog : Window
|
||||||
|
{
|
||||||
|
private readonly string _token;
|
||||||
|
|
||||||
|
private TextBlock? _reasonText;
|
||||||
|
private StackPanel? _tokenPanel;
|
||||||
|
private TextBox? _tokenText;
|
||||||
|
|
||||||
|
public BlockedDialog() : this(string.Empty, string.Empty)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BlockedDialog(string reason, string token)
|
||||||
|
{
|
||||||
|
_token = token ?? string.Empty;
|
||||||
|
InitializeComponent();
|
||||||
|
if (_reasonText != null)
|
||||||
|
{
|
||||||
|
_reasonText.Text = string.IsNullOrWhiteSpace(reason)
|
||||||
|
? "Tu dispositivo fue bloqueado por el administrador."
|
||||||
|
: reason;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(_token))
|
||||||
|
{
|
||||||
|
if (_tokenPanel != null)
|
||||||
|
{
|
||||||
|
_tokenPanel.IsVisible = true;
|
||||||
|
}
|
||||||
|
if (_tokenText != null)
|
||||||
|
{
|
||||||
|
_tokenText.Text = _token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnCopyClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_token))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||||
|
if (clipboard != null)
|
||||||
|
{
|
||||||
|
await clipboard.SetTextAsync(_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
_reasonText = this.FindControl<TextBlock>("ReasonText");
|
||||||
|
_tokenPanel = this.FindControl<StackPanel>("TokenPanel");
|
||||||
|
_tokenText = this.FindControl<TextBox>("TokenText");
|
||||||
|
}
|
||||||
|
}
|
||||||
192
windows/StreamPlayer.Desktop/Views/MainWindow.axaml
Normal file
192
windows/StreamPlayer.Desktop/Views/MainWindow.axaml
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:StreamPlayer.Desktop.ViewModels"
|
||||||
|
xmlns:models="using:StreamPlayer.Desktop.Models"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="StreamPlayer.Desktop.Views.MainWindow"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Icon="/Assets/avalonia-logo.ico"
|
||||||
|
Width="1200"
|
||||||
|
Height="720"
|
||||||
|
Title="StreamPlayer Desktop">
|
||||||
|
|
||||||
|
<Design.DataContext>
|
||||||
|
<vm:MainWindowViewModel/>
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="220,*" RowDefinitions="Auto,Auto,*"
|
||||||
|
IsEnabled="{Binding IsInteractionLocked, Converter={StaticResource InverseBooleanConverter}}">
|
||||||
|
<Border Grid.RowSpan="3"
|
||||||
|
Background="#111217"
|
||||||
|
Padding="0">
|
||||||
|
<ListBox ItemsSource="{Binding Sections}"
|
||||||
|
SelectedItem="{Binding SelectedSection}"
|
||||||
|
BorderThickness="0"
|
||||||
|
Background="Transparent"
|
||||||
|
Foreground="White">
|
||||||
|
<ListBox.Styles>
|
||||||
|
<Style Selector="ListBoxItem">
|
||||||
|
<Setter Property="Margin" Value="4"/>
|
||||||
|
<Setter Property="Padding" Value="14"/>
|
||||||
|
<Setter Property="CornerRadius" Value="6"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#2A2F3A"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#3A7AFE"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
</ListBox.Styles>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:ChannelSection">
|
||||||
|
<TextBlock Text="{Binding Title}"
|
||||||
|
FontSize="15"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="20,16,20,8"
|
||||||
|
Spacing="12">
|
||||||
|
<TextBlock Text="{Binding SelectedSection.Title, FallbackValue=StreamPlayer}"
|
||||||
|
FontSize="26"
|
||||||
|
FontWeight="SemiBold"/>
|
||||||
|
<Button Content="{Binding RefreshButtonLabel}"
|
||||||
|
Command="{Binding RefreshEventsCommand}"
|
||||||
|
IsVisible="{Binding IsShowingEvents}"
|
||||||
|
IsEnabled="{Binding IsRefreshingEvents, Converter={StaticResource InverseBooleanConverter}}"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Padding="12,4"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Grid.Row="1"
|
||||||
|
Text="{Binding StatusMessage}"
|
||||||
|
Margin="20,0,20,8"
|
||||||
|
Foreground="#8A8F9C"
|
||||||
|
FontStyle="Italic"/>
|
||||||
|
|
||||||
|
<Grid Grid.Column="1" Grid.Row="2">
|
||||||
|
<ScrollViewer IsVisible="{Binding IsShowingEvents, Converter={StaticResource InverseBooleanConverter}}"
|
||||||
|
Margin="10"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl ItemsSource="{Binding VisibleChannels}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel IsItemsHost="True" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:StreamChannel">
|
||||||
|
<Button Command="{Binding $parent[Window].DataContext.OpenChannelCommand}"
|
||||||
|
CommandParameter="{Binding .}"
|
||||||
|
Margin="6"
|
||||||
|
Padding="16"
|
||||||
|
Width="220"
|
||||||
|
HorizontalContentAlignment="Stretch">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding PageUrl}" FontSize="11" Foreground="#8A8F9C"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<ScrollViewer IsVisible="{Binding IsShowingEvents}"
|
||||||
|
Margin="10"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl ItemsSource="{Binding VisibleEvents}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:LiveEvent">
|
||||||
|
<Border Margin="0,0,0,8"
|
||||||
|
Padding="14"
|
||||||
|
Background="#181C24"
|
||||||
|
CornerRadius="8">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Title}"
|
||||||
|
FontSize="16"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding Subtitle}"
|
||||||
|
Foreground="#8A8F9C"
|
||||||
|
Margin="0,4,0,0"/>
|
||||||
|
<TextBlock Text="{Binding ChannelName}"
|
||||||
|
Foreground="#6FA8FF"
|
||||||
|
FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="6">
|
||||||
|
<Button Content="Ver canal"
|
||||||
|
Command="{Binding $parent[Window].DataContext.OpenEventCommand}"
|
||||||
|
CommandParameter="{Binding .}"
|
||||||
|
Padding="12,4"/>
|
||||||
|
<Border Background="{Binding IsLive, Converter={StaticResource LiveStatusBrushConverter}}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="6"
|
||||||
|
HorizontalAlignment="Right">
|
||||||
|
<TextBlock Text="{Binding Status}"
|
||||||
|
Foreground="White"
|
||||||
|
FontSize="12"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Border Background="#CC0E1016"
|
||||||
|
IsVisible="{Binding IsLoading}">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
|
||||||
|
<ProgressBar Width="220" IsIndeterminate="True"/>
|
||||||
|
<TextBlock Text="Cargando contenido..."
|
||||||
|
Foreground="White"
|
||||||
|
FontSize="16"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border Grid.ColumnSpan="2"
|
||||||
|
Grid.RowSpan="3"
|
||||||
|
Background="#CC000000"
|
||||||
|
IsVisible="{Binding IsInteractionLocked}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="12">
|
||||||
|
<ProgressBar Width="260"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsDeviceCheckInProgress}"/>
|
||||||
|
<TextBlock Text="{Binding DeviceStatusMessage}"
|
||||||
|
Foreground="White"
|
||||||
|
FontSize="18"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="0,4"/>
|
||||||
|
<TextBlock Text="Espera a que verifiquemos tu dispositivo…"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="#DDDDDD"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
208
windows/StreamPlayer.Desktop/Views/MainWindow.axaml.cs
Normal file
208
windows/StreamPlayer.Desktop/Views/MainWindow.axaml.cs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
using StreamPlayer.Desktop.ViewModels;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _lifetimeCts = new();
|
||||||
|
private MainWindowViewModel? _viewModel;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
Opened += OnOpened;
|
||||||
|
Closed += OnClosed;
|
||||||
|
AttachViewModel(DataContext as MainWindowViewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
AttachViewModel(DataContext as MainWindowViewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AttachViewModel(MainWindowViewModel? newViewModel)
|
||||||
|
{
|
||||||
|
if (_viewModel != null)
|
||||||
|
{
|
||||||
|
_viewModel.ChannelRequested -= OnChannelRequested;
|
||||||
|
_viewModel.UpdateAvailable -= OnUpdateAvailable;
|
||||||
|
_viewModel.DeviceStatusEvaluated -= OnDeviceStatusEvaluated;
|
||||||
|
_viewModel.ErrorRaised -= OnErrorRaised;
|
||||||
|
}
|
||||||
|
|
||||||
|
_viewModel = newViewModel;
|
||||||
|
if (_viewModel != null)
|
||||||
|
{
|
||||||
|
_viewModel.ChannelRequested += OnChannelRequested;
|
||||||
|
_viewModel.UpdateAvailable += OnUpdateAvailable;
|
||||||
|
_viewModel.DeviceStatusEvaluated += OnDeviceStatusEvaluated;
|
||||||
|
_viewModel.ErrorRaised += OnErrorRaised;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnOpened(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_viewModel == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _viewModel.InitializeAsync(_lifetimeCts.Token);
|
||||||
|
await _viewModel.CheckForUpdatesAsync(_lifetimeCts.Token);
|
||||||
|
await _viewModel.VerifyDeviceAsync(_lifetimeCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClosed(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_lifetimeCts.Cancel();
|
||||||
|
_lifetimeCts.Dispose();
|
||||||
|
AttachViewModel(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChannelRequested(object? sender, StreamChannel channel)
|
||||||
|
{
|
||||||
|
if (channel == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var player = new PlayerWindow(channel);
|
||||||
|
player.Show(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnUpdateAvailable(object? sender, UpdateInfo info)
|
||||||
|
{
|
||||||
|
var mandatory = info.IsMandatory(AppVersion.VersionCode);
|
||||||
|
var dialog = new UpdateDialog(info, mandatory);
|
||||||
|
var result = await dialog.ShowDialog<UpdateDialogAction>(this);
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case UpdateDialogAction.Download:
|
||||||
|
LaunchUrl(info.DownloadUrl);
|
||||||
|
if (mandatory)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case UpdateDialogAction.ViewRelease:
|
||||||
|
LaunchUrl(string.IsNullOrWhiteSpace(info.ReleasePageUrl)
|
||||||
|
? info.DownloadUrl
|
||||||
|
: info.ReleasePageUrl);
|
||||||
|
if (mandatory)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case UpdateDialogAction.Exit:
|
||||||
|
Close();
|
||||||
|
break;
|
||||||
|
case UpdateDialogAction.Skip:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnDeviceStatusEvaluated(object? sender, DeviceStatus status)
|
||||||
|
{
|
||||||
|
if (!status.IsBlocked)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dialog = new BlockedDialog(status.Reason, status.TokenPart);
|
||||||
|
await dialog.ShowDialog(this);
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnErrorRaised(object? sender, string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ShowInfoAsync("Aviso", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LaunchUrl(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = url,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ShowInfoAsync(string title, string message)
|
||||||
|
{
|
||||||
|
var okButton = new Button
|
||||||
|
{
|
||||||
|
Content = "Aceptar",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Width = 120
|
||||||
|
};
|
||||||
|
|
||||||
|
var stack = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 12
|
||||||
|
};
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = message,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
});
|
||||||
|
stack.Children.Add(okButton);
|
||||||
|
|
||||||
|
var dialog = new Window
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Width = 420,
|
||||||
|
SizeToContent = SizeToContent.Height,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
|
CanResize = false,
|
||||||
|
Content = new Border
|
||||||
|
{
|
||||||
|
Padding = new Thickness(20),
|
||||||
|
Child = stack
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
okButton.Click += (_, _) => dialog.Close();
|
||||||
|
|
||||||
|
return dialog.ShowDialog(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml
Normal file
74
windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||||
|
x:Class="StreamPlayer.Desktop.Views.PlayerWindow"
|
||||||
|
Width="960"
|
||||||
|
Height="540"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Title="StreamPlayer - Reproductor">
|
||||||
|
<Grid Background="Black">
|
||||||
|
<vlc:VideoView x:Name="VideoView"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
PointerPressed="OnVideoPointerPressed"/>
|
||||||
|
|
||||||
|
<Border x:Name="Overlay"
|
||||||
|
Background="#80000000"
|
||||||
|
Padding="20"
|
||||||
|
IsVisible="True">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock x:Name="ChannelNameText"
|
||||||
|
Text="Canal"
|
||||||
|
FontSize="20"
|
||||||
|
Foreground="White"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
DockPanel.Dock="Left"/>
|
||||||
|
<Button Content="Cerrar"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Click="OnCloseClicked"
|
||||||
|
Margin="12,0,0,0"
|
||||||
|
DockPanel.Dock="Right"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="LoadingOverlay"
|
||||||
|
Background="#CC000000"
|
||||||
|
IsVisible="True">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="12">
|
||||||
|
<ProgressBar IsIndeterminate="True" Width="280"/>
|
||||||
|
<TextBlock x:Name="StatusText"
|
||||||
|
Text="Preparando..."
|
||||||
|
Foreground="White"
|
||||||
|
FontSize="16"
|
||||||
|
TextAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="ErrorOverlay"
|
||||||
|
Background="#CC1E1E1E"
|
||||||
|
IsVisible="False">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="12">
|
||||||
|
<TextBlock Text="Ocurrió un error"
|
||||||
|
FontSize="20"
|
||||||
|
Foreground="White"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="ErrorText"
|
||||||
|
Text=""
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="#FFDDDD"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MaxWidth="420"
|
||||||
|
TextAlignment="Center"/>
|
||||||
|
<Button Content="Cerrar"
|
||||||
|
Width="140"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Click="OnCloseClicked"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
229
windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml.cs
Normal file
229
windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml.cs
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LibVLCSharp.Avalonia;
|
||||||
|
using LibVLCSharp.Shared;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
using StreamPlayer.Desktop.Services;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Views;
|
||||||
|
|
||||||
|
public partial class PlayerWindow : Window
|
||||||
|
{
|
||||||
|
private readonly StreamChannel _channel;
|
||||||
|
private readonly StreamUrlResolver _resolver = new();
|
||||||
|
private readonly CancellationTokenSource _lifetimeCts = new();
|
||||||
|
private LibVLC? _libVlc;
|
||||||
|
private MediaPlayer? _mediaPlayer;
|
||||||
|
private bool _overlayVisible = true;
|
||||||
|
|
||||||
|
private TextBlock? _channelNameText;
|
||||||
|
private Border? _loadingOverlay;
|
||||||
|
private Border? _errorOverlay;
|
||||||
|
private TextBlock? _statusText;
|
||||||
|
private VideoView? _videoView;
|
||||||
|
private TextBlock? _errorText;
|
||||||
|
private Border? _overlay;
|
||||||
|
|
||||||
|
public PlayerWindow() : this(new StreamChannel("Canal", string.Empty))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerWindow(StreamChannel channel)
|
||||||
|
{
|
||||||
|
_channel = channel;
|
||||||
|
InitializeComponent();
|
||||||
|
if (_channelNameText != null)
|
||||||
|
{
|
||||||
|
_channelNameText.Text = channel.Name;
|
||||||
|
}
|
||||||
|
Title = $"Reproduciendo {channel.Name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnOpened(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnOpened(e);
|
||||||
|
_ = LoadAndPlayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAndPlayAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (_loadingOverlay != null)
|
||||||
|
{
|
||||||
|
_loadingOverlay.IsVisible = true;
|
||||||
|
}
|
||||||
|
if (_errorOverlay != null)
|
||||||
|
{
|
||||||
|
_errorOverlay.IsVisible = false;
|
||||||
|
}
|
||||||
|
if (_statusText != null)
|
||||||
|
{
|
||||||
|
_statusText.Text = "Resolviendo stream…";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
string resolved = await _resolver.ResolveAsync(_channel.PageUrl, _lifetimeCts.Token);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => StartPlayback(resolved));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => ShowError($"No se pudo iniciar el stream: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartPlayback(string streamUrl)
|
||||||
|
{
|
||||||
|
ReleasePlayer();
|
||||||
|
_libVlc = new LibVLC();
|
||||||
|
_mediaPlayer = new MediaPlayer(_libVlc);
|
||||||
|
_mediaPlayer.Playing += MediaPlayerOnPlaying;
|
||||||
|
_mediaPlayer.Buffering += MediaPlayerOnBuffering;
|
||||||
|
_mediaPlayer.EncounteredError += MediaPlayerOnEncounteredError;
|
||||||
|
_mediaPlayer.EndReached += MediaPlayerOnEndReached;
|
||||||
|
if (_videoView != null)
|
||||||
|
{
|
||||||
|
_videoView.MediaPlayer = _mediaPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var media = new Media(_libVlc, new Uri(streamUrl));
|
||||||
|
media.AddOption($":http-referrer={_channel.PageUrl}");
|
||||||
|
media.AddOption(":http-header=Origin: https://streamtpmedia.com");
|
||||||
|
media.AddOption(":http-header=Accept: */*");
|
||||||
|
media.AddOption(":http-header=Connection: keep-alive");
|
||||||
|
media.AddOption(":network-caching=1500");
|
||||||
|
media.AddOption(":clock-jitter=0");
|
||||||
|
media.AddOption(":clock-synchro=0");
|
||||||
|
media.AddOption(":adaptive-use-access=true");
|
||||||
|
media.AddOption(":http-user-agent=Mozilla/5.0 (Linux; Android 13; Pixel 7 Build/TQ3A.230805.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0 Mobile Safari/537.36 ExoPlayer/2.18.7");
|
||||||
|
media.AddOption(":input-repeat=-1");
|
||||||
|
_mediaPlayer.Play(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MediaPlayerOnEndReached(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (_loadingOverlay != null)
|
||||||
|
{
|
||||||
|
_loadingOverlay.IsVisible = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MediaPlayerOnEncounteredError(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => ShowError("Error en la reproducción. Intenta nuevamente más tarde."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MediaPlayerOnBuffering(object? sender, MediaPlayerBufferingEventArgs e)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (_loadingOverlay != null)
|
||||||
|
{
|
||||||
|
_loadingOverlay.IsVisible = true;
|
||||||
|
}
|
||||||
|
if (_statusText != null)
|
||||||
|
{
|
||||||
|
_statusText.Text = "Conectando…";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MediaPlayerOnPlaying(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (_loadingOverlay != null)
|
||||||
|
{
|
||||||
|
_loadingOverlay.IsVisible = false;
|
||||||
|
}
|
||||||
|
SetOverlayVisible(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowError(string message)
|
||||||
|
{
|
||||||
|
if (_loadingOverlay != null)
|
||||||
|
{
|
||||||
|
_loadingOverlay.IsVisible = false;
|
||||||
|
}
|
||||||
|
if (_errorOverlay != null)
|
||||||
|
{
|
||||||
|
_errorOverlay.IsVisible = true;
|
||||||
|
}
|
||||||
|
if (_errorText != null)
|
||||||
|
{
|
||||||
|
_errorText.Text = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleasePlayer()
|
||||||
|
{
|
||||||
|
if (_mediaPlayer != null)
|
||||||
|
{
|
||||||
|
_mediaPlayer.Playing -= MediaPlayerOnPlaying;
|
||||||
|
_mediaPlayer.Buffering -= MediaPlayerOnBuffering;
|
||||||
|
_mediaPlayer.EncounteredError -= MediaPlayerOnEncounteredError;
|
||||||
|
_mediaPlayer.EndReached -= MediaPlayerOnEndReached;
|
||||||
|
_mediaPlayer.Dispose();
|
||||||
|
_mediaPlayer = null;
|
||||||
|
}
|
||||||
|
if (_libVlc != null)
|
||||||
|
{
|
||||||
|
_libVlc.Dispose();
|
||||||
|
_libVlc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnVideoPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
SetOverlayVisible(!_overlayVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetOverlayVisible(bool visible)
|
||||||
|
{
|
||||||
|
_overlayVisible = visible;
|
||||||
|
if (_overlay != null)
|
||||||
|
{
|
||||||
|
_overlay.IsVisible = visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosing(WindowClosingEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnClosing(e);
|
||||||
|
_lifetimeCts.Cancel();
|
||||||
|
ReleasePlayer();
|
||||||
|
_lifetimeCts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
_channelNameText = this.FindControl<TextBlock>("ChannelNameText");
|
||||||
|
_loadingOverlay = this.FindControl<Border>("LoadingOverlay");
|
||||||
|
_errorOverlay = this.FindControl<Border>("ErrorOverlay");
|
||||||
|
_statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
_videoView = this.FindControl<VideoView>("VideoView");
|
||||||
|
_errorText = this.FindControl<TextBlock>("ErrorText");
|
||||||
|
_overlay = this.FindControl<Border>("Overlay");
|
||||||
|
}
|
||||||
|
}
|
||||||
38
windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml
Normal file
38
windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="StreamPlayer.Desktop.Views.UpdateDialog"
|
||||||
|
Width="520"
|
||||||
|
SizeToContent="Height"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="False"
|
||||||
|
Title="Actualización disponible">
|
||||||
|
<Border Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="Actualización disponible"/>
|
||||||
|
<TextBlock x:Name="InfoText"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="Novedades"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Margin="0,8,0,0"/>
|
||||||
|
<ScrollViewer Height="180">
|
||||||
|
<TextBlock x:Name="NotesText"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||||
|
<Button Content="Ver release"
|
||||||
|
x:Name="ViewReleaseButton"
|
||||||
|
Click="OnViewReleaseClicked"/>
|
||||||
|
<Button Content="Descargar"
|
||||||
|
IsDefault="True"
|
||||||
|
Click="OnDownloadClicked"/>
|
||||||
|
<Button x:Name="DismissButton"
|
||||||
|
Content="Más tarde"
|
||||||
|
IsCancel="True"
|
||||||
|
Click="OnDismissClicked"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
96
windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml.cs
Normal file
96
windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using StreamPlayer.Desktop.Models;
|
||||||
|
|
||||||
|
namespace StreamPlayer.Desktop.Views;
|
||||||
|
|
||||||
|
public enum UpdateDialogAction
|
||||||
|
{
|
||||||
|
Skip,
|
||||||
|
Download,
|
||||||
|
ViewRelease,
|
||||||
|
Exit
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class UpdateDialog : Window
|
||||||
|
{
|
||||||
|
private readonly UpdateInfo _info;
|
||||||
|
private readonly bool _mandatory;
|
||||||
|
|
||||||
|
private TextBlock? _titleText;
|
||||||
|
private TextBlock? _infoText;
|
||||||
|
private TextBlock? _notesText;
|
||||||
|
private Button? _viewReleaseButton;
|
||||||
|
private Button? _dismissButton;
|
||||||
|
|
||||||
|
public UpdateDialog() : this(
|
||||||
|
new UpdateInfo(AppVersion.VersionCode, AppVersion.VersionName, string.Empty, string.Empty, string.Empty, 0, 0, string.Empty, AppVersion.VersionCode, false),
|
||||||
|
false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdateDialog(UpdateInfo info, bool mandatory)
|
||||||
|
{
|
||||||
|
_info = info;
|
||||||
|
_mandatory = mandatory;
|
||||||
|
InitializeComponent();
|
||||||
|
ConfigureView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfigureView()
|
||||||
|
{
|
||||||
|
if (_titleText != null)
|
||||||
|
{
|
||||||
|
_titleText.Text = _mandatory ? "Actualización obligatoria" : "Actualización disponible";
|
||||||
|
}
|
||||||
|
if (_infoText != null)
|
||||||
|
{
|
||||||
|
_infoText.Text =
|
||||||
|
$"Versión instalada: {AppVersion.VersionName} ({AppVersion.VersionCode}){Environment.NewLine}" +
|
||||||
|
$"Última versión: {_info.VersionName} ({_info.VersionCode}){Environment.NewLine}" +
|
||||||
|
$"Tamaño estimado: {_info.FormatSize()} Descargas: {_info.DownloadCount}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_notesText != null)
|
||||||
|
{
|
||||||
|
_notesText.Text = string.IsNullOrWhiteSpace(_info.ReleaseNotes)
|
||||||
|
? "La release no incluye notas."
|
||||||
|
: _info.ReleaseNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_viewReleaseButton != null && string.IsNullOrWhiteSpace(_info.ReleasePageUrl))
|
||||||
|
{
|
||||||
|
_viewReleaseButton.IsVisible = false;
|
||||||
|
}
|
||||||
|
if (_mandatory && _dismissButton != null)
|
||||||
|
{
|
||||||
|
_dismissButton.Content = "Salir";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDownloadClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Close(UpdateDialogAction.Download);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnViewReleaseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Close(UpdateDialogAction.ViewRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDismissClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Close(_mandatory ? UpdateDialogAction.Exit : UpdateDialogAction.Skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
_titleText = this.FindControl<TextBlock>("TitleText");
|
||||||
|
_infoText = this.FindControl<TextBlock>("InfoText");
|
||||||
|
_notesText = this.FindControl<TextBlock>("NotesText");
|
||||||
|
_viewReleaseButton = this.FindControl<Button>("ViewReleaseButton");
|
||||||
|
_dismissButton = this.FindControl<Button>("DismissButton");
|
||||||
|
}
|
||||||
|
}
|
||||||
18
windows/StreamPlayer.Desktop/app.manifest
Normal file
18
windows/StreamPlayer.Desktop/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<!-- This manifest is used on Windows only.
|
||||||
|
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||||
|
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="StreamPlayer.Desktop.Desktop"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- A list of the Windows versions that this application has been tested on
|
||||||
|
and is designed to work with. Uncomment the appropriate elements
|
||||||
|
and Windows will automatically select the most compatible environment. -->
|
||||||
|
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
Reference in New Issue
Block a user