本文档面向有 Kotlin/Android 开发经验的开发者,详细介绍如何开发 BiliPai 原生插件。
⚠️ 注意:原生插件需要修改源码并重新编译 APK。如果你只需要简单的内容过滤,请使用 JSON 规则插件。当前插件生态仍处于早期阶段:主仓库以内置插件与示例为主,第三方原生插件并未形成稳定生态。 编写或接入原生插件时,请以
core/plugin/、core/plugin/json/与PureApplication.kt的当前实现为准,不要假设接口在后续版本中保持不变。
- Android Studio 2024.1 或更高版本
- Kotlin 1.9+
- Gradle 8.0+
- Android SDK 36(Compile SDK)
git clone https://github.com/jay3-yy/BiliPai.git
cd BiliPaiapp/src/main/java/com/android/purebilibili/
├── core/plugin/ # 插件核心框架
│ ├── Plugin.kt # 基础接口
│ ├── PlayerPlugin.kt # 播放器插件接口
│ ├── FeedPlugin.kt # 推荐流插件接口
│ ├── DanmakuPlugin.kt # 弹幕插件接口
│ ├── PluginManager.kt # 插件管理器
│ └── PluginStore.kt # 配置持久化
└── feature/plugin/ # 内置插件实现
└── SponsorBlockPlugin.kt # 示例:空降助手
┌─────────────────────────────────────────────────────────────┐
│ PluginManager │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐│
│ │ register │ │ enable │ │ disable │ │ getEnabledPlugins││
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘│
└─────────────────────────────────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PlayerPlugin │ │ FeedPlugin │ │ DanmakuPlugin │
│ ─────────────── │ │ ─────────────── │ │ ─────────────── │
│ • onVideoLoad │ │ • shouldShowItem│ │ • filterDanmaku │
│ • onPositionUpd │ │ │ │ • styleDanmaku │
│ • onVideoEnd │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└────────────────────┴────────────────────┘
│
▼
┌─────────────────┐
│ PluginStore │
│ (DataStore 持久化)│
└─────────────────┘
在 feature/plugin/ 目录下创建新文件:
// MyPlugin.kt
package com.android.purebilibili.feature.plugin
import com.android.purebilibili.core.plugin.Plugin
class MyPlugin : Plugin {
override val id = "my_plugin"
override val name = "我的插件"
override val description = "这是一个示例插件"
override val version = "1.0.0"
override val author = "YourName"
}在 PureApplication.kt 的插件初始化区域中添加:
Looper.myQueue().addIdleHandler {
PluginManager.initialize(this)
PluginManager.register(SponsorBlockPlugin())
PluginManager.register(MyPlugin()) // 添加这行
false
}./gradlew assembleDebug所有插件必须实现 Plugin 接口:
interface Plugin {
/** 唯一标识符,如 "sponsorblock" */
val id: String
/** 显示名称,如 "空降助手" */
val name: String
/** 插件描述 */
val description: String
/** 版本号,如 "1.0.0" */
val version: String
/** 插件作者(可选,默认 "Unknown") */
val author: String
get() = "Unknown"
/** 插件图标(可选) */
val icon: ImageVector?
get() = null
/** 插件启用时调用 */
suspend fun onEnable() {}
/** 插件禁用时调用 */
suspend fun onDisable() {}
/** 插件配置界面(可选) */
@Composable
fun SettingsContent(): Unit = Unit
}| 属性 | 类型 | 必需 | 说明 |
|---|---|---|---|
id |
String | ✅ | 唯一标识符,用于存储配置 |
name |
String | ✅ | 在设置页显示的名称 |
description |
String | ✅ | 插件功能描述 |
version |
String | ✅ | 语义化版本号 |
author |
String | ❌ | 作者名称 |
icon |
ImageVector | ❌ | Material Icons 图标 |
用于控制视频播放行为,如自动跳过片段。
interface PlayerPlugin : Plugin {
/**
* 视频加载时回调
* @param bvid 视频 BV 号
* @param cid 视频 cid(分P)
*/
suspend fun onVideoLoad(bvid: String, cid: Long)
/**
* 播放位置更新回调(约每 500ms 调用一次)
* @param positionMs 当前播放位置(毫秒)
* @return 跳过动作
*/
suspend fun onPositionUpdate(positionMs: Long): SkipAction?
/**
* 视频播放结束时回调
*/
fun onVideoEnd() {}
}sealed class SkipAction {
/** 不执行跳过 */
object None : SkipAction()
/** 自动跳转到指定位置 */
data class SkipTo(
val positionMs: Long, // 跳转目标位置
val reason: String // 跳过原因(用于 Toast 提示)
) : SkipAction()
/** 显示跳过按钮(手动跳过模式) */
data class ShowButton(
val skipToMs: Long, // 点击后跳转位置
val label: String, // 按钮文字,如 "跳过广告"
val segmentId: String // 片段唯一 ID,防止重复显示
) : SkipAction()
}class SkipIntroPlugin : PlayerPlugin {
override val id = "skip_intro"
override val name = "跳过片头"
override val description = "自动跳过视频前 10 秒"
override val version = "1.0.0"
private var hasSkipped = false
override suspend fun onVideoLoad(bvid: String, cid: Long) {
hasSkipped = false
}
override suspend fun onPositionUpdate(positionMs: Long): SkipAction? {
if (!hasSkipped && positionMs < 10_000) {
hasSkipped = true
return SkipAction.SkipTo(10_000, "跳过片头")
}
return SkipAction.None
}
}用于过滤首页推荐视频。
interface FeedPlugin : Plugin {
/**
* 判断是否显示该推荐项
* @param item 视频数据
* @return true 显示,false 隐藏
*/
fun shouldShowItem(item: VideoItem): Boolean
}data class VideoItem(
val bvid: String, // BV 号
val title: String, // 标题
val duration: Int, // 时长(秒)
val owner: Owner?, // UP 主信息
val stat: Stat?, // 统计数据
// ...
)
data class Owner(
val mid: Long, // UP 主 UID
val name: String, // UP 主名称
)
data class Stat(
val view: Int, // 播放量
val like: Int, // 点赞数
val danmaku: Int, // 弹幕数
)class LowViewFilter : FeedPlugin {
override val id = "low_view_filter"
override val name = "低播放过滤"
override val description = "隐藏播放量低于 1000 的视频"
override val version = "1.0.0"
override fun shouldShowItem(item: VideoItem): Boolean {
return (item.stat?.view ?: 0) >= 1000
}
}用于过滤或美化弹幕。
interface DanmakuPlugin : Plugin {
/**
* 过滤弹幕
* @param danmaku 原始弹幕
* @return 处理后的弹幕,返回 null 表示屏蔽
*/
fun filterDanmaku(danmaku: DanmakuItem): DanmakuItem?
/**
* 获取弹幕样式
* @return 自定义样式,返回 null 使用默认样式
*/
fun styleDanmaku(danmaku: DanmakuItem): DanmakuStyle? = null
}data class DanmakuItem(
val id: Long, // 弹幕 ID
val content: String, // 弹幕内容
val timeMs: Long, // 出现时间(毫秒)
val type: Int, // 类型:1=滚动, 4=底部, 5=顶部
val color: Int, // 颜色(RGB)
val userId: String // 发送者 UID
)data class DanmakuStyle(
val textColor: Color? = null, // 文字颜色
val borderColor: Color? = null, // 描边颜色
val backgroundColor: Color? = null, // 背景色
val bold: Boolean = false, // 粗体
val scale: Float = 1.0f // 缩放
)class SubtitleHighlight : DanmakuPlugin {
override val id = "subtitle_highlight"
override val name = "同传高亮"
override val description = "高亮显示翻译弹幕"
override val version = "1.0.0"
override fun filterDanmaku(danmaku: DanmakuItem): DanmakuItem? = danmaku
override fun styleDanmaku(danmaku: DanmakuItem): DanmakuStyle? {
if (danmaku.content.startsWith("【") && danmaku.content.endsWith("】")) {
return DanmakuStyle(
textColor = Color(0xFFFFD700), // 金色
bold = true,
scale = 1.2f
)
}
return null
}
}使用 PluginStore 存储插件配置:
class MyPlugin : Plugin {
// 定义配置数据类
@Serializable
data class Config(
val threshold: Int = 1000,
val enabled: Boolean = true
)
private var config = Config()
override suspend fun onEnable() {
loadConfig()
}
private suspend fun loadConfig() {
val context = PluginManager.getContext()
val json = PluginStore.getConfigJson(context, id)
if (json != null) {
config = Json.decodeFromString(json)
}
}
private suspend fun saveConfig() {
val context = PluginManager.getContext()
val json = Json.encodeToString(config)
PluginStore.setConfigJson(context, id, json)
}
}通过 SettingsContent() 提供配置界面:
@Composable
override fun SettingsContent() {
val context = LocalContext.current
var threshold by remember { mutableStateOf(config.threshold) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "阈值设置",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text("播放量阈值")
Spacer(Modifier.weight(1f))
OutlinedTextField(
value = threshold.toString(),
onValueChange = {
threshold = it.toIntOrNull() ?: 0
// 保存配置
scope.launch {
config = config.copy(threshold = threshold)
saveConfig()
}
},
modifier = Modifier.width(100.dp)
)
}
// 开关示例
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text("启用过滤")
Spacer(Modifier.weight(1f))
CupertinoSwitch(
checked = config.enabled,
onCheckedChange = { enabled ->
scope.launch {
config = config.copy(enabled = enabled)
saveConfig()
}
}
)
}
}
}查看内置的 空降助手 插件作为完整参考:
该插件展示了:
- ✅ 完整的
PlayerPlugin实现 - ✅ 异步数据加载
- ✅ 配置持久化
- ✅ Compose UI 配置界面
- ✅ 自动跳过和按钮跳过两种模式
在 PureApplication.kt 的插件初始化区域注册:
// 文件: PureApplication.kt
Looper.myQueue().addIdleHandler {
// 初始化插件系统
PluginManager.initialize(this)
// 注册内置插件
PluginManager.register(SponsorBlockPlugin())
PluginManager.register(AdFilterPlugin())
PluginManager.register(DanmakuEnhancePlugin())
// 注册你的插件
PluginManager.register(MyCustomPlugin())
false
}// ❌ 不好:在 UI 线程执行耗时操作
override fun shouldShowItem(item: VideoItem): Boolean {
val result = heavyComputation(item) // 阻塞 UI
return result
}
// ✅ 好:预计算或缓存结果
private val cache = mutableMapOf<String, Boolean>()
override fun shouldShowItem(item: VideoItem): Boolean {
return cache.getOrPut(item.bvid) {
// 轻量级判断
item.duration > 60
}
}override suspend fun onVideoLoad(bvid: String, cid: Long) {
try {
// 网络请求
val data = api.fetchData(bvid)
processData(data)
} catch (e: Exception) {
Logger.e(TAG, "加载失败", e)
// 降级处理,不影响主功能
}
}private const val TAG = "MyPlugin"
// 使用项目统一的 Logger
Logger.d(TAG, "调试信息")
Logger.i(TAG, "普通信息")
Logger.w(TAG, "警告信息")
Logger.e(TAG, "错误信息", exception)override suspend fun onDisable() {
// 清理缓存
cache.clear()
// 取消任务
job?.cancel()
// 释放资源
connection?.close()
}adb logcat | grep "PluginManager\|MyPlugin"在设置 → 插件中心查看:
- 插件是否注册成功
- 插件是否启用
- 配置是否保存
使用 Android Studio 的 Apply Changes 功能加快迭代。
@Test
fun `filter should hide short videos`() {
val plugin = MyFilterPlugin()
val shortVideo = VideoItem(duration = 30, ...)
val longVideo = VideoItem(duration = 120, ...)
assertFalse(plugin.shouldShowItem(shortVideo))
assertTrue(plugin.shouldShowItem(longVideo))
}如果你开发了有用的插件,欢迎提交 PR!
- Fork 本仓库
- 创建插件分支:
git checkout -b plugin/my-plugin - 编写代码和测试
- 提交 PR 并描述插件功能
Q: 插件注册后没显示?
A: 确保在 PureApplication.kt 的插件初始化区域中调用了 PluginManager.register()
Q: 配置没保存?
A: 确保使用了 PluginStore 并正确调用了 setConfigJson()
Q: 插件和主程序版本不兼容?
A: 原生插件需要与主程序一起编译,更新主程序后需要重新编译插件
Made with ❤️ by BiliPai Team