feat: Implement Playlists and Programs Management

- Added PlaylistsGroupViewModel to manage playlists and selection.
- Introduced ProgramsGroupViewModel for handling program groups and subscriptions.
- Created ProgramsListViewModel to manage individual program listings.
- Developed SettingsViewModel for user settings including playlist management.
- Implemented TVPlayerViewModel as the main view model coordinating screens and data.
- Added PlayerView for video playback with LibVLC integration.
- Created XAML views for PlaylistsGroup, ProgramsGroup, ProgramsList, and Settings.
- Added sample M3U playlist for testing.
- Documented WPF build instructions and project structure in WPF-BUILD.md.
- Configured global.json for .NET SDK versioning.
This commit is contained in:
Vladimir
2026-03-22 12:11:24 +02:00
parent a6ec011e79
commit 1e8e444376
82 changed files with 2970 additions and 1687 deletions
+28
View File
@@ -0,0 +1,28 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TV_Player.AvaloniaApp.ViewModels"
xmlns:views="clr-namespace:TV_Player.AvaloniaApp.Views"
x:Class="TV_Player.AvaloniaApp.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
<Application.DataTemplates>
<DataTemplate DataType="vm:PlaylistsGroupViewModel">
<views:PlaylistsGroupView />
</DataTemplate>
<DataTemplate DataType="vm:ProgramsGroupViewModel">
<views:ProgramsGroupView />
</DataTemplate>
<DataTemplate DataType="vm:ProgramsListViewModel">
<views:ProgramsListView />
</DataTemplate>
<DataTemplate DataType="vm:PlayerViewModel">
<views:PlayerView />
</DataTemplate>
<DataTemplate DataType="vm:SettingsViewModel">
<views:SettingsView />
</DataTemplate>
</Application.DataTemplates>
</Application>
+29
View File
@@ -0,0 +1,29 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using TV_Player.AvaloniaApp.ViewModels;
namespace TV_Player.AvaloniaApp;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = TVPlayerViewModel.Instance.MainWindowViewModel
};
// Initialize screens after the Lazy singleton is fully constructed
TVPlayerViewModel.Instance.Initialize();
}
base.OnFrameworkInitializationCompleted();
}
}
+38
View File
@@ -0,0 +1,38 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Class="TV_Player.AvaloniaApp.MainWindow"
mc:Ignorable="d"
Width="1400"
Height="860"
MinWidth="980"
MinHeight="640"
Title="TV Player"
WindowState="{Binding CurrentWindowState}"
Background="#0B1220">
<Grid RowDefinitions="Auto,*">
<Border IsVisible="{Binding IsTopPanelVisible}"
Background="#AA111827"
BorderBrush="#33FFFFFF"
BorderThickness="0,0,0,1"
Padding="20,14">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<Button Content="Settings" Command="{Binding SettingsCommand}" MinWidth="92" Margin="0,0,12,0" />
<Button Grid.Column="1" Content="Back" Command="{Binding BackCommand}" MinWidth="92" Margin="0,0,12,0" />
<TextBlock Grid.Column="2"
Text="{Binding TopPanelTitle}"
FontSize="26"
FontWeight="SemiBold"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
<Button Grid.Column="3" Content="Fullscreen" Command="{Binding FullscreenCommand}" MinWidth="110" Margin="0,0,12,0" />
<Button Grid.Column="4" Content="Close" Command="{Binding CloseAppCommand}" MinWidth="92" />
</Grid>
</Border>
<Border Grid.Row="1" Padding="20">
<ContentControl Content="{Binding CurrentViewModel}" />
</Border>
</Grid>
</Window>
+29
View File
@@ -0,0 +1,29 @@
using Avalonia.Controls;
using Avalonia.Input;
using TV_Player.AvaloniaApp.ViewModels;
namespace TV_Player.AvaloniaApp;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
KeyDown += OnKeyDown;
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainWindowViewModel viewModel)
return;
if (e.Key == Key.Escape)
{
viewModel.OnCloseAppButtonClick();
}
else if (e.Key == Key.Back)
{
viewModel.TriggerBack();
}
}
}
+15
View File
@@ -0,0 +1,15 @@
using Avalonia;
using System;
namespace TV_Player.AvaloniaApp;
internal static class Program
{
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>TV_Player</RootNamespace>
<AssemblyName>TV Player Avalonia</AssemblyName>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="LibVLCSharp" Version="3.9.6" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.Reactive" Version="6.0.2" />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="$([MSBuild]::IsOSPlatform('macos'))" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="$([MSBuild]::IsOSPlatform('windows'))" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\TV Player WPF\PlaylistWorker\GroupInfo.cs" Link="Shared\GroupInfo.cs" />
<Compile Include="..\TV Player WPF\PlaylistWorker\M3UInfo.cs" Link="Shared\M3UInfo.cs" />
<Compile Include="..\TV Player WPF\PlaylistWorker\M3UParser.cs" Link="Shared\M3UParser.cs" />
<Compile Include="..\TV Player WPF\ViewModels\ObservableViewModelBase.cs" Link="Shared\ObservableViewModelBase.cs" />
<Compile Include="..\TV Player WPF\ViewModels\ProgramsData.cs" Link="Shared\ProgramsData.cs" />
<Compile Include="..\TV Player WPF\ViewModels\SettingsModel.cs" Link="Shared\SettingsModel.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,78 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using CommunityToolkit.Mvvm.Input;
using System.Windows.Input;
namespace TV_Player.AvaloniaApp.ViewModels;
public class MainWindowViewModel : TV_Player.ObservableViewModelBase
{
private object? _currentViewModel;
public object? CurrentViewModel
{
get => _currentViewModel;
set => SetProperty(ref _currentViewModel, value);
}
private bool _isTopPanelVisible;
public bool IsTopPanelVisible
{
get => _isTopPanelVisible;
set => SetProperty(ref _isTopPanelVisible, value);
}
private string _topPanelTitle = string.Empty;
public string TopPanelTitle
{
get => _topPanelTitle;
set => SetProperty(ref _topPanelTitle, value);
}
private WindowState _currentWindowState = WindowState.Normal;
public WindowState CurrentWindowState
{
get => _currentWindowState;
set => SetProperty(ref _currentWindowState, value);
}
public ICommand FullscreenCommand { get; }
public ICommand CloseAppCommand { get; }
public ICommand BackCommand { get; }
public ICommand SettingsCommand { get; }
public Action? ButtonBackAction { get; set; }
public MainWindowViewModel()
{
BackCommand = new RelayCommand(TriggerBack);
FullscreenCommand = new RelayCommand(OnFullScreenButtonClick);
SettingsCommand = new RelayCommand(OnSettingsButtonClick);
CloseAppCommand = new RelayCommand(OnCloseAppButtonClick);
}
public void OnFullScreenButtonClick()
{
CurrentWindowState = CurrentWindowState == WindowState.FullScreen
? WindowState.Normal
: WindowState.FullScreen;
}
public void OnCloseAppButtonClick()
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
}
private void OnSettingsButtonClick()
{
TVPlayerViewModel.Instance.ShowSettingsScreen();
}
public void TriggerBack()
{
ButtonBackAction?.Invoke();
}
}
@@ -0,0 +1,256 @@
using CommunityToolkit.Mvvm.Input;
using System.Diagnostics;
using System.Reactive.Linq;
using System.Windows.Input;
namespace TV_Player.AvaloniaApp.ViewModels;
public class PlayerViewModel : TV_Player.ObservableViewModelBase, IDisposable
{
private readonly IDisposable _programSubscription;
private IDisposable? _programGuideDisposable;
private IDisposable? _timer;
private M3UInfo _currentProgram;
private List<M3UInfo> _programs = new();
private ProgramGuide? _currentGuide;
private ProgramInfo? _currentProgramInfo;
private int _currentProgramIndex;
public ICommand BackCommand { get; }
public ICommand FullscreenCommand { get; }
public ICommand NextCommand { get; }
public ICommand PreviousCommand { get; }
public ICommand ShowProgramListCommand { get; }
public ICommand CloseAppCommand { get; }
public ICommand OpenStreamCommand { get; }
private string _topPanelTitle = string.Empty;
public string TopPanelTitle
{
get => _topPanelTitle;
set => SetProperty(ref _topPanelTitle, value);
}
private string _programGuideText = string.Empty;
public string ProgramGuideText
{
get => _programGuideText;
set => SetProperty(ref _programGuideText, value);
}
private string _startProgram = string.Empty;
public string StartProgram
{
get => _startProgram;
set => SetProperty(ref _startProgram, value);
}
private string _endProgram = string.Empty;
public string EndProgram
{
get => _endProgram;
set => SetProperty(ref _endProgram, value);
}
private int _durationValue;
public int DurationValue
{
get => _durationValue;
set => SetProperty(ref _durationValue, value);
}
private bool _isProgramInfoVisible;
public bool IsProgramInfoVisible
{
get => _isProgramInfoVisible;
set => SetProperty(ref _isProgramInfoVisible, value);
}
private bool _programGuideVisible;
public bool ProgramGuideVisible
{
get => _programGuideVisible;
set => SetProperty(ref _programGuideVisible, value);
}
private string _streamUrl = string.Empty;
public string StreamUrl
{
get => _streamUrl;
set => SetProperty(ref _streamUrl, value);
}
private string _playbackStatus = "Buffering stream...";
public string PlaybackStatus
{
get => _playbackStatus;
set => SetProperty(ref _playbackStatus, value);
}
private bool _hasPlaybackStatus = true;
public bool HasPlaybackStatus
{
get => _hasPlaybackStatus;
set => SetProperty(ref _hasPlaybackStatus, value);
}
private List<ProgramInfo> _programItems = new();
public List<ProgramInfo> Programs
{
get => _programItems;
set => SetProperty(ref _programItems, value);
}
public PlayerViewModel(M3UInfo selectedProgram)
{
_currentProgram = selectedProgram;
BackCommand = new RelayCommand(OnButtonBackClick);
NextCommand = new RelayCommand(NextProgram);
PreviousCommand = new RelayCommand(PreviousProgram);
FullscreenCommand = new RelayCommand(TVPlayerViewModel.Instance.FullScreenToggle);
CloseAppCommand = new RelayCommand(TVPlayerViewModel.Instance.CloseAppCommand);
ShowProgramListCommand = new RelayCommand(ShowProgramList);
OpenStreamCommand = new RelayCommand(OpenStreamExternally);
_programSubscription = TVPlayerViewModel.Instance.CurrentProgramsData!
.AllPrograms
.Subscribe(programs =>
{
_programs = programs.Where(p => p.GroupTitle == _currentProgram.GroupTitle).ToList();
_currentProgramIndex = _programs.FindIndex(p => p.Name == _currentProgram.Name);
if (_currentProgramIndex < 0)
_currentProgramIndex = 0;
});
UpdateUi();
}
public void SetPlaybackStatus(string status)
{
PlaybackStatus = status;
HasPlaybackStatus = !string.IsNullOrWhiteSpace(status);
}
private void UpdateUi()
{
TVPlayerViewModel.Instance.TopPanelVisible(false, _currentProgram.Name);
TopPanelTitle = _currentProgram.Name;
StreamUrl = _currentProgram.Url;
SetPlaybackStatus(string.IsNullOrWhiteSpace(StreamUrl)
? "No stream URL available for this channel."
: "Buffering stream...");
ProgramGuideVisible = false;
_programGuideDisposable?.Dispose();
_timer?.Dispose();
_programGuideDisposable = TVPlayerViewModel.Instance.CurrentProgramsData!
.ProgramGuideInfo
.Subscribe(async _ =>
{
try
{
_currentGuide = await TVPlayerViewModel.Instance.CurrentProgramsData.GetGuideByProgram(_currentProgram.TvgID);
UpdateScreenInfo();
_timer = Observable.Interval(TimeSpan.FromMinutes(1)).Subscribe(_ => UpdateScreenInfo());
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to load program guide: {ex.Message}");
}
});
}
private void PreviousProgram()
{
if (_programs.Count == 0)
return;
_currentProgramIndex = (_currentProgramIndex - 1 + _programs.Count) % _programs.Count;
_currentProgram = _programs[_currentProgramIndex];
UpdateUi();
}
private void NextProgram()
{
if (_programs.Count == 0)
return;
_currentProgramIndex = (_currentProgramIndex + 1) % _programs.Count;
_currentProgram = _programs[_currentProgramIndex];
UpdateUi();
}
private void ShowProgramList()
{
ProgramGuideVisible = !ProgramGuideVisible;
}
private void OpenStreamExternally()
{
if (string.IsNullOrWhiteSpace(StreamUrl))
return;
Process.Start(new ProcessStartInfo
{
FileName = StreamUrl,
UseShellExecute = true
});
}
private void UpdateScreenInfo()
{
try
{
if (_currentGuide?.Programs == null || _currentGuide.Programs.Count == 0)
{
IsProgramInfoVisible = false;
Programs = new List<ProgramInfo>();
return;
}
_currentProgramInfo = _currentGuide.Programs
.FirstOrDefault(item => item.StartTime <= DateTime.Now && item.EndTime >= DateTime.Now);
if (_currentProgramInfo == null)
{
IsProgramInfoVisible = false;
Programs = _currentGuide.Programs.Take(7).ToList();
return;
}
var currentIndex = _currentGuide.Programs.FindIndex(x => x.Title == _currentProgramInfo.Title);
Programs = _currentGuide.Programs.Skip(Math.Max(currentIndex, 0)).Take(7).ToList();
IsProgramInfoVisible = true;
ProgramGuideText = _currentProgramInfo.Title;
StartProgram = _currentProgramInfo.StartTime.ToShortTimeString();
EndProgram = _currentProgramInfo.EndTime.ToShortTimeString();
var totalMinutes = (_currentProgramInfo.EndTime - _currentProgramInfo.StartTime).TotalMinutes;
DurationValue = totalMinutes <= 0
? 0
: (int)((DateTime.Now - _currentProgramInfo.StartTime).TotalMinutes / totalMinutes * 100);
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to update screen info: {ex.Message}");
}
}
private void OnButtonBackClick()
{
TVPlayerViewModel.Instance.ShowProgramsListScreen(new GroupInfo
{
Name = _currentProgram.GroupTitle,
Count = 0
});
}
public void Dispose()
{
_programSubscription.Dispose();
_programGuideDisposable?.Dispose();
_timer?.Dispose();
}
}
@@ -0,0 +1,32 @@
using CommunityToolkit.Mvvm.Input;
using System.Windows.Input;
namespace TV_Player.AvaloniaApp.ViewModels;
public class PlaylistsGroupViewModel : TV_Player.ObservableViewModelBase
{
private List<GroupInfo> _programs = new();
public List<GroupInfo> Programs
{
get => _programs;
set => SetProperty(ref _programs, value);
}
public ICommand SelectPlaylistCommand { get; }
public PlaylistsGroupViewModel()
{
SelectPlaylistCommand = new RelayCommand<GroupInfo>(OnItemSelected);
Programs = TVPlayerViewModel.Instance.PlayListsData
.Select(x => new GroupInfo { Name = x.Key, Count = 0 })
.ToList();
}
private void OnItemSelected(GroupInfo? group)
{
if (group == null)
return;
TVPlayerViewModel.Instance.ShowProgramsGroupScreen(group.Name);
}
}
@@ -0,0 +1,58 @@
using CommunityToolkit.Mvvm.Input;
using System.Windows.Input;
using TV_Player.ViewModels;
namespace TV_Player.AvaloniaApp.ViewModels;
public class ProgramsGroupViewModel : TV_Player.ObservableViewModelBase, IDisposable
{
private List<GroupInfo> _programs = new();
private readonly IDisposable _groupInformationSubscriber;
public List<GroupInfo> Programs
{
get => _programs;
set => SetProperty(ref _programs, value);
}
public ICommand SelectGroupCommand { get; }
public ProgramsGroupViewModel()
{
System.Diagnostics.Debug.WriteLine("[ProgramsGroupViewModel] Initializing...");
SelectGroupCommand = new RelayCommand<GroupInfo>(OnItemSelected);
if (TVPlayerViewModel.Instance.CurrentProgramsData == null)
{
System.Diagnostics.Debug.WriteLine("[ProgramsGroupViewModel] ERROR: CurrentProgramsData is null!");
Programs = new List<GroupInfo>();
return;
}
_groupInformationSubscriber = TVPlayerViewModel.Instance.CurrentProgramsData
.GroupsInformation
.Subscribe(groups =>
{
System.Diagnostics.Debug.WriteLine($"[ProgramsGroupViewModel.Subscribe] Received {groups.Count} groups from observable");
// Filter hidden groups but keep all others (including "undefined")
var filteredGroups = SettingsModel.HiddenGroups == null
? groups
: groups.Where(g => !SettingsModel.HiddenGroups.Contains(g.Name.ToLowerInvariant())).ToList();
System.Diagnostics.Debug.WriteLine($"[ProgramsGroupViewModel] Groups after filter: {string.Join(", ", filteredGroups.Select(g => $"{g.Name}({g.Count})"))}");
Programs = filteredGroups;
});
}
private void OnItemSelected(GroupInfo? group)
{
if (group == null)
return;
TVPlayerViewModel.Instance.ShowProgramsListScreen(group);
}
public void Dispose()
{
_groupInformationSubscriber.Dispose();
}
}
@@ -0,0 +1,51 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using Avalonia.Threading;
namespace TV_Player.AvaloniaApp.ViewModels;
public class ProgramsListViewModel : TV_Player.ObservableViewModelBase, IDisposable
{
private readonly IDisposable _programSubscription;
private ObservableCollection<M3UInfo> _programs = new();
public ObservableCollection<M3UInfo> Programs
{
get => _programs;
set => SetProperty(ref _programs, value);
}
public ICommand SelectProgramCommand { get; }
public ProgramsListViewModel(GroupInfo groupInfo)
{
SelectProgramCommand = new RelayCommand<M3UInfo>(OnItemSelected);
_programSubscription = TVPlayerViewModel.Instance.CurrentProgramsData!
.AllPrograms
.Subscribe(newPrograms =>
{
var filteredPrograms = newPrograms
.Where(p => p.GroupTitle == groupInfo.Name && !string.IsNullOrWhiteSpace(p.Url))
.ToList();
Dispatcher.UIThread.Post(() =>
{
Programs = new ObservableCollection<M3UInfo>(filteredPrograms);
});
});
}
private void OnItemSelected(M3UInfo? program)
{
if (program == null)
return;
TVPlayerViewModel.Instance.ShowPlayerScreen(program);
}
public void Dispose()
{
_programSubscription.Dispose();
}
}
@@ -0,0 +1,101 @@
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
using System.Windows.Input;
using TV_Player.ViewModels;
namespace TV_Player.AvaloniaApp.ViewModels;
public class SettingsViewModel : TV_Player.ObservableViewModelBase
{
private string _playlistName = string.Empty;
public string PlaylistName
{
get => _playlistName;
set => SetProperty(ref _playlistName, value);
}
private string _playlistUrl = string.Empty;
public string PlaylistURL
{
get => _playlistUrl;
set => SetProperty(ref _playlistUrl, value);
}
private ObservableCollection<KeyValuePair<string, string>> _playlists = new();
public ObservableCollection<KeyValuePair<string, string>> Playlists
{
get => _playlists;
set => SetProperty(ref _playlists, value);
}
private bool _startFullScreen;
public bool StartFullScreen
{
get => _startFullScreen;
set => SetProperty(ref _startFullScreen, value);
}
private bool _startLastScreen;
public bool StartLastScreen
{
get => _startLastScreen;
set => SetProperty(ref _startLastScreen, value);
}
public ICommand SaveCommand { get; }
public ICommand PlaylistDeleteCommand { get; }
public ICommand BackCommand { get; }
public ICommand AddPlaylistCommand { get; }
public SettingsViewModel()
{
SaveCommand = new RelayCommand(OnSaveSettings);
BackCommand = new RelayCommand(OnBackCommand);
AddPlaylistCommand = new RelayCommand(OnAddPlaylistCommand);
PlaylistDeleteCommand = new RelayCommand<KeyValuePair<string, string>>(OnPlaylistDeleteCommand);
StartFullScreen = SettingsModel.StartFullScreen;
StartLastScreen = SettingsModel.StartFromLastScreen;
Playlists = SettingsModel.Playlists == null
? new ObservableCollection<KeyValuePair<string, string>>()
: new ObservableCollection<KeyValuePair<string, string>>(SettingsModel.Playlists);
}
private void OnAddPlaylistCommand()
{
var url = PlaylistURL?.Trim();
var name = PlaylistName?.Trim();
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
return;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uriResult) ||
(uriResult.Scheme != Uri.UriSchemeHttp && uriResult.Scheme != Uri.UriSchemeHttps))
return;
if (Playlists.Any(pair => pair.Key.Equals(name, StringComparison.OrdinalIgnoreCase)))
return;
Playlists.Add(new KeyValuePair<string, string>(name, url));
PlaylistName = string.Empty;
PlaylistURL = string.Empty;
}
private void OnPlaylistDeleteCommand(KeyValuePair<string, string> pair)
{
Playlists.Remove(pair);
}
private void OnBackCommand()
{
TVPlayerViewModel.Instance.SelectScreen();
}
private void OnSaveSettings()
{
SettingsModel.StartFullScreen = StartFullScreen;
SettingsModel.StartFromLastScreen = StartLastScreen;
SettingsModel.Playlists = Playlists.ToDictionary(pair => pair.Key, pair => pair.Value);
SettingsModel.SaveSetttings();
TVPlayerViewModel.Instance.InitializeTVWithData();
}
}
@@ -0,0 +1,159 @@
using TV_Player.ViewModels;
namespace TV_Player.AvaloniaApp.ViewModels;
public class TVPlayerViewModel : IDisposable
{
private static readonly Lazy<TVPlayerViewModel> LazyInstance = new(() => new TVPlayerViewModel());
public static TVPlayerViewModel Instance => LazyInstance.Value;
private readonly MainWindowViewModel _mainWindowViewModel;
public MainWindowViewModel MainWindowViewModel => _mainWindowViewModel;
public ProgramsData? CurrentProgramsData { get; private set; }
public Dictionary<string, ProgramsData> PlayListsData { get; } = new();
public string? CurrentPlaylistName { get; private set; }
private TVPlayerViewModel()
{
_mainWindowViewModel = new MainWindowViewModel();
SettingsModel.LoadSettings();
}
public void Initialize()
{
InitializeTVWithData();
}
public void InitializeTVWithData()
{
if (SettingsModel.Playlists is { Count: > 0 })
{
PlayListsData.Clear();
foreach (var playlist in SettingsModel.Playlists)
{
PlayListsData[playlist.Key] = new ProgramsData(playlist.Key, playlist.Value);
}
if (SettingsModel.StartFullScreen)
{
FullScreenToggle();
}
if (SettingsModel.StartFromLastScreen)
{
SelectScreen();
}
else
{
ShowPlaylistsGroupScreen();
}
}
else
{
ShowSettingsScreen();
}
}
public void SelectScreen()
{
switch (SettingsModel.LastScreen)
{
case nameof(ProgramsListViewModel):
if (SettingsModel.Group != null)
ShowProgramsListScreen(SettingsModel.Group);
else
ShowPlaylistsGroupScreen();
break;
case nameof(PlayerViewModel):
if (SettingsModel.Program != null)
ShowPlayerScreen(SettingsModel.Program);
else
ShowPlaylistsGroupScreen();
break;
default:
ShowPlaylistsGroupScreen();
break;
}
}
public void ShowPlaylistsGroupScreen()
{
SettingsModel.LastScreen = nameof(ProgramsGroupViewModel);
TopPanelVisible(true, "Playlists");
SetPageContext(new PlaylistsGroupViewModel());
}
public void ShowProgramsGroupScreen(string playlistName)
{
var selectedData = PlayListsData.First(x => x.Key == playlistName);
CurrentPlaylistName = selectedData.Key;
CurrentProgramsData = selectedData.Value;
SettingsModel.LastScreen = nameof(ProgramsGroupViewModel);
TopPanelVisible(true, "Groups");
SetBackButtonAction(ShowPlaylistsGroupScreen);
SetPageContext(new ProgramsGroupViewModel());
}
public void ShowProgramsListScreen(GroupInfo group)
{
if (CurrentPlaylistName == null)
return;
SettingsModel.Group = group;
SettingsModel.LastScreen = nameof(ProgramsListViewModel);
TopPanelVisible(true, group.Name);
SetBackButtonAction(() => ShowProgramsGroupScreen(CurrentPlaylistName));
SetPageContext(new ProgramsListViewModel(group));
}
public void ShowPlayerScreen(M3UInfo program)
{
SettingsModel.Program = program;
SettingsModel.LastScreen = nameof(PlayerViewModel);
SetPageContext(new PlayerViewModel(program));
}
public void ShowSettingsScreen()
{
TopPanelVisible(false, string.Empty);
SetPageContext(new SettingsViewModel());
}
public void TopPanelVisible(bool value, string title)
{
_mainWindowViewModel.IsTopPanelVisible = value;
_mainWindowViewModel.TopPanelTitle = title;
}
public void FullScreenToggle()
{
_mainWindowViewModel.OnFullScreenButtonClick();
}
public void CloseAppCommand()
{
_mainWindowViewModel.OnCloseAppButtonClick();
}
public void SetBackButtonAction(Action action)
{
_mainWindowViewModel.ButtonBackAction = action;
}
private void SetPageContext(object viewModel)
{
if (_mainWindowViewModel.CurrentViewModel is IDisposable disposable)
disposable.Dispose();
_mainWindowViewModel.CurrentViewModel = viewModel;
SettingsModel.SaveSetttings();
}
public void Dispose()
{
if (_mainWindowViewModel.CurrentViewModel is IDisposable disposable)
disposable.Dispose();
}
}
+72
View File
@@ -0,0 +1,72 @@
<UserControl 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="TV_Player.AvaloniaApp.Views.PlayerView">
<Grid RowDefinitions="*,Auto" ColumnDefinitions="280,*">
<Border Grid.RowSpan="2"
Background="#AA111827"
Padding="16"
CornerRadius="16"
IsVisible="{Binding ProgramGuideVisible}">
<StackPanel Spacing="10">
<TextBlock Text="Upcoming" FontSize="22" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Programs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Padding="8" Background="#1F2937" CornerRadius="10" Margin="0,0,0,8">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding StartTime, StringFormat='{}{0:HH:mm}'}" />
<TextBlock Text="-" />
<TextBlock Text="{Binding EndTime, StringFormat='{}{0:HH:mm}'}" />
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Border Grid.Column="1" Background="#0F172A" CornerRadius="20" Padding="20" Margin="16,0,0,16">
<Grid RowDefinitions="Auto,*,Auto">
<TextBlock Text="{Binding TopPanelTitle}" FontSize="30" FontWeight="Bold" TextAlignment="Center" Margin="0,0,0,16" />
<Grid Grid.Row="1">
<vlc:VideoView x:Name="VideoView" />
<Border Background="#66000000" Padding="14" CornerRadius="12"
HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="20"
IsVisible="{Binding HasPlaybackStatus}">
<StackPanel Spacing="8" MaxWidth="720">
<TextBlock Text="{Binding PlaybackStatus}" TextWrapping="Wrap" TextAlignment="Center" />
<SelectableTextBlock Text="{Binding StreamUrl}" TextWrapping="Wrap" IsVisible="False" />
</StackPanel>
</Border>
</Grid>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12">
<Button Content="Previous" Command="{Binding PreviousCommand}" />
<Button Content="Open Externally" Command="{Binding OpenStreamCommand}" />
<Button Content="Next" Command="{Binding NextCommand}" />
</StackPanel>
</Grid>
</Border>
<Border Grid.Row="1" Grid.Column="1" Background="#AA111827" Padding="16" CornerRadius="16">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto" VerticalAlignment="Center">
<Button Content="Back" Command="{Binding BackCommand}" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" IsVisible="{Binding IsProgramInfoVisible}" Spacing="6">
<TextBlock Text="{Binding ProgramGuideText}" FontSize="18" FontWeight="SemiBold" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding StartProgram}" />
<ProgressBar Width="260" Maximum="100" Value="{Binding DurationValue}" />
<TextBlock Text="{Binding EndProgram}" />
</StackPanel>
</StackPanel>
<Button Grid.Column="2" Content="Guide" Command="{Binding ShowProgramListCommand}" Margin="0,0,12,0" />
<Button Grid.Column="3" Content="Fullscreen" Command="{Binding FullscreenCommand}" Margin="0,0,12,0" />
<Button Grid.Column="4" Content="Open" Command="{Binding OpenStreamCommand}" Margin="0,0,12,0" />
<Button Grid.Column="5" Content="Close" Command="{Binding CloseAppCommand}" />
</Grid>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,109 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LibVLCSharp.Shared;
using System.ComponentModel;
using TV_Player.AvaloniaApp.ViewModels;
namespace TV_Player.AvaloniaApp.Views;
public partial class PlayerView : UserControl
{
private LibVLC? _libVlc;
private MediaPlayer? _mediaPlayer;
private PlayerViewModel? _viewModel;
private bool _initialized;
public PlayerView()
{
AvaloniaXamlLoader.Load(this);
DataContextChanged += OnDataContextChanged;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
}
private void OnAttachedToVisualTree(object? sender, Avalonia.VisualTreeAttachmentEventArgs e)
{
if (_initialized)
return;
Core.Initialize();
_libVlc = new LibVLC(enableDebugLogs: true);
_mediaPlayer = new MediaPlayer(_libVlc)
{
EnableHardwareDecoding = true
};
VideoView.MediaPlayer = _mediaPlayer;
_initialized = true;
PlayCurrentStream();
}
private void OnDetachedFromVisualTree(object? sender, Avalonia.VisualTreeAttachmentEventArgs e)
{
if (_viewModel != null)
{
_viewModel.PropertyChanged -= OnViewModelPropertyChanged;
}
_mediaPlayer?.Stop();
_mediaPlayer?.Dispose();
_libVlc?.Dispose();
_mediaPlayer = null;
_libVlc = null;
_initialized = false;
}
private void OnDataContextChanged(object? sender, System.EventArgs e)
{
if (_viewModel != null)
{
_viewModel.PropertyChanged -= OnViewModelPropertyChanged;
}
_viewModel = DataContext as PlayerViewModel;
if (_viewModel != null)
{
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
}
PlayCurrentStream();
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(PlayerViewModel.StreamUrl))
{
PlayCurrentStream();
}
}
private void PlayCurrentStream()
{
if (!_initialized || _viewModel == null || _mediaPlayer == null || _libVlc == null)
return;
if (string.IsNullOrWhiteSpace(_viewModel.StreamUrl))
{
_viewModel.SetPlaybackStatus("No stream URL available for this channel.");
return;
}
try
{
if (_mediaPlayer.Media != null)
{
_mediaPlayer.Stop();
_mediaPlayer.Media.Dispose();
}
var media = new Media(_libVlc, new Uri(_viewModel.StreamUrl));
_mediaPlayer.Media = media;
_mediaPlayer.Play();
_viewModel.SetPlaybackStatus("Playing with embedded VLC.");
}
catch (System.Exception ex)
{
_viewModel.SetPlaybackStatus($"Embedded playback failed: {ex.Message}. Use Open Externally as a fallback.");
}
}
}
@@ -0,0 +1,33 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="TV_Player.AvaloniaApp.Views.PlaylistsGroupView"
x:Name="Root">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Programs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Width="260"
Height="120"
Margin="10"
Padding="18"
CornerRadius="16"
Background="#AA111827"
BorderBrush="#EAB308"
BorderThickness="2"
Command="{Binding DataContext.SelectPlaylistCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Name}" FontSize="24" FontWeight="Bold" TextWrapping="Wrap" />
<TextBlock Text="Open playlist" Foreground="#D1D5DB" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace TV_Player.AvaloniaApp.Views;
public partial class PlaylistsGroupView : UserControl
{
public PlaylistsGroupView()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,43 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="TV_Player.AvaloniaApp.Views.ProgramsGroupView">
<Grid RowDefinitions="Auto,*">
<!-- Debug Info Header -->
<Border Grid.Row="0" Background="#1a1a2e" Padding="16" Margin="0,0,0,4">
<TextBlock Text="{Binding Programs.Count, StringFormat='Groups Loaded: {0}'}"
Foreground="#EAB308"
FontSize="14"
FontWeight="Bold" />
</Border>
<!-- Groups List -->
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Programs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Width="260"
Height="140"
Margin="10"
Padding="18"
CornerRadius="16"
Background="#AA111827"
BorderBrush="#EAB308"
BorderThickness="2"
Command="{Binding DataContext.SelectGroupCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Name}" FontSize="24" FontWeight="Bold" TextWrapping="Wrap" />
<TextBlock Text="{Binding Count, StringFormat='Channels: {0}'}" Foreground="#D1D5DB" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace TV_Player.AvaloniaApp.Views;
public partial class ProgramsGroupView : UserControl
{
public ProgramsGroupView()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,34 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="TV_Player.AvaloniaApp.Views.ProgramsListView">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Programs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Width="160"
Height="170"
Margin="8"
Padding="10"
CornerRadius="14"
Background="#AA111827"
BorderBrush="#EAB308"
BorderThickness="2"
Command="{Binding DataContext.SelectProgramCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<StackPanel Spacing="10">
<Border Height="90" Background="#1F2937" CornerRadius="10">
<Image Source="{Binding Logo}" Stretch="Uniform" />
</Border>
<TextBlock Text="{Binding Name}" TextWrapping="Wrap" TextAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace TV_Player.AvaloniaApp.Views;
public partial class ProgramsListView : UserControl
{
public ProgramsListView()
{
AvaloniaXamlLoader.Load(this);
}
}
@@ -0,0 +1,47 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="TV_Player.AvaloniaApp.Views.SettingsView">
<ScrollViewer>
<StackPanel Width="900" HorizontalAlignment="Center" Spacing="18">
<TextBlock Text="Settings" FontSize="30" FontWeight="Bold" HorizontalAlignment="Center" />
<Border Background="#AA111827" Padding="18" CornerRadius="16">
<StackPanel Spacing="12">
<TextBlock Text="Playlist URL" />
<TextBox Text="{Binding PlaylistURL}" Watermark="https://example.com/playlist.m3u" />
<TextBlock Text="Playlist name" />
<TextBox Text="{Binding PlaylistName}" Watermark="My IPTV" />
<Button Content="Add playlist" Command="{Binding AddPlaylistCommand}" HorizontalAlignment="Left" />
</StackPanel>
</Border>
<Border Background="#AA111827" Padding="18" CornerRadius="16">
<StackPanel Spacing="12">
<TextBlock Text="Playlists" FontSize="22" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Playlists}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="220,*,Auto" Margin="0,4">
<TextBlock Text="{Binding Key}" VerticalAlignment="Center" Margin="0,0,12,0" />
<TextBlock Grid.Column="1" Text="{Binding Value}" VerticalAlignment="Center" TextWrapping="Wrap" Margin="0,0,12,0" />
<Button Grid.Column="2"
Content="Remove"
Command="{Binding DataContext.PlaylistDeleteCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<CheckBox IsChecked="{Binding StartFullScreen}" Content="Start in fullscreen" />
<CheckBox IsChecked="{Binding StartLastScreen}" Content="Remember last screen" />
<StackPanel Orientation="Horizontal" Spacing="12" HorizontalAlignment="Center">
<Button Content="Back" Command="{Binding BackCommand}" MinWidth="120" />
<Button Content="Save" Command="{Binding SaveCommand}" MinWidth="120" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace TV_Player.AvaloniaApp.Views;
public partial class SettingsView : UserControl
{
public SettingsView()
{
AvaloniaXamlLoader.Load(this);
}
}
View File