modules/Core/InteractiveMode.psm1
|
# Interactive Mode Module # Provides keyboard navigation for lists (playlists, search results, etc.) $script:InteractiveMode = $false $script:CurrentItems = @() $script:SelectedIndex = 0 $script:ViewportStart = 0 $script:HeaderLines = 5 function Get-TerminalSize { try { $width = $Host.UI.RawUI.WindowSize.Width $height = $Host.UI.RawUI.WindowSize.Height } catch { $width = 80 $height = 24 } if ($width -lt 40) { $width = 40 } if ($height -lt 10) { $height = 10 } return @{ Width = $width; Height = $height } } function Start-InteractiveMode { <# .SYNOPSIS Start interactive navigation mode with arrow keys .PARAMETER Items Array of items to navigate (tracks, playlists, etc.) .PARAMETER Title Title to display at the top .EXAMPLE Start-InteractiveMode -Items $searchResults -Title "Search Results" #> param( [Parameter(Mandatory=$true)] $Items, [string]$Title = "Interactive Mode" ) if (-not $Items -or $Items.Count -eq 0) { Write-Host "❌ No items to navigate" -ForegroundColor Red return } $script:InteractiveMode = $true $script:CurrentItems = $Items $script:SelectedIndex = 0 $script:ViewportStart = 0 $termSize = Get-TerminalSize $separatorWidth = [Math]::Min($termSize.Width - 1, 80) Clear-Host Write-Host "" Write-Host "🎮 $Title" -ForegroundColor Cyan Write-Host ("=" * $separatorWidth) -ForegroundColor DarkGray Write-Host "⌨️ ↑↓ Navigate | Enter Play | Space Queue | Esc Exit" -ForegroundColor Yellow Write-Host "" Show-InteractiveItems try { while ($script:InteractiveMode) { if (-not $Host.UI.RawUI.KeyAvailable) { Start-Sleep -Milliseconds 100 continue } $key = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") switch ($key.VirtualKeyCode) { 38 { # Up arrow if ($script:SelectedIndex -gt 0) { $script:SelectedIndex-- Show-InteractiveItems } } 40 { # Down arrow if ($script:SelectedIndex -lt ($script:CurrentItems.Count - 1)) { $script:SelectedIndex++ Show-InteractiveItems } } 13 { # Enter - Play $selectedItem = $script:CurrentItems[$script:SelectedIndex] Play-SpotifyItem -Item $selectedItem $script:InteractiveMode = $false } 32 { # Space - Queue $selectedItem = $script:CurrentItems[$script:SelectedIndex] Queue-SpotifyItem -Item $selectedItem Show-InteractiveItems } 27 { # Escape - Exit $script:InteractiveMode = $false Write-Host "" Write-Host "👋 Exited interactive mode" -ForegroundColor Yellow } { $_ -ge 49 -and $_ -le 57 } { # Number keys 1-9 $number = $_ - 48 if ($number -le $script:CurrentItems.Count) { $script:SelectedIndex = $number - 1 Show-InteractiveItems } } } } } catch { Write-Warning "Interactive mode error: $($_.Exception.Message)" $script:InteractiveMode = $false } } function Show-InteractiveItems { $termSize = Get-TerminalSize $termWidth = $termSize.Width $termHeight = $termSize.Height # Calculate visible item slots: total height minus header (5 lines) minus footer (2 lines for scroll indicators + blank) $maxVisible = $termHeight - $script:HeaderLines - 3 if ($maxVisible -lt 3) { $maxVisible = 3 } $totalItems = $script:CurrentItems.Count # Adjust viewport to keep selected item visible if ($script:SelectedIndex -lt $script:ViewportStart) { $script:ViewportStart = $script:SelectedIndex } if ($script:SelectedIndex -ge ($script:ViewportStart + $maxVisible)) { $script:ViewportStart = $script:SelectedIndex - $maxVisible + 1 } if ($script:ViewportStart -lt 0) { $script:ViewportStart = 0 } $viewEnd = [Math]::Min($script:ViewportStart + $maxVisible, $totalItems) try { # Move cursor to start of list area [Console]::SetCursorPosition(0, $script:HeaderLines) # Show "more above" indicator $clearStr = " " * ($termWidth - 1) if ($script:ViewportStart -gt 0) { $aboveText = " ▲ $($script:ViewportStart) more above" if ($aboveText.Length -gt ($termWidth - 1)) { $aboveText = $aboveText.Substring(0, $termWidth - 1) } Write-Host $clearStr -NoNewline [Console]::SetCursorPosition(0, [Console]::CursorTop) Write-Host $aboveText -ForegroundColor DarkCyan } else { Write-Host $clearStr -NoNewline [Console]::SetCursorPosition(0, [Console]::CursorTop) Write-Host "" } # Draw visible items for ($i = $script:ViewportStart; $i -lt $viewEnd; $i++) { $item = $script:CurrentItems[$i] $isSelected = ($i -eq $script:SelectedIndex) $prefix = if ($isSelected) { "► " } else { " " } $color = if ($isSelected) { "Yellow" } else { "White" } # Build display text $displayText = Get-ItemDisplayText -Item $item -Index $i # Truncate to fit terminal width $fullLine = "$prefix$displayText" if ($fullLine.Length -gt ($termWidth - 1)) { $fullLine = $fullLine.Substring(0, $termWidth - 4) + "..." } # Clear line and write Write-Host $clearStr -NoNewline [Console]::SetCursorPosition(0, [Console]::CursorTop) Write-Host $fullLine -ForegroundColor $color } # Clear remaining lines in viewport area $linesDrawn = ($viewEnd - $script:ViewportStart) $remainingSlots = $maxVisible - $linesDrawn for ($j = 0; $j -lt $remainingSlots; $j++) { Write-Host $clearStr -NoNewline [Console]::SetCursorPosition(0, [Console]::CursorTop) Write-Host "" } # Show "more below" indicator $belowCount = $totalItems - $viewEnd if ($belowCount -gt 0) { $belowText = " ▼ $belowCount more below" if ($belowText.Length -gt ($termWidth - 1)) { $belowText = $belowText.Substring(0, $termWidth - 1) } Write-Host $clearStr -NoNewline [Console]::SetCursorPosition(0, [Console]::CursorTop) Write-Host $belowText -ForegroundColor DarkCyan } else { Write-Host $clearStr -NoNewline [Console]::SetCursorPosition(0, [Console]::CursorTop) Write-Host "" } # Status line $statusText = " ($($script:SelectedIndex + 1)/$totalItems)" Write-Host $clearStr -NoNewline [Console]::SetCursorPosition(0, [Console]::CursorTop) Write-Host $statusText -ForegroundColor DarkGray } catch { # Fallback if console manipulation fails for ($i = 0; $i -lt $script:CurrentItems.Count; $i++) { $item = $script:CurrentItems[$i] $isSelected = ($i -eq $script:SelectedIndex) $prefix = if ($isSelected) { "► " } else { " " } Write-Host "$prefix$($i + 1). $($item.name)" -ForegroundColor $(if ($isSelected) { "Yellow" } else { "White" }) } } } function Get-ItemDisplayText { param($Item, [int]$Index) if ($Item.type -eq "playlist" -or $Item.search_type -eq "playlist") { $trackInfo = if ($Item.description) { " • $($Item.description)" } else { "" } return "$($Index + 1). 📁 $($Item.name)$trackInfo" } elseif ($Item.PSObject.Properties.Name -contains 'track') { $track = $Item.track $artists = ($track.artists | ForEach-Object { $_.name }) -join ", " return "$($Index + 1). 🎵 $($track.name) - $artists" } elseif ($Item.type -eq "episode" -or $Item.PSObject.Properties.Name -contains 'show') { $showName = if ($Item.show) { $Item.show.name } else { "" } return "$($Index + 1). 🎙️ $($Item.name) - $showName" } elseif ($Item.PSObject.Properties.Name -contains 'artists') { $artists = ($Item.artists | ForEach-Object { $_.name }) -join ", " return "$($Index + 1). 🎵 $($Item.name) - $artists" } else { return "$($Index + 1). $($Item.name)" } } function Play-SpotifyItem { param($Item) if (-not $Item) { return } try { # Handle playlist vs track differently if ($Item.type -eq "playlist" -or $Item.search_type -eq "playlist") { # Play playlist $playlistUri = if ($Item.uri) { $Item.uri } else { "spotify:playlist:$($Item.id)" } $body = @{ context_uri = $playlistUri } try { Invoke-SpotifyApi -Method PUT -Path "/me/player/play" -Body $body | Out-Null Write-Host "▶️ Playing playlist: $($Item.name)" -ForegroundColor Green } catch { # If no active device, try to activate one Write-Host "🎵 No active device. Looking for available devices..." -ForegroundColor Yellow try { $devicesResponse = Invoke-SpotifyApi -Method GET -Path "/me/player/devices" if (-not $devicesResponse -or -not $devicesResponse.devices -or $devicesResponse.devices.Count -eq 0) { Write-Host "❌ No available devices found. Please start Spotify on a device." -ForegroundColor Red return } $firstDevice = $devicesResponse.devices[0] Write-Host "🔄 Activating device: $($firstDevice.name)..." -ForegroundColor Cyan $transferBody = @{ device_ids = @($firstDevice.id); play = $false } Invoke-SpotifyApi -Method PUT -Path "/me/player" -Body $transferBody | Out-Null Start-Sleep -Milliseconds 500 # Try again to play Invoke-SpotifyApi -Method PUT -Path "/me/player/play" -Body $body | Out-Null Write-Host "▶️ Playing playlist: $($Item.name)" -ForegroundColor Green } catch { Write-Host "❌ Could not play playlist: $($_.Exception.Message)" -ForegroundColor Red } } } else { # Play single track $uri = if ($Item.uri) { $Item.uri } elseif ($Item.track -and $Item.track.uri) { $Item.track.uri } elseif ($Item.Uri) { $Item.Uri } else { throw "No URI found for item" } $body = @{ uris = @($uri) } # Get display name $name = if ($Item.name) { $Item.name } elseif ($Item.track) { $Item.track.name } else { "Unknown" } try { Invoke-SpotifyApi -Method PUT -Path "/me/player/play" -Body $body | Out-Null if ($Item.type -eq "episode" -or ($Item.track -and $Item.track.type -eq "episode")) { Write-Host "▶️ Playing podcast episode: $name" -ForegroundColor Magenta } else { $artists = if ($Item.artists) { ($Item.artists | ForEach-Object { $_.name }) -join ", " } elseif ($Item.track -and $Item.track.artists) { ($Item.track.artists | ForEach-Object { $_.name }) -join ", " } else { "Unknown Artist" } Write-Host "▶️ Playing: $name by $artists" -ForegroundColor Green } } catch { # If no active device, try to activate one Write-Host "🎵 No active device. Looking for available devices..." -ForegroundColor Yellow try { $devicesResponse = Invoke-SpotifyApi -Method GET -Path "/me/player/devices" if (-not $devicesResponse -or -not $devicesResponse.devices -or $devicesResponse.devices.Count -eq 0) { Write-Host "❌ No available devices found. Please start Spotify on a device." -ForegroundColor Red return } $firstDevice = $devicesResponse.devices[0] Write-Host "🔄 Activating device: $($firstDevice.name)..." -ForegroundColor Cyan $transferBody = @{ device_ids = @($firstDevice.id); play = $false } Invoke-SpotifyApi -Method PUT -Path "/me/player" -Body $transferBody | Out-Null Start-Sleep -Milliseconds 500 # Try again to play Invoke-SpotifyApi -Method PUT -Path "/me/player/play" -Body $body | Out-Null if ($Item.type -eq "episode" -or ($Item.track -and $Item.track.type -eq "episode")) { Write-Host "▶️ Playing podcast episode: $name" -ForegroundColor Magenta } else { $artists = if ($Item.artists) { ($Item.artists | ForEach-Object { $_.name }) -join ", " } elseif ($Item.track -and $Item.track.artists) { ($Item.track.artists | ForEach-Object { $_.name }) -join ", " } else { "Unknown Artist" } Write-Host "▶️ Playing: $name by $artists" -ForegroundColor Green } } catch { Write-Host "❌ Could not play track: $($_.Exception.Message)" -ForegroundColor Red } } } } catch { Write-Host "❌ Error: $($_.Exception.Message)" -ForegroundColor Red Write-Verbose "Stack trace: $($_.ScriptStackTrace)" } } function Queue-SpotifyItem { param($Item) if (-not $Item) { return } try { # Determine URI based on item structure $uri = if ($Item.uri) { $Item.uri } elseif ($Item.track -and $Item.track.uri) { $Item.track.uri } elseif ($Item.Uri) { $Item.Uri } else { throw "No URI found for item" } Invoke-SpotifyApi -Method POST -Path "/me/player/queue?uri=$uri" | Out-Null $name = if ($Item.name) { $Item.name } elseif ($Item.track) { $Item.track.name } else { "Unknown" } Write-Host "✅ Added to queue: $name" -ForegroundColor Green } catch { Write-Host "❌ Could not add to queue: $($_.Exception.Message)" -ForegroundColor Red } } Export-ModuleMember -Function @( 'Start-InteractiveMode', 'Show-InteractiveItems', 'Get-ItemDisplayText', 'Play-SpotifyItem', 'Queue-SpotifyItem' ) |