Kotlin Compose Multiplatform 桌面端音乐播放器与数据库实现指南

Viewed 0

一、前言

在前两篇文章中,我们已经介绍了项目工程结构、打包配置以及基础使用。本文将深入探讨 Kotlin+Compose+Multiplatform 在桌面端的实现,具体涵盖数据库使用、音乐播放器开发、音频动画效果以及 LRC 歌词展示。下面将逐一详细分解。

二、数据库使用

在 Kotlin+Compose+Multiplatform 桌面端应用中,有多种数据库可供选择。本案例采用以下方案:

  • Kotlinx.Serialization:用于序列化。
  • SQLite JDBC 驱动:提供轻量级持久化支持。

该方案的优势包括:

  1. 通过 Kotlin 序列化直接将数据库查询结果映射为数据类。
  2. 编译器插件生成序列化代码,避免运行时反射开销。
  3. 支持跨平台数据格式转换(如 SQLite 结果转 JSON 再转 UI 模型)。
  4. SQLite 与 JDBC 组合实现嵌入式数据库,无需额外服务部署,适合桌面端。
  5. 通过 JDBC 标准接口实现 ACID 事务,兼容所有 Kotlin/JVM 平台(Mac、Windows、Linux)。
  6. 与 Compose 状态管理无缝集成,支持协程异步操作。

接入配置

build.gradle 中添加以下配置:

首先,应用 Kotlin 序列化插件:

plugins {
    kotlin("plugin.serialization") version "1.9.0"
}

然后在 jvmMain.dependencies 中引入依赖:

jvmMain.dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
    implementation("org.xerial:sqlite-jdbc:3.42.0.0")
}

具体使用

1. 创建数据库连接

private val dbName = "music.db"
private val connection: Connection by lazy {
    val dbPath = DatabaseUtils.getDatabasePath(dbName)
    DriverManager.getConnection("jdbc:sqlite:$dbPath")
}

2. 创建表结构

通过 connection.createStatement().executeUpdate 执行 SQL 语句创建表:

private fun createTable() {
    connection.createStatement().executeUpdate(
        """
        CREATE TABLE IF NOT EXISTS play_list (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            musicID TEXT NOT NULL,
            name TEXT NOT NULL,
            singer TEXT NOT NULL,
            pic TEXT NOT NULL,
            url TEXT NOT NULL,
            lrc TEXT NOT NULL,
            musicSuffer TEXT NOT NULL,
            localFile INTEGER CHECK (localFile IN (0, 1))
        )
        """
    )
}

3. 插入数据

注意参数占位符 ? 的顺序从 1 开始:

fun inserPlayItem(user: MusicItem): Int {
    val sql = "INSERT INTO play_list (musicID, name, singer, pic, url, lrc, musicSuffer, localFile) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
    val statement = connection.prepareStatement(sql)
    statement.setString(1, user.musicID)
    statement.setString(2, user.name)
    statement.setString(3, user.singer)
    statement.setString(4, user.pic)
    statement.setString(5, user.url)
    statement.setString(6, user.lrc)
    statement.setString(7, user.musicSuffer)
    statement.setBoolean(8, user.localFile)
    statement.executeUpdate()
    return statement.generatedKeys.getInt(1) // 返回生成的 ID
}

4. 查询数据

将查询结果转化为实体列表:

fun getAllPlayList(): List<MusicItem> {
    val list = mutableListOf<MusicItem>()
    val resultSet = connection.createStatement().executeQuery("SELECT * FROM play_list ORDER BY id ASC")
    while (resultSet.next()) {
        list.add(
            MusicItem(
                resultSet.getString("musicID"),
                resultSet.getString("name"),
                resultSet.getString("singer"),
                resultSet.getString("pic"),
                resultSet.getString("url"),
                resultSet.getString("lrc"),
                resultSet.getString("musicSuffer"),
                resultSet.getBoolean("localFile"),
                resultSet.getInt("id")
            )
        )
    }
    return list
}

5. 删除操作

包括删除表、清空数据或按条件删除:

// 删除表结构
fun dropTable(): Int {
    val statement = connection.createStatement()
    statement.executeUpdate("DROP TABLE IF EXISTS play_list")
    statement.close()
    return 1
}

// 清空播放列表数据
fun delList(): Int {
    val statement = connection.createStatement()
    statement.executeUpdate("DELETE FROM play_list")
    statement.close()
    return 1
}

// 根据 musicID 删除指定数据
fun delItemByID(musicID: String): Int {
    val statement = connection.prepareStatement("DELETE FROM play_list WHERE musicID = ?").apply {
        setString(1, musicID)
    }
    statement.executeUpdate()
    statement.close()
    return 1
}

6. 更新数据

修改指定字段的值:

fun updateField(musicID: String): Int {
    val sql = "UPDATE play_list SET localFile = ? WHERE musicID = ?"
    val statement = connection.prepareStatement(sql).apply {
        setInt(1, 1)
        setString(2, musicID)
        executeUpdate()
    }
    statement.close()
    return 1
}

三、音乐播放器

在 Kotlin+Compose+Multiplatform 桌面端,常见的音乐播放方案包括 ComposeMultiplatformMediaPlayer,它依赖 VLC 环境配置。本案例采用 JavaFX 提供的 Media/MediaPlayer(org.openjfx:javafx),无需额外安装,具有以下优势:

  1. 原生支持 MP3、WAV、AAC 等主流音频格式解码。
  2. 通过 DirectX(Windows)或 CoreAudio(macOS)等原生接口实现硬件加速,降低 CPU 占用。
  3. 一套代码无缝运行于 Windows、macOS 和 Linux,自动适配平台音频驱动。
  4. 从 JDK11 起模块化设计,依赖精准控制,应用打包体积减少 40% 以上。

接入配置

build.gradle 中添加 JavaFX 插件和依赖:

plugins {
    id("org.openjfx.javafxplugin") version "0.1.0"
}

javafx {
    version = "20.0.2"
    modules = listOf("javafx.media", "javafx.graphics", "javafx-swing")
}

jvmMain.dependencies 中根据平台添加依赖:

jvmMain.dependencies {
    val javafxPlatform = System.getProperty("os.name").lowercase().let {
        when {
            it.contains("linux") -> "linux"
            it.contains("mac") -> "mac"
            else -> "win"
        }
    }

    implementation("org.openjfx:javafx-base:20.0.2:$javafxPlatform") {
        exclude(group = "org.openjfx", module = "javafx-base")
    }
    implementation("org.openjfx:javafx-media:20.0.2:$javafxPlatform") {
        exclude(group = "org.openjfx", module = "javafx-media")
    }
    implementation("org.openjfx:javafx-graphics:20.0.2:$javafxPlatform") {
        exclude(group = "org.openjfx", module = "javafx-graphics")
    }
    implementation("org.openjfx:javafx-swing:20.0.2:$javafxPlatform") {
        exclude(group = "org.openjfx", module = "javafx-swing")
    }
}

使用步骤和 API

1. 初始化

提前调用 Platform.startup { } 进行初始化。

2. 加载和播放音乐

必须在主线程执行,例如包裹在 Platform.runLater { }SwingUtilities.invokeLater { } 中:

val media = Media(cacheFile.toURI().toString())
mediaPlayer = MediaPlayer(media).apply {
    setOnReady {
        play()
    }
}

3. 播放控制

提供播放状态、暂停和停止方法:

fun isPlaying() = mediaPlayer?.status == MediaPlayer.Status.PLAYING
fun pause() = mediaPlayer?.pause()
fun stop() = mediaPlayer?.stop()

4. 上一首/下一首

通过播放列表的当前索引切换:position + 1 播放下一首,position - 1 播放上一首,然后重新加载资源播放。

5. 时间和进度

  • 获取当前播放时间:val time = mediaPlayer?.currentTime?.toSeconds()?.toLong() ?: 0L
  • 获取总时长:fun totalDuration(): Long = mediaPlayer?.totalDuration?.toSeconds()?.toLong() ?: 0L
  • 根据当前时间和总时长计算播放进度。
  • 拖动进度条跳转:mediaPlayer?.seek(Duration.seconds(seconds))

6. 文件缓存

根据是否为本地文件设置缓存:

val cacheFile = if (!localFile) {
    File(PlatformKVStore.getDownloadDir(), downloadFileName).apply {
        DownLoadUtils.instance.WXDownload2(playItem.url, this@apply)
    }
} else {
    File(PlatformKVStore.getDownloadDir(), downloadFileName)
}
val media = Media(cacheFile.toURI().toString())

四、音频动画效果

音频动画效果主要包括专辑封面旋转和实时频谱可视化(条形变化和圆圈周围动态条纹)。

获取音频数据

在 JavaFX 媒体框架中,通过设置音频频谱参数来获取实时数据:

  • audioSpectrumInterval:频谱数据更新间隔(秒),默认 0.1 秒,较短间隔提高实时性但增加 CPU 负载。
  • audioSpectrumNumBands:频段数量,默认 128,增加可提高分辨率但增加计算复杂度。
  • audioSpectrumThreshold:灵敏度阈值(dB),默认 -60dB,低于此值的信号被过滤。

本案例配置如下:

MediaPlayer(media).apply {
    audioSpectrumInterval = 0.03
    audioSpectrumNumBands = 320
    audioSpectrumThreshold = -60
    audioSpectrumListener = AudioSpectrumListener { _, _, magnitudes, phases ->
        // magnitudes 为频谱数据,FloatArray 类型
    }
}

Compose UI 实现

1. 专辑封面旋转

使用 AsyncImage 并通过 graphicsLayer 修改 rotationZ 实现旋转:

AsyncImage(
    model = picUrl,
    contentDescription = "唱片封面",
    modifier = Modifier
        .padding(20.dp, 0.dp, 0.dp, 80.dp)
        .size(300.dp)
        .clip(CircleShape)
        .border(width = 45.dp, color = Color.Black, shape = CircleShape)
        .padding(45.dp)
        .graphicsLayer {
            rotationZ = viewModel.sheetDiskRotate.value
        },
    contentScale = ContentScale.Crop
)

2. 频谱可视化

使用 Compose 的 Canvas 绘制条形频谱和圆圈周围动态频谱。

条形频谱示例:

@Composable
fun AudioVisualizer(viewModel: PlayerViewModel) {
    var canvasWidth by remember { mutableFloatStateOf(0f) }
    val spectrumData by viewModel.spectrumDataFlow.collectAsState()
    Canvas(
        modifier = Modifier
            .padding(10.dp, 430.dp, 0.dp, 0.dp)
            .width(300.dp)
            .onSizeChanged { canvasWidth = it.width.toFloat() }
    ) {
        if (spectrumData.isNotEmpty()) {
            drawRect(Color.Transparent)
            val barWidth = canvasWidth / spectrumData.size
            spectrumData.forEachIndexed { i, value ->
                val newValue = if (value == -60.0f) randomInRange(-60f, -45f) else value
                val height = (newValue + 60) * 3f // 标准化幅度值
                drawRect(
                    color = Color.hsv(i * 360f / spectrumData.size, 1f, 1f),
                    topLeft = Offset(i * barWidth, size.height - height),
                    size = Size(barWidth * 0.8f, height)
                )
            }
        }
    }
}

圆圈周围动态频谱示例:

@Composable
fun CircularAudioVisualizer(viewModel: PlayerViewModel) {
    var center by remember { mutableStateOf(Offset.Zero) }
    var baseRadius by remember { mutableStateOf(0f) }
    val spectrumData by viewModel.spectrumDataFlow.collectAsState()
    Canvas(
        modifier = Modifier
            .padding(20.dp, 0.dp, 0.dp, 80.dp)
            .size(280.dp)
            .onSizeChanged {
                center = Offset(it.width.toFloat() / 2, it.height.toFloat() / 2)
                baseRadius = min(it.width, it.height) * 0.55f
            }
    ) {
        if (spectrumData.isNotEmpty()) {
            drawCircle(Color.Transparent, baseRadius, center, style = Stroke(2f))
            val angleStep = 2f * PI / spectrumData.size
            spectrumData.forEachIndexed { i, value ->
                val newValue = value
                val normalized = newValue * 2 / 60f
                val spikeLength = baseRadius * 0.08f * normalized
                val angle = i * angleStep
                val startX = center.x + baseRadius * cos(angle).toFloat()
                val startY = center.y + baseRadius * sin(angle).toFloat()
                val endX = startX + spikeLength * cos(angle).toFloat()
                val endY = startY + spikeLength * sin(angle).toFloat()
                drawLine(
                    color = Color.hsv(i * 360f / spectrumData.size, 1f, 1f),
                    start = Offset(startX, startY),
                    end = Offset(endX, endY),
                    strokeWidth = 6f
                )
                drawCircle(
                    color = Color.hsv(i * 360f / spectrumData.size, 1f, 1f),
                    radius = 4f,
                    center = Offset(endX, endY)
                )
            }
        }
    }
}

五、LRC歌词展示

LRC歌词展示通过 Compose 的 LazyColumn 实现。解析 LRC 歌词文件,获取播放器当前时间,与歌词时间戳对比,确定当前歌词行索引。根据索引设置当前行文字的颜色和大小,其余行使用默认样式。具体实现细节可参考项目源码。

六、总结

本文是 Kotlin+Compose+Multiplatform 桌面端实现系列的第三篇,详细介绍了数据库使用、音乐播放器开发、音频动画效果和 LRC 歌词展示。通过本案例,您可以掌握在跨平台桌面应用中集成持久化存储、媒体播放和动态 UI 效果的实践方法。希望这些内容对您的开发工作有所帮助。

0 Answers