Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

# 1.8.0

- 新增桌面音乐播放器,支持专辑浏览、歌曲列表和播放
- 新增音乐库导航页面,移动端和桌面端均支持
- 新增漫画阅读器,支持章节列表和分页/列表两种阅读模式

# 1.7.2
Expand Down
59 changes: 59 additions & 0 deletions lib/api/music/MusicApi.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import 'package:dio/dio.dart';
import 'package:ikaros/api/dio_client.dart';

/// 音乐模块 API.
/// 对接服务端 music 模块的 REST 接口.
class MusicApi {
/// 查询专辑列表
Future<Map<String, dynamic>> listAlbums(
{int page = 1, int size = 20}) async {
String apiUrl = "/api/v1/music/album/list";
try {
Dio dio = await DioClient.getDio();
var response =
await dio.get(apiUrl, queryParameters: {"page": page, "size": size});
if (response.statusCode != 200) {
return {"items": [], "total": 0};
}
return response.data is Map ? response.data as Map<String, dynamic> : {};
} catch (e) {
return {"items": [], "total": 0};
}
}

/// 查询专辑下的歌曲
Future<List<Map<String, dynamic>>> listSongs(String albumId) async {
String apiUrl = "/api/v1/music/album/$albumId/songs";
try {
Dio dio = await DioClient.getDio();
var response = await dio.get(apiUrl);
if (response.statusCode != 200) {
return [];
}
List<dynamic> list = response.data is List ? response.data as List : [];
return list.cast<Map<String, dynamic>>();
} catch (e) {
return [];
}
}

/// 搜索专辑
Future<Map<String, dynamic>> searchAlbums(
String keyword, int page, int size) async {
String apiUrl = "/api/v1/music/album/search";
try {
Dio dio = await DioClient.getDio();
var response = await dio.get(apiUrl, queryParameters: {
"keyword": keyword,
"page": page,
"size": size,
});
if (response.statusCode != 200) {
return {"items": [], "total": 0};
}
return response.data is Map ? response.data as Map<String, dynamic> : {};
} catch (e) {
return {"items": [], "total": 0};
}
}
}
47 changes: 47 additions & 0 deletions lib/api/music/SubsonicApi.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import 'package:ikaros/api/auth/AuthApi.dart';
import 'package:ikaros/api/auth/AuthParams.dart';

/// Subsonic API 客户端.
/// 用于音频流播放和音乐库浏览.
class SubsonicApi {
/// 获取歌曲的音频流 URL
static Future<String> getStreamUrl(String songId) async {
AuthParams? authParams = await AuthApi().getAuthParams();
if (authParams == null || authParams.baseUrl.isEmpty) {
return "";
}
String baseUrl = authParams.baseUrl;
// Subsonic streaming: /rest/stream?id={songId}&u={user}&p={pass}&f=json
return "$baseUrl/rest/stream?id=$songId&u=${authParams.username}&p=enc:${authParams.token}&c=ikaros_app&f=json";
}

/// 获取专辑封面图 URL
static Future<String> getCoverArtUrl(String albumId) async {
AuthParams? authParams = await AuthApi().getAuthParams();
if (authParams == null || authParams.baseUrl.isEmpty) {
return "";
}
String baseUrl = authParams.baseUrl;
return "$baseUrl/rest/getCoverArt?id=al-$albumId&u=${authParams.username}&p=enc:${authParams.token}&c=ikaros_app";
}

/// 获取播放列表
static Future<String> getPlaylistUrl() async {
AuthParams? authParams = await AuthApi().getAuthParams();
if (authParams == null || authParams.baseUrl.isEmpty) {
return "";
}
String baseUrl = authParams.baseUrl;
return "$baseUrl/rest/getPlaylists?u=${authParams.username}&p=enc:${authParams.token}&c=ikaros_app&f=json";
}

/// 发送 Scrobble 记录
static Future<String> getScrobbleUrl(String songId, {int time = 0}) async {
AuthParams? authParams = await AuthApi().getAuthParams();
if (authParams == null || authParams.baseUrl.isEmpty) {
return "";
}
String baseUrl = authParams.baseUrl;
return "$baseUrl/rest/scrobble?id=$songId&time=$time&u=${authParams.username}&p=enc:${authParams.token}&c=ikaros_app&f=json";
}
}
222 changes: 222 additions & 0 deletions lib/component/lyrics_widget.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:ikaros/utils/lyrics_parser.dart';

/// 歌词显示方向
enum LyricsOrientation { vertical, horizontal }

/// 卡拉OK歌词组件
/// 支持竖排列表和横排单行两种布局
class LyricsWidget extends StatefulWidget {
/// 歌词数据
final ParsedLyrics lyrics;

/// 当前播放位置(毫秒)
final double positionMs;

/// 显示方向
final LyricsOrientation orientation;

/// 歌词颜色
final Color textColor;

/// 高亮颜色(卡拉OK当前字)
final Color highlightColor;

/// 已唱颜色
final Color sungColor;

/// 字体大小
final double fontSize;

const LyricsWidget({
super.key,
required this.lyrics,
required this.positionMs,
this.orientation = LyricsOrientation.vertical,
this.textColor = Colors.white54,
this.highlightColor = Colors.blue,
this.sungColor = Colors.white,
this.fontSize = 16,
});

@override
State<LyricsWidget> createState() => _LyricsWidgetState();
}

class _LyricsWidgetState extends State<LyricsWidget> {
final ScrollController _scrollController = ScrollController();
Timer? _scrollTimer;

@override
void didUpdateWidget(LyricsWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.positionMs != widget.positionMs) {
_autoScrollToCurrent();
}
}

@override
void dispose() {
_scrollController.dispose();
_scrollTimer?.cancel();
super.dispose();
}

void _autoScrollToCurrent() {
_scrollTimer?.cancel();
_scrollTimer = Timer(const Duration(milliseconds: 100), () {
if (!_scrollController.hasClients) return;
final index = widget.lyrics.indexAt(widget.positionMs);
if (index < 0) return;
final itemHeight = widget.fontSize + 16; // 行高估算
final target = index * itemHeight - 100;
if (target > 0) {
_scrollController.animateTo(
target.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}

@override
Widget build(BuildContext context) {
if (widget.lyrics.isEmpty) {
return const Center(child: Text("暂无歌词", style: TextStyle(color: Colors.white38)));
}

return widget.orientation == LyricsOrientation.vertical
? _buildVertical()
: _buildHorizontal();
}

/// 竖排歌词列表
Widget _buildVertical() {
final currentIndex = widget.lyrics.indexAt(widget.positionMs);

return ListView.builder(
controller: _scrollController,
itemCount: widget.lyrics.length,
itemBuilder: (context, index) {
final line = widget.lyrics.lines[index];
final isCurrent = index == currentIndex;
final isSung = index < currentIndex;

return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
alignment: Alignment.center,
child: line.words != null && isCurrent
? _buildKaraokeText(line, isSung: false)
: Text(
line.text,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isCurrent ? widget.fontSize + 2 : widget.fontSize,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
color: isSung
? widget.sungColor
: isCurrent
? widget.highlightColor
: widget.textColor,
),
),
);
},
);
}

/// 横排单行卡拉OK(桌面歌词)
Widget _buildHorizontal() {
final currentIndex = widget.lyrics.indexAt(widget.positionMs);

if (currentIndex < 0) {
return const SizedBox.shrink();
}

final currentLine = widget.lyrics.lines[currentIndex];
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: currentLine.words != null
? _buildKaraokeText(currentLine, isSung: false)
: ShaderMask(
shaderCallback: (bounds) {
final progress = currentLine.progressAt(widget.positionMs);
return LinearGradient(
colors: [widget.sungColor, widget.textColor],
stops: [progress, progress],
).createShader(bounds);
},
blendMode: BlendMode.srcIn,
child: Text(
currentLine.text,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: widget.fontSize + 4,
fontWeight: FontWeight.bold,
),
),
),
),
);
}

/// 逐字卡拉OK文本
Widget _buildKaraokeText(LyricsLine line, {bool isSung = false}) {
if (line.words == null || line.words!.isEmpty) {
return Text(
line.text,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: widget.fontSize + 2,
fontWeight: FontWeight.bold,
color: isSung ? widget.sungColor : widget.highlightColor,
),
);
}

final words = line.words!;
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: [
for (int i = 0; i < words.length; i++)
_buildWordSpan(words[i], i, line),
],
),
);
}

TextSpan _buildWordSpan(WordTiming word, int index, LyricsLine line) {
final isActive = line.wordIndexAt(widget.positionMs) == index;
final wordProgress = line.wordProgressAt(widget.positionMs);

if (isActive && wordProgress > 0) {
// 当前正在唱的字的卡拉OK渐变色
return TextSpan(
text: word.word,
style: TextStyle(
fontSize: widget.fontSize + 2,
fontWeight: FontWeight.bold,
foreground: Paint()
..shader = LinearGradient(
colors: [widget.sungColor, widget.highlightColor],
stops: [wordProgress, wordProgress],
).createShader(const Rect.fromLTWH(0, 0, 200, 50)),
),
);
}

return TextSpan(
text: word.word,
style: TextStyle(
fontSize: widget.fontSize + 2,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? widget.highlightColor : widget.sungColor,
),
);
}
}
17 changes: 16 additions & 1 deletion lib/layout.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:ikaros/collection/collections.dart';
import 'package:ikaros/music/music_library.dart';
import 'package:ikaros/subject/subject.dart';
import 'package:ikaros/subject/subjects.dart';
import 'package:ikaros/user/user.dart';
Expand Down Expand Up @@ -81,7 +82,12 @@ class _MobileLayoutState extends State<MobileLayout> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: <Widget>[const CollectionPage(), const SubjectsPage(), const UserPage()][_pageIndex],
body: <Widget>[
const CollectionPage(),
const SubjectsPage(),
const MusicLibraryPage(),
const UserPage()
][_pageIndex],
bottomNavigationBar: NavigationBar(
onDestinationSelected: (int index) {
setState(() {
Expand All @@ -98,6 +104,10 @@ class _MobileLayoutState extends State<MobileLayout> {
icon: Icon(Icons.tv),
label: '条目',
),
NavigationDestination(
icon: Icon(Icons.library_music),
label: '音乐',
),
NavigationDestination(
icon: Icon(Icons.account_circle),
label: '我的',
Expand All @@ -124,6 +134,7 @@ class _DesktopLayoutState extends State<DesktopLayout> {
final List<Widget> _pages = [
const CollectionPage(),
const SubjectsPage(),
const MusicLibraryPage(),
const UserPage(),
];

Expand Down Expand Up @@ -158,6 +169,10 @@ class _DesktopLayoutState extends State<DesktopLayout> {
icon: Icon(Icons.tv),
label: Text('条目'),
),
NavigationRailDestination(
icon: Icon(Icons.library_music),
label: Text('音乐'),
),
NavigationRailDestination(
icon: Icon(Icons.account_circle),
label: Text('我的'),
Expand Down
Loading