Skip to content

Commit bd6c520

Browse files
committed
feat(splayer): 新增 SPlayer-Next 音乐播放器控制插件
1 parent 309af84 commit bd6c520

14 files changed

Lines changed: 1509 additions & 0 deletions

plugin/splayer/client.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Package splayer 控制 SPlayer-Next 播放器:通过其外部 HTTP API 实现播放控制、
2+
// 播放状态/歌词查询与会话播放列表管理。
3+
// 需在 SPlayer-Next「设置 → 外部 API」中开启并勾选「允许局域网访问」。
4+
// API 文档: https://github.com/SPlayer-Dev/SPlayer-Next/blob/dev/docs/api.md
5+
package splayer
6+
7+
import (
8+
"bytes"
9+
"encoding/json"
10+
"fmt"
11+
"io"
12+
"net/http"
13+
"os"
14+
"time"
15+
16+
"github.com/pkg/errors"
17+
)
18+
19+
// apiBase SPlayer-Next 外部 API 地址,可用环境变量 SPLAYER_API 覆盖
20+
var apiBase = "http://192.168.1.242:14558"
21+
22+
var httpClient = &http.Client{Timeout: 5 * time.Second}
23+
24+
func init() {
25+
if v := os.Getenv("SPLAYER_API"); v != "" {
26+
apiBase = v
27+
}
28+
}
29+
30+
// artist 歌手
31+
type artist struct {
32+
Name string `json:"name"`
33+
}
34+
35+
// album 专辑
36+
type album struct {
37+
Name string `json:"name"`
38+
}
39+
40+
// track 曲目信息(外部 API Track 的子集)
41+
type track struct {
42+
ID string `json:"id"`
43+
Source string `json:"source"`
44+
Title string `json:"title"`
45+
Artists []artist `json:"artists"`
46+
Album album `json:"album"`
47+
Duration int64 `json:"duration"` // 毫秒
48+
Cover string `json:"cover"`
49+
Codec string `json:"-"`
50+
BitRate int64 `json:"-"`
51+
}
52+
53+
// UnmarshalJSON 从原始 Track 提取常用字段(quality 嵌套在 track 内)
54+
func (t *track) UnmarshalJSON(data []byte) error {
55+
type alias track
56+
var a struct {
57+
alias
58+
Quality *struct {
59+
Codec string `json:"codec"`
60+
BitRate int64 `json:"bitRate"`
61+
} `json:"quality"`
62+
}
63+
if err := json.Unmarshal(data, &a); err != nil {
64+
return err
65+
}
66+
*t = track(a.alias)
67+
if a.Quality != nil {
68+
t.Codec = a.Quality.Codec
69+
t.BitRate = a.Quality.BitRate
70+
}
71+
return nil
72+
}
73+
74+
// nowPlaying GET /api/now-playing 响应
75+
type nowPlaying struct {
76+
Track *track `json:"track"`
77+
Position int64 `json:"position"` // 毫秒
78+
Playing bool `json:"playing"`
79+
State string `json:"state"`
80+
LyricAvailable bool `json:"lyricAvailable"`
81+
LyricLineCount int `json:"lyricLineCount"`
82+
}
83+
84+
// lyricWord 逐字歌词单词(词级时间戳部分歌词源才有,缺失时为 0,
85+
// 渲染端自动回退行内匀速近似)
86+
type lyricWord struct {
87+
Word string `json:"word"`
88+
StartTime int64 `json:"startTime"`
89+
EndTime int64 `json:"endTime"`
90+
}
91+
92+
// lyricLine 歌词行(时间为毫秒)
93+
type lyricLine struct {
94+
Words []lyricWord `json:"words"`
95+
TranslatedLyric string `json:"translatedLyric"`
96+
StartTime int64 `json:"startTime"`
97+
EndTime int64 `json:"endTime"`
98+
}
99+
100+
// lyricData GET /api/lyrics 响应
101+
type lyricData struct {
102+
TrackID string `json:"trackId"`
103+
Lyric []lyricLine `json:"lyric"`
104+
LyricOffsetMs int64 `json:"lyricOffsetMs"`
105+
}
106+
107+
// playerStatus GET /api/status 响应
108+
type playerStatus struct {
109+
State string `json:"state"`
110+
Position int64 `json:"position"`
111+
Duration int64 `json:"duration"`
112+
Volume float64 `json:"volume"`
113+
IsFinished bool `json:"isFinished"`
114+
}
115+
116+
// apiGet GET /api/<path> 并解析 JSON 响应
117+
func apiGet(path string, out any) error {
118+
resp, err := httpClient.Get(apiBase + "/api/" + path)
119+
if err != nil {
120+
return errors.Wrapf(err, "无法连接 SPlayer-Next(%s)", apiBase)
121+
}
122+
defer resp.Body.Close()
123+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
124+
if resp.StatusCode != http.StatusOK {
125+
return errors.Errorf("SPlayer API %s 返回 %d: %s", path, resp.StatusCode, truncateBytes(body, 120))
126+
}
127+
if err := json.Unmarshal(body, out); err != nil {
128+
return errors.Wrapf(err, "解析 SPlayer API %s 响应失败", path)
129+
}
130+
return nil
131+
}
132+
133+
// apiPost POST /api/<path>,body 可为 nil(控制类接口无请求体)
134+
func apiPost(path string, body any) error {
135+
var rd io.Reader
136+
if body != nil {
137+
b, err := json.Marshal(body)
138+
if err != nil {
139+
return err
140+
}
141+
rd = bytes.NewReader(b)
142+
}
143+
resp, err := httpClient.Post(apiBase+"/api/"+path, "application/json", rd)
144+
if err != nil {
145+
return errors.Wrapf(err, "无法连接 SPlayer-Next(%s)", apiBase)
146+
}
147+
defer resp.Body.Close()
148+
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
149+
if resp.StatusCode != http.StatusOK {
150+
return errors.Errorf("SPlayer API %s 返回 %d: %s", path, resp.StatusCode, truncateBytes(respBody, 120))
151+
}
152+
return nil
153+
}
154+
155+
// getNowPlaying 获取当前播放快照(曲目+进度)
156+
func getNowPlaying() (*nowPlaying, error) {
157+
np := &nowPlaying{}
158+
if err := apiGet("now-playing", np); err != nil {
159+
return nil, err
160+
}
161+
return np, nil
162+
}
163+
164+
// getLyric 获取当前曲目完整歌词
165+
func getLyric() (*lyricData, error) {
166+
ld := &lyricData{}
167+
if err := apiGet("lyrics", ld); err != nil {
168+
return nil, err
169+
}
170+
return ld, nil
171+
}
172+
173+
// getStatus 获取播放状态
174+
func getStatus() (*playerStatus, error) {
175+
st := &playerStatus{}
176+
if err := apiGet("status", st); err != nil {
177+
return nil, err
178+
}
179+
return st, nil
180+
}
181+
182+
// playerControl 执行无参控制命令(play/pause/stop/next/prev)
183+
func playerControl(name string) error {
184+
return apiPost(name, nil)
185+
}
186+
187+
// seekTo 跳转到指定毫秒位置
188+
func seekTo(positionMs int64) error {
189+
return apiPost("seek", map[string]int64{"positionMs": positionMs})
190+
}
191+
192+
// setVolume 设置音量(0~1)
193+
func setVolume(volume float64) error {
194+
return apiPost("volume", map[string]float64{"volume": volume})
195+
}
196+
197+
// truncateBytes 截断字节串用于错误信息
198+
func truncateBytes(b []byte, n int) string {
199+
if len(b) > n {
200+
return string(b[:n]) + "..."
201+
}
202+
return string(b)
203+
}
204+
205+
// lineText 歌词行文本:主歌词 + 可选翻译
206+
func (l *lyricLine) lineText() string {
207+
text := ""
208+
for _, w := range l.Words {
209+
text += w.Word
210+
}
211+
if l.TranslatedLyric != "" {
212+
text += "\n" + l.TranslatedLyric
213+
}
214+
return text
215+
}
216+
217+
// currentLine 定位播放位置对应的歌词行下标(-1 表示前奏/间奏无歌词)
218+
func (ld *lyricData) currentLine(positionMs int64) int {
219+
idx := -1
220+
for i := range ld.Lyric {
221+
if ld.Lyric[i].StartTime <= positionMs {
222+
idx = i
223+
} else {
224+
break
225+
}
226+
}
227+
return idx
228+
}
229+
230+
// fmtMs 毫秒 → mm:ss
231+
func fmtMs(ms int64) string {
232+
if ms < 0 {
233+
ms = 0
234+
}
235+
s := ms / 1000
236+
return fmt.Sprintf("%02d:%02d", s/60, s%60)
237+
}
4.4 MB
Binary file not shown.
11.4 MB
Binary file not shown.
135 KB
Binary file not shown.
62.5 KB
Binary file not shown.
555 KB
Loading
36 KB
Binary file not shown.
94.4 KB
Binary file not shown.
213 KB
Loading

0 commit comments

Comments
 (0)