modules/LiveDisplay/LiveDisplayEngine.psm1
|
# Live Display Engine Module # Provides real-time display updates and terminal manipulation for Spotify CLI using namespace System.Management.Automation using namespace System.Text using namespace System.Management.Automation.Runspaces # ANSI Escape Codes and Console Utilities class AnsiCodes { static [string] $Reset = "`e[0m" static [string] $Bold = "`e[1m" static [string] $Dim = "`e[2m" static [string] $Italic = "`e[3m" static [string] $Underline = "`e[4m" static [string] $Blink = "`e[5m" static [string] $Reverse = "`e[7m" static [string] $Strikethrough = "`e[9m" # Cursor movement static [string] $CursorHome = "`e[H" static [string] $CursorUp = "`e[A" static [string] $CursorDown = "`e[B" static [string] $CursorRight = "`e[C" static [string] $CursorLeft = "`e[D" static [string] $SaveCursor = "`e[s" static [string] $RestoreCursor = "`e[u" static [string] $HideCursor = "`e[?25l" static [string] $ShowCursor = "`e[?25h" # Screen manipulation static [string] $ClearScreen = "`e[2J" static [string] $ClearLine = "`e[2K" static [string] $ClearToEnd = "`e[0K" static [string] $ClearToStart = "`e[1K" # Colors static [hashtable] $Colors = @{ Black = "`e[30m"; Red = "`e[31m"; Green = "`e[32m"; Yellow = "`e[33m" Blue = "`e[34m"; Magenta = "`e[35m"; Cyan = "`e[36m"; White = "`e[37m" BrightBlack = "`e[90m"; BrightRed = "`e[91m"; BrightGreen = "`e[92m" BrightYellow = "`e[93m"; BrightBlue = "`e[94m"; BrightMagenta = "`e[95m" BrightCyan = "`e[96m"; BrightWhite = "`e[97m" } static [hashtable] $BackgroundColors = @{ Black = "`e[40m"; Red = "`e[41m"; Green = "`e[42m"; Yellow = "`e[43m" Blue = "`e[44m"; Magenta = "`e[45m"; Cyan = "`e[46m"; White = "`e[47m" BrightBlack = "`e[100m"; BrightRed = "`e[101m"; BrightGreen = "`e[102m" BrightYellow = "`e[103m"; BrightBlue = "`e[104m"; BrightMagenta = "`e[105m" BrightCyan = "`e[106m"; BrightWhite = "`e[107m" } static [string] MoveCursor([int]$row, [int]$col) { return "`e[$row;${col}H" } static [string] MoveCursorUp([int]$lines) { return "`e[${lines}A" } static [string] MoveCursorDown([int]$lines) { return "`e[${lines}B" } static [string] MoveCursorRight([int]$cols) { return "`e[${cols}C" } static [string] MoveCursorLeft([int]$cols) { return "`e[${cols}D" } } # Progress bar styles enumeration enum ProgressBarStyle { Blocks Bars Dots Arrows Circles } # Console Renderer class with ANSI escape code support class ConsoleRenderer { [bool] $AnsiSupported = $false [hashtable] $SavedCursorPosition = @{} [StringBuilder] $DisplayBuffer [int] $TerminalWidth = 80 [int] $TerminalHeight = 24 [ProgressBarStyle] $DefaultProgressStyle = [ProgressBarStyle]::Blocks # Progress bar character sets [hashtable] $ProgressChars = @{ [ProgressBarStyle]::Blocks = @{ Filled = "█"; Empty = "░"; Partial = @("▏","▎","▍","▌","▋","▊","▉") } [ProgressBarStyle]::Bars = @{ Filled = "="; Empty = "-"; Partial = @() } [ProgressBarStyle]::Dots = @{ Filled = "●"; Empty = "○"; Partial = @() } [ProgressBarStyle]::Arrows = @{ Filled = "►"; Empty = "▷"; Partial = @() } [ProgressBarStyle]::Circles = @{ Filled = "⬤"; Empty = "⬜"; Partial = @() } } ConsoleRenderer() { $this.DisplayBuffer = [StringBuilder]::new() $this.DetectAnsiSupport() $this.DetectTerminalSize() } [void] DetectAnsiSupport() { try { # Test ANSI support by attempting to query cursor position $originalPos = $Host.UI.RawUI.CursorPosition [Console]::Write([AnsiCodes]::SaveCursor) [Console]::Write([AnsiCodes]::RestoreCursor) $this.AnsiSupported = $true } catch { $this.AnsiSupported = $false } } [void] DetectTerminalSize() { try { $this.TerminalWidth = $Host.UI.RawUI.WindowSize.Width $this.TerminalHeight = $Host.UI.RawUI.WindowSize.Height } catch { # Fallback to default values $this.TerminalWidth = 80 $this.TerminalHeight = 24 } } [void] InitializeLiveMode() { if ($this.AnsiSupported) { $this.DisplayBuffer.Append([AnsiCodes]::SaveCursor) | Out-Null $this.DisplayBuffer.Append([AnsiCodes]::HideCursor) | Out-Null $this.DisplayBuffer.Append([AnsiCodes]::ClearScreen) | Out-Null $this.DisplayBuffer.Append([AnsiCodes]::CursorHome) | Out-Null } else { Clear-Host } $this.FlushBuffer() } [void] CleanupAndExit() { if ($this.AnsiSupported) { $this.DisplayBuffer.Append([AnsiCodes]::ShowCursor) | Out-Null $this.DisplayBuffer.Append([AnsiCodes]::RestoreCursor) | Out-Null $this.DisplayBuffer.Append([AnsiCodes]::Reset) | Out-Null } $this.FlushBuffer() } [void] ClearScreen() { if ($this.AnsiSupported) { $this.DisplayBuffer.Append([AnsiCodes]::ClearScreen) | Out-Null $this.DisplayBuffer.Append([AnsiCodes]::CursorHome) | Out-Null } else { Clear-Host } } [void] MoveCursor([int]$row, [int]$col) { if ($this.AnsiSupported) { $this.DisplayBuffer.Append([AnsiCodes]::MoveCursor($row, $col)) | Out-Null } } [void] SaveCursorPosition() { if ($this.AnsiSupported) { $this.DisplayBuffer.Append([AnsiCodes]::SaveCursor) | Out-Null } else { try { $this.SavedCursorPosition = @{ X = $Host.UI.RawUI.CursorPosition.X Y = $Host.UI.RawUI.CursorPosition.Y } } catch { # Ignore if cursor position cannot be saved } } } [void] RestoreCursorPosition() { if ($this.AnsiSupported) { $this.DisplayBuffer.Append([AnsiCodes]::RestoreCursor) | Out-Null } else { try { if ($this.SavedCursorPosition.Count -gt 0) { $Host.UI.RawUI.CursorPosition = [System.Management.Automation.Host.Coordinates]::new( $this.SavedCursorPosition.X, $this.SavedCursorPosition.Y ) } } catch { # Ignore if cursor position cannot be restored } } } [void] WriteColoredText([string]$text, [string]$color, [string]$backgroundColor = "") { if ($this.AnsiSupported) { if ([AnsiCodes]::Colors.ContainsKey($color)) { $this.DisplayBuffer.Append([AnsiCodes]::Colors[$color]) | Out-Null } if ($backgroundColor -and [AnsiCodes]::BackgroundColors.ContainsKey($backgroundColor)) { $this.DisplayBuffer.Append([AnsiCodes]::BackgroundColors[$backgroundColor]) | Out-Null } $this.DisplayBuffer.Append($text) | Out-Null $this.DisplayBuffer.Append([AnsiCodes]::Reset) | Out-Null } else { $this.DisplayBuffer.Append($text) | Out-Null } } [void] WriteLine([string]$text = "") { $this.DisplayBuffer.AppendLine($text) | Out-Null } [void] Write([string]$text) { $this.DisplayBuffer.Append($text) | Out-Null } [string] CreateProgressBar([int]$current, [int]$total, [int]$width = 40, [ProgressBarStyle]$style = [ProgressBarStyle]::Blocks) { if ($total -le 0) { return "" } $percentage = [Math]::Min(100, [Math]::Max(0, ($current / $total) * 100)) $filled = [Math]::Floor(($percentage / 100) * $width) $empty = $width - $filled $chars = $this.ProgressChars[$style] $filledChar = $chars.Filled $emptyChar = $chars.Empty # Handle partial characters for block style if ($style -eq [ProgressBarStyle]::Blocks -and $chars.Partial.Count -gt 0) { $remainder = (($percentage / 100) * $width) - $filled if ($remainder -gt 0 -and $empty -gt 0) { $partialIndex = [Math]::Floor($remainder * $chars.Partial.Count) if ($partialIndex -lt $chars.Partial.Count) { $partialChar = $chars.Partial[$partialIndex] return ($filledChar * $filled) + $partialChar + ($emptyChar * ($empty - 1)) } } } return ($filledChar * $filled) + ($emptyChar * $empty) } [void] UpdateProgressBar([int]$current, [int]$total, [string]$trackName = "", [ProgressBarStyle]$style = [ProgressBarStyle]::Blocks) { $width = [Math]::Min(60, $this.TerminalWidth - 20) $progressBar = $this.CreateProgressBar($current, $total, $width, $style) $percentage = if ($total -gt 0) { [Math]::Round(($current / $total) * 100) } else { 0 } $currentTime = $this.FormatDuration($current) $totalTime = $this.FormatDuration($total) if ($trackName) { $this.WriteColoredText("♪ $trackName", "Cyan") $this.WriteLine() } $this.WriteColoredText("[$progressBar]", "Magenta") $this.Write(" $percentage%") $this.WriteLine() $this.WriteColoredText("$currentTime / $totalTime", "Gray") $this.WriteLine() } [void] UpdateTrackInfo([hashtable]$trackData) { if (-not $trackData -or -not $trackData.ContainsKey('name')) { $this.WriteColoredText("No track information available", "Yellow") $this.WriteLine() return } $isPlaying = $trackData.ContainsKey('is_playing') ? $trackData.is_playing : $false $statusIcon = if ($isPlaying) { "▶" } else { "⏸" } # Track status and name $this.WriteColoredText("$statusIcon Now Playing:", "Cyan") $this.WriteLine() $this.WriteColoredText("🎵 $($trackData.name)", "White") $this.WriteLine() # Artist information if ($trackData.ContainsKey('artists') -and $trackData.artists) { $artists = if ($trackData.artists -is [array]) { ($trackData.artists | ForEach-Object { if ($_ -is [hashtable]) { $_.name } else { $_ } }) -join ", " } else { $trackData.artists } $this.WriteColoredText("👤 $artists", "Yellow") $this.WriteLine() } # Album information if ($trackData.ContainsKey('album') -and $trackData.album) { $albumName = if ($trackData.album -is [hashtable]) { $trackData.album.name } else { $trackData.album } $this.WriteColoredText("📀 $albumName", "Green") $this.WriteLine() } # Progress bar if ($trackData.ContainsKey('duration_ms') -and $trackData.duration_ms -gt 0) { $progress = $trackData.ContainsKey('progress_ms') ? $trackData.progress_ms : 0 $this.WriteLine() $this.UpdateProgressBar($progress, $trackData.duration_ms, "", $this.DefaultProgressStyle) } } [string] FormatDuration([int]$milliseconds) { $totalSeconds = [Math]::Floor($milliseconds / 1000) $minutes = [Math]::Floor($totalSeconds / 60) $seconds = $totalSeconds % 60 return "{0}:{1:D2}" -f $minutes, $seconds } [void] SetDisplayMode([string]$mode) { switch ($mode.ToLower()) { "minimal" { $this.DefaultProgressStyle = [ProgressBarStyle]::Dots } "detailed" { $this.DefaultProgressStyle = [ProgressBarStyle]::Blocks } "compact" { $this.DefaultProgressStyle = [ProgressBarStyle]::Bars } default { $this.DefaultProgressStyle = [ProgressBarStyle]::Blocks } } } [void] FlushBuffer() { if ($this.DisplayBuffer.Length -gt 0) { [Console]::Write($this.DisplayBuffer.ToString()) $this.DisplayBuffer.Clear() } } [void] ClearCurrentLine() { if ($this.AnsiSupported) { $this.DisplayBuffer.Append([AnsiCodes]::ClearLine) | Out-Null } } [hashtable] GetTerminalInfo() { return @{ Width = $this.TerminalWidth Height = $this.TerminalHeight AnsiSupported = $this.AnsiSupported ProgressStyles = [Enum]::GetNames([ProgressBarStyle]) } } } # Background Thread Manager for live updates class BackgroundThreadManager { [Runspace] $UpdateRunspace [PowerShell] $UpdatePowerShell [System.Threading.CancellationTokenSource] $CancellationTokenSource [System.Threading.ManualResetEventSlim] $ShutdownEvent [bool] $IsRunning = $false [scriptblock] $UpdateCallback [int] $UpdateInterval = 1000 [hashtable] $SharedData [System.Threading.Mutex] $DataMutex BackgroundThreadManager([scriptblock]$updateCallback, [int]$intervalMs = 1000) { $this.UpdateCallback = $updateCallback $this.UpdateInterval = $intervalMs $this.CancellationTokenSource = [System.Threading.CancellationTokenSource]::new() $this.ShutdownEvent = [System.Threading.ManualResetEventSlim]::new($false) $this.SharedData = @{} $this.DataMutex = [System.Threading.Mutex]::new() } [void] Start() { if ($this.IsRunning) { return } try { # Create a new runspace for background updates $this.UpdateRunspace = [RunspaceFactory]::CreateRunspace() $this.UpdateRunspace.Open() # Create PowerShell instance $this.UpdatePowerShell = [PowerShell]::Create() $this.UpdatePowerShell.Runspace = $this.UpdateRunspace # Set up the background update script $updateScript = { param( [scriptblock]$Callback, [int]$Interval, [System.Threading.CancellationToken]$CancellationToken, [System.Threading.ManualResetEventSlim]$ShutdownEvent, [hashtable]$SharedData, [System.Threading.Mutex]$DataMutex ) try { while (-not $CancellationToken.IsCancellationRequested) { try { # Execute the update callback $result = & $Callback # Update shared data thread-safely if ($result -and $DataMutex.WaitOne(100)) { try { $SharedData.Clear() if ($result -is [hashtable]) { foreach ($key in $result.Keys) { $SharedData[$key] = $result[$key] } } } finally { $DataMutex.ReleaseMutex() } } # Wait for the specified interval or cancellation if (-not $CancellationToken.WaitHandle.WaitOne($Interval)) { # Continue if not cancelled } } catch { # Log error but continue running Write-Warning "Background update error: $($_.Exception.Message)" Start-Sleep -Milliseconds 1000 } } } finally { $ShutdownEvent.Set() } } # Add parameters and start the background script $this.UpdatePowerShell.AddScript($updateScript) | Out-Null $this.UpdatePowerShell.AddParameter("Callback", $this.UpdateCallback) | Out-Null $this.UpdatePowerShell.AddParameter("Interval", $this.UpdateInterval) | Out-Null $this.UpdatePowerShell.AddParameter("CancellationToken", $this.CancellationTokenSource.Token) | Out-Null $this.UpdatePowerShell.AddParameter("ShutdownEvent", $this.ShutdownEvent) | Out-Null $this.UpdatePowerShell.AddParameter("SharedData", $this.SharedData) | Out-Null $this.UpdatePowerShell.AddParameter("DataMutex", $this.DataMutex) | Out-Null # Start the background execution $this.UpdatePowerShell.BeginInvoke() | Out-Null $this.IsRunning = $true } catch { $this.Cleanup() throw "Failed to start background thread: $($_.Exception.Message)" } } [void] Stop() { if (-not $this.IsRunning) { return } try { # Signal cancellation $this.CancellationTokenSource.Cancel() # Wait for shutdown with timeout if (-not $this.ShutdownEvent.Wait(5000)) { Write-Warning "Background thread did not shut down gracefully within timeout" } $this.IsRunning = $false } catch { Write-Warning "Error stopping background thread: $($_.Exception.Message)" } finally { $this.Cleanup() } } [hashtable] GetSharedData() { $result = @{} if ($this.DataMutex.WaitOne(100)) { try { foreach ($key in $this.SharedData.Keys) { $result[$key] = $this.SharedData[$key] } } finally { $this.DataMutex.ReleaseMutex() } } return $result } [void] SetSharedData([hashtable]$data) { if ($this.DataMutex.WaitOne(100)) { try { $this.SharedData.Clear() foreach ($key in $data.Keys) { $this.SharedData[$key] = $data[$key] } } finally { $this.DataMutex.ReleaseMutex() } } } [bool] IsHealthy() { return $this.IsRunning -and $this.UpdatePowerShell -and $this.UpdatePowerShell.InvocationStateInfo.State -eq [PSInvocationState]::Running } [void] Cleanup() { try { if ($this.UpdatePowerShell) { if ($this.UpdatePowerShell.InvocationStateInfo.State -eq [PSInvocationState]::Running) { $this.UpdatePowerShell.Stop() } $this.UpdatePowerShell.Dispose() $this.UpdatePowerShell = $null } if ($this.UpdateRunspace) { $this.UpdateRunspace.Close() $this.UpdateRunspace.Dispose() $this.UpdateRunspace = $null } if ($this.CancellationTokenSource) { $this.CancellationTokenSource.Dispose() $this.CancellationTokenSource = $null } if ($this.ShutdownEvent) { $this.ShutdownEvent.Dispose() $this.ShutdownEvent = $null } if ($this.DataMutex) { $this.DataMutex.Dispose() $this.DataMutex = $null } } catch { Write-Warning "Error during cleanup: $($_.Exception.Message)" } } } # Ctrl+C Handler for graceful shutdown class GracefulShutdownHandler { [BackgroundThreadManager] $ThreadManager [ConsoleRenderer] $Renderer [bool] $ShutdownRequested = $false [System.ConsoleCancelEventHandler] $CancelHandler GracefulShutdownHandler([BackgroundThreadManager]$threadManager, [ConsoleRenderer]$renderer) { $this.ThreadManager = $threadManager $this.Renderer = $renderer # Create the cancel event handler $this.CancelHandler = { param($sender, $e) # Prevent immediate termination $e.Cancel = $true # Set shutdown flag $this.ShutdownRequested = $true # Initiate graceful shutdown $this.InitiateShutdown() }.GetNewClosure() } [void] Register() { try { [Console]::CancelKeyPress += $this.CancelHandler } catch { Write-Warning "Could not register Ctrl+C handler: $($_.Exception.Message)" } } [void] Unregister() { try { if ($this.CancelHandler) { [Console]::CancelKeyPress -= $this.CancelHandler } } catch { Write-Warning "Could not unregister Ctrl+C handler: $($_.Exception.Message)" } } [void] InitiateShutdown() { if ($this.ShutdownRequested) { return } $this.ShutdownRequested = $true try { # Show shutdown message if ($this.Renderer) { $this.Renderer.WriteLine() $this.Renderer.WriteColoredText("Shutting down gracefully...", "Yellow") $this.Renderer.WriteLine() $this.Renderer.FlushBuffer() } # Stop background thread if ($this.ThreadManager) { $this.ThreadManager.Stop() } # Cleanup renderer if ($this.Renderer) { $this.Renderer.CleanupAndExit() } } catch { Write-Warning "Error during graceful shutdown: $($_.Exception.Message)" } finally { $this.Unregister() } } [bool] ShouldExit() { return $this.ShutdownRequested } } # Windows Terminal Integration Module class WindowsTerminalIntegrator { [bool] $IsWindowsTerminal = $false [string] $TerminalExecutable = "wt.exe" [hashtable] $SidecarConfig = @{} [string] $SidecarPaneId = "" [bool] $SidecarActive = $false WindowsTerminalIntegrator() { $this.DetectWindowsTerminal() } [void] DetectWindowsTerminal() { try { # Check if running in Windows Terminal by examining environment variables $wtSession = $env:WT_SESSION $wtProfile = $env:WT_PROFILE_ID if ($wtSession -or $wtProfile) { $this.IsWindowsTerminal = $true return } # Alternative detection: check parent process $parentProcess = Get-WmiObject Win32_Process -Filter "ProcessId=$PID" | ForEach-Object { Get-Process -Id $_.ParentProcessId -ErrorAction SilentlyContinue } if ($parentProcess -and $parentProcess.ProcessName -eq "WindowsTerminal") { $this.IsWindowsTerminal = $true return } # Check if wt.exe is available $wtPath = Get-Command "wt.exe" -ErrorAction SilentlyContinue if ($wtPath) { $this.IsWindowsTerminal = $true } } catch { $this.IsWindowsTerminal = $false } } [bool] IsSupported() { return $this.IsWindowsTerminal } [hashtable] GetCapabilities() { return @{ IsWindowsTerminal = $this.IsWindowsTerminal SupportsSplitPane = $this.IsWindowsTerminal SupportsTabManagement = $this.IsWindowsTerminal TerminalExecutable = $this.TerminalExecutable } } [bool] CreateSidecarPane([string]$position = "right", [int]$size = 40) { if (-not $this.IsWindowsTerminal) { Write-Warning "Windows Terminal not detected. Sidecar mode not available." return $false } try { $sizeParam = if ($position -in @("left", "right")) { "--size", $size } else { "--size", $size } $splitDirection = switch ($position.ToLower()) { "right" { "--horizontal" } "left" { "--horizontal" } "bottom" { "--vertical" } "top" { "--vertical" } default { "--horizontal" } } # Create new pane with PowerShell $wtArgs = @( "split-pane", $splitDirection, $sizeParam, "--profile", "PowerShell", "--commandline", "powershell.exe -NoExit -Command `"Write-Host 'Spotify Live Display - Sidecar Mode' -ForegroundColor Cyan`"" ) $process = Start-Process -FilePath $this.TerminalExecutable -ArgumentList $wtArgs -PassThru -WindowStyle Hidden if ($process) { $this.SidecarActive = $true $this.SidecarConfig = @{ Position = $position Size = $size ProcessId = $process.Id } # Wait a moment for pane to be created Start-Sleep -Milliseconds 500 return $true } } catch { Write-Warning "Failed to create sidecar pane: $($_.Exception.Message)" return $false } return $false } [void] SendToSidecar([string]$content) { if (-not $this.SidecarActive) { return } try { # Use wt.exe to send content to the active pane # Note: This is a simplified approach. In practice, you might need more sophisticated # inter-process communication or use Windows Terminal's command palette features $escapedContent = $content -replace '"', '\"' $command = "Write-Host `"$escapedContent`"" $wtArgs = @( "send-text", "--pane-id", "1", # Assuming sidecar is pane 1 $command ) Start-Process -FilePath $this.TerminalExecutable -ArgumentList $wtArgs -WindowStyle Hidden -Wait } catch { Write-Warning "Failed to send content to sidecar: $($_.Exception.Message)" } } [void] UpdateSidecarContent([hashtable]$trackData) { if (-not $this.SidecarActive -or -not $trackData) { return } try { # Build sidecar display content $content = $this.BuildSidecarDisplay($trackData) # Clear and update sidecar $this.SendToSidecar("Clear-Host") $this.SendToSidecar($content) } catch { Write-Warning "Failed to update sidecar content: $($_.Exception.Message)" } } [string] BuildSidecarDisplay([hashtable]$trackData) { $sb = [System.Text.StringBuilder]::new() # Header $sb.AppendLine("╔══════════════════════════════════════╗") | Out-Null $sb.AppendLine("║ Spotify Live Display ║") | Out-Null $sb.AppendLine("╠══════════════════════════════════════╣") | Out-Null # Track info if ($trackData.ContainsKey('name')) { $trackName = $trackData.name if ($trackName.Length -gt 34) { $trackName = $trackName.Substring(0, 31) + "..." } $sb.AppendLine("║ 🎵 $($trackName.PadRight(32)) ║") | Out-Null } if ($trackData.ContainsKey('artists')) { $artists = if ($trackData.artists -is [array]) { ($trackData.artists | ForEach-Object { if ($_ -is [hashtable]) { $_.name } else { $_ } }) -join ", " } else { $trackData.artists } if ($artists.Length -gt 34) { $artists = $artists.Substring(0, 31) + "..." } $sb.AppendLine("║ 👤 $($artists.PadRight(32)) ║") | Out-Null } if ($trackData.ContainsKey('album')) { $album = if ($trackData.album -is [hashtable]) { $trackData.album.name } else { $trackData.album } if ($album.Length -gt 34) { $album = $album.Substring(0, 31) + "..." } $sb.AppendLine("║ 📀 $($album.PadRight(32)) ║") | Out-Null } # Progress bar if ($trackData.ContainsKey('duration_ms') -and $trackData.duration_ms -gt 0) { $progress = $trackData.ContainsKey('progress_ms') ? $trackData.progress_ms : 0 $percentage = [Math]::Round(($progress / $trackData.duration_ms) * 100) $barWidth = 30 $filled = [Math]::Floor(($percentage / 100) * $barWidth) $empty = $barWidth - $filled $progressBar = ("█" * $filled) + ("░" * $empty) $sb.AppendLine("║ [$progressBar] ║") | Out-Null $currentTime = $this.FormatDuration($progress) $totalTime = $this.FormatDuration($trackData.duration_ms) $timeDisplay = "$currentTime / $totalTime ($percentage%)" $sb.AppendLine("║ $($timeDisplay.PadRight(36)) ║") | Out-Null } # Status $isPlaying = $trackData.ContainsKey('is_playing') ? $trackData.is_playing : $false $status = if ($isPlaying) { "▶ Playing" } else { "⏸ Paused" } $sb.AppendLine("║ $($status.PadRight(36)) ║") | Out-Null # Footer $sb.AppendLine("╚══════════════════════════════════════╝") | Out-Null return $sb.ToString() } [string] FormatDuration([int]$milliseconds) { $totalSeconds = [Math]::Floor($milliseconds / 1000) $minutes = [Math]::Floor($totalSeconds / 60) $seconds = $totalSeconds % 60 return "{0}:{1:D2}" -f $minutes, $seconds } [void] CloseSidecar() { if (-not $this.SidecarActive) { return } try { # Close the sidecar pane $wtArgs = @("close-pane", "--pane-id", "1") Start-Process -FilePath $this.TerminalExecutable -ArgumentList $wtArgs -WindowStyle Hidden -Wait $this.SidecarActive = $false $this.SidecarConfig = @{} } catch { Write-Warning "Failed to close sidecar pane: $($_.Exception.Message)" } } [hashtable] GetSidecarStatus() { return @{ Active = $this.SidecarActive Configuration = $this.SidecarConfig IsSupported = $this.IsWindowsTerminal } } } # Sidecar Display Engine for Windows Terminal class SidecarDisplayEngine : LiveDisplayEngineBase { [WindowsTerminalIntegrator] $TerminalIntegrator [hashtable] $SidecarConfig = @{} [string] $Position = "right" [int] $Size = 40 SidecarDisplayEngine([hashtable]$config) : base($config) { if ($config.ContainsKey('Position')) { $this.Position = $config.Position } if ($config.ContainsKey('Size')) { $this.Size = $config.Size } $this.TerminalIntegrator = [WindowsTerminalIntegrator]::new() } [void] ValidateConfiguration() { $validPositions = @("left", "right", "top", "bottom") if ($this.Position -notin $validPositions) { throw [System.ArgumentException]::new("Position must be one of: $($validPositions -join ', ')") } if ($this.Size -lt 20 -or $this.Size -gt 80) { throw [System.ArgumentException]::new("Size must be between 20 and 80") } } [void] InitializeDisplay() { if (-not $this.TerminalIntegrator.IsSupported()) { throw [System.NotSupportedException]::new("Windows Terminal not detected. Sidecar mode requires Windows Terminal.") } $success = $this.TerminalIntegrator.CreateSidecarPane($this.Position, $this.Size) if (-not $success) { throw [System.InvalidOperationException]::new("Failed to create sidecar pane") } } [void] UpdateDisplay([hashtable]$data) { if ($data -and $data.ContainsKey('track')) { $trackData = $data.track.Clone() # Add playback state information if ($data.ContainsKey('is_playing')) { $trackData['is_playing'] = $data.is_playing } if ($data.ContainsKey('progress_ms')) { $trackData['progress_ms'] = $data.progress_ms } $this.TerminalIntegrator.UpdateSidecarContent($trackData) } } [void] CleanupDisplay() { $this.TerminalIntegrator.CloseSidecar() } [bool] IsSupported() { return $this.TerminalIntegrator.IsSupported() } [hashtable] GetCapabilities() { return $this.TerminalIntegrator.GetCapabilities() } [hashtable] GetSidecarStatus() { return $this.TerminalIntegrator.GetSidecarStatus() } } # Base interface for all display engines class IDisplayEngine { [void] Initialize() { throw [System.NotImplementedException]::new() } [void] Update([hashtable]$data) { throw [System.NotImplementedException]::new() } [void] Cleanup() { throw [System.NotImplementedException]::new() } [bool] IsSupported() { throw [System.NotImplementedException]::new() } } # Abstract base class for live display implementations class LiveDisplayEngineBase : IDisplayEngine { [bool] $IsInitialized = $false [hashtable] $Configuration = @{} [BackgroundThreadManager] $ThreadManager [GracefulShutdownHandler] $ShutdownHandler [scriptblock] $DataFetchCallback LiveDisplayEngineBase([hashtable]$config) { $this.Configuration = $config } [void] Initialize() { if ($this.IsInitialized) { return } $this.ValidateConfiguration() $this.InitializeDisplay() $this.SetupBackgroundUpdates() $this.IsInitialized = $true } [void] ValidateConfiguration() { # Override in derived classes for specific validation } [void] InitializeDisplay() { # Override in derived classes for specific initialization } [void] SetupBackgroundUpdates() { if ($this.DataFetchCallback) { $refreshInterval = $this.Configuration.ContainsKey('RefreshInterval') ? $this.Configuration.RefreshInterval : 1000 $this.ThreadManager = [BackgroundThreadManager]::new($this.DataFetchCallback, $refreshInterval) } } [void] StartLiveMode([scriptblock]$dataCallback) { if (-not $this.IsInitialized) { $this.Initialize() } $this.DataFetchCallback = $dataCallback if (-not $this.ThreadManager) { $this.SetupBackgroundUpdates() } if ($this.ThreadManager) { # Set up graceful shutdown handling $renderer = $this.GetRenderer() if ($renderer) { $this.ShutdownHandler = [GracefulShutdownHandler]::new($this.ThreadManager, $renderer) $this.ShutdownHandler.Register() } # Start background updates $this.ThreadManager.Start() # Main display loop $this.RunDisplayLoop() } } [void] RunDisplayLoop() { while ($this.ThreadManager -and $this.ThreadManager.IsHealthy()) { # Check for shutdown request if ($this.ShutdownHandler -and $this.ShutdownHandler.ShouldExit()) { break } # Get updated data from background thread $data = $this.ThreadManager.GetSharedData() # Update display if data is available if ($data -and $data.Count -gt 0) { try { $this.UpdateDisplay($data) } catch { Write-Warning "Display update error: $($_.Exception.Message)" } } # Brief sleep to prevent excessive CPU usage Start-Sleep -Milliseconds 100 } } [ConsoleRenderer] GetRenderer() { # Override in derived classes to return the renderer instance return $null } [void] Update([hashtable]$data) { if (-not $this.IsInitialized) { throw [System.InvalidOperationException]::new("Engine not initialized") } $this.UpdateDisplay($data) } [void] UpdateDisplay([hashtable]$data) { # Override in derived classes for specific update logic } [void] Cleanup() { try { # Unregister shutdown handler if ($this.ShutdownHandler) { $this.ShutdownHandler.Unregister() $this.ShutdownHandler = $null } # Stop background thread if ($this.ThreadManager) { $this.ThreadManager.Stop() $this.ThreadManager = $null } # Cleanup display $this.CleanupDisplay() } catch { Write-Warning "Error during cleanup: $($_.Exception.Message)" } finally { $this.IsInitialized = $false } } [void] CleanupDisplay() { # Override in derived classes for specific cleanup } [bool] IsSupported() { # Override in derived classes for capability detection return $true } } # Console-based live display engine using ConsoleRenderer class ConsoleDisplayEngine : LiveDisplayEngineBase { [int] $RefreshInterval = 1000 [ConsoleRenderer] $Renderer [hashtable] $LastDisplayData = @{} [string] $DisplayMode = "detailed" [bool] $PerformanceMode = $true ConsoleDisplayEngine([hashtable]$config) : base($config) { if ($config.ContainsKey('RefreshInterval')) { $this.RefreshInterval = $config.RefreshInterval } if ($config.ContainsKey('DisplayMode')) { $this.DisplayMode = $config.DisplayMode } if ($config.ContainsKey('PerformanceMode')) { $this.PerformanceMode = $config.PerformanceMode } # Use optimized renderer if performance mode is enabled if ($this.PerformanceMode) { # Load the performance optimizer module $performanceModulePath = Join-Path $PSScriptRoot "PerformanceOptimizer.psm1" if (Test-Path $performanceModulePath) { . $performanceModulePath $this.Renderer = [OptimizedConsoleRenderer]::new($true) } else { Write-Warning "Performance optimizer module not found. Using standard renderer." $this.Renderer = [ConsoleRenderer]::new() } } else { $this.Renderer = [ConsoleRenderer]::new() } } [void] ValidateConfiguration() { if ($this.RefreshInterval -lt 500 -or $this.RefreshInterval -gt 5000) { throw [System.ArgumentException]::new("RefreshInterval must be between 500 and 5000 milliseconds") } $validModes = @("minimal", "detailed", "compact") if ($this.DisplayMode -notin $validModes) { throw [System.ArgumentException]::new("DisplayMode must be one of: $($validModes -join ', ')") } } [void] InitializeDisplay() { $this.Renderer.SetDisplayMode($this.DisplayMode) $this.Renderer.InitializeLiveMode() } [ConsoleRenderer] GetRenderer() { return $this.Renderer } [void] UpdateDisplay([hashtable]$data) { # Only update if data has changed to reduce flicker $dataChanged = $this.HasDataChanged($data) if ($dataChanged) { # Clear screen and move to home position $this.Renderer.ClearScreen() # Render track information if ($data -and $data.ContainsKey('track')) { $trackData = $data.track.Clone() # Add playback state information if ($data.ContainsKey('is_playing')) { $trackData['is_playing'] = $data.is_playing } if ($data.ContainsKey('progress_ms')) { $trackData['progress_ms'] = $data.progress_ms } $this.Renderer.UpdateTrackInfo($trackData) } else { $this.Renderer.WriteColoredText("No track information available", "Yellow") $this.Renderer.WriteLine() } # Add timestamp for debugging if ($this.DisplayMode -eq "detailed") { $this.Renderer.WriteLine() $this.Renderer.WriteColoredText("Last updated: $(Get-Date -Format 'HH:mm:ss')", "Gray") $this.Renderer.WriteLine() } # Flush the display buffer $this.Renderer.FlushBuffer() $this.LastDisplayData = if ($data) { $data.Clone() } else { @{} } } } [bool] HasDataChanged([hashtable]$newData) { if (-not $this.LastDisplayData -or $this.LastDisplayData.Count -eq 0) { return $true } if (-not $newData -or $newData.Count -eq 0) { return $true } # Check key fields for changes $keyFields = @('track', 'is_playing', 'progress_ms') foreach ($field in $keyFields) { $oldValue = $this.LastDisplayData.ContainsKey($field) ? $this.LastDisplayData[$field] : $null $newValue = $newData.ContainsKey($field) ? $newData[$field] : $null if ($field -eq 'track') { # For track data, check if track ID changed $oldId = if ($oldValue -and $oldValue.ContainsKey('id')) { $oldValue.id } else { $null } $newId = if ($newValue -and $newValue.ContainsKey('id')) { $newValue.id } else { $null } if ($oldId -ne $newId) { return $true } } elseif ($field -eq 'progress_ms') { # For progress, only update if change is significant (> 1 second) if ($oldValue -and $newValue) { $diff = [Math]::Abs($newValue - $oldValue) if ($diff -gt 1000) { return $true } } elseif ($oldValue -ne $newValue) { return $true } } else { if ($oldValue -ne $newValue) { return $true } } } return $false } [void] CleanupDisplay() { $this.Renderer.CleanupAndExit() } [bool] IsSupported() { return $true # Console display is always supported } [hashtable] GetDisplayInfo() { $info = $this.Renderer.GetTerminalInfo() # Add performance stats if using optimized renderer if ($this.PerformanceMode -and $this.Renderer -is [OptimizedConsoleRenderer]) { $info['PerformanceStats'] = $this.Renderer.GetPerformanceStats() } return $info } [void] SetProgressBarStyle([ProgressBarStyle]$style) { $this.Renderer.DefaultProgressStyle = $style } [void] SetPerformanceMode([bool]$enabled) { $this.PerformanceMode = $enabled if ($this.Renderer -is [OptimizedConsoleRenderer]) { $this.Renderer.SetPerformanceMode($enabled) } } [void] SetTargetFPS([int]$fps) { if ($this.Renderer -is [OptimizedConsoleRenderer]) { $this.Renderer.SetTargetFPS($fps) } } [hashtable] GetPerformanceStats() { if ($this.Renderer -is [OptimizedConsoleRenderer]) { return $this.Renderer.GetPerformanceStats() } return @{} } } # Factory function to create display engines function New-LiveDisplayEngine { param( [Parameter(Mandatory = $true)] [ValidateSet("Console", "Sidecar")] [string]$Type, [hashtable]$Configuration = @{} ) switch ($Type) { "Console" { return [ConsoleDisplayEngine]::new($Configuration) } "Sidecar" { return [SidecarDisplayEngine]::new($Configuration) } default { throw [System.ArgumentException]::new("Unknown display engine type: $Type") } } } # Utility function to test display capabilities function Test-DisplayCapabilities { $console = [ConsoleDisplayEngine]::new(@{}) $sidecar = [SidecarDisplayEngine]::new(@{}) return @{ Console = @{ Supported = $console.IsSupported() Info = $console.GetDisplayInfo() } Sidecar = @{ Supported = $sidecar.IsSupported() Capabilities = $sidecar.GetCapabilities() } WindowsTerminal = [WindowsTerminalIntegrator]::new().GetCapabilities() } } # Export classes and functions Export-ModuleMember -Function @( 'New-LiveDisplayEngine', 'Test-DisplayCapabilities' ) -Variable @() |