Public/Start-MusicForProgramming.ps1

function Start-MusicForProgramming {
    <#
    .SYNOPSIS
        Starts playback of a Music For Programming episode.

    .DESCRIPTION
        Plays a Music For Programming episode using the platform's default audio
        handler. Defaults to a random episode.

        Local files in the default music directory are always preferred over
        streaming. Falls back to streaming when a file is not available locally.

        Playback runs in the background by default so your shell stays usable.
        Use -Foreground if you want blocking playback.

    .PARAMETER EpisodeNumber
        Play a specific episode by number (1–78+). Cannot be combined with -Random.

    .PARAMETER Random
        Explicitly request a randomly chosen episode. This is the default behaviour
        when no parameter set is specified.

    .PARAMETER Path
        Directory containing locally downloaded MFP files. Local files are preferred
        over streaming. Defaults to ~/Music/MusicForProgramming.
        Override for all commands at once via:
            $PSDefaultParameterValues['*-MusicForProgramming:Path'] = 'D:\MyMusic'

    .PARAMETER Foreground
        Play in the foreground (blocking) instead of background.

    .EXAMPLE
        Start-MusicForProgramming

        Plays a random episode — local if available, otherwise streams.

    .EXAMPLE
        Start-MusicForProgramming -EpisodeNumber 42

        Plays episode 42 — local if downloaded, otherwise streams.

    .EXAMPLE
        Start-MusicForProgramming -Random

        Picks a random episode.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Random')]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'ByNumber')]
        [ArgumentCompleter({
            param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
            (Get-MusicForProgramming).EpisodeNumber |
                Where-Object { "$_" -like "$wordToComplete*" } |
                Sort-Object
        })]
        [int]$EpisodeNumber,

        [Parameter(ParameterSetName = 'Random')]
        [switch]$Random,

        [Parameter()]
        [string]$Path = $script:DefaultMusicPath,

        [Parameter()]
        [switch]$Foreground
    )

    if ($PSCmdlet.ParameterSetName -eq 'ByNumber') {
        Write-Verbose "Looking up episode $EpisodeNumber (local-first)"
        $episode = Get-MusicForProgramming -EpisodeNumber $EpisodeNumber -Path $Path
        if (-not $episode) { throw "Episode $EpisodeNumber was not found." }
    }
    else {
        Write-Verbose "Selecting a random episode (local-first)"
        $episode = Get-MusicForProgramming -Path $Path | Get-Random
    }

    $displayTitle = if ($episode.PSObject.Properties['FullTitle']) {
        $episode.FullTitle
    }
    else {
        $episode.Title
    }
    $displayDuration = if ($episode.PSObject.Properties['Duration']) {
        $episode.Duration
    }
    else {
        ''
    }
    Write-Verbose "Selected: $displayTitle $displayDuration"

    # Episode objects from local collection have FilePath; remote ones have Url
    if ($episode.FilePath) {
        $playback = Invoke-MFPLocalPlay -FilePath $episode.FilePath -Foreground:$Foreground
        $result = "Playing local: $($episode.Title)"
    }
    else {
        $playback = Invoke-MFPStreamPlay -Url $episode.Url -Foreground:$Foreground
        $result = "Streaming: $($episode.Title)"
    }

    if ($playback -and $playback.Trackable) {
        $result = "$result (pid $($playback.ProcessId))"
    }
    elseif (-not $Foreground) {
        $result = "$result (untracked player)"
    }

    Write-Host $result
    return $result
}

# ---------------------------------------------------------------------------
# Private helpers — not exported
# ---------------------------------------------------------------------------

function Invoke-MFPLocalPlay {
    <#
        Plays a local audio file using the best available method for the
        current platform and tracks background process state when possible.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$FilePath,

        [Parameter()]
        [switch]$Foreground
    )

    if (-not (Test-Path $FilePath)) {
        throw "Audio file not found: $FilePath"
    }

    if ($IsMacOS) {
        Write-Verbose "Using 'afplay' on macOS"
        if ($Foreground) {
            try {
                & afplay $FilePath
                Clear-MFPPlaybackState
                return
            }
            catch {
                throw "Failed to play '$FilePath' on macOS: $_"
            }
        }

        try {
            $proc = Start-Process -FilePath 'afplay' -ArgumentList $FilePath -PassThru
            return Set-MFPPlaybackState -Process $proc -Target $FilePath -Mode 'local'
        }
        catch {
            throw "Failed to play '$FilePath' on macOS: $_"
        }
    }
    elseif ($IsLinux) {
        $player = foreach ($cmd in 'mpg123', 'mpv', 'ffplay', 'vlc') {
            if (Get-Command $cmd -ErrorAction SilentlyContinue) { $cmd; break }
        }
        if (-not $player) {
            if ($Foreground) {
                throw "No supported Linux player found (mpg123/mpv/ffplay/vlc)."
            }

            # Fall back to xdg-open (untracked)
            Write-Verbose "No CLI audio player found; using xdg-open (untracked)"
            xdg-open $FilePath
            Clear-MFPPlaybackState
            return [PSCustomObject]@{ Trackable = $false }
        }

        Write-Verbose "Using '$player' on Linux"
        if ($Foreground) {
            & $player $FilePath
            Clear-MFPPlaybackState
            return
        }

        $proc = Start-Process -FilePath $player -ArgumentList $FilePath -PassThru
        return Set-MFPPlaybackState -Process $proc -Target $FilePath -Mode 'local'
    }
    else {
        # Windows — open with the default associated application
        Write-Verbose "Using Start-Process on Windows"
        try {
            $proc = Start-Process -FilePath $FilePath -PassThru
            if ($Foreground -and $proc) {
                Wait-Process -Id $proc.Id
                Clear-MFPPlaybackState
                return
            }

            if ($proc) {
                return Set-MFPPlaybackState -Process $proc -Target $FilePath -Mode 'local'
            }

            Clear-MFPPlaybackState
            return [PSCustomObject]@{ Trackable = $false }
        }
        catch {
            throw "Failed to open '$FilePath' on Windows: $_"
        }
    }
}

function Invoke-MFPStreamPlay {
    <#
        Opens a streaming URL and tracks process state when possible.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Url,

        [Parameter()]
        [switch]$Foreground
    )

    if ($IsMacOS) {
        # Prefer a player process we can track, then fall back to open
        $player = foreach ($cmd in 'mpv', 'vlc', 'ffplay') {
            if (Get-Command $cmd -ErrorAction SilentlyContinue) { $cmd; break }
        }

        if ($player) {
            Write-Verbose "Using '$player' to stream on macOS"
            if ($Foreground) {
                & $player $Url
                Clear-MFPPlaybackState
                return
            }

            $proc = Start-Process -FilePath $player -ArgumentList $Url -PassThru
            return Set-MFPPlaybackState -Process $proc -Target $Url -Mode 'stream'
        }

        Write-Verbose "Opening stream with 'open' on macOS (untracked)"
        try {
            open $Url
            Clear-MFPPlaybackState
            return [PSCustomObject]@{ Trackable = $false }
        }
        catch {
            throw "Failed to open stream on macOS: $_"
        }
    }
    elseif ($IsLinux) {
        # Prefer a player that can stream directly
        $player = foreach ($cmd in 'mpv', 'vlc', 'ffplay', 'mpg123') {
            if (Get-Command $cmd -ErrorAction SilentlyContinue) { $cmd; break }
        }

        if ($player) {
            Write-Verbose "Using '$player' to stream on Linux"
            if ($Foreground) {
                & $player $Url
                Clear-MFPPlaybackState
                return
            }

            $proc = Start-Process -FilePath $player -ArgumentList $Url -PassThru
            return Set-MFPPlaybackState -Process $proc -Target $Url -Mode 'stream'
        }

        if ($Foreground) {
            throw "No supported Linux streaming player found (mpv/vlc/ffplay/mpg123)."
        }

        Write-Verbose "No streaming player found; using xdg-open (untracked)"
        xdg-open $Url
        Clear-MFPPlaybackState
        return [PSCustomObject]@{ Trackable = $false }
    }
    else {
        # Windows — let the OS handle the URL (opens in default browser/media player)
        Write-Verbose "Using Start-Process on Windows"
        $proc = Start-Process $Url -PassThru
        if ($Foreground -and $proc) {
            Wait-Process -Id $proc.Id
            Clear-MFPPlaybackState
            return
        }

        if ($proc) {
            return Set-MFPPlaybackState -Process $proc -Target $Url -Mode 'stream'
        }

        Clear-MFPPlaybackState
        return [PSCustomObject]@{ Trackable = $false }
    }
}

function Set-MFPPlaybackState {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [System.Diagnostics.Process]$Process,
        [Parameter(Mandatory)] [string]$Target,
        [Parameter(Mandatory)] [string]$Mode
    )

    $state = [PSCustomObject]@{
        Trackable  = $true
        ProcessId  = $Process.Id
        Mode       = $Mode
        Target     = $Target
        StartedAt  = Get-Date
    }

    $script:CurrentPlayback = $state
    return $state
}

function Clear-MFPPlaybackState {
    [CmdletBinding()]
    param()

    $script:CurrentPlayback = $null
}