From f99562546051bb03ce093c5a519787f50a688f38 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 22 Mar 2026 16:58:11 +0200 Subject: [PATCH] feat: Add macOS VLC native bundling and improve UI styling - Added AsyncImageLoader package for improved image loading. - Implemented macOS native VLC library bundling with a script to fetch and copy necessary files. - Enhanced PlayerView UI with updated colors and improved layout for better user experience. - Refactored media playback logic in PlayerView to handle buffering and errors more gracefully. - Updated Playlists and Programs views to use consistent styling and improved text colors. - Introduced a new settings layout with better organization and visual appeal. --- .DS_Store | Bin 6148 -> 6148 bytes TV Player Avalonia/App.axaml | 135 +++++- TV Player Avalonia/MainWindow.axaml | 41 +- TV Player Avalonia/TV Player Avalonia.csproj | 31 +- TV Player Avalonia/Views/PlayerView.axaml | 66 +-- TV Player Avalonia/Views/PlayerView.axaml.cs | 430 +++++++++++++++++- .../Views/PlaylistsGroupView.axaml | 5 +- .../Views/ProgramsGroupView.axaml | 7 +- .../Views/ProgramsListView.axaml | 14 +- TV Player Avalonia/Views/SettingsView.axaml | 128 +++--- TV Player Avalonia/scripts/fetch-vlc-macos.sh | 68 +++ 11 files changed, 761 insertions(+), 164 deletions(-) create mode 100644 TV Player Avalonia/scripts/fetch-vlc-macos.sh diff --git a/.DS_Store b/.DS_Store index a5623a63c267c60ee0524c2943f44867a19080a2..a11fa4b5d39102c048ab3825db381525287e1aa4 100644 GIT binary patch delta 71 zcmZoMXfc=|#>B)qF;Q%yo+2a9#(>?7j69QhSgI%QWR>3R#CnuzW5EKZ&Fmcf96)88 Z1v$PmPv#eKB!kF;Q%yo+6{b#(>?7ixpUy7zHNtFjd!cGK4UMF(@zuFyt^KGE_38 zG8E;c8wMxm=N2%40L#&fKoX0p+f|sz`z8wk709!$Qot + RequestedThemeVariant="Default"> + + + + + + + + + + + - + - + - + + + + + diff --git a/TV Player Avalonia/MainWindow.axaml b/TV Player Avalonia/MainWindow.axaml index 5a192d8..544eff3 100644 --- a/TV Player Avalonia/MainWindow.axaml +++ b/TV Player Avalonia/MainWindow.axaml @@ -10,43 +10,42 @@ MinHeight="450" Title="TV" WindowState="{Binding CurrentWindowState}" - SystemDecorations="None"> + Background="#EEF1F5"> - - - - - + - - - - - + - + + + diff --git a/TV Player Avalonia/TV Player Avalonia.csproj b/TV Player Avalonia/TV Player Avalonia.csproj index a33e095..9542744 100644 --- a/TV Player Avalonia/TV Player Avalonia.csproj +++ b/TV Player Avalonia/TV Player Avalonia.csproj @@ -10,6 +10,7 @@ + @@ -18,7 +19,6 @@ - @@ -29,4 +29,33 @@ + + + + PreserveNewest + natives/macos/%(RecursiveDir)%(Filename)%(Extension) + + + + + + + + + + + + + + + + + + diff --git a/TV Player Avalonia/Views/PlayerView.axaml b/TV Player Avalonia/Views/PlayerView.axaml index 3f4986e..a3b9748 100644 --- a/TV Player Avalonia/Views/PlayerView.axaml +++ b/TV Player Avalonia/Views/PlayerView.axaml @@ -5,51 +5,55 @@ - + - + + - - - - + - + - - + + @@ -57,7 +61,7 @@ - + @@ -65,43 +69,45 @@ - - - - - + diff --git a/TV Player Avalonia/Views/PlayerView.axaml.cs b/TV Player Avalonia/Views/PlayerView.axaml.cs index 721e19c..57df67c 100644 --- a/TV Player Avalonia/Views/PlayerView.axaml.cs +++ b/TV Player Avalonia/Views/PlayerView.axaml.cs @@ -1,7 +1,13 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; +using Avalonia.Threading; using LibVLCSharp.Shared; +using System.Diagnostics; using System.ComponentModel; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Web; using TV_Player.AvaloniaApp.ViewModels; namespace TV_Player.AvaloniaApp.Views; @@ -10,12 +16,17 @@ public partial class PlayerView : UserControl { private LibVLC? _libVlc; private MediaPlayer? _mediaPlayer; + private Media? _currentMedia; private PlayerViewModel? _viewModel; private bool _initialized; + private CancellationTokenSource? _bufferingWatchdogCts; + private string? _activeStreamUrl; + private bool _fallbackAttempted; public PlayerView() { AvaloniaXamlLoader.Load(this); + Log("PlayerView ctor"); DataContextChanged += OnDataContextChanged; AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; @@ -26,35 +37,178 @@ public partial class PlayerView : UserControl if (_initialized) return; - Core.Initialize(); - _libVlc = new LibVLC(enableDebugLogs: true); - _mediaPlayer = new MediaPlayer(_libVlc) + Log("Attach to visual tree -> initializing VLC"); + try { - EnableHardwareDecoding = true + string? macPluginDirectory = null; + if (OperatingSystem.IsMacOS()) + { + if (!TryGetMacLibVlcPaths(out var libVlcDirectory, out macPluginDirectory)) + { + const string message = "VLC native dependencies not found. Build once with internet to auto-bundle natives/macos (lib + plugins)."; + Log(message); + _viewModel?.SetPlaybackStatus(message); + return; + } + + if (!string.IsNullOrWhiteSpace(macPluginDirectory) && Directory.Exists(macPluginDirectory)) + { + Environment.SetEnvironmentVariable("VLC_PLUGIN_PATH", macPluginDirectory); + Log($"VLC_PLUGIN_PATH set to: {macPluginDirectory}"); + } + + var dyldLibraryPath = PrependPath(Environment.GetEnvironmentVariable("DYLD_LIBRARY_PATH"), libVlcDirectory); + var dyldFallbackPath = PrependPath(Environment.GetEnvironmentVariable("DYLD_FALLBACK_LIBRARY_PATH"), libVlcDirectory); + Environment.SetEnvironmentVariable("DYLD_LIBRARY_PATH", dyldLibraryPath); + Environment.SetEnvironmentVariable("DYLD_FALLBACK_LIBRARY_PATH", dyldFallbackPath); + Log($"DYLD_LIBRARY_PATH set to: {dyldLibraryPath}"); + Log($"DYLD_FALLBACK_LIBRARY_PATH set to: {dyldFallbackPath}"); + + Log($"Core.Initialize(path) start: {libVlcDirectory}"); + Core.Initialize(libVlcDirectory); + Log("Core.Initialize(path) completed"); + } + else + { + Log("Core.Initialize() start"); + Core.Initialize(); + Log("Core.Initialize() completed"); + } + + _libVlc = new LibVLC(enableDebugLogs: true); + Log("LibVLC instance created"); + + _mediaPlayer = new MediaPlayer(_libVlc) + { + // Software decode is more reliable for mixed IPTV codecs on macOS. + EnableHardwareDecoding = !OperatingSystem.IsMacOS() + }; + Log($"MediaPlayer created. HW decoding enabled: {_mediaPlayer.EnableHardwareDecoding}"); + + _mediaPlayer.Buffering += OnMediaPlayerBuffering; + _mediaPlayer.Playing += OnMediaPlayerPlaying; + _mediaPlayer.EncounteredError += OnMediaPlayerEncounteredError; + _mediaPlayer.EndReached += OnMediaPlayerEndReached; + _mediaPlayer.Stopped += OnMediaPlayerStopped; + + VideoView.MediaPlayer = _mediaPlayer; + _initialized = true; + Log("VideoView.MediaPlayer assigned"); + PlayCurrentStream(); + } + catch (System.Exception ex) + { + Log($"VLC initialization failed: {ex}"); + _viewModel?.SetPlaybackStatus($"VLC init failed: {ex.Message}"); + } + } + + private static bool TryGetMacLibVlcPaths(out string libDirectory, out string pluginDirectory) + { + var outputBase = AppContext.BaseDirectory; + var candidates = new[] + { + outputBase, + Path.Combine(outputBase, "natives", "macos", "lib"), + Path.Combine(outputBase, "lib"), + Path.Combine(outputBase, "libvlc", "osx-x64", "lib"), + "/Applications/VLC.app/Contents/MacOS/lib", + "/opt/homebrew/lib", + "/usr/local/lib" }; - VideoView.MediaPlayer = _mediaPlayer; - _initialized = true; - PlayCurrentStream(); + foreach (var candidateDir in candidates) + { + var libvlc = Path.Combine(candidateDir, "libvlc.dylib"); + var libvlccore = Path.Combine(candidateDir, "libvlccore.dylib"); + if (File.Exists(libvlc) && File.Exists(libvlccore)) + { + var pluginCandidates = new[] + { + Path.Combine(candidateDir, "plugins"), + Path.Combine(Path.GetDirectoryName(candidateDir) ?? string.Empty, "plugins") + }; + + pluginDirectory = string.Empty; + foreach (var candidatePluginDir in pluginCandidates) + { + if (Directory.Exists(candidatePluginDir)) + { + pluginDirectory = candidatePluginDir; + break; + } + } + + libDirectory = candidateDir; + Log($"Found macOS VLC libs in: {candidateDir}"); + if (!string.IsNullOrWhiteSpace(pluginDirectory)) + { + Log($"Found macOS VLC plugins in: {pluginDirectory}"); + } + else + { + Log("macOS VLC plugins directory not found next to libraries"); + } + return true; + } + } + + libDirectory = string.Empty; + pluginDirectory = string.Empty; + Log("macOS VLC libs not found. Checked directories: " + string.Join("; ", candidates)); + return false; + } + + private static string PrependPath(string? existingPaths, string pathToPrepend) + { + if (string.IsNullOrWhiteSpace(pathToPrepend)) + return existingPaths ?? string.Empty; + + if (string.IsNullOrWhiteSpace(existingPaths)) + return pathToPrepend; + + var parts = existingPaths.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Contains(pathToPrepend)) + return existingPaths; + + return pathToPrepend + ":" + existingPaths; } private void OnDetachedFromVisualTree(object? sender, Avalonia.VisualTreeAttachmentEventArgs e) { + Log("Detach from visual tree -> disposing player resources"); if (_viewModel != null) { _viewModel.PropertyChanged -= OnViewModelPropertyChanged; } + if (_mediaPlayer != null) + { + _mediaPlayer.Buffering -= OnMediaPlayerBuffering; + _mediaPlayer.Playing -= OnMediaPlayerPlaying; + _mediaPlayer.EncounteredError -= OnMediaPlayerEncounteredError; + _mediaPlayer.EndReached -= OnMediaPlayerEndReached; + _mediaPlayer.Stopped -= OnMediaPlayerStopped; + } + _mediaPlayer?.Stop(); + _bufferingWatchdogCts?.Cancel(); + _bufferingWatchdogCts?.Dispose(); + _currentMedia?.Dispose(); _mediaPlayer?.Dispose(); _libVlc?.Dispose(); + _bufferingWatchdogCts = null; + _currentMedia = null; _mediaPlayer = null; _libVlc = null; + _activeStreamUrl = null; + _fallbackAttempted = false; _initialized = false; } private void OnDataContextChanged(object? sender, System.EventArgs e) { + Log($"DataContext changed. New type: {DataContext?.GetType().Name ?? "null"}"); if (_viewModel != null) { _viewModel.PropertyChanged -= OnViewModelPropertyChanged; @@ -73,6 +227,7 @@ public partial class PlayerView : UserControl { if (e.PropertyName == nameof(PlayerViewModel.StreamUrl)) { + Log("ViewModel StreamUrl changed -> replaying stream"); PlayCurrentStream(); } } @@ -80,30 +235,273 @@ public partial class PlayerView : UserControl private void PlayCurrentStream() { if (!_initialized || _viewModel == null || _mediaPlayer == null || _libVlc == null) + { + Log($"PlayCurrentStream skipped: initialized={_initialized}, hasVm={_viewModel != null}, hasPlayer={_mediaPlayer != null}, hasLibVlc={_libVlc != null}"); return; + } if (string.IsNullOrWhiteSpace(_viewModel.StreamUrl)) { + Log("PlayCurrentStream aborted: empty StreamUrl"); _viewModel.SetPlaybackStatus("No stream URL available for this channel."); return; } try { - if (_mediaPlayer.Media != null) - { - _mediaPlayer.Stop(); - _mediaPlayer.Media.Dispose(); - } + var streamUrl = _viewModel.StreamUrl.Trim(); + Log($"PlayCurrentStream start. Url={SanitizeUrlForLog(streamUrl)}"); + _activeStreamUrl = streamUrl; + _fallbackAttempted = false; - var media = new Media(_libVlc, new Uri(_viewModel.StreamUrl)); - _mediaPlayer.Media = media; - _mediaPlayer.Play(); - _viewModel.SetPlaybackStatus("Playing with embedded VLC."); + _mediaPlayer.Stop(); + _currentMedia?.Dispose(); + _currentMedia = null; + + _viewModel.SetPlaybackStatus("Buffering stream..."); + StartBufferingWatchdog(); + + var started = StartPlayback(streamUrl, fallback: false); + Log($"Primary playback start result: {started}"); + if (!started) + { + _viewModel.SetPlaybackStatus("Stream failed to start. Try another channel or Open Externally."); + } } catch (System.Exception ex) { + Log($"PlayCurrentStream exception: {ex}"); _viewModel.SetPlaybackStatus($"Embedded playback failed: {ex.Message}. Use Open Externally as a fallback."); } } + + private static Media BuildMediaFromStreamUrl(LibVLC libVlc, string rawStreamUrl) + { + // Common IPTV format: http://.../stream.m3u8|user-agent=...&referer=... + var splitIndex = rawStreamUrl.IndexOf('|'); + if (splitIndex <= 0) + { + return new Media(libVlc, rawStreamUrl, FromType.FromLocation); + } + + var baseUrl = rawStreamUrl[..splitIndex].Trim(); + var optionsSegment = rawStreamUrl[(splitIndex + 1)..].Trim(); + var media = new Media(libVlc, baseUrl, FromType.FromLocation); + + if (string.IsNullOrWhiteSpace(optionsSegment)) + { + return media; + } + + var pairs = optionsSegment.Split('&', System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries); + foreach (var pair in pairs) + { + var equalsIndex = pair.IndexOf('='); + if (equalsIndex <= 0 || equalsIndex == pair.Length - 1) + { + continue; + } + + var key = pair[..equalsIndex].Trim().ToLowerInvariant(); + var value = HttpUtility.UrlDecode(pair[(equalsIndex + 1)..].Trim()); + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + switch (key) + { + case "user-agent": + case "http-user-agent": + media.AddOption($":http-user-agent={value}"); + break; + case "referer": + case "referrer": + case "http-referrer": + case "http-referer": + media.AddOption($":http-referrer={value}"); + break; + case "origin": + media.AddOption($":http-header=Origin={value}"); + break; + case "cookie": + case "cookies": + media.AddOption($":http-header=Cookie={value}"); + break; + } + } + + return media; + } + + private bool StartPlayback(string streamUrl, bool fallback) + { + if (_mediaPlayer == null || _libVlc == null) + return false; + + Log($"StartPlayback called. fallback={fallback}, url={SanitizeUrlForLog(streamUrl)}"); + + _currentMedia?.Dispose(); + _currentMedia = BuildMediaFromStreamUrl(_libVlc, streamUrl); + _currentMedia.AddOption(":network-caching=1500"); + _currentMedia.AddOption(":live-caching=1500"); + _currentMedia.AddOption(":http-reconnect=true"); + _currentMedia.AddOption(":codec=avcodec"); + if (OperatingSystem.IsMacOS()) + { + _currentMedia.AddOption(":avcodec-hw=none"); + } + + if (fallback) + { + _currentMedia.AddOption(":demux=any"); + _currentMedia.AddOption(":hls-segment-threads=4"); + _currentMedia.AddOption(":http-continuous=true"); + Log("Fallback options applied: demux=any, hls-segment-threads=4, http-continuous=true"); + } + + var started = _mediaPlayer.Play(_currentMedia); + Log($"MediaPlayer.Play returned: {started}"); + return started; + } + + private void StartBufferingWatchdog() + { + _bufferingWatchdogCts?.Cancel(); + _bufferingWatchdogCts?.Dispose(); + _bufferingWatchdogCts = new CancellationTokenSource(); + var token = _bufferingWatchdogCts.Token; + Log("Buffering watchdog started (12s)"); + + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(12), token); + } + catch (TaskCanceledException) + { + return; + } + + if (token.IsCancellationRequested) + return; + + if (_mediaPlayer == null || _viewModel == null || string.IsNullOrWhiteSpace(_activeStreamUrl)) + return; + + if (_mediaPlayer.IsPlaying || _fallbackAttempted) + return; + + _fallbackAttempted = true; + Log("Buffering watchdog triggered fallback attempt"); + Dispatcher.UIThread.Post(() => + { + if (_viewModel == null) + return; + + _viewModel.SetPlaybackStatus("Still buffering, retrying with fallback settings..."); + var started = StartPlayback(_activeStreamUrl!, fallback: true); + Log($"Fallback playback start result: {started}"); + if (!started) + { + _viewModel.SetPlaybackStatus("Fallback playback failed. Try Open Externally for this channel."); + } + }); + }, token); + } + + private void OnMediaPlayerBuffering(object? sender, MediaPlayerBufferingEventArgs e) + { + if (_viewModel == null) + return; + + if (e.Cache < 1 || ((int)e.Cache % 10) == 0) + { + Log($"MediaPlayer buffering: {e.Cache:0}%"); + } + + Dispatcher.UIThread.Post(() => + { + _viewModel.SetPlaybackStatus($"Buffering stream... {e.Cache:0}%"); + }); + } + + private void OnMediaPlayerPlaying(object? sender, System.EventArgs e) + { + if (_viewModel == null) + return; + + Dispatcher.UIThread.Post(() => + { + _bufferingWatchdogCts?.Cancel(); + Log("MediaPlayer event: Playing"); + _viewModel.SetPlaybackStatus(string.Empty); + }); + } + + private void OnMediaPlayerEncounteredError(object? sender, System.EventArgs e) + { + if (_viewModel == null) + return; + + Dispatcher.UIThread.Post(() => + { + _bufferingWatchdogCts?.Cancel(); + Log("MediaPlayer event: EncounteredError"); + _viewModel.SetPlaybackStatus("Playback error. The stream may be unavailable or unsupported."); + }); + } + + private void OnMediaPlayerEndReached(object? sender, System.EventArgs e) + { + if (_viewModel == null) + return; + + Dispatcher.UIThread.Post(() => + { + _bufferingWatchdogCts?.Cancel(); + Log("MediaPlayer event: EndReached"); + _viewModel.SetPlaybackStatus("Stream ended."); + }); + } + + private void OnMediaPlayerStopped(object? sender, System.EventArgs e) + { + if (_viewModel == null) + return; + + Dispatcher.UIThread.Post(() => + { + _bufferingWatchdogCts?.Cancel(); + Log("MediaPlayer event: Stopped"); + if (string.IsNullOrWhiteSpace(_viewModel.PlaybackStatus)) + { + _viewModel.SetPlaybackStatus("Playback stopped."); + } + }); + } + + private static string SanitizeUrlForLog(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return ""; + + var trimmed = url.Trim(); + var tokenIndex = trimmed.IndexOf("token=", System.StringComparison.OrdinalIgnoreCase); + if (tokenIndex >= 0) + { + return ""; + } + + var splitIndex = trimmed.IndexOf('|'); + return splitIndex > 0 ? trimmed[..splitIndex] + "|" : trimmed; + } + + private static void Log(string message) + { + var line = $"[PlayerView] {DateTime.Now:HH:mm:ss.fff} {message}"; + Debug.WriteLine(line); + Console.WriteLine(line); + } } diff --git a/TV Player Avalonia/Views/PlaylistsGroupView.axaml b/TV Player Avalonia/Views/PlaylistsGroupView.axaml index 16ef0d6..ee5b3c0 100644 --- a/TV Player Avalonia/Views/PlaylistsGroupView.axaml +++ b/TV Player Avalonia/Views/PlaylistsGroupView.axaml @@ -19,13 +19,10 @@ Height="70" Margin="8,6" Classes="card" - Background="#B0000000" - BorderBrush="Yellow" - BorderThickness="2" Command="{Binding DataContext.SelectPlaylistCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" CommandParameter="{Binding}"> diff --git a/TV Player Avalonia/Views/ProgramsListView.axaml b/TV Player Avalonia/Views/ProgramsListView.axaml index 66ae9b7..9cf73e5 100644 --- a/TV Player Avalonia/Views/ProgramsListView.axaml +++ b/TV Player Avalonia/Views/ProgramsListView.axaml @@ -1,5 +1,6 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + diff --git a/TV Player Avalonia/scripts/fetch-vlc-macos.sh b/TV Player Avalonia/scripts/fetch-vlc-macos.sh new file mode 100644 index 0000000..3404b1d --- /dev/null +++ b/TV Player Avalonia/scripts/fetch-vlc-macos.sh @@ -0,0 +1,68 @@ +#!/bin/sh +set -eu + +project_dir="$1" +lib_dir="$project_dir/natives/macos/lib" +plugins_dir="$project_dir/natives/macos/plugins" + +if [ -f "$lib_dir/libvlccore.dylib" ] && [ -f "$lib_dir/libvlc.dylib" ] && [ -d "$plugins_dir" ]; then + echo "[VLC] macOS native libs and plugins already bundled" + exit 0 +fi + +download_dir="$project_dir/obj/vlc-download" +mount_point="$download_dir/mount" +mkdir -p "$download_dir" "$project_dir/natives/macos" + +arch="$(uname -m)" +case "$arch" in + arm64|aarch64) + dmg_name="vlc-3.0.21-arm64.dmg" + ;; + x86_64) + dmg_name="vlc-3.0.21-intel64.dmg" + ;; + *) + echo "[VLC] Unsupported macOS architecture: $arch" + exit 1 + ;; +esac + +url="https://get.videolan.org/vlc/3.0.21/macosx/$dmg_name" +dmg_path="$download_dir/$dmg_name" + +if [ ! -f "$dmg_path" ]; then + echo "[VLC] Downloading $url" + curl -L --fail --retry 3 --retry-delay 2 -o "$dmg_path" "$url" +fi + +if [ -d "$mount_point" ]; then + hdiutil detach "$mount_point" >/dev/null 2>&1 || true +fi +mkdir -p "$mount_point" + +cleanup() { + hdiutil detach "$mount_point" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "[VLC] Mounting DMG" +hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_point" >/dev/null + +if [ ! -d "$mount_point/VLC.app/Contents/MacOS/lib" ]; then + echo "[VLC] Could not find VLC libs inside mounted image" + exit 1 +fi + +echo "[VLC] Copying native libraries and plugins" +rm -rf "$project_dir/natives/macos/lib" +rm -rf "$project_dir/natives/macos/plugins" +cp -R "$mount_point/VLC.app/Contents/MacOS/lib" "$project_dir/natives/macos/" +if [ -d "$mount_point/VLC.app/Contents/MacOS/plugins" ]; then + cp -R "$mount_point/VLC.app/Contents/MacOS/plugins" "$project_dir/natives/macos/" +fi + +hdiutil detach "$mount_point" >/dev/null 2>&1 || true +trap - EXIT + +echo "[VLC] Bundled macOS native runtime under $project_dir/natives/macos" \ No newline at end of file