用Compose做一个视频下载软件—开篇

我使用 Compose 开发 Android 端应用已经有两年,已经算是比较晚入坑的了。在这期间,也一直在关注 Compose 跨平台,平时也会体验一下。

目前我认为 Compose 跨平台有两个无可比拟的优势------针对 Android 开发者尤其是用过 Compose Android 的开发者来讲:

  • 使用 Kotlin
  • 一致性的 Compose 体验。

这也使得它的学习门槛非常低,几乎能做到各个平台的无缝衔接。

于是乎,我打算开发一个桌面软件,来体验一下 Compose 的桌面开发。

灵感

我经常会从 Youtube 上下载一些视频,然后放到电视上观看。

Youtube 上下载视频,现在已经比较有非常成熟的工具了------yt-dlp

通过它,可以轻松的下载 Youtube 视频,但是这个工具是个命令行工具,而且支持的命令非常多,如果对于视频有着较多的需求的话,他的命令就会非常复杂。我其实每次都记不住那么多命令,很多时候都是现查询的命令。这对于这个下载工具的使用来讲,非常不方便。

所以萌生了一个想法------给这个工具开发一款GUI,可以在桌面端使用。

这款工具至少支持如下的功能:

  1. Youtube 上下载视频。
  2. 能够支持比较简单的视频下载选项,例如分辨率、是否包含音频等。
  3. 能够支持简单的视频处理,例如转码,压缩等。

好的,需求明确,那么我们,立即开始吧!

准备工作

我的整体想法是,利用 yt-dlp 下载使用,使用 FFmpeg 对视频进行处理,所以,在正式开发软件之前,需要在电脑上安装 yt-dlpFFmpeg

yt-dlp
FFmpeg

因为都是命令行工具,所以只需要下载安装之后,在环境变量中设置即可。

检验一下我们的安装成果,打开命令行工具:

ffmpeg -version 的结果比较多,这里只截取了一部分展示。

好的,在正式开发软件之前,基本的准备工作已经完成了,现在,我们正式进入 Compose 桌面软件的开发。

开始

打开 compose-multiplatform 官网,点击右上角 Get Start 按钮。

此时,我们会进入到 kotlin-multiplatform 官网文档,有兴趣可以阅读一下这里的文档,对 kotlin-multiplatform 有一个了解。

下滑到这里

点击 KMP web wizard 连接,此时,我们来到了创建 KMP 项目的向导:

我们只勾选 Desktop 选项:

项目名称和项目 ID ,可以按照个人喜好填写,这里我的项目使用了 Ytor (歪图儿)这个名称,后续将会使用 Ytor 称呼该 App

然后点击下载。完毕之后,解压,使用 IntelliJ IDEA 打开。那么此时,我们的初始工程已经创建完毕。

第一个窗口

观察目录结构:

这里有一个 composeApp 的主目录,这里就是我们主要编写 Ytor 代码的地方,里面包含一个 desktopMain 的目录。如果你是真的包含多端的项目(移动端,桌面端,甚至包含网页端),那么这里的目录结果会跟这个有所区别。

此时,我们已经可以运行第一个版本的 Ytor 了。

找到这个目录下的 main.kt 文件,运行这个 main 函数。

Ytor 的第一个窗口,启动!!!

优化一下

为了让项目更加顺手(更加符合 Android 的开发习惯),我会添加一下额外的依赖:

libs.version.toml

toml 复制代码
[versions]
kotlinx-io = "0.7.0" # io
navigation = "2.9.0-beta01" # navigation

[libraries]
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } 

compose-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" } 

[plugins]
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

project 级别 build.gradle.kts

bash 复制代码
alias(libs.plugins.kotlinSerialization) apply false

app 级别 build.gradle.kts

Kotlin 复制代码
commonMain.dependencies {
    implementation(compose.runtime)
    implementation(compose.foundation)
    implementation(compose.material3)
    implementation(compose.ui)
    implementation(compose.components.resources)
    implementation(compose.components.uiToolingPreview)
    
    // 可以使用viewmode构建
    implementation(libs.androidx.lifecycle.viewmodel)
    implementation(libs.androidx.lifecycle.runtimeCompose) 
    
    // 添加 io
    implementation(libs.kotlinx.io.core)

    // 添加 navigation
    implementation(libs.compose.navigation)
}

我们会用到一些素材,素材相关地址:

  • 图标,在 iconfont 下载。
  • 主题,在 material-theme-builder 构建 Material 主题,这里构建的主体会以代码的形式提供,之后导入到我们的工程即可。

CMP 中,使用资源和 Android 有一点点区别:

Kotlin 复制代码
// 文字资源
stringResource(Res.string.app_name)

// 图片资源
painterResource(Res.drawable.ytor)

我添加了一些扩展,可以方便资源的使用:

Kotlin 复制代码
@Composable
fun StringResource.inString():String {
    return stringResource(this)
}

@Composable
fun StringResource.inString(vararg formatArgs: Any):String {
    return stringResource(this, *formatArgs)
}

@Composable
fun StringArrayResource.inStringArray():List<String> {
    return stringArrayResource(this)
}


@Composable
fun  DrawableResource.inPainter(): Painter {
    return painterResource(this)
}


val Dp.toPxInt :Int @Composable get() =  with(LocalDensity.current) { [email protected]().toInt() }
val Dp.toPx :Float @Composable get() =  with(LocalDensity.current) { [email protected]() }
val Dp.toSp :TextUnit @Composable get() =  with(LocalDensity.current) { [email protected]() }
val Int.px :Dp @Composable get() =  with(LocalDensity.current) { [email protected]() }

我们会用到一个 IconButton 的控件,该控件可以提供

这样的显示效果,同时可以支持点击。

我写了一个几个 Compose 函数,统一了当前的按钮样式:

Kotlin 复制代码
@Composable
fun AppFilledIconButton(
    modifier: Modifier,
    icon: DrawableResource,
    contentDescription: String?,
    onClick: () -> Unit,
) {
    FilledIconButton(
        onClick = onClick, modifier = modifier.size(40.dp)
    ) {
        Icon(
            painter = icon.inPainter(), modifier = Modifier.padding(8.dp).fillMaxSize(), contentDescription = contentDescription
        )
    }
}

@Composable
fun AppIconButton(
    modifier: Modifier,
    icon: DrawableResource,
    contentDescription: String?,
    onClick: () -> Unit,
) {
    IconButton(
        onClick = onClick, modifier = modifier.size(40.dp)
    ) {
        Icon(
            painter = icon.inPainter(), modifier = Modifier.padding(8.dp).fillMaxSize(), contentDescription = contentDescription
        )
    }
}

// 一个单纯的返回按钮
@Composable
fun BackButton(modifier: Modifier = Modifier, onBack: (() -> Unit)? = null) {
    val currentBack by rememberUpdatedState(onBack)
    val controller = LocalController.current // 这里是自己写的,不是 Compose 自带的
    AppIconButton(modifier, icon = Res.drawable.back, contentDescription = Res.string.back.inString()) {
        currentBack?.invoke() ?: controller.navigateUp()
    }
}

好的,接下来我们统一一下主题。在之前使用 material-theme-builder 创建的主题文件中,稍微修改一下 Theme.kt

Kotlin 复制代码
@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable() () -> Unit
) {
  val colorScheme = if (darkTheme) darkScheme else lightScheme

  MaterialTheme(
    colorScheme = colorScheme,
    typography = AppTypography,
    content = content
  )
}

main.kt 中,使用这个主题:

Kotlin 复制代码
Window(
    onCloseRequest = ::exitApplication,
    undecorated = false,
    icon = Res.drawable.ytor.inPainter(),
    title = Res.string.app_name.inString(),
) {
    AppTheme {
        Surface(
            modifier = Modifier.fillMaxSize()
        ) {
            App()
        }
    }
}

Compose-Navigation 创建一个扩展,可以更加方便的使用页面:

Kotlin 复制代码
inline fun <reified T : Any>  NavGraphBuilder.composablePage(
    typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
    deepLinks: List<NavDeepLink> = emptyList(),
    noinline enterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = null,
    noinline exitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = null,
    noinline popEnterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = enterTransition,
    noinline popExitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = exitTransition,
    noinline sizeTransform: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards SizeTransform?)? = null,
    noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {
    composable<T>(
        typeMap = typeMap,
        deepLinks = deepLinks,
        enterTransition = enterTransition,
        exitTransition = exitTransition,
        popEnterTransition = popEnterTransition,
        popExitTransition = popExitTransition,
        sizeTransform = sizeTransform,
        content = content
    )
}

两个页面

然后,我们准备编写两个页面(主要是为了展示页面跳转),一个主页,一个设置页面。

我的想法是,主页有一个设置按钮,可以跳转到设置页面,然后设置页面可以点击返回按钮返回到主页。

首先,我们先编写 Navigation 路由,新版本声明路由参数非常简单:

Kotlin 复制代码
import kotlinx.serialization.Serializable

@Serializable
object HomeNav

@Serializable
object SettingNav

App.kt 中,添加这两个页面的节点:

Kotlin 复制代码
// 声明一个 LocalController 的扩展,可以在任何地方使用 NavController
val LocalController = staticCompositionLocalOf<NavHostController> { error("null controller") }

@Composable
@Preview
fun App() {
    // Creates the NavController
    val navController = rememberNavController()

    CompositionLocalProvider(
        LocalController provides navController // provides navController
    ) {

        NavHost(navController = navController, startDestination = HomeNav) {
            composablePage<HomeNav> {
                HomePage(entry = it) // 主页
            }
            composablePage<SettingNav> {
                SettingPage(entry = it) // 设置页面
            }
        }
    }
}

主页:

Kotlin 复制代码
@Composable
fun HomePage(
    entry: NavBackStackEntry,
) {

    val controller = LocalController.current

    Box(modifier = Modifier.safeContentPadding().fillMaxSize()) {

        AppFilledIconButton(
            modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
            icon = Res.drawable.setting,
            contentDescription = Res.string.setting.inString(),
        ) {
            controller.navigate(SettingNav) // 
        }

        Text(text = "Hello ${Res.string.app_name.inString()}", modifier = Modifier.align(Alignment.Center))
    }
}

设置页面:

Kotlin 复制代码
@Composable
fun SettingPage(
    entry: NavBackStackEntry,
    vm: SettingViewModel = viewModel { SettingViewModel() }
) {
    val yt by vm.ytVersion.collectAsState()
    val ffmpeg by vm.ffmpegVersion.collectAsState()

    Box(
        modifier = Modifier.safeContentPadding().fillMaxSize()
    ) {
        BackButton(
            modifier = Modifier.padding(12.dp)
        )

        Column(modifier = Modifier.align(Alignment.Center)) {
            Text("Yt-dlp Version: $yt")
            Text("FFmpeg Version: $ffmpeg")
        }
    }
}

SettingViewModel

Kotlin 复制代码
class SettingViewModel : ViewModel() {

    private val _ffmpegVersion = MutableStateFlow("")
    val ffmpegVersion: StateFlow<String> = _ffmpegVersion.asStateFlow()

    private val _ytVersion = MutableStateFlow("")
    val ytVersion: StateFlow<String> = _ytVersion.asStateFlow()

    init {
        viewModelScope.launch(Dispatchers.IO) {
            val ffmpegProcess = Runtime.getRuntime().exec(arrayOf("ffmpeg","-version"))
            val ffmpegResult = ffmpegProcess.inputStream.bufferedReader().readLine()
            _ffmpegVersion.update { ffmpegResult }

            val ytProcess = Runtime.getRuntime().exec(arrayOf("yt-dlp","--version"))
            val ytResult = ytProcess.inputStream.bufferedReader().readLine()
            _ytVersion.update { ytResult }
        }
    }
}

基本代码已经完成,好的,启动:

是的,Ytor 的第一个带有一定功能的窗口,完成!!!

总结

总结一下到目前的进展:

  1. 完成基本的环境依赖(FFmpegyt-dlp)。
  2. 设定 Ytor 的主题。
  3. 完成基本的跳转功能。
  4. 测试了一下命令行参数。

源码地址: github.com/kapaseker/Y...

相关推荐
哒哒哒52852023 分钟前
HTTP缓存
前端·面试
T___26 分钟前
从入门到放弃?带你重新认识 Headless UI
前端·设计模式
wordbaby27 分钟前
React Router 中调用 Actions 的三种方式详解
前端·react.js
黄丽萍33 分钟前
前端Vue3项目代码开发规范
前端
curdcv_po37 分钟前
🏄公司报销,培养我成一名 WebGL 工程师⛵️
前端
Jolyne_1 小时前
前端常用的树处理方法总结
前端·算法·面试
wordbaby1 小时前
后端的力量,前端的体验:React Router Server Action 的魔力
前端·react.js
Alang1 小时前
Mac Mini M4 16G 内存本地大模型性能横评:9 款模型实测对比
前端·llm·aigc
林太白1 小时前
Rust-连接数据库
前端·后端·rust
wordbaby1 小时前
让数据“流动”起来:React Router Client Action 与组件的无缝协作
前端·react.js