EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

SafeLaunch.kt

package dev.stave.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

/**
 * Coroutine exception handler shared by every ViewModel.launch site.
 *
 * Without this, uncaught exceptions from viewModelScope.launch bubble up to the JVM
 * default handler, which Compose Desktop / Swing surfaces as an opaque "Error"
 * dialog showing only the exception message — e.g. `index: 57, size: 57` after a
 * right-click → Play Next, with no stack trace and no way to recover gracefully.
 *
 * Logging here means the same exception becomes a stderr stack trace
 * (recoverable from `./gradlew :desktop:run` output or a shell-launched packaged
 * install) and the user keeps playing music. The app remains responsive.
 *
 * Note: This swallows the exception by *handling* it — viewModelScope's
 * SupervisorJob doesn't cancel siblings, so other launches continue normally.
 */
private val viewModelExceptionHandler = CoroutineExceptionHandler { _, throwable ->
    System.err.println(
        "[Earhorn] ViewModel coroutine threw (${throwable.javaClass.simpleName}):"
    )
    throwable.printStackTrace(System.err)
}

/**
 * Like viewModelScope.launch, but installs [viewModelExceptionHandler] so an
 * unexpected throw becomes a logged stack trace rather than an error dialog.
 *
 * Use for any coroutine triggered by a user action (right-click handlers,
 * button clicks, menu items, drag callbacks, etc). Long-running collection
 * jobs (state flows, lifecycle observers) can also use this; the handler
 * only fires on actually-uncaught exceptions.
 */
fun ViewModel.safeLaunch(block: suspend CoroutineScope.() -> Unit): Job =
    viewModelScope.launch(viewModelExceptionHandler, block = block)