diff --git a/.gitignore b/.gitignore
index de2f7d4..e522683 100644
--- a/.gitignore
+++ b/.gitignore
@@ -134,3 +134,9 @@ app/debug/
dashboard/node_modules/
dashboard/server.log
dashboard/config.json
+
+# Windows desktop project artifacts
+windows/StreamPlayer.Desktop/.vs/
+windows/StreamPlayer.Desktop/bin/
+windows/StreamPlayer.Desktop/obj/
+windows/StreamPlayer.Desktop/ResolverTest/
diff --git a/windows/StreamPlayer.Desktop/App.axaml b/windows/StreamPlayer.Desktop/App.axaml
new file mode 100644
index 0000000..9ffa5d7
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/App.axaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/StreamPlayer.Desktop/App.axaml.cs b/windows/StreamPlayer.Desktop/App.axaml.cs
new file mode 100644
index 0000000..bac4483
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/App.axaml.cs
@@ -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().ToArray();
+
+ // remove each entry found
+ foreach (var plugin in dataValidationPluginsToRemove)
+ {
+ BindingPlugins.DataValidators.Remove(plugin);
+ }
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/AppVersion.cs b/windows/StreamPlayer.Desktop/AppVersion.cs
new file mode 100644
index 0000000..3900cd6
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/AppVersion.cs
@@ -0,0 +1,14 @@
+namespace StreamPlayer.Desktop;
+
+///
+/// Centraliza los metadatos de versión y endpoints compartidos con la app Android original.
+///
+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";
+}
diff --git a/windows/StreamPlayer.Desktop/Assets/avalonia-logo.ico b/windows/StreamPlayer.Desktop/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..f7da8bb
Binary files /dev/null and b/windows/StreamPlayer.Desktop/Assets/avalonia-logo.ico differ
diff --git a/windows/StreamPlayer.Desktop/Converters/BooleanToBrushConverter.cs b/windows/StreamPlayer.Desktop/Converters/BooleanToBrushConverter.cs
new file mode 100644
index 0000000..bbe82bb
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Converters/BooleanToBrushConverter.cs
@@ -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;
+}
diff --git a/windows/StreamPlayer.Desktop/Converters/InverseBooleanConverter.cs b/windows/StreamPlayer.Desktop/Converters/InverseBooleanConverter.cs
new file mode 100644
index 0000000..63c9fed
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Converters/InverseBooleanConverter.cs
@@ -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;
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/JsonExtensions.cs b/windows/StreamPlayer.Desktop/JsonExtensions.cs
new file mode 100644
index 0000000..ca6bee0
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/JsonExtensions.cs
@@ -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;
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Models/ChannelSection.cs b/windows/StreamPlayer.Desktop/Models/ChannelSection.cs
new file mode 100644
index 0000000..cb416d0
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Models/ChannelSection.cs
@@ -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 Channels)
+{
+ public bool IsEvents => Type == SectionType.Events;
+}
diff --git a/windows/StreamPlayer.Desktop/Models/DeviceStatus.cs b/windows/StreamPlayer.Desktop/Models/DeviceStatus.cs
new file mode 100644
index 0000000..09dc83e
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Models/DeviceStatus.cs
@@ -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);
+}
diff --git a/windows/StreamPlayer.Desktop/Models/LiveEvent.cs b/windows/StreamPlayer.Desktop/Models/LiveEvent.cs
new file mode 100644
index 0000000..2f1322a
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Models/LiveEvent.cs
@@ -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}";
+ }
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Models/StreamChannel.cs b/windows/StreamPlayer.Desktop/Models/StreamChannel.cs
new file mode 100644
index 0000000..02c93e6
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Models/StreamChannel.cs
@@ -0,0 +1,3 @@
+namespace StreamPlayer.Desktop.Models;
+
+public sealed record StreamChannel(string Name, string PageUrl);
diff --git a/windows/StreamPlayer.Desktop/Models/UpdateInfo.cs b/windows/StreamPlayer.Desktop/Models/UpdateInfo.cs
new file mode 100644
index 0000000..7cf6ad7
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Models/UpdateInfo.cs
@@ -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]}";
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Program.cs b/windows/StreamPlayer.Desktop/Program.cs
new file mode 100644
index 0000000..b38e3a3
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Program.cs
@@ -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()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/windows/StreamPlayer.Desktop/Services/ChannelRepository.cs b/windows/StreamPlayer.Desktop/Services/ChannelRepository.cs
new file mode 100644
index 0000000..3f7808f
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Services/ChannelRepository.cs
@@ -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 Channels = BuildChannels();
+
+ public static IReadOnlyList GetChannels() => Channels;
+
+ private static IReadOnlyList BuildChannels()
+ {
+ var list = new List
+ {
+ 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();
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Services/DeviceRegistryService.cs b/windows/StreamPlayer.Desktop/Services/DeviceRegistryService.cs
new file mode 100644
index 0000000..781813b
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Services/DeviceRegistryService.cs
@@ -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 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();
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Services/DnsHelper.cs b/windows/StreamPlayer.Desktop/Services/DnsHelper.cs
new file mode 100644
index 0000000..b3ac569
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Services/DnsHelper.cs
@@ -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.
+ }
+ }
+ });
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Services/EventService.cs b/windows/StreamPlayer.Desktop/Services/EventService.cs
new file mode 100644
index 0000000..bcb07f8
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Services/EventService.cs
@@ -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> 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 events)
+ {
+ events = Array.Empty();
+ 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 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 ParseEvents(string json)
+ {
+ using var document = JsonDocument.Parse(json);
+ var results = new List();
+ 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;
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Services/SectionBuilder.cs b/windows/StreamPlayer.Desktop/Services/SectionBuilder.cs
new file mode 100644
index 0000000..617f6f8
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Services/SectionBuilder.cs
@@ -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 BuildSections()
+ {
+ var sections = new List
+ {
+ new("Eventos en vivo", SectionType.Events, Array.Empty())
+ };
+
+ var grouped = ChannelRepository.GetChannels()
+ .GroupBy(channel => DeriveGroupName(channel.Name))
+ .ToDictionary(group => group.Key, group => (IReadOnlyList)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;
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs b/windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs
new file mode 100644
index 0000000..9cae5ca
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs
@@ -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;
+
+///
+/// Replica el resolvedor ofuscado que utiliza la app Android para reconstruir la URL real del stream.
+///
+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 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 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 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();
+ 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);
+}
diff --git a/windows/StreamPlayer.Desktop/Services/UpdateService.cs b/windows/StreamPlayer.Desktop/Services/UpdateService.cs
new file mode 100644
index 0000000..7255763
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Services/UpdateService.cs
@@ -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 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 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 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 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;
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs b/windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs
new file mode 100644
index 0000000..1201651
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs
@@ -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 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 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 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);
+}
diff --git a/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.csproj b/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.csproj
new file mode 100644
index 0000000..9345e60
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.csproj
@@ -0,0 +1,36 @@
+
+
+ WinExe
+ net8.0
+ 9.4.6
+ 9.4.6.0
+ 9.4.6.0
+ enable
+ app.manifest
+ true
+ AnyCPU;x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
diff --git a/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.sln b/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.sln
new file mode 100644
index 0000000..7cff154
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.sln
@@ -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
diff --git a/windows/StreamPlayer.Desktop/ViewLocator.cs b/windows/StreamPlayer.Desktop/ViewLocator.cs
new file mode 100644
index 0000000..eccb44b
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/ViewLocator.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using StreamPlayer.Desktop.ViewModels;
+
+namespace StreamPlayer.Desktop;
+
+///
+/// Given a view model, returns the corresponding view if possible.
+///
+[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;
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/ViewModels/MainWindowViewModel.cs b/windows/StreamPlayer.Desktop/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..9fa8bad
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/ViewModels/MainWindowViewModel.cs
@@ -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 _sections;
+ private readonly AsyncRelayCommand _refreshEventsCommand;
+ private readonly RelayCommand _openChannelCommand;
+ private readonly RelayCommand _openEventCommand;
+
+ private bool _isInitialized;
+ private CancellationTokenSource? _eventsCts;
+
+ public ObservableCollection VisibleChannels { get; } = new();
+ public ObservableCollection 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 Sections => _sections;
+
+ public string RefreshButtonLabel => IsRefreshingEvents
+ ? "Actualizando..."
+ : "Actualizar eventos";
+
+ public bool IsInteractionLocked => IsDeviceCheckInProgress || !IsDeviceAllowed;
+
+ public IAsyncRelayCommand RefreshEventsCommand => _refreshEventsCommand;
+ public IRelayCommand OpenChannelCommand => _openChannelCommand;
+ public IRelayCommand OpenEventCommand => _openEventCommand;
+
+ public event EventHandler? ChannelRequested;
+ public event EventHandler? ErrorRaised;
+ public event EventHandler? UpdateAvailable;
+ public event EventHandler? DeviceStatusEvaluated;
+
+ public MainWindowViewModel()
+ {
+ var sections = SectionBuilder.BuildSections().ToList();
+ _sections = new ReadOnlyCollection(sections);
+ _refreshEventsCommand = new AsyncRelayCommand(
+ () => LoadEventsAsync(forceRefresh: true, CancellationToken.None),
+ () => IsShowingEvents && !IsRefreshingEvents && CanInteract());
+
+ _openChannelCommand = new RelayCommand(
+ channel =>
+ {
+ if (channel != null)
+ {
+ ChannelRequested?.Invoke(this, channel);
+ }
+ },
+ _ => CanInteract());
+
+ _openEventCommand = new RelayCommand(
+ 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));
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/ViewModels/ViewModelBase.cs b/windows/StreamPlayer.Desktop/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..175400f
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/ViewModels/ViewModelBase.cs
@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace StreamPlayer.Desktop.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
diff --git a/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml b/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml
new file mode 100644
index 0000000..29d7c0e
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml.cs b/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml.cs
new file mode 100644
index 0000000..8b8fdcb
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml.cs
@@ -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("ReasonText");
+ _tokenPanel = this.FindControl("TokenPanel");
+ _tokenText = this.FindControl("TokenText");
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Views/MainWindow.axaml b/windows/StreamPlayer.Desktop/Views/MainWindow.axaml
new file mode 100644
index 0000000..d57344e
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Views/MainWindow.axaml
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/StreamPlayer.Desktop/Views/MainWindow.axaml.cs b/windows/StreamPlayer.Desktop/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..9142ac7
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Views/MainWindow.axaml.cs
@@ -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(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);
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml b/windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml
new file mode 100644
index 0000000..361f77a
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml.cs b/windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml.cs
new file mode 100644
index 0000000..a976d87
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Views/PlayerWindow.axaml.cs
@@ -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("ChannelNameText");
+ _loadingOverlay = this.FindControl("LoadingOverlay");
+ _errorOverlay = this.FindControl("ErrorOverlay");
+ _statusText = this.FindControl("StatusText");
+ _videoView = this.FindControl("VideoView");
+ _errorText = this.FindControl("ErrorText");
+ _overlay = this.FindControl("Overlay");
+ }
+}
diff --git a/windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml b/windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml
new file mode 100644
index 0000000..4ed3156
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml.cs b/windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml.cs
new file mode 100644
index 0000000..a18eb57
--- /dev/null
+++ b/windows/StreamPlayer.Desktop/Views/UpdateDialog.axaml.cs
@@ -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("TitleText");
+ _infoText = this.FindControl("InfoText");
+ _notesText = this.FindControl("NotesText");
+ _viewReleaseButton = this.FindControl