Add Windows desktop version

This commit is contained in:
renato97
2025-12-17 19:20:55 +00:00
parent 93dbe0941e
commit 8921d7f2a6
36 changed files with 2760 additions and 0 deletions

6
.gitignore vendored
View File

@@ -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/

View 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>

View 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);
}
}
}

View 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";
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}

View 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);
}

View 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}";
}
}
}

View File

@@ -0,0 +1,3 @@
namespace StreamPlayer.Desktop.Models;
public sealed record StreamChannel(string Name, string PageUrl);

View 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]}";
}
}

View 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();
}

View 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();
}
}

View File

@@ -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();
}
}

View 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.
}
}
});
}
}

View 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;
}
}

View 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;
}
}

View 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);
}

View 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;
}
}

View 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);
}

View 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>

View 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

View 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;
}
}

View 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));
}
}

View File

@@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace StreamPlayer.Desktop.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View 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>

View 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");
}
}

View 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>

View 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);
}
}

View 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>

View 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");
}
}

View 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>

View 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");
}
}

View 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>