5. 导航、播放与导入
上一节已经能打开原生歌单详情,本节补齐歌曲搜索、播放地址、播放按钮和歌单导入。完成后的调用关系如下:
| 用户操作 | 后台动作或 Provider | 最终结果 |
|---|---|---|
| 点击歌单 | playlist.open | 打开澜音原生歌单详情 |
| 点击歌曲或“播放推荐” | tracks.play | 替换队列并开始播放 |
| 播放器读取歌曲 | tracks.resolve | 返回可加载的 WAV 地址 |
| 点击“导入歌单” | playlist.import | 打开澜音已有导入窗口 |
| 导入窗口读取 ID | importer getTracks | 分页返回标准歌曲 |
本节完整替换 provider.ts、native.ts 和 Manifest,并新增本地音频服务。
1. 完成 Provider
完整替换 src/provider.ts:
src/provider.ts 完整内容
ts
import {
assertResourceRef,
type ContentEntity,
type JsonValue,
type OperationContext,
type PluginContext,
type ResourceRef,
} from '@shiqianjiang/ceru-plugin-sdk'
import type { AccountController } from './account'
import {
CONNECTION_ID,
cursorOffset,
fault,
PAGE_SIZE,
playlists,
PROVIDER_ID,
tracks,
type PlaylistRecord,
type TrackRecord,
} from './data'
export type Catalog = {
resource(kind: 'track' | 'playlist', id: string): ResourceRef
trackEntity(track: TrackRecord): ContentEntity
playlistEntity(playlist: PlaylistRecord): ContentEntity
ownedRef(value: unknown, kind: 'track' | 'playlist'): ResourceRef
}
export function createCatalog(ctx: PluginContext, account: AccountController): Catalog {
const resource = (kind: 'track' | 'playlist', id: string): ResourceRef => ({
pluginId: ctx.plugin.id,
providerId: PROVIDER_ID,
connectionId: CONNECTION_ID,
kind,
id,
data: { catalog: 'tutorial-v1' },
})
const trackEntity = (track: TrackRecord): ContentEntity => ({
ref: resource('track', track.id),
title: track.title,
subtitle: track.artist,
playable: true,
durationMs: track.durationMs,
capabilities: ['play'],
metadata: {
artists: [track.artist],
album: { title: track.album },
durationMs: track.durationMs,
qualities: ['128k', '320k'],
},
})
const playlistEntity = (playlist: PlaylistRecord): ContentEntity => ({
ref: resource('playlist', playlist.id),
title: playlist.title,
subtitle: playlist.description,
capabilities: ['open', 'import'],
playlist: {
description: playlist.description,
author: account.getSession()?.displayName ?? '演示账号',
trackCount: playlist.trackIds.length,
},
})
const ownedRef = (value: unknown, kind: 'track' | 'playlist') => {
assertResourceRef(value)
if (
value.pluginId !== ctx.plugin.id ||
value.providerId !== PROVIDER_ID ||
value.connectionId !== CONNECTION_ID ||
value.kind !== kind
) {
throw fault('资源不属于当前插件与账号连接', 'NOT_FOUND')
}
return value
}
return { resource, trackEntity, playlistEntity, ownedRef }
}
export function registerProvider(
ctx: PluginContext,
account: AccountController,
catalog: Catalog,
) {
ctx.effects.add(
ctx.providers.register(PROVIDER_ID, {
tracks: {
async search(request, operation) {
operation.signal.throwIfAborted()
account.requireAccount()
const query = request.query.trim().toLocaleLowerCase()
const items = tracks
.filter((item) => `${item.title} ${item.artist}`.toLocaleLowerCase().includes(query))
.slice(0, Math.max(1, Math.min(request.limit, 20)))
.map(catalog.trackEntity)
return { items, totalEstimate: items.length }
},
async resolve(ref, quality, operation) {
operation.signal.throwIfAborted()
account.requireAccount()
if (quality && !['128k', '320k'].includes(quality)) {
return ctx.playback.failure({ code: 'UNSUPPORTED', message: '不支持该音质' })
}
const track = tracks.find((item) => item.id === catalog.ownedRef(ref, 'track').id)
if (!track) return ctx.playback.failure({ code: 'NOT_FOUND', message: '歌曲不存在' })
return {
ok: true,
url: `http://127.0.0.1:43130/audio/${encodeURIComponent(track.id)}.wav`,
expiresAt: Date.now() + 60_000,
}
},
},
playlists: {
async list(_resource, cursor, operation) {
operation.signal.throwIfAborted()
account.requireAccount()
const offset = cursorOffset(cursor)
const items = playlists.slice(offset, offset + PAGE_SIZE).map(catalog.playlistEntity)
return {
items,
totalEstimate: playlists.length,
...(offset + items.length < playlists.length
? { nextCursor: String(offset + items.length) }
: {}),
}
},
async get(ref, cursor, operation) {
operation.signal.throwIfAborted()
account.requireAccount()
const playlist = playlists.find(
(item) => item.id === catalog.ownedRef(ref, 'playlist').id,
)
if (!playlist) throw fault('歌单不存在', 'NOT_FOUND')
const offset = cursorOffset(cursor)
const pageIds = playlist.trackIds.slice(offset, offset + PAGE_SIZE)
const items = pageIds
.map((id) => tracks.find((item) => item.id === id))
.filter((item): item is TrackRecord => !!item)
.map(catalog.trackEntity)
return {
name: playlist.title,
playlist: catalog.playlistEntity(playlist).playlist,
items,
totalEstimate: playlist.trackIds.length,
...(offset + items.length < playlist.trackIds.length
? { nextCursor: String(offset + items.length) }
: {}),
}
},
},
}),
)
ctx.effects.add(
ctx.playlistImporters.register('tutorial-playlist', {
async getTracks(request, operation) {
account.requireAccount()
const playlist = playlists.find((item) => item.id === request.value.trim())
if (!playlist) throw fault('请输入 demo-favorites 或 demo-evening', 'NOT_FOUND')
const offset = cursorOffset(request.cursor)
const size = Math.max(1, Math.min(request.limit, 100))
const items = playlist.trackIds
.slice(offset, offset + size)
.map((id) => tracks.find((item) => item.id === id))
.filter((item): item is TrackRecord => !!item)
.map(catalog.trackEntity)
operation.signal.throwIfAborted()
return {
name: playlist.title,
playlist: catalog.playlistEntity(playlist).playlist,
items,
totalEstimate: playlist.trackIds.length,
...(offset + items.length < playlist.trackIds.length
? { nextCursor: String(offset + items.length) }
: {}),
}
},
}),
)
}
export async function playTracks(
ctx: PluginContext,
account: AccountController,
catalog: Catalog,
value: JsonValue,
operation: OperationContext,
) {
account.requireAccount()
const input = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined
const values = Array.isArray(input?.refs) ? input.refs : input?.ref ? [input.ref] : []
const refs = values.map((item) => catalog.ownedRef(item, 'track'))
if (!refs.length) throw fault('没有可播放的歌曲', 'NOT_FOUND')
let grant = await ctx.permissions.query({ key: 'playback' })
if (grant.status !== 'granted') {
grant = await ctx.permissions.request({ key: 'playback', intent: operation.userIntent })
}
if (grant.status !== 'granted') throw fault('请允许插件控制播放', 'PERMISSION_DENIED')
const items = refs
.map((ref) => tracks.find((item) => item.id === ref.id))
.filter((item): item is TrackRecord => !!item)
.map(catalog.trackEntity)
if (!items.length) throw fault('歌曲不存在', 'NOT_FOUND')
const call = { permissionKey: 'playback', operation }
await ctx.queue.replace(items, call)
await ctx.player.play(refs[0], call)
return null
}这个文件新增三块行为:
tracks.search()从演示数据返回标准ContentEntity;tracks.resolve()验证账号、资源归属和音质,再返回短期音频 URL;playlistImporters.register()按导入窗口给出的value/cursor/limit返回一页歌曲。
tracks.play 不是 Provider 方法。它先请求 player.control 权限,再调用:
ts
const call = { permissionKey: 'playback', operation }
await ctx.queue.replace(items, call)
await ctx.player.play(refs[0], call)queue.replace() 需要完整歌曲实体,队列才能立即显示标题、歌手和时长;player.play() 再指定第一首歌曲的 ref。用户拒绝权限时,两项服务都不应调用。
2. 接上 Native View 动作
完整替换 src/native.ts:
src/native.ts 完整内容
ts
import {
defineNativeView,
type JsonValue,
type PluginContext,
} from '@shiqianjiang/ceru-plugin-sdk'
import type { AccountController } from './account'
import { playlists, tracks } from './data'
import { playTracks, type Catalog } from './provider'
export function registerNative(
ctx: PluginContext,
account: AccountController,
catalog: Catalog,
) {
const register = (
id: string,
handler: Parameters<PluginContext['actions']['register']>[1],
) => ctx.effects.add(ctx.actions.register(id, handler))
register(
'render.library',
defineNativeView(async () => {
const session = account.getSession()
if (!session) {
return {
type: 'page',
title: '演示音乐',
description: '连接演示账号后查看原生歌单与歌曲。',
actions: [{ label: '连接账号', action: 'account.open', primary: true }],
sections: [],
}
}
return {
type: 'page',
title: '我的演示音乐',
description: `${session.displayName} · 内容由插件提供,界面由澜音渲染`,
actions: [
{
label: '播放推荐',
action: 'tracks.play',
input: {
refs: tracks.slice(0, 2).map((item) => catalog.resource('track', item.id)),
} as unknown as JsonValue,
primary: true,
},
{ label: '刷新', action: 'library.refresh' },
],
sections: [
{
id: 'playlists',
title: '我的歌单',
layout: 'grid',
items: playlists.map(catalog.playlistEntity),
onOpen: 'playlist.open',
itemActions: [{ label: '导入歌单', action: 'playlist.import' }],
},
{
id: 'tracks',
title: '今日推荐',
layout: 'list',
items: tracks.slice(0, 3).map(catalog.trackEntity),
onPlay: 'tracks.play',
},
],
}
}),
)
register('library.refresh', async () => {
await ctx.ui.setState('library', {
account: account.publicAccount(),
changedAt: Date.now(),
})
return null
})
register('playlist.open', async (input) => {
const value = input && typeof input === 'object' && !Array.isArray(input) ? input : undefined
const ref = catalog.ownedRef(value?.ref, 'playlist')
await ctx.ui.navigation.open({ page: 'playlist', ref })
return null
})
register('playlist.import', async (input) => {
const value = input && typeof input === 'object' && !Array.isArray(input) ? input : undefined
const ref = catalog.ownedRef(value?.ref, 'playlist')
await ctx.ui.playlistImport.open({
importerId: 'tutorial-playlist',
initialValue: ref.id,
title: `导入 ${playlists.find((item) => item.id === ref.id)?.title ?? '演示歌单'}`,
})
return null
})
register('tracks.play', (input, operation) =>
playTracks(ctx, account, catalog, input, operation),
)
}宿主为不同位置生成的动作输入不同:
| Native View 位置 | 点击时的输入 |
|---|---|
sections[].onOpen | { ref } |
sections[].onPlay | { ref, refs } |
sections[].itemActions | 固定 input 与当前 { ref } 合并 |
页面顶部 actions | 只使用动作自己的 input |
因此 tracks.play 同时接受单个 ref 和一组 refs;playlist.import 从当前卡片取得完整歌单 ref,再把 ref.id 填入导入窗口。
3. 使用最终入口
完整替换 src/index.ts:
ts
import { definePlugin } from '@shiqianjiang/ceru-plugin-sdk'
import { createAccount } from './account'
import { registerNative } from './native'
import { createCatalog, registerProvider } from './provider'
export default definePlugin(async (ctx) => {
const account = await createAccount(ctx)
const catalog = createCatalog(ctx, account)
registerProvider(ctx, account, catalog)
registerNative(ctx, account, catalog)
})4. 完成 Manifest
完整替换 ceru.plugin.json:
ceru.plugin.json 完整内容
json
{
"manifest": {
"manifestVersion": 2,
"id": "tutorial.account-native",
"name": "账号与原生歌单教程",
"version": "0.1.0",
"description": "演示账号菜单、Vue 登录弹窗、原生歌单与宿主播放能力",
"author": "Your Name",
"license": "MIT",
"engines": {
"hostApi": "^2.0.0",
"logicRuntime": "ceru-js@1",
"libraries": { "vue": "^3.5.0" }
},
"modules": {
"logic": {
"entry": "logic.main",
"activation": ["onCommand:account.open", "onProvider:tutorial-account"]
},
"surfaces": [
{
"id": "account",
"kind": "web",
"entry": "view.account",
"title": "连接演示账号",
"presentation": { "kind": "modal", "size": 360 },
"lifecycle": { "closeAction": "account.cancel" }
},
{
"id": "library",
"kind": "native",
"entry": "render.library",
"title": "我的演示音乐"
}
]
},
"contributes": {
"providers": [
{
"id": "tutorial-account",
"name": "演示账号音乐",
"protocols": ["music.search@1", "music.resolve@1", "music.playlists@1"],
"qualities": ["128k", "320k"],
"icon": { "kind": "host", "name": "music-note" },
"connectionMode": "single"
}
],
"commands": [
{
"id": "account.open",
"title": "连接演示账号",
"action": "account.open",
"view": "account"
},
{ "id": "account.summary", "title": "读取账号摘要", "action": "account.summary" },
{ "id": "account.session", "title": "读取公开账号状态", "action": "account.session" },
{ "id": "account.start", "title": "开始演示登录", "action": "account.start" },
{ "id": "account.approve", "title": "模拟手机确认", "action": "account.approve" },
{ "id": "account.poll", "title": "检查演示登录", "action": "account.poll" },
{ "id": "account.cancel", "title": "取消演示登录", "action": "account.cancel" },
{ "id": "account.logout", "title": "退出演示账号", "action": "account.logout" },
{ "id": "render.library", "title": "渲染原生音乐页", "action": "render.library" },
{ "id": "library.refresh", "title": "刷新原生音乐页", "action": "library.refresh" },
{ "id": "playlist.open", "title": "打开原生歌单详情", "action": "playlist.open" },
{ "id": "playlist.import", "title": "导入演示歌单", "action": "playlist.import" },
{ "id": "tracks.play", "title": "播放演示歌曲", "action": "tracks.play" }
],
"accountItems": [
{
"id": "demo-account",
"title": "演示音乐账号",
"view": "account",
"action": "account.summary",
"logoutAction": "account.logout"
}
],
"playlistSections": [
{
"id": "tutorial-library",
"title": "演示账号歌单",
"view": "library",
"order": 20
}
],
"playlistImporters": [
{
"id": "tutorial-playlist",
"title": "演示账号歌单",
"providerId": "tutorial-account",
"description": "输入 demo-favorites 或 demo-evening",
"placeholder": "demo-favorites"
}
]
},
"permissions": [
{
"key": "playback",
"name": "player.control",
"reason": "点击原生歌曲时替换播放队列并开始播放"
},
{
"key": "account.network",
"name": "network.request",
"optional": true,
"reason": "换成真实平台接口后请求登录、账号与音乐数据"
},
{
"key": "account.private-network",
"name": "network.private",
"optional": true,
"reason": "开发时连接本机模拟音频服务"
}
],
"dataSchemas": { "config": 1, "state": 1 }
},
"entries": {
"logic.main": "src/index.ts",
"view.account": "src/view.ts"
},
"resources": {},
"output": "dist/plugin.js",
"framework": "vue"
}这里同时完成四项声明:
- Provider 协议增加
music.search@1和music.resolve@1; - Native View 用到的每个动作都进入
commands; playlistImporters的 ID 与后台注册、打开窗口时的importerId一致;playback权限把 Manifest key 映射到宿主的player.control。
qualities 只声明本例真正接受的 128k 和 320k。平台 ID 与音质 ID 的推荐写法见平台与音质约定,它们是协作约定,不是强制枚举。
5. 新增本地音频服务
在工程根目录新建 mock-audio.mjs:
mock-audio.mjs 完整内容
js
import { createServer } from 'node:http'
const port = 43130
function wav(frequency = 440, seconds = 4) {
const sampleRate = 22050
const samples = sampleRate * seconds
const dataSize = samples * 2
const buffer = Buffer.alloc(44 + dataSize)
buffer.write('RIFF', 0)
buffer.writeUInt32LE(36 + dataSize, 4)
buffer.write('WAVEfmt ', 8)
buffer.writeUInt32LE(16, 16)
buffer.writeUInt16LE(1, 20)
buffer.writeUInt16LE(1, 22)
buffer.writeUInt32LE(sampleRate, 24)
buffer.writeUInt32LE(sampleRate * 2, 28)
buffer.writeUInt16LE(2, 32)
buffer.writeUInt16LE(16, 34)
buffer.write('data', 36)
buffer.writeUInt32LE(dataSize, 40)
for (let index = 0; index < samples; index++) {
const fade = Math.min(1, index / 1500, (samples - index) / 1500)
const value = Math.sin((index / sampleRate) * Math.PI * 2 * frequency) * 0.16 * fade
buffer.writeInt16LE(Math.round(value * 32767), 44 + index * 2)
}
return buffer
}
createServer((request, response) => {
if (request.url?.startsWith('/audio/')) {
const id = request.url.split('/').at(-1) ?? ''
const frequency = 330 + ([...id].reduce((sum, char) => sum + char.charCodeAt(0), 0) % 330)
const body = wav(frequency)
response.writeHead(200, {
'Content-Type': 'audio/wav',
'Content-Length': body.length,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
})
response.end(body)
return
}
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
response.end(JSON.stringify({ ok: true, service: 'ceru-account-native-tutorial' }))
}).listen(port, '127.0.0.1', () => {
console.log(`Mock audio: http://127.0.0.1:${port}`)
})在 package.json 的 scripts 中加入:
json
"mock": "node mock-audio.mjs"这个服务按歌曲 ID 生成一段有声音的 WAV。它只供本地验证,npm run build 不会把服务打进 plugin.js。
6. 运行本节
第一个终端启动音频服务:
shell
npm run mock看到以下地址后保持运行:
text
Mock audio: http://127.0.0.1:43130第二个终端运行插件:
shell
npm run typecheck
npm run build
npm run dev先登录演示账号,再检查:
- 搜索
rain能返回Soft Rain; library · native显示歌单和“今日推荐”;- 点击歌单进入原生详情,歌曲能继续分页;
- 点击播放时先出现
player.control授权,允许后队列被替换; tracks.resolve返回的地址能加载 RIFF/WAVE 音频;- 歌单卡片的“导入歌单”打开
tutorial-playlistimporter,demo-favorites能分批返回四首歌。
常见错误:
- 搜索来源不出现:Provider 的
protocols、激活条件和providers.register()ID 必须一致; - 点击播放无反应:检查
tracks.play是否已声明为命令,以及权限 key 是否为playback; - 授权被拒后仍修改队列:权限结果不是
granted时应立即抛错; - 播放器收到 404:确认
npm run mock仍在运行且端口是43130; - 导入窗口提示 importer 不存在:三处
tutorial-playlist必须同名; - 其他插件的 ref 能被打开:所有入口都应先调用
catalog.ownedRef()。