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