From 1ae771635f6cbccb5ae7647899e36acaf888be4b Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 12 Nov 2025 00:03:41 +0800 Subject: [PATCH 01/50] audio controller --- apps/flutter/lib/app.dart | 6 +- apps/flutter/lib/audio_handler.dart | 78 +++++++++---------- apps/flutter/lib/main.dart | 6 +- .../lib/play_indicator/play_indicator.dart | 13 ---- .../lib/player_controller/actions.dart | 38 +++++++++ .../index.dart | 8 +- .../player_controller/player_controller.dart | 50 ++++++++++++ apps/flutter/lib/states/audio.dart | 12 +++ apps/flutter/lib/states/playlist.dart | 2 +- apps/flutter/lib/states/playqueue.dart | 2 +- 10 files changed, 152 insertions(+), 63 deletions(-) delete mode 100644 apps/flutter/lib/play_indicator/play_indicator.dart create mode 100644 apps/flutter/lib/player_controller/actions.dart rename apps/flutter/lib/{play_indicator => player_controller}/index.dart (57%) create mode 100644 apps/flutter/lib/player_controller/player_controller.dart create mode 100644 apps/flutter/lib/states/audio.dart diff --git a/apps/flutter/lib/app.dart b/apps/flutter/lib/app.dart index 3461f242..c958d14a 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -1,5 +1,6 @@ import 'package:cicada/audio_handler.dart'; -import 'package:cicada/play_indicator/index.dart'; +import 'package:cicada/player_controller/index.dart'; +import 'package:cicada/states/audio.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:provider/provider.dart'; @@ -39,6 +40,7 @@ class _AppContentState extends State { ChangeNotifierProvider.value(value: musicbill_state.musicbillState), ChangeNotifierProvider.value(value: playlistState), ChangeNotifierProvider.value(value: playqueueState), + ChangeNotifierProvider.value(value: audioState), ], child: MaterialApp( home: Column( @@ -64,7 +66,7 @@ class _AppContentState extends State { }, ), ), - PlayIndicatorContainer(), + PlayerControllerContainer(), ], ), ), diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 81a40f83..868bd2fc 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -1,4 +1,5 @@ import 'package:audio_service/audio_service.dart'; +import 'package:cicada/states/audio.dart'; import 'package:just_audio/just_audio.dart'; import './states/playqueue.dart'; @@ -7,7 +8,43 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final player = AudioPlayer(); MyAudioHandler() { - player.playerStateStream.listen(_broadcastState); + player.playerStateStream.listen((PlayerState state) { + audioState.updatePlaying(state.playing); + playbackState.add( + PlaybackState( + controls: [ + if (playqueueState.playqueueIndex > 0) MediaControl.skipToPrevious, + state.playing ? MediaControl.pause : MediaControl.play, + MediaControl.skipToNext, + ], + systemActions: { + MediaAction.play, + MediaAction.pause, + MediaAction.playPause, + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + MediaAction.skipToPrevious, + MediaAction.skipToNext, + }, + processingState: { + ProcessingState.idle: AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[state.processingState]!, + playing: state.playing, + updatePosition: player.position, + bufferedPosition: player.bufferedPosition, + speed: player.speed, + ), + ); + + if (state.processingState == ProcessingState.completed) { + playqueueState.next(); + } + }); } @override @@ -43,7 +80,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { mediaItem.add(item); } - void listen() { + void subscribe() { playqueueState.addListener(() { final currentQueueMusic = playqueueState.currentMusic; if (currentQueueMusic != null && @@ -53,41 +90,4 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } }); } - - void _broadcastState(PlayerState state) { - playbackState.add( - PlaybackState( - controls: [ - if (playqueueState.playqueueIndex > 0) MediaControl.skipToPrevious, - state.playing ? MediaControl.pause : MediaControl.play, - MediaControl.skipToNext, - ], - systemActions: { - MediaAction.play, - MediaAction.pause, - MediaAction.playPause, - MediaAction.seek, - MediaAction.seekForward, - MediaAction.seekBackward, - MediaAction.skipToPrevious, - MediaAction.skipToNext, - }, - processingState: { - ProcessingState.idle: AudioProcessingState.idle, - ProcessingState.loading: AudioProcessingState.loading, - ProcessingState.buffering: AudioProcessingState.buffering, - ProcessingState.ready: AudioProcessingState.ready, - ProcessingState.completed: AudioProcessingState.completed, - }[state.processingState]!, - playing: state.playing, - updatePosition: player.position, - bufferedPosition: player.bufferedPosition, - speed: player.speed, - ), - ); - - if (state.processingState == ProcessingState.completed) { - playqueueState.next(); - } - } } diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart index f62ce7aa..b05d9f40 100644 --- a/apps/flutter/lib/main.dart +++ b/apps/flutter/lib/main.dart @@ -26,13 +26,13 @@ void main() async { } final audioHandler = await AudioService.init(builder: () => MyAudioHandler()); - audioHandler.listen(); + audioHandler.subscribe(); GetIt.instance.registerSingleton(audioHandler); await serverState.initialize(); - playlistState.listen(); - playqueueState.listen(); + playlistState.subscribe(); + playqueueState.subscribe(); runApp( MultiProvider( diff --git a/apps/flutter/lib/play_indicator/play_indicator.dart b/apps/flutter/lib/play_indicator/play_indicator.dart deleted file mode 100644 index a605eb80..00000000 --- a/apps/flutter/lib/play_indicator/play_indicator.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:cicada/states/playqueue.dart'; -import 'package:flutter/material.dart'; - -class PlayIndicator extends StatelessWidget { - final PlayqueueMusic playqueueMusic; - - const PlayIndicator({super.key, required this.playqueueMusic}); - - @override - Widget build(BuildContext context) { - return Text(playqueueMusic.music.name); - } -} diff --git a/apps/flutter/lib/player_controller/actions.dart b/apps/flutter/lib/player_controller/actions.dart new file mode 100644 index 00000000..f561aeba --- /dev/null +++ b/apps/flutter/lib/player_controller/actions.dart @@ -0,0 +1,38 @@ +import 'package:cicada/audio_handler.dart'; +import 'package:cicada/states/audio.dart'; +import 'package:cicada/states/playqueue.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:provider/provider.dart'; + +class Actions extends StatelessWidget { + const Actions({super.key}); + + @override + Widget build(BuildContext context) { + final playing = context.watch().playing; + final spacing = SizedBox(width: 10); + return Row( + children: [ + spacing, + IconButton( + onPressed: () { + final audioHandler = GetIt.instance.get(); + playing ? audioHandler.pause() : audioHandler.play(); + }, + icon: Icon(playing ? Icons.pause : Icons.play_arrow), + ), + spacing, + IconButton( + onPressed: () { + playqueueState.next(); + }, + icon: Icon(Icons.skip_next), + ), + spacing, + IconButton(onPressed: () {}, icon: Icon(Icons.list_outlined)), + spacing, + ], + ); + } +} diff --git a/apps/flutter/lib/play_indicator/index.dart b/apps/flutter/lib/player_controller/index.dart similarity index 57% rename from apps/flutter/lib/play_indicator/index.dart rename to apps/flutter/lib/player_controller/index.dart index 16235b79..6264800b 100644 --- a/apps/flutter/lib/play_indicator/index.dart +++ b/apps/flutter/lib/player_controller/index.dart @@ -1,16 +1,16 @@ -import 'package:cicada/play_indicator/play_indicator.dart'; +import 'package:cicada/player_controller/player_controller.dart'; import 'package:cicada/states/playqueue.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class PlayIndicatorContainer extends StatelessWidget { - const PlayIndicatorContainer({super.key}); +class PlayerControllerContainer extends StatelessWidget { + const PlayerControllerContainer({super.key}); @override Widget build(BuildContext context) { final currentMusic = context.watch().currentMusic; return currentMusic == null ? Container() - : PlayIndicator(playqueueMusic: currentMusic); + : PlayController(playqueueMusic: currentMusic); } } diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart new file mode 100644 index 00000000..a7534838 --- /dev/null +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -0,0 +1,50 @@ +import 'package:cicada/states/playqueue.dart'; +import 'package:flutter/material.dart'; +import './actions.dart' as actions; + +class PlayController extends StatelessWidget { + final PlayqueueMusic playqueueMusic; + + const PlayController({super.key, required this.playqueueMusic}); + + @override + Widget build(BuildContext context) { + final music = playqueueMusic.music; + final cover = music.cover; + return Container( + color: Colors.white, + height: 60, + child: Row( + // crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (cover != null) + AspectRatio( + aspectRatio: 1, + child: Image.network(cover, fit: BoxFit.cover), + ), + SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + music.name, + style: TextStyle(fontSize: 16), + textAlign: TextAlign.left, + ), + Text( + music.singers.isEmpty + ? 'Unknown singers' + : music.singers.map((s) => s.name).join(','), + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + actions.Actions(), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/states/audio.dart b/apps/flutter/lib/states/audio.dart new file mode 100644 index 00000000..e9f06b5d --- /dev/null +++ b/apps/flutter/lib/states/audio.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; + +class AudioState extends ChangeNotifier { + bool playing = false; + + void updatePlaying(bool p) { + playing = p; + notifyListeners(); + } +} + +final audioState = AudioState(); diff --git a/apps/flutter/lib/states/playlist.dart b/apps/flutter/lib/states/playlist.dart index c3e34e3a..1605064f 100644 --- a/apps/flutter/lib/states/playlist.dart +++ b/apps/flutter/lib/states/playlist.dart @@ -28,7 +28,7 @@ class PlaylistState extends ChangeNotifier { notifyListeners(); } - void Function() listen() { + void Function() subscribe() { final playMusicSubscription = eventBus.on().listen((event) { addMusicList([event.music]); }); diff --git a/apps/flutter/lib/states/playqueue.dart b/apps/flutter/lib/states/playqueue.dart index a8bd7806..193d9c8a 100644 --- a/apps/flutter/lib/states/playqueue.dart +++ b/apps/flutter/lib/states/playqueue.dart @@ -72,7 +72,7 @@ class PlayqueueState extends ChangeNotifier { } } - void Function() listen() { + void Function() subscribe() { final playMusicSubscription = eventBus.on().listen((event) { jump(event.music); next(); From f63a9ca8ce0a4fcba49a0dbb76c41a3420d7833b Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 18 Nov 2025 23:12:14 +0800 Subject: [PATCH 02/50] playqueue view --- .../lib/player_controller/actions.dart | 60 ++- .../lib/player_controller/playlist.dart | 10 + .../lib/player_controller/playqueue.dart | 23 + apps/flutter/pubspec.lock | 8 +- package-lock.json | 398 ++++-------------- readme.md | 1 + 6 files changed, 182 insertions(+), 318 deletions(-) create mode 100644 apps/flutter/lib/player_controller/playlist.dart create mode 100644 apps/flutter/lib/player_controller/playqueue.dart diff --git a/apps/flutter/lib/player_controller/actions.dart b/apps/flutter/lib/player_controller/actions.dart index f561aeba..731381da 100644 --- a/apps/flutter/lib/player_controller/actions.dart +++ b/apps/flutter/lib/player_controller/actions.dart @@ -1,9 +1,11 @@ -import 'package:cicada/audio_handler.dart'; -import 'package:cicada/states/audio.dart'; -import 'package:cicada/states/playqueue.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:provider/provider.dart'; +import '../audio_handler.dart'; +import './playqueue.dart'; +import './playlist.dart'; +import '../states/audio.dart'; +import '../states/playqueue.dart'; class Actions extends StatelessWidget { const Actions({super.key}); @@ -30,7 +32,57 @@ class Actions extends StatelessWidget { icon: Icon(Icons.skip_next), ), spacing, - IconButton(onPressed: () {}, icon: Icon(Icons.list_outlined)), + IconButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return FractionallySizedBox( + heightFactor: 0.8, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + ), + ), + child: DefaultTabController( + length: 2, + initialIndex: 1, + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + const TabBar( + tabs: [ + Tab(text: "播放列表"), + Tab(text: "播放队列"), + ], + ), + Expanded( + child: TabBarView( + children: [Playlist(), Playqueue()], + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + icon: Icon(Icons.list_outlined), + ), spacing, ], ); diff --git a/apps/flutter/lib/player_controller/playlist.dart b/apps/flutter/lib/player_controller/playlist.dart new file mode 100644 index 00000000..15689554 --- /dev/null +++ b/apps/flutter/lib/player_controller/playlist.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class Playlist extends StatelessWidget { + const Playlist({super.key}); + + @override + Widget build(BuildContext context) { + return Text("playlist"); + } +} diff --git a/apps/flutter/lib/player_controller/playqueue.dart b/apps/flutter/lib/player_controller/playqueue.dart new file mode 100644 index 00000000..4ad4e0d1 --- /dev/null +++ b/apps/flutter/lib/player_controller/playqueue.dart @@ -0,0 +1,23 @@ +import '../states/playqueue.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class Playqueue extends StatelessWidget { + const Playqueue({super.key}); + + @override + Widget build(BuildContext context) { + final playqueue = context.watch().playqueue; + return ListView.builder( + itemCount: playqueue.length, + itemBuilder: (context, index) { + final playqueueMusic = playqueue[playqueue.length - index - 1]; + return ListTile( + leading: const Icon(Icons.music_note_outlined), + title: Text(playqueueMusic.music.name), + onTap: () {}, + ); + }, + ); + } +} diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock index 6b99055d..22eb221f 100644 --- a/apps/flutter/pubspec.lock +++ b/apps/flutter/pubspec.lock @@ -300,10 +300,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -625,10 +625,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/package-lock.json b/package-lock.json index e3f35b3a..48d4bc2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -215,6 +215,7 @@ "version": "7.22.15", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -627,7 +628,6 @@ "version": "7.20.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-plugin-utils": "^7.20.2", @@ -660,7 +660,6 @@ "version": "7.22.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-export-default-from": "^7.22.5" @@ -676,7 +675,6 @@ "version": "7.18.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" @@ -692,7 +690,6 @@ "version": "7.18.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-numeric-separator": "^7.10.4" @@ -708,7 +705,6 @@ "version": "7.20.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/compat-data": "^7.20.5", "@babel/helper-compilation-targets": "^7.20.7", @@ -727,7 +723,6 @@ "version": "7.18.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" @@ -743,7 +738,6 @@ "version": "7.21.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", @@ -830,7 +824,6 @@ "version": "7.22.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" }, @@ -856,7 +849,6 @@ "version": "7.22.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" }, @@ -1292,7 +1284,6 @@ "version": "7.22.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-flow": "^7.22.5" @@ -1692,7 +1683,6 @@ "version": "7.22.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" }, @@ -1707,7 +1697,6 @@ "version": "7.22.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" }, @@ -1933,6 +1922,7 @@ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.2.tgz", "integrity": "sha512-BW3gsuDD+rvHL2VO2SjAUNTBe5YrjsTiDyqamPDWY723na3/yPQ65X5oQkFVJZ0o50/2d+svm1rkPoJeR1KxVQ==", "dev": true, + "peer": true, "dependencies": { "@babel/compat-data": "^7.23.2", "@babel/helper-compilation-targets": "^7.22.15", @@ -2026,7 +2016,6 @@ "version": "7.22.15", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-validator-option": "^7.22.15", @@ -2095,7 +2084,6 @@ "version": "7.22.15", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "clone-deep": "^4.0.1", "find-cache-dir": "^2.0.0", @@ -2114,7 +2102,6 @@ "version": "2.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "commondir": "^1.0.1", "make-dir": "^2.0.0", @@ -2128,7 +2115,6 @@ "version": "3.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^3.0.0" }, @@ -2140,7 +2126,6 @@ "version": "3.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" @@ -2153,7 +2138,6 @@ "version": "2.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -2166,7 +2150,6 @@ "version": "2.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -2181,7 +2164,6 @@ "version": "3.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^2.0.0" }, @@ -2193,7 +2175,6 @@ "version": "3.0.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -2202,7 +2183,6 @@ "version": "3.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "find-up": "^3.0.0" }, @@ -2214,7 +2194,6 @@ "version": "5.7.2", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver" } @@ -2223,7 +2202,6 @@ "version": "0.5.21", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -2572,14 +2550,12 @@ "node_modules/@hapi/hoek": { "version": "9.3.0", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@hapi/topo": { "version": "5.1.0", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@hapi/hoek": "^9.0.0" } @@ -2792,7 +2768,6 @@ "version": "29.6.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "^29.6.3" }, @@ -3048,6 +3023,7 @@ "node_modules/@jimp/custom": { "version": "0.22.10", "license": "MIT", + "peer": true, "dependencies": { "@jimp/core": "^0.22.10" } @@ -3078,6 +3054,7 @@ "node_modules/@jimp/plugin-blit": { "version": "0.22.10", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.10" }, @@ -3088,6 +3065,7 @@ "node_modules/@jimp/plugin-blur": { "version": "0.22.10", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.10" }, @@ -3108,6 +3086,7 @@ "node_modules/@jimp/plugin-color": { "version": "0.22.10", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.10", "tinycolor2": "^1.6.0" @@ -3145,6 +3124,7 @@ "node_modules/@jimp/plugin-crop": { "version": "0.22.10", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.10" }, @@ -3248,6 +3228,7 @@ "node_modules/@jimp/plugin-resize": { "version": "0.22.10", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.10" }, @@ -3258,6 +3239,7 @@ "node_modules/@jimp/plugin-rotate": { "version": "0.22.10", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.10" }, @@ -3271,6 +3253,7 @@ "node_modules/@jimp/plugin-scale": { "version": "0.22.10", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.10" }, @@ -3591,7 +3574,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-clean": "11.3.6", "@react-native-community/cli-config": "11.3.6", @@ -3622,7 +3604,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-tools": "11.3.6", "chalk": "^4.1.2", @@ -3634,7 +3615,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-tools": "11.3.6", "chalk": "^4.1.2", @@ -3648,7 +3628,6 @@ "version": "1.0.10", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -3657,7 +3636,6 @@ "version": "5.2.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", @@ -3672,7 +3650,6 @@ "version": "2.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" @@ -3685,7 +3662,6 @@ "version": "3.14.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -3698,7 +3674,6 @@ "version": "4.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" @@ -3711,7 +3686,6 @@ "version": "3.0.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -3720,7 +3694,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "serve-static": "^1.13.1" } @@ -3729,7 +3702,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-config": "11.3.6", "@react-native-community/cli-platform-android": "11.3.6", @@ -3755,7 +3727,6 @@ "version": "4.1.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3764,7 +3735,6 @@ "version": "6.0.0", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -3776,7 +3746,6 @@ "version": "7.5.4", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -3791,7 +3760,6 @@ "version": "5.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^4.1.0" }, @@ -3802,14 +3770,12 @@ "node_modules/@react-native-community/cli-doctor/node_modules/yallist": { "version": "4.0.0", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@react-native-community/cli-doctor/node_modules/yaml": { "version": "2.3.2", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">= 14" } @@ -3818,7 +3784,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-platform-android": "11.3.6", "@react-native-community/cli-tools": "11.3.6", @@ -3831,7 +3796,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-tools": "11.3.6", "chalk": "^4.1.2", @@ -3844,7 +3808,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-tools": "11.3.6", "chalk": "^4.1.2", @@ -3858,7 +3821,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-server-api": "11.3.6", "@react-native-community/cli-tools": "11.3.6", @@ -3877,7 +3839,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.0.0", "react-refresh": "^0.4.0" @@ -3890,7 +3851,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-debugger-ui": "11.3.6", "@react-native-community/cli-tools": "11.3.6", @@ -3907,7 +3867,6 @@ "version": "26.6.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -3923,7 +3882,6 @@ "version": "15.0.15", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/yargs-parser": "*" } @@ -3932,7 +3890,6 @@ "version": "4.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3947,7 +3904,6 @@ "version": "2.0.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3958,14 +3914,12 @@ "node_modules/@react-native-community/cli-server-api/node_modules/color-name": { "version": "1.1.4", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-native-community/cli-server-api/node_modules/pretty-format": { "version": "26.6.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", @@ -3979,14 +3933,12 @@ "node_modules/@react-native-community/cli-server-api/node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-native-community/cli-server-api/node_modules/ws": { "version": "7.5.9", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -4007,7 +3959,6 @@ "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "appdirsjs": "^1.2.4", "chalk": "^4.1.2", @@ -4024,7 +3975,6 @@ "version": "6.0.0", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -4036,7 +3986,6 @@ "version": "2.6.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "mime": "cli.js" }, @@ -4048,7 +3997,6 @@ "version": "7.5.4", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -4062,14 +4010,12 @@ "node_modules/@react-native-community/cli-tools/node_modules/yallist": { "version": "4.0.0", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@react-native-community/cli-types": { "version": "11.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "joi": "^17.2.1" } @@ -4078,7 +4024,6 @@ "version": "4.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -4091,7 +4036,6 @@ "version": "8.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", @@ -4105,7 +4049,6 @@ "version": "4.0.0", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -4114,7 +4057,6 @@ "version": "5.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -4126,7 +4068,6 @@ "version": "6.0.0", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -4138,7 +4079,6 @@ "version": "2.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -4153,7 +4093,6 @@ "version": "4.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -4165,7 +4104,6 @@ "version": "7.5.4", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -4180,7 +4118,6 @@ "version": "0.1.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -4188,20 +4125,17 @@ "node_modules/@react-native-community/cli/node_modules/yallist": { "version": "4.0.0", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@react-native/assets-registry": { "version": "0.72.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-native/codegen": { "version": "0.72.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.20.0", "flow-parser": "^0.206.0", @@ -4215,26 +4149,22 @@ "node_modules/@react-native/gradle-plugin": { "version": "0.72.11", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-native/js-polyfills": { "version": "0.72.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-native/normalize-colors": { "version": "0.72.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-native/virtualized-lists": { "version": "0.72.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" @@ -4371,7 +4301,6 @@ "version": "8.14.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.26.7", @@ -4418,7 +4347,6 @@ "version": "0.21.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -4428,7 +4356,6 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", "dev": true, - "peer": true, "engines": { "node": ">=12.7.0" }, @@ -4527,7 +4454,6 @@ "version": "4.1.4", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@hapi/hoek": "^9.0.0" } @@ -4535,14 +4461,12 @@ "node_modules/@sideway/formula": { "version": "3.0.1", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { "version": "0.27.8", @@ -5101,6 +5025,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -5171,7 +5096,6 @@ "version": "0.26.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" } @@ -5323,6 +5247,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.19.0.tgz", "integrity": "sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", "@typescript-eslint/scope-manager": "6.19.0", @@ -5434,6 +5359,7 @@ "version": "6.6.0", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.6.0", "@typescript-eslint/types": "6.6.0", @@ -6058,7 +5984,6 @@ "version": "3.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -6081,6 +6006,7 @@ "version": "8.10.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6160,6 +6086,7 @@ "version": "6.12.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6218,8 +6145,7 @@ "node_modules/anser": { "version": "1.4.10", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-escapes": { "version": "4.3.2", @@ -6238,7 +6164,6 @@ "version": "0.2.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "colorette": "^1.0.7", "slice-ansi": "^2.0.0", @@ -6249,7 +6174,6 @@ "version": "4.1.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -6257,14 +6181,12 @@ "node_modules/ansi-fragments/node_modules/colorette": { "version": "1.4.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-fragments/node_modules/is-fullwidth-code-point": { "version": "2.0.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -6273,7 +6195,6 @@ "version": "2.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", @@ -6287,7 +6208,6 @@ "version": "5.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^4.1.0" }, @@ -6343,8 +6263,7 @@ "node_modules/appdirsjs": { "version": "1.2.7", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/aproba": { "version": "2.0.0", @@ -6520,7 +6439,6 @@ "version": "0.15.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.0.1" }, @@ -6532,7 +6450,6 @@ "version": "1.0.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -6545,8 +6462,7 @@ "node_modules/async-limiter": { "version": "1.0.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynciterator.prototype": { "version": "1.0.0", @@ -6585,7 +6501,6 @@ "version": "7.0.0-bridge.0", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@babel/core": "^7.0.0-0" } @@ -6680,6 +6595,7 @@ "version": "3.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -6748,14 +6664,12 @@ "node_modules/babel-plugin-syntax-trailing-function-commas": { "version": "7.0.0-beta.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/babel-plugin-transform-flow-enums": { "version": "0.0.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } @@ -6787,7 +6701,6 @@ "version": "3.4.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/plugin-proposal-class-properties": "^7.0.0", "@babel/plugin-proposal-object-rest-spread": "^7.0.0", @@ -7039,6 +6952,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -7242,7 +7156,6 @@ "version": "2.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^2.0.0" }, @@ -7254,7 +7167,6 @@ "version": "2.0.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -7263,7 +7175,6 @@ "version": "2.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "caller-callsite": "^2.0.0" }, @@ -7741,8 +7652,7 @@ "node_modules/command-exists": { "version": "1.2.9", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/commander": { "version": "9.5.0", @@ -7826,7 +7736,6 @@ "version": "3.7.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -7849,7 +7758,6 @@ "version": "2.6.9", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -7857,8 +7765,7 @@ "node_modules/connect/node_modules/ms": { "version": "2.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/console-control-strings": { "version": "1.1.0", @@ -7942,6 +7849,7 @@ "version": "8.12.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -8351,8 +8259,7 @@ "node_modules/debounce": { "version": "1.2.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.3.4", @@ -8378,7 +8285,6 @@ "version": "1.2.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8502,8 +8408,7 @@ "node_modules/denodeify": { "version": "1.2.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/depd": { "version": "2.0.0", @@ -8516,7 +8421,6 @@ "version": "4.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native/normalize-colors": "*", "invariant": "*", @@ -8871,7 +8775,6 @@ "version": "2.1.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "stackframe": "^1.3.4" } @@ -8880,7 +8783,6 @@ "version": "1.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.7", "escape-html": "~1.0.3" @@ -9069,6 +8971,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9257,6 +9160,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -9520,6 +9424,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-16.6.2.tgz", "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -9608,6 +9513,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==", "dev": true, + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -9829,7 +9735,6 @@ "version": "5.0.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -10096,7 +10001,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "strnum": "^1.0.5" }, @@ -10252,7 +10156,6 @@ "version": "1.1.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -10270,7 +10173,6 @@ "version": "2.6.9", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -10278,14 +10180,12 @@ "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/on-finished": { "version": "2.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -10350,14 +10250,12 @@ "node_modules/flow-enums-runtime": { "version": "0.0.5", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/flow-parser": { "version": "0.206.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.4.0" } @@ -10948,14 +10846,12 @@ "node_modules/hermes-estree": { "version": "0.12.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/hermes-parser": { "version": "0.12.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "hermes-estree": "0.12.0" } @@ -10964,7 +10860,6 @@ "version": "0.0.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "source-map": "^0.7.3" }, @@ -10976,7 +10871,6 @@ "version": "0.7.4", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">= 8" } @@ -11352,7 +11246,6 @@ "version": "1.0.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "queue": "6.0.2" }, @@ -11523,8 +11416,7 @@ "node_modules/ip": { "version": "1.1.8", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ipaddr.js": { "version": "2.1.0", @@ -11679,7 +11571,6 @@ "version": "0.3.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12007,7 +11898,6 @@ "version": "1.1.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -12221,7 +12111,6 @@ "version": "1.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/react-reconciler": "^0.28.0" }, @@ -12233,7 +12122,6 @@ "version": "0.28.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" } @@ -12260,6 +12148,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -12897,7 +12786,6 @@ "version": "17.10.1", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@hapi/hoek": "^9.0.0", "@hapi/topo": "^5.0.0", @@ -12930,20 +12818,17 @@ "node_modules/jsc-android": { "version": "250231.0.0", "dev": true, - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/jsc-safe-url": { "version": "0.2.4", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/jscodeshift": { "version": "0.14.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.13.16", "@babel/parser": "^7.13.16", @@ -12976,7 +12861,6 @@ "version": "2.4.3", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "graceful-fs": "^4.1.11", "imurmurhash": "^0.1.4", @@ -13136,8 +13020,7 @@ "node_modules/json-parse-better-errors": { "version": "1.0.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -13842,8 +13725,7 @@ "node_modules/lodash.throttle": { "version": "4.1.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -13942,7 +13824,6 @@ "version": "0.7.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-fragments": "^0.2.1", "dayjs": "^1.8.15", @@ -13956,7 +13837,6 @@ "version": "6.0.0", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -13967,7 +13847,6 @@ "version": "4.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -13980,7 +13859,6 @@ "version": "5.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -13992,7 +13870,6 @@ "version": "2.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -14007,7 +13884,6 @@ "version": "4.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -14018,14 +13894,12 @@ "node_modules/logkitty/node_modules/y18n": { "version": "4.0.3", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/logkitty/node_modules/yargs": { "version": "15.4.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", @@ -14047,7 +13921,6 @@ "version": "18.1.3", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" @@ -14241,7 +14114,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "@babel/core": "^7.20.0", @@ -14303,7 +14175,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.20.0", "hermes-parser": "0.12.0", @@ -14317,7 +14188,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "metro-core": "0.76.7", "rimraf": "^3.0.2" @@ -14330,7 +14200,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16" } @@ -14339,7 +14208,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", @@ -14357,7 +14225,6 @@ "version": "1.0.10", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -14366,7 +14233,6 @@ "version": "5.2.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", @@ -14381,7 +14247,6 @@ "version": "2.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" @@ -14394,7 +14259,6 @@ "version": "3.14.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -14407,7 +14271,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.0.0", "react-refresh": "^0.4.0" @@ -14420,7 +14283,6 @@ "version": "4.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" @@ -14433,7 +14295,6 @@ "version": "3.0.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -14442,7 +14303,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash.throttle": "^4.1.1", "metro-resolver": "0.76.7" @@ -14455,7 +14315,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "anymatch": "^3.0.3", "debug": "^2.2.0", @@ -14481,7 +14340,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -14497,7 +14355,6 @@ "version": "16.0.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/yargs-parser": "*" } @@ -14506,7 +14363,6 @@ "version": "2.6.9", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -14515,7 +14371,6 @@ "version": "4.0.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -14524,7 +14379,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } @@ -14533,7 +14387,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "^27.5.1", "@types/node": "*", @@ -14550,7 +14403,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -14564,7 +14416,6 @@ "version": "8.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -14578,14 +14429,12 @@ "node_modules/metro-file-map/node_modules/ms": { "version": "2.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/metro-inspector-proxy": { "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "connect": "^3.6.5", "debug": "^2.2.0", @@ -14604,7 +14453,6 @@ "version": "2.6.9", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -14612,14 +14460,12 @@ "node_modules/metro-inspector-proxy/node_modules/ms": { "version": "2.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/metro-inspector-proxy/node_modules/ws": { "version": "7.5.9", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -14640,7 +14486,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "terser": "^5.15.0" }, @@ -14652,7 +14497,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "uglify-es": "^3.1.9" }, @@ -14664,7 +14508,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.20.0", "@babel/plugin-proposal-async-generator-functions": "^7.0.0", @@ -14717,7 +14560,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.20.0", "babel-preset-fbjs": "^3.4.0", @@ -14736,7 +14578,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16" } @@ -14745,7 +14586,6 @@ "version": "0.76.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.0.0", "react-refresh": "^0.4.0" @@ -14758,7 +14598,6 @@ "version": "0.76.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", @@ -14777,7 +14616,6 @@ "version": "0.76.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "invariant": "^2.2.4", "metro-source-map": "0.76.8", @@ -14797,7 +14635,6 @@ "version": "0.5.7", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14806,7 +14643,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "invariant": "^2.2.4", "metro-source-map": "0.76.7", @@ -14826,7 +14662,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", @@ -14845,7 +14680,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16" } @@ -14854,7 +14688,6 @@ "version": "0.5.7", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14863,7 +14696,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", @@ -14879,7 +14711,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", @@ -14902,7 +14733,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", @@ -14921,7 +14751,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16" } @@ -14930,7 +14759,6 @@ "version": "0.5.7", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14938,14 +14766,12 @@ "node_modules/metro/node_modules/ci-info": { "version": "2.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/metro/node_modules/debug": { "version": "2.6.9", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -14954,7 +14780,6 @@ "version": "4.0.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -14963,7 +14788,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -14977,7 +14801,6 @@ "version": "8.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -14992,7 +14815,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.0.0", "react-refresh": "^0.4.0" @@ -15005,7 +14827,6 @@ "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", @@ -15023,14 +14844,12 @@ "node_modules/metro/node_modules/ms": { "version": "2.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/metro/node_modules/ob1": { "version": "0.76.7", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16" } @@ -15039,7 +14858,6 @@ "version": "0.5.7", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15048,7 +14866,6 @@ "version": "7.5.9", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -15244,7 +15061,6 @@ "version": "0.5.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -15372,7 +15188,6 @@ "version": "3.0.4", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12.0.0" } @@ -15431,7 +15246,6 @@ "version": "0.1.17", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimatch": "^3.0.2" }, @@ -15588,7 +15402,6 @@ "version": "1.15.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.12.0" }, @@ -15696,8 +15509,7 @@ "node_modules/nullthrows": { "version": "1.1.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/nwsapi": { "version": "2.2.7", @@ -15709,7 +15521,6 @@ "version": "0.76.8", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16" } @@ -15892,7 +15703,6 @@ "version": "6.4.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-wsl": "^1.1.0" }, @@ -16234,7 +16044,6 @@ "version": "4.0.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -16585,6 +16394,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -16820,6 +16630,7 @@ "version": "15.8.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -16937,7 +16748,6 @@ "version": "6.0.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "~2.0.3" } @@ -17047,6 +16857,7 @@ "version": "18.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -17069,7 +16880,6 @@ "version": "4.28.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -17079,7 +16889,6 @@ "version": "7.5.9", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -17100,6 +16909,7 @@ "version": "18.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -17138,7 +16948,8 @@ "node_modules/react-is": { "version": "18.2.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-json-view": { "version": "1.21.3", @@ -17173,7 +16984,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/react-reconciler": "^0.28.2", "its-fine": "^1.1.1", @@ -17190,7 +17000,6 @@ "version": "0.28.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" } @@ -17199,7 +17008,6 @@ "version": "0.29.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -17298,7 +17106,6 @@ "version": "4.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -17313,7 +17120,6 @@ "version": "2.0.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -17324,20 +17130,17 @@ "node_modules/react-native/node_modules/color-name": { "version": "1.1.4", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-native/node_modules/memoize-one": { "version": "5.2.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-native/node_modules/pretty-format": { "version": "26.6.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", @@ -17352,7 +17155,6 @@ "version": "26.6.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -17368,7 +17170,6 @@ "version": "15.0.15", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/yargs-parser": "*" } @@ -17376,14 +17177,12 @@ "node_modules/react-native/node_modules/pretty-format/node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-native/node_modules/promise": { "version": "8.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "asap": "~2.0.6" } @@ -17391,14 +17190,12 @@ "node_modules/react-native/node_modules/regenerator-runtime": { "version": "0.13.11", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-native/node_modules/scheduler": { "version": "0.24.0-canary-efb381bbf-20230505", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -17407,7 +17204,6 @@ "version": "0.27.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.21.0" @@ -17423,7 +17219,6 @@ "version": "0.21.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -17432,7 +17227,6 @@ "version": "0.4.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17598,7 +17392,6 @@ "version": "2.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debounce": "^1.2.1" }, @@ -17658,14 +17451,12 @@ "node_modules/readline": { "version": "1.3.0", "dev": true, - "license": "BSD", - "peer": true + "license": "BSD" }, "node_modules/recast": { "version": "0.21.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ast-types": "0.15.2", "esprima": "~4.0.0", @@ -17824,8 +17615,7 @@ "node_modules/require-main-filename": { "version": "2.0.0", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/requires-port": { "version": "1.0.0", @@ -17986,6 +17776,7 @@ "version": "2.79.1", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -18149,6 +17940,7 @@ "version": "1.66.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -18324,7 +18116,6 @@ "version": "2.1.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -18772,14 +18563,12 @@ "node_modules/stackframe": { "version": "1.3.4", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/stacktrace-parser": { "version": "0.1.10", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "type-fest": "^0.7.1" }, @@ -18791,7 +18580,6 @@ "version": "0.7.1", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=8" } @@ -19033,8 +18821,7 @@ "node_modules/strnum": { "version": "1.0.5", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/strtok3": { "version": "6.3.0", @@ -19108,8 +18895,7 @@ "node_modules/sudo-prompt": { "version": "9.2.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/supports-color": { "version": "5.5.0", @@ -19137,7 +18923,6 @@ "version": "0.1.3", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "react": ">=17.0" } @@ -19237,7 +19022,6 @@ "version": "0.8.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "rimraf": "~2.6.2" }, @@ -19257,7 +19041,6 @@ "version": "2.6.3", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -19437,8 +19220,7 @@ "node_modules/throat": { "version": "5.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/through": { "version": "2.3.8", @@ -19448,7 +19230,6 @@ "version": "2.0.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" @@ -19457,14 +19238,12 @@ "node_modules/through2/node_modules/isarray": { "version": "1.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/through2/node_modules/readable-stream": { "version": "2.3.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -19478,14 +19257,12 @@ "node_modules/through2/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/through2/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -19636,6 +19413,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -19845,6 +19623,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19875,7 +19654,6 @@ "version": "3.3.9", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "commander": "~2.13.0", "source-map": "~0.6.1" @@ -19890,8 +19668,7 @@ "node_modules/uglify-es/node_modules/commander": { "version": "2.13.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/uid-safe": { "version": "2.1.5", @@ -20105,7 +19882,6 @@ "version": "1.2.0", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } @@ -20173,8 +19949,7 @@ "node_modules/vlq": { "version": "1.0.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", @@ -20231,6 +20006,7 @@ "version": "5.88.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -20277,6 +20053,7 @@ "version": "5.1.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -20351,6 +20128,7 @@ "version": "8.12.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -20458,6 +20236,7 @@ "version": "8.12.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -20752,8 +20531,7 @@ "node_modules/which-module": { "version": "2.0.1", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/which-typed-array": { "version": "1.1.11", @@ -20869,6 +20647,7 @@ "version": "8.12.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -21131,7 +20910,6 @@ "version": "6.2.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "async-limiter": "~1.0.0" } diff --git a/readme.md b/readme.md index 56c9eeab..2174b27b 100644 --- a/readme.md +++ b/readme.md @@ -12,6 +12,7 @@ A multi-user music service for self-hosting. ## Feature - No collection of privacy +- No advertisements - Multiple users - Shared musicbill between users - Support of importing music and music directory From d6ae6af9c3fb1d255dc2e10d25758041ff679ba0 Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 18 Nov 2025 23:18:59 +0800 Subject: [PATCH 03/50] playlist view --- .../lib/player_controller/playlist.dart | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/flutter/lib/player_controller/playlist.dart b/apps/flutter/lib/player_controller/playlist.dart index 15689554..475be483 100644 --- a/apps/flutter/lib/player_controller/playlist.dart +++ b/apps/flutter/lib/player_controller/playlist.dart @@ -1,10 +1,35 @@ +import 'package:cicada/event_bus.dart'; +import 'package:cicada/states/playlist.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class Playlist extends StatelessWidget { const Playlist({super.key}); @override Widget build(BuildContext context) { - return Text("playlist"); + final playlist = context.watch().playlist; + return playlist.isEmpty + ? Text("Playlist is empty") + : Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: playlist.length, + itemBuilder: (context, index) { + final playlistMusic = playlist[index]; + return ListTile( + leading: const Icon(Icons.music_note_outlined), + title: Text(playlistMusic.music.name), + onTap: () => eventBus.fire( + PlayMusicEvent(music: playlistMusic.music), + ), + ); + }, + ), + ), + Text("action"), + ], + ); } } From a0340da7dee3c544fb88fb84c532cd6546405db6 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 5 Jan 2026 22:10:43 +0800 Subject: [PATCH 04/50] fromJSON --> fromJson --- apps/flutter/lib/models/music.dart | 4 ++-- apps/flutter/lib/models/singer.dart | 2 +- apps/flutter/lib/server/api/get_musicbill.dart | 6 +++--- .../lib/server/api/get_musicbill_list.dart | 4 ++-- apps/flutter/lib/server/api/get_profile.dart | 4 ++-- apps/flutter/lib/server/base/get_captcha.dart | 4 ++-- apps/flutter/lib/server/base/get_metadata.dart | 4 ++-- apps/flutter/lib/server/request.dart | 4 ++-- apps/flutter/lib/states/server.dart | 16 ++++++++-------- apps/flutter/lib/user_management/index.dart | 4 ++++ 10 files changed, 28 insertions(+), 24 deletions(-) diff --git a/apps/flutter/lib/models/music.dart b/apps/flutter/lib/models/music.dart index def9c31d..eb42b017 100644 --- a/apps/flutter/lib/models/music.dart +++ b/apps/flutter/lib/models/music.dart @@ -16,13 +16,13 @@ class Music { required this.singers, }); - factory Music.fromJSON(Map json) => Music( + factory Music.fromJson(Map json) => Music( id: json['id'], name: json['name'], asset: prefixServerOrigin(json['asset'])!, cover: prefixServerOrigin(json['cover']), singers: (json['singers'] as List) - .map((json) => Singer.fromJSON(json)) + .map((json) => Singer.fromJson(json)) .toList(), ); } diff --git a/apps/flutter/lib/models/singer.dart b/apps/flutter/lib/models/singer.dart index 23d98bfe..2d0070b9 100644 --- a/apps/flutter/lib/models/singer.dart +++ b/apps/flutter/lib/models/singer.dart @@ -13,7 +13,7 @@ class Singer { required this.aliases, }); - factory Singer.fromJSON(Map json) => Singer( + factory Singer.fromJson(Map json) => Singer( id: json['id'], name: json['name'], avatar: prefixServerOrigin(json['avatar']), diff --git a/apps/flutter/lib/server/api/get_musicbill.dart b/apps/flutter/lib/server/api/get_musicbill.dart index 7bd6d4dd..8cd5dae5 100644 --- a/apps/flutter/lib/server/api/get_musicbill.dart +++ b/apps/flutter/lib/server/api/get_musicbill.dart @@ -9,11 +9,11 @@ class Musicbill { Musicbill({required this.name, required this.cover, required this.musicList}); - factory Musicbill.fromJSON(Map json) => Musicbill( + factory Musicbill.fromJson(Map json) => Musicbill( name: json['name'], cover: prefixServerOrigin(json['cover']), musicList: (json['musicList'] as List) - .map((json) => Music.fromJSON(json)) + .map((json) => Music.fromJson(json)) .toList(), ); } @@ -24,5 +24,5 @@ Future getMusicbill({required String id}) async { query: {"id": id}, withToken: true, ); - return Musicbill.fromJSON(responseData); + return Musicbill.fromJson(responseData); } diff --git a/apps/flutter/lib/server/api/get_musicbill_list.dart b/apps/flutter/lib/server/api/get_musicbill_list.dart index 6e497273..87650a7b 100644 --- a/apps/flutter/lib/server/api/get_musicbill_list.dart +++ b/apps/flutter/lib/server/api/get_musicbill_list.dart @@ -8,7 +8,7 @@ class Musicbill { Musicbill({required this.id, required this.name, required this.cover}); - factory Musicbill.fromJSON(Map json) => Musicbill( + factory Musicbill.fromJson(Map json) => Musicbill( id: json['id'], name: json['name'], cover: prefixServerOrigin(json['cover']), @@ -21,6 +21,6 @@ Future> getMusicbillList() async { withToken: true, ); return (responseData as List) - .map((json) => Musicbill.fromJSON(json)) + .map((json) => Musicbill.fromJson(json)) .toList(); } diff --git a/apps/flutter/lib/server/api/get_profile.dart b/apps/flutter/lib/server/api/get_profile.dart index 225824bc..58fde7e0 100644 --- a/apps/flutter/lib/server/api/get_profile.dart +++ b/apps/flutter/lib/server/api/get_profile.dart @@ -20,7 +20,7 @@ class Profile { required this.twoFAEnabled, }); - factory Profile.fromJSON(Map json) => Profile( + factory Profile.fromJson(Map json) => Profile( id: json['id'], username: json['username'], avatar: prefixServerOrigin(json['avatar']), @@ -36,5 +36,5 @@ Future getProfile(String? token) async { headers[TOKEN_HEADER_KEY] = token; } final responseData = await httpGet(path: "/api/profile", headers: headers); - return Profile.fromJSON(responseData); + return Profile.fromJson(responseData); } diff --git a/apps/flutter/lib/server/base/get_captcha.dart b/apps/flutter/lib/server/base/get_captcha.dart index 81c9fb9a..28ed9bb9 100644 --- a/apps/flutter/lib/server/base/get_captcha.dart +++ b/apps/flutter/lib/server/base/get_captcha.dart @@ -6,11 +6,11 @@ class Captcha { Captcha({required this.id, required this.svg}); - factory Captcha.fromJSON(Map json) => + factory Captcha.fromJson(Map json) => Captcha(id: json['id'], svg: json['svg']); } Future getCaptcha() async { final responseData = await httpGet(path: "/base/captcha"); - return Captcha.fromJSON(responseData); + return Captcha.fromJson(responseData); } diff --git a/apps/flutter/lib/server/base/get_metadata.dart b/apps/flutter/lib/server/base/get_metadata.dart index 1a06ae69..3b7f0d52 100644 --- a/apps/flutter/lib/server/base/get_metadata.dart +++ b/apps/flutter/lib/server/base/get_metadata.dart @@ -7,7 +7,7 @@ class Metadata { Metadata({required this.hostname, required this.version}); - factory Metadata.fromJSON(Map json) => + factory Metadata.fromJson(Map json) => Metadata(hostname: json['hostname'], version: json['version']); } @@ -16,5 +16,5 @@ Future getMetadata(String? origin) async { origin: origin ?? serverState.currentServer!.origin, path: "/base/metadata", ); - return Metadata.fromJSON(responseData); + return Metadata.fromJson(responseData); } diff --git a/apps/flutter/lib/server/request.dart b/apps/flutter/lib/server/request.dart index 895b6923..e5697c2a 100644 --- a/apps/flutter/lib/server/request.dart +++ b/apps/flutter/lib/server/request.dart @@ -10,7 +10,7 @@ class ResponseWrapper { ResponseWrapper({required this.code, required this.data}); - factory ResponseWrapper.fromJSON(Map json) => + factory ResponseWrapper.fromJson(Map json) => ResponseWrapper(code: json['code'], data: json['data']); } @@ -26,7 +26,7 @@ Future handleResponse(Response response) async { "The server responsed with code \"${response.statusCode}\"", ); } - final responseData = ResponseWrapper.fromJSON(response.data); + final responseData = ResponseWrapper.fromJson(response.data); if (responseData.code != 'success') { throw Exception("The server responsed with code \"${responseData.code}\""); } diff --git a/apps/flutter/lib/states/server.dart b/apps/flutter/lib/states/server.dart index cae8bac3..4de88b90 100644 --- a/apps/flutter/lib/states/server.dart +++ b/apps/flutter/lib/states/server.dart @@ -20,7 +20,7 @@ class User { required this.nickname, }); - factory User.fromJSON(Map json) => User( + factory User.fromJson(Map json) => User( token: json['token'], avatar: json['avatar'], id: json['id'], @@ -29,7 +29,7 @@ class User { nickname: json['nickname'], ); - Map toJSON() => { + Map toJson() => { 'token': token, 'avatar': avatar, 'id': id, @@ -52,21 +52,21 @@ class Server { required this.users, }); - factory Server.fromJSON(Map json) => Server( + factory Server.fromJson(Map json) => Server( origin: json['origin'], hostname: json['hostname'], version: json['version'], users: (json['users'] as List) - .map((json) => User.fromJSON(json)) + .map((json) => User.fromJson(json)) .toList(), ); - Map toJSON() { + Map toJson() { return { 'origin': origin, 'hostname': hostname, 'version': version, - 'users': users.map((user) => user.toJSON()).toList(), + 'users': users.map((user) => user.toJson()).toList(), }; } } @@ -84,7 +84,7 @@ class ServerState extends ChangeNotifier { (user) => user.id == selectedUserId, ); - Map toJSON() { + Map toJson() { return { 'selectedServerOrigin': selectedServerOrigin, 'selectedUserId': selectedUserId, @@ -99,7 +99,7 @@ class ServerState extends ChangeNotifier { final undecodedServerList = jsonDecode(server['serverList']) as List; final serverList = undecodedServerList - .map((json) => Server.fromJSON(json)) + .map((json) => Server.fromJson(json)) .toList(); this.serverList = serverList; selectedServerOrigin = server['selectedServerOrigin']; diff --git a/apps/flutter/lib/user_management/index.dart b/apps/flutter/lib/user_management/index.dart index 0d5f273d..bf489f47 100644 --- a/apps/flutter/lib/user_management/index.dart +++ b/apps/flutter/lib/user_management/index.dart @@ -97,6 +97,10 @@ class _UserManagementState extends State { ); Navigator.pop(context); } catch (e) { + /** + * @todo error handle + * @author mebtte + */ print(e); } }, From 8d37d0d97a9860cd08c84813e6a1e836b54d95be Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 19 Jan 2026 23:13:34 +0800 Subject: [PATCH 05/50] support 2fa --- .../lib/server/base/login_with_2fa.dart | 17 +++ apps/flutter/lib/server/request.dart | 6 +- apps/flutter/lib/server/server_exception.dart | 9 ++ apps/flutter/lib/user_management/index.dart | 91 +++++++++----- .../lib/user_management/two_fa_widget.dart | 112 ++++++++++++++++++ 5 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 apps/flutter/lib/server/base/login_with_2fa.dart create mode 100644 apps/flutter/lib/server/server_exception.dart create mode 100644 apps/flutter/lib/user_management/two_fa_widget.dart diff --git a/apps/flutter/lib/server/base/login_with_2fa.dart b/apps/flutter/lib/server/base/login_with_2fa.dart new file mode 100644 index 00000000..f6468213 --- /dev/null +++ b/apps/flutter/lib/server/base/login_with_2fa.dart @@ -0,0 +1,17 @@ +import '../request.dart'; + +Future loginWith2FA({ + required String username, + required String password, + required String twoFAToken, +}) async { + final token = await httpPost( + path: "/base/login_with_2fa", + data: { + "username": username, + "password": password, + "twoFAToken": twoFAToken, + }, + ); + return token; +} diff --git a/apps/flutter/lib/server/request.dart b/apps/flutter/lib/server/request.dart index e5697c2a..d1b39759 100644 --- a/apps/flutter/lib/server/request.dart +++ b/apps/flutter/lib/server/request.dart @@ -1,6 +1,7 @@ import '../constants/index.dart'; import '../states/server.dart'; import 'package:dio/dio.dart'; +import './server_exception.dart'; final dio = Dio(); @@ -28,7 +29,10 @@ Future handleResponse(Response response) async { } final responseData = ResponseWrapper.fromJson(response.data); if (responseData.code != 'success') { - throw Exception("The server responsed with code \"${responseData.code}\""); + throw ServerException( + code: responseData.code, + message: response.data['message'] ?? responseData.code, + ); } return responseData.data; } diff --git a/apps/flutter/lib/server/server_exception.dart b/apps/flutter/lib/server/server_exception.dart new file mode 100644 index 00000000..cd5ede4d --- /dev/null +++ b/apps/flutter/lib/server/server_exception.dart @@ -0,0 +1,9 @@ +class ServerException implements Exception { + final String code; + final String message; + + ServerException({required this.code, required this.message}); + + @override + String toString() => 'ServerException(#$code): $message'; +} diff --git a/apps/flutter/lib/user_management/index.dart b/apps/flutter/lib/user_management/index.dart index bf489f47..297d8d23 100644 --- a/apps/flutter/lib/user_management/index.dart +++ b/apps/flutter/lib/user_management/index.dart @@ -1,9 +1,11 @@ import '../../server/api/get_profile.dart'; import '../../server/base/get_captcha.dart'; import '../../server/base/login.dart'; +import '../../server/server_exception.dart'; import '../../states/server.dart'; import '../../widgets/captcha.dart'; import 'package:flutter/material.dart'; +import './two_fa_widget.dart'; class UserManagement extends StatefulWidget { const UserManagement({super.key}); @@ -68,43 +70,68 @@ class _UserManagementState extends State { } showModalBottomSheet( context: context, - builder: (context) => SizedBox( - width: double.infinity, - child: Center( - child: CaptchaWidget( - onContinue: - ({ - required Captcha captcha, - required String input, - }) async { - try { - final token = await login( - username: username, - password: password, - captchaId: captcha.id, - captchaValue: input, - ); - final profile = await getProfile(token); - serverState.addUser( - User( - token: token, - avatar: profile.avatar, - id: profile.id, - twoFAEnabled: profile.twoFAEnabled, - username: profile.username, - nickname: profile.nickname, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: CaptchaWidget( + onContinue: + ({ + required Captcha captcha, + required String input, + }) async { + try { + final token = await login( + username: username, + password: password, + captchaId: captcha.id, + captchaValue: input, + ); + final profile = await getProfile(token); + serverState.addUser( + User( + token: token, + avatar: profile.avatar, + id: profile.id, + twoFAEnabled: profile.twoFAEnabled, + username: profile.username, + nickname: profile.nickname, + ), + ); + Navigator.pop(context); + } on ServerException catch (e) { + if (e.code == 'need_2fa') { + Navigator.pop(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => SizedBox( + width: double.infinity, + child: Padding( + padding: EdgeInsets.all(16.0), + child: TwoFAWidget( + username: username, + password: password, + ), + ), ), ); - Navigator.pop(context); - } catch (e) { - /** + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } + } catch (e) { + /** * @todo error handle * @author mebtte */ - print(e); - } - }, - ), + print(e); + } + }, ), ), ); diff --git a/apps/flutter/lib/user_management/two_fa_widget.dart b/apps/flutter/lib/user_management/two_fa_widget.dart new file mode 100644 index 00000000..1d88b08e --- /dev/null +++ b/apps/flutter/lib/user_management/two_fa_widget.dart @@ -0,0 +1,112 @@ +import '../../server/base/login_with_2fa.dart'; +import '../../server/api/get_profile.dart'; +import '../../states/server.dart'; +import 'package:flutter/material.dart'; + +class TwoFAWidget extends StatefulWidget { + final String username; + final String password; + + const TwoFAWidget({required this.username, required this.password}); + + @override + State createState() => _TwoFAWidgetState(); +} + +class _TwoFAWidgetState extends State { + late TextEditingController twoFATokenController; + bool isLoading = false; + + @override + void initState() { + super.initState(); + twoFATokenController = TextEditingController(); + } + + @override + void dispose() { + twoFATokenController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + top: 16, + left: 16, + right: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Enter 2FA Code", + style: Theme.of(context).textTheme.titleLarge, + ), + SizedBox(height: 16), + TextField( + controller: twoFATokenController, + decoration: InputDecoration( + label: Text("2FA Code"), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: isLoading + ? null + : () async { + final twoFAToken = twoFATokenController.text; + if (twoFAToken.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Please enter 2FA code")), + ); + return; + } + + setState(() => isLoading = true); + try { + final token = await loginWith2FA( + username: widget.username, + password: widget.password, + twoFAToken: twoFAToken, + ); + final profile = await getProfile(token); + serverState.addUser( + User( + token: token, + avatar: profile.avatar, + id: profile.id, + twoFAEnabled: profile.twoFAEnabled, + username: profile.username, + nickname: profile.nickname, + ), + ); + Navigator.pop(context); + } catch (e) { + /** + * @todo error handle + * @author mebtte + */ + setState(() => isLoading = false); + } + }, + child: isLoading + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text("Submit"), + ), + SizedBox(height: 16), + ], + ), + ), + ); + } +} From 2dc2db64bcba20928bedd10617877483b0ed86a8 Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 21 Jan 2026 22:41:18 +0800 Subject: [PATCH 06/50] create musicbill --- .vscode/extensions.json | 3 + .vscode/launch.json | 11 +- .../pages/home/create_musicbill_dialog.dart | 108 +++++++++++ apps/flutter/lib/pages/home/index.dart | 27 +-- .../lib/pages/home/musicbill_list.dart | 168 ++++++++++++++++++ .../lib/pages/home/user_info_card.dart | 109 ++++++++++++ .../lib/server/api/create_musicbill.dart | 18 ++ apps/flutter/lib/states/server.dart | 3 +- .../lib/user_management/two_fa_widget.dart | 6 +- .../lib/utils/prefix_server_origin.dart | 4 +- 10 files changed, 434 insertions(+), 23 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 apps/flutter/lib/pages/home/create_musicbill_dialog.dart create mode 100644 apps/flutter/lib/pages/home/musicbill_list.dart create mode 100644 apps/flutter/lib/pages/home/user_info_card.dart create mode 100644 apps/flutter/lib/server/api/create_musicbill.dart diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..ff1a0d06 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dart-code.dart-code", "dart-code.flutter"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 6dd3598c..49d94ce6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,7 +2,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Debug server", + "name": "Server", "request": "launch", "type": "node", "runtimeArgs": [ @@ -16,6 +16,15 @@ "env": { "TS_NODE_PROJECT": "${workspaceFolder}/apps/cli/tsconfig.json" } + }, + { + "name": "Flutter - macOS", + "request": "launch", + "type": "dart", + "program": "${workspaceFolder}/apps/flutter/lib/main.dart", + "cwd": "${workspaceFolder}/apps/flutter", + "deviceId": "macos", + "flutterMode": "debug" } ] } diff --git a/apps/flutter/lib/pages/home/create_musicbill_dialog.dart b/apps/flutter/lib/pages/home/create_musicbill_dialog.dart new file mode 100644 index 00000000..0b428595 --- /dev/null +++ b/apps/flutter/lib/pages/home/create_musicbill_dialog.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import '../../server/api/create_musicbill.dart'; +import '../../states/musicbill.dart'; + +/// 创建音乐清单对话框 +/// +/// 提供输入框让用户输入音乐清单名称,并提供确认和取消按钮 +class CreateMusicbillDialog extends StatefulWidget { + const CreateMusicbillDialog({super.key}); + + @override + State createState() => _CreateMusicbillDialogState(); +} + +class _CreateMusicbillDialogState extends State { + late TextEditingController _nameController; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + /// 处理创建操作 + Future _handleCreate() async { + final name = _nameController.text.trim(); + + // 验证输入 + if (name.isEmpty) { + setState(() { + _errorMessage = 'Please enter a musicbill name'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // 调用 API 创建 musicbill + await createMusicbill(name: name); + + // 重新加载列表 + musicbillState.reloadMusicbillList(silence: true); + + // 关闭对话框 + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Create Musicbill'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _nameController, + autofocus: true, + decoration: InputDecoration( + labelText: 'Musicbill Name', + hintText: 'Enter name', + border: const OutlineInputBorder(), + errorText: _errorMessage, + ), + enabled: !_isLoading, + onSubmitted: (_) => _handleCreate(), + ), + ], + ), + actions: [ + TextButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isLoading ? null : _handleCreate, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index d391c3de..58ae2182 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -1,4 +1,7 @@ import '../../states/musicbill.dart'; +import '../../states/server.dart'; +import './user_info_card.dart'; +import './musicbill_list.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,28 +11,14 @@ class Home extends StatelessWidget { @override Widget build(BuildContext context) { final musicbillList = context.watch().musicbillList; + final currentUser = context.watch().currentUser; + final currentServer = context.watch().currentServer; + return Scaffold( - appBar: AppBar(title: Text("My Musicbill")), body: Column( children: [ - if (musicbillList.isNotEmpty) - Expanded( - child: ListView.builder( - itemCount: musicbillList.length, - itemBuilder: (context, index) { - final musicbill = musicbillList[index]; - return ListTile( - leading: const Icon(Icons.list), - title: Text(musicbill.name), - onTap: () => Navigator.pushNamed( - context, - "/musicbill", - arguments: {"id": musicbill.id}, - ), - ); - }, - ), - ), + UserInfoCard(user: currentUser, server: currentServer), + MusicbillList(musicbillList: musicbillList), ], ), ); diff --git a/apps/flutter/lib/pages/home/musicbill_list.dart b/apps/flutter/lib/pages/home/musicbill_list.dart new file mode 100644 index 00000000..e21a08f9 --- /dev/null +++ b/apps/flutter/lib/pages/home/musicbill_list.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import '../../states/musicbill.dart'; +import './create_musicbill_dialog.dart'; + +/// 音乐清单列表组件 +/// 显示用户的所有音乐清单,支持空状态展示 +class MusicbillList extends StatelessWidget { + final List musicbillList; + + const MusicbillList({super.key, required this.musicbillList}); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + children: [ + _buildHeader(context), + if (musicbillList.isEmpty) + _buildEmptyState(context) + else + _buildList(context), + ], + ), + ); + } + + /// 构建标题栏 + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Text( + 'My Musicbills', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${musicbillList.length}', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showCreateDialog(context), + tooltip: 'Create musicbill', + ), + ], + ), + ); + } + + /// 显示创建音乐清单对话框 + void _showCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => const CreateMusicbillDialog(), + ); + } + + /// 构建列表 + Widget _buildList(BuildContext context) { + return Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: musicbillList.length, + itemBuilder: (context, index) { + final musicbill = musicbillList[index]; + return _buildMusicbillCard(context, musicbill); + }, + ), + ); + } + + /// 构建空状态 + Widget _buildEmptyState(BuildContext context) { + return Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.library_music_outlined, + size: 80, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No musicbills yet', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Colors.grey[600]), + ), + ], + ), + ), + ); + } + + /// 构建音乐清单卡片 + Widget _buildMusicbillCard(BuildContext context, Musicbill musicbill) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: _buildCover(context, musicbill), + title: Text( + musicbill.name, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pushNamed( + context, + "/musicbill", + arguments: {"id": musicbill.id}, + ), + ), + ); + } + + /// 构建封面 + Widget _buildCover(BuildContext context, Musicbill musicbill) { + if (musicbill.cover != null && musicbill.cover!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + musicbill.cover!, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), + ); + } + + return _buildDefaultCover(context); + } + + /// 构建默认封面 + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.library_music, color: Theme.of(context).primaryColor), + ); + } +} diff --git a/apps/flutter/lib/pages/home/user_info_card.dart b/apps/flutter/lib/pages/home/user_info_card.dart new file mode 100644 index 00000000..5d5b67ba --- /dev/null +++ b/apps/flutter/lib/pages/home/user_info_card.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import '../../states/server.dart'; + +/// 用户信息卡片组件 +/// 显示用户头像、昵称、用户名和服务器信息 +class UserInfoCard extends StatelessWidget { + final User? user; + final Server? server; + + const UserInfoCard({super.key, required this.user, required this.server}); + + @override + Widget build(BuildContext context) { + if (user == null) { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.all(16), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + _buildAvatar(context), + const SizedBox(width: 20), + _buildUserDetails(context), + ], + ), + ), + ), + ); + } + + /// 构建用户头像 + Widget _buildAvatar(BuildContext context) { + return CircleAvatar( + radius: 40, + backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), + backgroundImage: user!.avatar != null && user!.avatar!.isNotEmpty + ? NetworkImage(user!.avatar!) + : null, + child: user!.avatar == null || user!.avatar!.isEmpty + ? Icon(Icons.person, size: 40, color: Theme.of(context).primaryColor) + : null, + ); + } + + /// 构建用户详细信息 + Widget _buildUserDetails(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildNickname(context), + const SizedBox(height: 4), + _buildUsername(context), + if (server != null) ...[ + const SizedBox(height: 8), + _buildServerInfo(context), + ], + ], + ), + ); + } + + /// 构建昵称 + Widget _buildNickname(BuildContext context) { + return Text( + user!.nickname, + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ); + } + + /// 构建用户名 + Widget _buildUsername(BuildContext context) { + return Text( + '@${user!.username}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]), + overflow: TextOverflow.ellipsis, + ); + } + + /// 构建服务器信息 + Widget _buildServerInfo(BuildContext context) { + return Row( + children: [ + Icon(Icons.cloud, size: 16, color: Colors.grey[500]), + const SizedBox(width: 4), + Expanded( + child: Text( + server!.hostname, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey[500]), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/server/api/create_musicbill.dart b/apps/flutter/lib/server/api/create_musicbill.dart new file mode 100644 index 00000000..21d19f71 --- /dev/null +++ b/apps/flutter/lib/server/api/create_musicbill.dart @@ -0,0 +1,18 @@ +import '../request.dart'; + +/// 创建音乐清单 +/// +/// 参数: +/// - name: 音乐清单名称 +/// +/// 返回: +/// - 创建成功的音乐清单 ID +Future createMusicbill({required String name}) async { + final responseData = await httpPost( + path: "/api/musicbill", + data: {"name": name}, + withToken: true, + ); + // 服务器直接返回 ID 字符串 + return responseData as String; +} diff --git a/apps/flutter/lib/states/server.dart b/apps/flutter/lib/states/server.dart index 4de88b90..eed098fb 100644 --- a/apps/flutter/lib/states/server.dart +++ b/apps/flutter/lib/states/server.dart @@ -1,5 +1,6 @@ import '../extensions/list.dart'; import '../utils/preference.dart'; +import '../utils/prefix_server_origin.dart'; import 'package:flutter/material.dart'; import 'dart:convert'; @@ -22,7 +23,7 @@ class User { factory User.fromJson(Map json) => User( token: json['token'], - avatar: json['avatar'], + avatar: prefixServerOrigin(json['avatar']), id: json['id'], twoFAEnabled: json['twoFAEnabled'], username: json['username'], diff --git a/apps/flutter/lib/user_management/two_fa_widget.dart b/apps/flutter/lib/user_management/two_fa_widget.dart index 1d88b08e..64ab64b9 100644 --- a/apps/flutter/lib/user_management/two_fa_widget.dart +++ b/apps/flutter/lib/user_management/two_fa_widget.dart @@ -7,7 +7,11 @@ class TwoFAWidget extends StatefulWidget { final String username; final String password; - const TwoFAWidget({required this.username, required this.password}); + const TwoFAWidget({ + super.key, + required this.username, + required this.password, + }); @override State createState() => _TwoFAWidgetState(); diff --git a/apps/flutter/lib/utils/prefix_server_origin.dart b/apps/flutter/lib/utils/prefix_server_origin.dart index 56904532..dda4d974 100644 --- a/apps/flutter/lib/utils/prefix_server_origin.dart +++ b/apps/flutter/lib/utils/prefix_server_origin.dart @@ -1,5 +1,7 @@ import '../states/server.dart'; String? prefixServerOrigin(String? asset) { - return asset == null ? null : "${serverState.currentServer!.origin}$asset"; + return asset == null + ? null + : "${serverState.currentServer?.origin ?? ''}$asset"; } From 7c3123772536384d1daf7d907701be5173671b7f Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 21 Jan 2026 22:50:14 +0800 Subject: [PATCH 07/50] profile and logout --- apps/flutter/lib/app.dart | 7 + .../lib/pages/home/user_info_card.dart | 21 ++- apps/flutter/lib/pages/profile/index.dart | 151 ++++++++++++++++++ 3 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 apps/flutter/lib/pages/profile/index.dart diff --git a/apps/flutter/lib/app.dart b/apps/flutter/lib/app.dart index c958d14a..d24fab10 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -12,6 +12,7 @@ import './states/musicbill.dart' as musicbill_state; import './user_management/index.dart'; import './states/server.dart'; import './pages/musicbill/index.dart' as musicbill_page; +import './pages/profile/index.dart'; class AppContent extends StatefulWidget { const AppContent({super.key}); @@ -58,6 +59,12 @@ class _AppContentState extends State { musicbill_page.Musicbill(id: args['id']), ); } + case '/profile': + { + return MaterialPageRoute( + builder: (_) => const ProfilePage(), + ); + } default: { return MaterialPageRoute(builder: (_) => Home()); diff --git a/apps/flutter/lib/pages/home/user_info_card.dart b/apps/flutter/lib/pages/home/user_info_card.dart index 5d5b67ba..f2d1140d 100644 --- a/apps/flutter/lib/pages/home/user_info_card.dart +++ b/apps/flutter/lib/pages/home/user_info_card.dart @@ -20,14 +20,19 @@ class UserInfoCard extends StatelessWidget { child: Card( elevation: 4, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - _buildAvatar(context), - const SizedBox(width: 20), - _buildUserDetails(context), - ], + child: InkWell( + onTap: () => Navigator.pushNamed(context, '/profile'), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + _buildAvatar(context), + const SizedBox(width: 20), + _buildUserDetails(context), + const Icon(Icons.chevron_right, color: Colors.grey), + ], + ), ), ), ), diff --git a/apps/flutter/lib/pages/profile/index.dart b/apps/flutter/lib/pages/profile/index.dart new file mode 100644 index 00000000..08cd46c0 --- /dev/null +++ b/apps/flutter/lib/pages/profile/index.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../states/server.dart'; + +/// 用户个人资料页面 +/// 显示用户信息和提供退出登录功能 +class ProfilePage extends StatelessWidget { + const ProfilePage({super.key}); + + @override + Widget build(BuildContext context) { + final currentUser = context.watch().currentUser; + final currentServer = context.watch().currentServer; + + if (currentUser == null) { + return Scaffold( + appBar: AppBar(title: const Text('Profile')), + body: const Center(child: Text('No user logged in')), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Profile')), + body: ListView( + children: [ + _buildUserHeader(context, currentUser, currentServer), + const Divider(), + _buildUserInfo(context, currentUser, currentServer), + const Divider(), + _buildActions(context), + ], + ), + ); + } + + /// 构建用户头部 + Widget _buildUserHeader(BuildContext context, User user, Server? server) { + return Container( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + CircleAvatar( + radius: 50, + backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), + backgroundImage: user.avatar != null && user.avatar!.isNotEmpty + ? NetworkImage(user.avatar!) + : null, + child: user.avatar == null || user.avatar!.isEmpty + ? Icon( + Icons.person, + size: 50, + color: Theme.of(context).primaryColor, + ) + : null, + ), + const SizedBox(height: 16), + Text( + user.nickname, + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + '@${user.username}', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]), + ), + ], + ), + ); + } + + /// 构建用户信息列表 + Widget _buildUserInfo(BuildContext context, User user, Server? server) { + return Column( + children: [ + ListTile( + leading: const Icon(Icons.cloud), + title: const Text('Server'), + subtitle: Text(server?.hostname ?? 'Unknown'), + ), + ListTile( + leading: const Icon(Icons.link), + title: const Text('Server URL'), + subtitle: Text(server?.origin ?? 'Unknown'), + ), + ListTile( + leading: const Icon(Icons.security), + title: const Text('Two-Factor Authentication'), + subtitle: Text(user.twoFAEnabled ? 'Enabled' : 'Disabled'), + trailing: user.twoFAEnabled + ? const Icon(Icons.check_circle, color: Colors.green) + : const Icon(Icons.cancel, color: Colors.grey), + ), + ], + ); + } + + /// 构建操作按钮 + Widget _buildActions(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton.icon( + onPressed: () => _showLogoutDialog(context), + icon: const Icon(Icons.logout), + label: const Text('Logout'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ], + ), + ); + } + + /// 显示退出登录确认对话框 + void _showLogoutDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + // 关闭对话框 + Navigator.of(dialogContext).pop(); + // 返回到根路由 + Navigator.of(context).popUntil((route) => route.isFirst); + // 退出登录(这会触发 App 重新构建并显示登录页面) + serverState.reselectUser(); + }, + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Logout'), + ), + ], + ), + ); + } +} From fedd28dc749ee6271b2e677056e13d90f0ecb5e3 Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 21 Jan 2026 22:55:03 +0800 Subject: [PATCH 08/50] home safearea --- apps/flutter/lib/pages/home/index.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index 58ae2182..42a457a1 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -15,11 +15,13 @@ class Home extends StatelessWidget { final currentServer = context.watch().currentServer; return Scaffold( - body: Column( - children: [ - UserInfoCard(user: currentUser, server: currentServer), - MusicbillList(musicbillList: musicbillList), - ], + body: SafeArea( + child: Column( + children: [ + UserInfoCard(user: currentUser, server: currentServer), + MusicbillList(musicbillList: musicbillList), + ], + ), ), ); } From d9952434f96c97a8c1c74048ba7433d6ed5f4f6e Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 21 Jan 2026 23:01:48 +0800 Subject: [PATCH 09/50] improve musicbill list --- apps/flutter/lib/pages/home/index.dart | 7 +- .../lib/pages/home/musicbill_card.dart | 66 +++++++++ .../lib/pages/home/musicbill_list.dart | 132 +++--------------- .../lib/pages/home/musicbill_list_header.dart | 67 +++++++++ 4 files changed, 159 insertions(+), 113 deletions(-) create mode 100644 apps/flutter/lib/pages/home/musicbill_card.dart create mode 100644 apps/flutter/lib/pages/home/musicbill_list_header.dart diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index 42a457a1..db8ae558 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -10,7 +10,7 @@ class Home extends StatelessWidget { @override Widget build(BuildContext context) { - final musicbillList = context.watch().musicbillList; + final musicbillState = context.watch(); final currentUser = context.watch().currentUser; final currentServer = context.watch().currentServer; @@ -19,7 +19,10 @@ class Home extends StatelessWidget { child: Column( children: [ UserInfoCard(user: currentUser, server: currentServer), - MusicbillList(musicbillList: musicbillList), + MusicbillList( + musicbillList: musicbillState.musicbillList, + isLoading: musicbillState.loading, + ), ], ), ), diff --git a/apps/flutter/lib/pages/home/musicbill_card.dart b/apps/flutter/lib/pages/home/musicbill_card.dart new file mode 100644 index 00000000..4dcfc5b2 --- /dev/null +++ b/apps/flutter/lib/pages/home/musicbill_card.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import '../../states/musicbill.dart'; + +/// 音乐清单卡片组件 +/// 显示单个音乐清单的信息(封面、名称) +class MusicbillCard extends StatelessWidget { + final Musicbill musicbill; + + const MusicbillCard({super.key, required this.musicbill}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: _buildCover(context), + title: Text( + musicbill.name, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pushNamed( + context, + "/musicbill", + arguments: {"id": musicbill.id}, + ), + ), + ); + } + + /// 构建封面 + Widget _buildCover(BuildContext context) { + if (musicbill.cover != null && musicbill.cover!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + musicbill.cover!, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), + ); + } + + return _buildDefaultCover(context); + } + + /// 构建默认封面 + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.library_music, color: Theme.of(context).primaryColor), + ); + } +} diff --git a/apps/flutter/lib/pages/home/musicbill_list.dart b/apps/flutter/lib/pages/home/musicbill_list.dart index e21a08f9..5f19b3b0 100644 --- a/apps/flutter/lib/pages/home/musicbill_list.dart +++ b/apps/flutter/lib/pages/home/musicbill_list.dart @@ -1,90 +1,56 @@ import 'package:flutter/material.dart'; import '../../states/musicbill.dart'; -import './create_musicbill_dialog.dart'; +import './musicbill_card.dart'; +import './musicbill_list_header.dart'; /// 音乐清单列表组件 -/// 显示用户的所有音乐清单,支持空状态展示 +/// 显示用户的所有音乐清单,支持空状态展示和加载状态 class MusicbillList extends StatelessWidget { final List musicbillList; + final bool isLoading; - const MusicbillList({super.key, required this.musicbillList}); + const MusicbillList({ + super.key, + required this.musicbillList, + this.isLoading = false, + }); @override Widget build(BuildContext context) { return Expanded( child: Column( children: [ - _buildHeader(context), - if (musicbillList.isEmpty) + MusicbillListHeader(count: musicbillList.length), + if (isLoading) + _buildLoadingState() + else if (musicbillList.isEmpty) _buildEmptyState(context) else - _buildList(context), + _buildList(), ], ), ); } - /// 构建标题栏 - Widget _buildHeader(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Text( - 'My Musicbills', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${musicbillList.length}', - style: TextStyle( - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.add), - onPressed: () => _showCreateDialog(context), - tooltip: 'Create musicbill', - ), - ], - ), - ); - } - - /// 显示创建音乐清单对话框 - void _showCreateDialog(BuildContext context) { - showDialog( - context: context, - builder: (context) => const CreateMusicbillDialog(), - ); - } - /// 构建列表 - Widget _buildList(BuildContext context) { + Widget _buildList() { return Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: musicbillList.length, itemBuilder: (context, index) { final musicbill = musicbillList[index]; - return _buildMusicbillCard(context, musicbill); + return MusicbillCard(musicbill: musicbill); }, ), ); } + /// 构建加载状态 + Widget _buildLoadingState() { + return const Expanded(child: Center(child: CircularProgressIndicator())); + } + /// 构建空状态 Widget _buildEmptyState(BuildContext context) { return Expanded( @@ -109,60 +75,4 @@ class MusicbillList extends StatelessWidget { ), ); } - - /// 构建音乐清单卡片 - Widget _buildMusicbillCard(BuildContext context, Musicbill musicbill) { - return Card( - margin: const EdgeInsets.only(bottom: 12), - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: _buildCover(context, musicbill), - title: Text( - musicbill.name, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.pushNamed( - context, - "/musicbill", - arguments: {"id": musicbill.id}, - ), - ), - ); - } - - /// 构建封面 - Widget _buildCover(BuildContext context, Musicbill musicbill) { - if (musicbill.cover != null && musicbill.cover!.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - musicbill.cover!, - width: 56, - height: 56, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(context); - }, - ), - ); - } - - return _buildDefaultCover(context); - } - - /// 构建默认封面 - Widget _buildDefaultCover(BuildContext context) { - return Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.library_music, color: Theme.of(context).primaryColor), - ); - } } diff --git a/apps/flutter/lib/pages/home/musicbill_list_header.dart b/apps/flutter/lib/pages/home/musicbill_list_header.dart new file mode 100644 index 00000000..cf97e4fe --- /dev/null +++ b/apps/flutter/lib/pages/home/musicbill_list_header.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import './create_musicbill_dialog.dart'; + +/// 音乐清单列表标题栏组件 +/// 显示标题、数量徽章和添加按钮 +class MusicbillListHeader extends StatelessWidget { + final int count; + + const MusicbillListHeader({super.key, required this.count}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Text( + 'My Musicbills', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + _buildCountBadge(context), + const Spacer(), + _buildAddButton(context), + ], + ), + ); + } + + /// 构建数量徽章 + Widget _buildCountBadge(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ); + } + + /// 构建添加按钮 + Widget _buildAddButton(BuildContext context) { + return IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showCreateDialog(context), + tooltip: 'Create musicbill', + ); + } + + /// 显示创建音乐清单对话框 + void _showCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => const CreateMusicbillDialog(), + ); + } +} From afa0f615bb0a00b700ef3507ab799689a15fdb1c Mon Sep 17 00:00:00 2001 From: mebtte Date: Thu, 22 Jan 2026 22:29:04 +0800 Subject: [PATCH 10/50] improve style of player controller --- apps/flutter/lib/app.dart | 64 +++++---- .../lib/pages/home/musicbill_list.dart | 6 +- apps/flutter/lib/pages/musicbill/index.dart | 6 +- apps/flutter/lib/pages/profile/index.dart | 2 + .../lib/player_controller/actions.dart | 13 +- .../player_controller/player_controller.dart | 133 ++++++++++++++---- apps/flutter/lib/theme.dart | 91 ++++++++++++ .../lib/widgets/player_bottom_spacer.dart | 18 +++ 8 files changed, 274 insertions(+), 59 deletions(-) create mode 100644 apps/flutter/lib/theme.dart create mode 100644 apps/flutter/lib/widgets/player_bottom_spacer.dart diff --git a/apps/flutter/lib/app.dart b/apps/flutter/lib/app.dart index d24fab10..1792997a 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -13,6 +13,7 @@ import './user_management/index.dart'; import './states/server.dart'; import './pages/musicbill/index.dart' as musicbill_page; import './pages/profile/index.dart'; +import './theme.dart'; class AppContent extends StatefulWidget { const AppContent({super.key}); @@ -22,6 +23,7 @@ class AppContent extends StatefulWidget { } class _AppContentState extends State { + // ... initState and reassemble ... @override void initState() { super.initState(); @@ -44,36 +46,41 @@ class _AppContentState extends State { ChangeNotifierProvider.value(value: audioState), ], child: MaterialApp( - home: Column( + theme: appTheme, + home: Stack( children: [ - Expanded( - child: Navigator( - key: GlobalKey(), - onGenerateRoute: (setting) { - switch (setting.name) { - case '/musicbill': - { - final args = setting.arguments as Map; - return MaterialPageRoute( - builder: (_) => - musicbill_page.Musicbill(id: args['id']), - ); - } - case '/profile': - { - return MaterialPageRoute( - builder: (_) => const ProfilePage(), - ); - } - default: - { - return MaterialPageRoute(builder: (_) => Home()); - } - } - }, - ), + // ... (Navigator and PlayerController) + Navigator( + key: GlobalKey(), + onGenerateRoute: (setting) { + switch (setting.name) { + case '/musicbill': + { + final args = setting.arguments as Map; + return MaterialPageRoute( + builder: (_) => + musicbill_page.Musicbill(id: args['id']), + ); + } + case '/profile': + { + return MaterialPageRoute( + builder: (_) => const ProfilePage(), + ); + } + default: + { + return MaterialPageRoute(builder: (_) => Home()); + } + } + }, + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: PlayerControllerContainer(), ), - PlayerControllerContainer(), ], ), ), @@ -88,6 +95,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) { final serverState = context.watch(); return MaterialApp( + theme: appTheme, home: serverState.currentServer == null ? ServerManagement() : serverState.currentUser == null diff --git a/apps/flutter/lib/pages/home/musicbill_list.dart b/apps/flutter/lib/pages/home/musicbill_list.dart index 5f19b3b0..05d6bd13 100644 --- a/apps/flutter/lib/pages/home/musicbill_list.dart +++ b/apps/flutter/lib/pages/home/musicbill_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../states/musicbill.dart'; import './musicbill_card.dart'; import './musicbill_list_header.dart'; +import '../../widgets/player_bottom_spacer.dart'; /// 音乐清单列表组件 /// 显示用户的所有音乐清单,支持空状态展示和加载状态 @@ -37,8 +38,11 @@ class MusicbillList extends StatelessWidget { return Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: musicbillList.length, + itemCount: musicbillList.length + 1, itemBuilder: (context, index) { + if (index == musicbillList.length) { + return const PlayerBottomSpacer(); + } final musicbill = musicbillList[index]; return MusicbillCard(musicbill: musicbill); }, diff --git a/apps/flutter/lib/pages/musicbill/index.dart b/apps/flutter/lib/pages/musicbill/index.dart index 57713a42..fe3094a0 100644 --- a/apps/flutter/lib/pages/musicbill/index.dart +++ b/apps/flutter/lib/pages/musicbill/index.dart @@ -4,6 +4,7 @@ import '../../utils/get_musicbill_by_id.dart'; import '../../states/musicbill.dart' as musicbill_state; import '../../event_bus.dart'; import './actions.dart' as actions; +import '../../widgets/player_bottom_spacer.dart'; const uuid = Uuid(); @@ -49,8 +50,11 @@ class _MusicbillState extends State { else Expanded( child: ListView.builder( - itemCount: musicbill.musicList.length, + itemCount: musicbill.musicList.length + 1, itemBuilder: (context, index) { + if (index == musicbill.musicList.length) { + return const PlayerBottomSpacer(); + } final music = musicbill.musicList[index]; return ListTile( leading: const Icon(Icons.music_note_outlined), diff --git a/apps/flutter/lib/pages/profile/index.dart b/apps/flutter/lib/pages/profile/index.dart index 08cd46c0..3612771f 100644 --- a/apps/flutter/lib/pages/profile/index.dart +++ b/apps/flutter/lib/pages/profile/index.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../states/server.dart'; +import '../../widgets/player_bottom_spacer.dart'; /// 用户个人资料页面 /// 显示用户信息和提供退出登录功能 @@ -28,6 +29,7 @@ class ProfilePage extends StatelessWidget { _buildUserInfo(context, currentUser, currentServer), const Divider(), _buildActions(context), + const PlayerBottomSpacer(), ], ), ); diff --git a/apps/flutter/lib/player_controller/actions.dart b/apps/flutter/lib/player_controller/actions.dart index 731381da..9c4ca50a 100644 --- a/apps/flutter/lib/player_controller/actions.dart +++ b/apps/flutter/lib/player_controller/actions.dart @@ -13,16 +13,18 @@ class Actions extends StatelessWidget { @override Widget build(BuildContext context) { final playing = context.watch().playing; - final spacing = SizedBox(width: 10); + final spacing = SizedBox(width: 2); return Row( children: [ - spacing, IconButton( onPressed: () { final audioHandler = GetIt.instance.get(); playing ? audioHandler.pause() : audioHandler.play(); }, icon: Icon(playing ? Icons.pause : Icons.play_arrow), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: BoxConstraints(minWidth: 32, minHeight: 32), ), spacing, IconButton( @@ -30,6 +32,9 @@ class Actions extends StatelessWidget { playqueueState.next(); }, icon: Icon(Icons.skip_next), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: BoxConstraints(minWidth: 32, minHeight: 32), ), spacing, IconButton( @@ -82,8 +87,10 @@ class Actions extends StatelessWidget { ); }, icon: Icon(Icons.list_outlined), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: BoxConstraints(minWidth: 32, minHeight: 32), ), - spacing, ], ); } diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index a7534838..aee51e50 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -1,3 +1,4 @@ +import 'dart:ui'; import 'package:cicada/states/playqueue.dart'; import 'package:flutter/material.dart'; import './actions.dart' as actions; @@ -11,39 +12,119 @@ class PlayController extends StatelessWidget { Widget build(BuildContext context) { final music = playqueueMusic.music; final cover = music.cover; + return Container( - color: Colors.white, - height: 60, - child: Row( - // crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (cover != null) - AspectRatio( - aspectRatio: 1, - child: Image.network(cover, fit: BoxFit.cover), + margin: const EdgeInsets.fromLTRB(8, 0, 8, 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 16, + offset: Offset(0, -4), + spreadRadius: 0, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + height: 54, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.95), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 1, + ), ), - SizedBox(width: 10), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - music.name, - style: TextStyle(fontSize: 16), - textAlign: TextAlign.left, - ), - Text( - music.singers.isEmpty - ? 'Unknown singers' - : music.singers.map((s) => s.name).join(','), - style: TextStyle(fontSize: 12), + // 封面 + if (cover != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: AspectRatio( + aspectRatio: 1, + child: Image.network( + cover, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), + ), + ) + else + _buildDefaultCover(context), + + const SizedBox(width: 8), + + // 歌曲信息 + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + music.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Colors.black87, + height: 1.2, + decoration: TextDecoration.none, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + music.singers.isEmpty + ? 'Unknown singers' + : music.singers.map((s) => s.name).join(', '), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.normal, + color: Colors.black54, + height: 1.2, + decoration: TextDecoration.none, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), + + const SizedBox(width: 4), + + // 操作按钮 + actions.Actions(), ], ), ), - actions.Actions(), - ], + ), + ), + ); + } + + /// 构建默认封面 + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.music_note, + color: Theme.of(context).primaryColor, + size: 20, ), ); } diff --git a/apps/flutter/lib/theme.dart b/apps/flutter/lib/theme.dart new file mode 100644 index 00000000..976f5433 --- /dev/null +++ b/apps/flutter/lib/theme.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +final appTheme = ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + textTheme: const TextTheme( + // 最大的标题 (例如页面 AppBar 标题) + headlineLarge: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + ), + headlineMedium: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + ), + headlineSmall: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + ), + + // 内容标题 (例如列表项标题) + titleLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0, + ), + titleMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + titleSmall: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + + // 正文文本 + bodyLarge: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + ), + bodyMedium: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + letterSpacing: 0.2, + ), + bodySmall: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + letterSpacing: 0.4, + ), + + // 标签 (例如按钮文字) + labelLarge: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + labelMedium: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + labelSmall: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ), + // 调整 AppBar 标题样式 + appBarTheme: const AppBarTheme( + centerTitle: true, + titleTextStyle: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: Colors.black87, + letterSpacing: -0.5, + ), + ), + // 调整 ListTile 的默认样式 + listTileTheme: const ListTileThemeData( + dense: true, // 让列表更紧凑 + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 0), + minLeadingWidth: 24, + ), +); diff --git a/apps/flutter/lib/widgets/player_bottom_spacer.dart b/apps/flutter/lib/widgets/player_bottom_spacer.dart new file mode 100644 index 00000000..938c0e31 --- /dev/null +++ b/apps/flutter/lib/widgets/player_bottom_spacer.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../states/playqueue.dart'; + +/// 播放器底部留白组件 +/// 当有音乐播放显示播放器时,提供相应高度的留白,防止内容被遮挡 +class PlayerBottomSpacer extends StatelessWidget { + const PlayerBottomSpacer({super.key}); + + @override + Widget build(BuildContext context) { + final currentMusic = context.watch().currentMusic; + if (currentMusic == null) return const SizedBox.shrink(); + + // 播放器高度 54 + margin bottom 8 + extra spacing 20 + return const SizedBox(height: 82); + } +} From b1d3ad59022a1d6624fb0c91ac3efbf31eef13aa Mon Sep 17 00:00:00 2001 From: mebtte Date: Thu, 22 Jan 2026 23:38:39 +0800 Subject: [PATCH 11/50] improve styles --- apps/flutter/lib/pages/home/index.dart | 18 +- .../lib/pages/home/musicbill_card.dart | 91 ++++++--- .../lib/pages/home/musicbill_list.dart | 10 +- .../lib/pages/home/musicbill_list_header.dart | 43 ++-- .../lib/pages/home/user_info_card.dart | 183 +++++++++++++----- apps/flutter/lib/pages/profile/index.dart | 4 +- .../player_controller/player_controller.dart | 21 +- 7 files changed, 261 insertions(+), 109 deletions(-) diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index db8ae558..09b714a3 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -15,16 +15,14 @@ class Home extends StatelessWidget { final currentServer = context.watch().currentServer; return Scaffold( - body: SafeArea( - child: Column( - children: [ - UserInfoCard(user: currentUser, server: currentServer), - MusicbillList( - musicbillList: musicbillState.musicbillList, - isLoading: musicbillState.loading, - ), - ], - ), + body: Column( + children: [ + UserInfoCard(user: currentUser, server: currentServer), + MusicbillList( + musicbillList: musicbillState.musicbillList, + isLoading: musicbillState.loading, + ), + ], ), ); } diff --git a/apps/flutter/lib/pages/home/musicbill_card.dart b/apps/flutter/lib/pages/home/musicbill_card.dart index 4dcfc5b2..cb66307b 100644 --- a/apps/flutter/lib/pages/home/musicbill_card.dart +++ b/apps/flutter/lib/pages/home/musicbill_card.dart @@ -10,22 +10,65 @@ class MusicbillCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: 12), - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: _buildCover(context), - title: Text( - musicbill.name, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.pushNamed( - context, - "/musicbill", - arguments: {"id": musicbill.id}, + return Container( + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => Navigator.pushNamed( + context, + "/musicbill", + arguments: {"id": musicbill.id}, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + _buildCover(context), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + musicbill.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (musicbill.status == MusicbillStatus.SUCCESSFUL) ...[ + const SizedBox(height: 2), + Text( + '${musicbill.musicList.length} tracks', + style: const TextStyle( + fontSize: 10, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + Icon(Icons.chevron_right, color: Colors.grey[400], size: 16), + ], + ), + ), ), ), ); @@ -38,8 +81,8 @@ class MusicbillCard extends StatelessWidget { borderRadius: BorderRadius.circular(8), child: Image.network( musicbill.cover!, - width: 56, - height: 56, + width: 44, + height: 44, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return _buildDefaultCover(context); @@ -54,13 +97,17 @@ class MusicbillCard extends StatelessWidget { /// 构建默认封面 Widget _buildDefaultCover(BuildContext context) { return Container( - width: 56, - height: 56, + width: 44, + height: 44, decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withOpacity(0.1), + color: Theme.of(context).primaryColor.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), ), - child: Icon(Icons.library_music, color: Theme.of(context).primaryColor), + child: Icon( + Icons.library_music_rounded, + color: Theme.of(context).primaryColor.withValues(alpha: 0.8), + size: 22, + ), ); } } diff --git a/apps/flutter/lib/pages/home/musicbill_list.dart b/apps/flutter/lib/pages/home/musicbill_list.dart index 05d6bd13..9008cb1b 100644 --- a/apps/flutter/lib/pages/home/musicbill_list.dart +++ b/apps/flutter/lib/pages/home/musicbill_list.dart @@ -27,17 +27,21 @@ class MusicbillList extends StatelessWidget { else if (musicbillList.isEmpty) _buildEmptyState(context) else - _buildList(), + _buildList(context), ], ), ); } /// 构建列表 - Widget _buildList() { + Widget _buildList(BuildContext context) { return Expanded( child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.only( + left: 16, + right: 16, + bottom: MediaQuery.of(context).padding.bottom, + ), itemCount: musicbillList.length + 1, itemBuilder: (context, index) { if (index == musicbillList.length) { diff --git a/apps/flutter/lib/pages/home/musicbill_list_header.dart b/apps/flutter/lib/pages/home/musicbill_list_header.dart index cf97e4fe..352d4a9e 100644 --- a/apps/flutter/lib/pages/home/musicbill_list_header.dart +++ b/apps/flutter/lib/pages/home/musicbill_list_header.dart @@ -11,14 +11,17 @@ class MusicbillListHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( 'My Musicbills', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 18, + color: Colors.black87, + ), ), const SizedBox(width: 8), _buildCountBadge(context), @@ -32,17 +35,17 @@ class MusicbillListHeader extends StatelessWidget { /// 构建数量徽章 Widget _buildCountBadge(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + color: Theme.of(context).primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), ), child: Text( '$count', style: TextStyle( color: Theme.of(context).primaryColor, - fontWeight: FontWeight.bold, - fontSize: 14, + fontWeight: FontWeight.w600, + fontSize: 12, ), ), ); @@ -50,10 +53,24 @@ class MusicbillListHeader extends StatelessWidget { /// 构建添加按钮 Widget _buildAddButton(BuildContext context) { - return IconButton( - icon: const Icon(Icons.add), - onPressed: () => _showCreateDialog(context), - tooltip: 'Create musicbill', + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showCreateDialog(context), + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + Icons.add, + color: Theme.of(context).primaryColor, + size: 20, + ), + ), + ), ); } diff --git a/apps/flutter/lib/pages/home/user_info_card.dart b/apps/flutter/lib/pages/home/user_info_card.dart index f2d1140d..26334d58 100644 --- a/apps/flutter/lib/pages/home/user_info_card.dart +++ b/apps/flutter/lib/pages/home/user_info_card.dart @@ -15,59 +15,121 @@ class UserInfoCard extends StatelessWidget { return const SizedBox.shrink(); } - return Container( - margin: const EdgeInsets.all(16), - child: Card( - elevation: 4, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: InkWell( - onTap: () => Navigator.pushNamed(context, '/profile'), - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - _buildAvatar(context), - const SizedBox(width: 20), - _buildUserDetails(context), - const Icon(Icons.chevron_right, color: Colors.grey), - ], + return AspectRatio( + aspectRatio: 1.6, + child: Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), ), - ), + ], + ), + clipBehavior: Clip.hardEdge, + child: Stack( + children: [ + // 背景头像(从右侧逐渐显现) + Positioned.fill(child: _buildBackgroundAvatar(context)), + // 渐变遮罩(从上到下,顶部透明到底部不透明) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.0), + Colors.black.withValues(alpha: 0.3), + Colors.black.withValues(alpha: 0.6), + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + ), + // 内容层 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Padding( + padding: const EdgeInsets.all(20), + child: _buildUserDetails(context), + ), + ), + // 交互层 (水波纹) + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.pushNamed(context, '/profile'), + ), + ), + ), + ], ), ), ); } - /// 构建用户头像 - Widget _buildAvatar(BuildContext context) { - return CircleAvatar( - radius: 40, - backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), - backgroundImage: user!.avatar != null && user!.avatar!.isNotEmpty - ? NetworkImage(user!.avatar!) - : null, - child: user!.avatar == null || user!.avatar!.isEmpty - ? Icon(Icons.person, size: 40, color: Theme.of(context).primaryColor) - : null, + /// 构建背景头像 + Widget _buildBackgroundAvatar(BuildContext context) { + if (user!.avatar != null && user!.avatar!.isNotEmpty) { + return Image.network( + user!.avatar!, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultBackground(context); + }, + ); + } + return _buildDefaultBackground(context); + } + + /// 构建默认背景 + Widget _buildDefaultBackground(BuildContext context) { + return Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).primaryColor.withValues(alpha: 0.3), + Theme.of(context).primaryColor.withValues(alpha: 0.1), + ], + ), + ), + child: Icon( + Icons.person_rounded, + size: 60, + color: Theme.of(context).primaryColor.withValues(alpha: 0.3), + ), ); } /// 构建用户详细信息 Widget _buildUserDetails(BuildContext context) { - return Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildNickname(context), - const SizedBox(height: 4), - _buildUsername(context), - if (server != null) ...[ - const SizedBox(height: 8), - _buildServerInfo(context), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildNickname(context), + const SizedBox(height: 2), + _buildUsername(context), + if (server != null) ...[ + const SizedBox(height: 6), + _buildServerInfo(context), ], - ), + ], ); } @@ -75,9 +137,14 @@ class UserInfoCard extends StatelessWidget { Widget _buildNickname(BuildContext context) { return Text( user!.nickname, - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.white, + shadows: [ + Shadow(color: Colors.black54, blurRadius: 12, offset: Offset(0, 2)), + ], + ), overflow: TextOverflow.ellipsis, ); } @@ -86,9 +153,13 @@ class UserInfoCard extends StatelessWidget { Widget _buildUsername(BuildContext context) { return Text( '@${user!.username}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]), + style: const TextStyle( + fontSize: 14, + color: Colors.white, + shadows: [ + Shadow(color: Colors.black54, blurRadius: 10, offset: Offset(0, 1)), + ], + ), overflow: TextOverflow.ellipsis, ); } @@ -97,14 +168,22 @@ class UserInfoCard extends StatelessWidget { Widget _buildServerInfo(BuildContext context) { return Row( children: [ - Icon(Icons.cloud, size: 16, color: Colors.grey[500]), - const SizedBox(width: 4), + const Icon(Icons.cloud_outlined, size: 14, color: Colors.white), + const SizedBox(width: 6), Expanded( child: Text( server!.hostname, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Colors.grey[500]), + style: const TextStyle( + fontSize: 12, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black45, + blurRadius: 8, + offset: Offset(0, 1), + ), + ], + ), overflow: TextOverflow.ellipsis, ), ), diff --git a/apps/flutter/lib/pages/profile/index.dart b/apps/flutter/lib/pages/profile/index.dart index 3612771f..0b1011a1 100644 --- a/apps/flutter/lib/pages/profile/index.dart +++ b/apps/flutter/lib/pages/profile/index.dart @@ -43,7 +43,9 @@ class ProfilePage extends StatelessWidget { children: [ CircleAvatar( radius: 50, - backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), + backgroundColor: Theme.of( + context, + ).primaryColor.withValues(alpha: 0.1), backgroundImage: user.avatar != null && user.avatar!.isNotEmpty ? NetworkImage(user.avatar!) : null, diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index aee51e50..11029d9c 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -14,12 +14,17 @@ class PlayController extends StatelessWidget { final cover = music.cover; return Container( - margin: const EdgeInsets.fromLTRB(8, 0, 8, 8), + margin: EdgeInsets.fromLTRB( + 6, + 6, + 6, + 6 + MediaQuery.of(context).padding.bottom, + ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.15), + color: Colors.black.withValues(alpha: 0.15), blurRadius: 16, offset: Offset(0, -4), spreadRadius: 0, @@ -32,12 +37,12 @@ class PlayController extends StatelessWidget { filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( height: 54, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.95), + color: Colors.white.withValues(alpha: 0.95), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), width: 1, ), ), @@ -115,10 +120,10 @@ class PlayController extends StatelessWidget { /// 构建默认封面 Widget _buildDefaultCover(BuildContext context) { return Container( - width: 46, - height: 46, + width: 42, + height: 42, decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withOpacity(0.1), + color: Theme.of(context).primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), ), child: Icon( From 601e446a809f75129b0827a606354b7d5ffa8824 Mon Sep 17 00:00:00 2001 From: mebtte Date: Fri, 23 Jan 2026 22:47:36 +0800 Subject: [PATCH 12/50] improve musicbill page --- apps/flutter/lib/app.dart | 9 +- apps/flutter/lib/pages/musicbill/actions.dart | 56 +++-- .../lib/pages/musicbill/bottom_toolbar.dart | 110 +++++++++ apps/flutter/lib/pages/musicbill/index.dart | 60 +++-- .../pages/musicbill/music_list_content.dart | 37 +++ .../lib/pages/musicbill/music_list_item.dart | 152 ++++++++++++ .../pages/musicbill/music_option_menu.dart | 217 ++++++++++++++++++ .../lib/pages/musicbill/musicbill_header.dart | 157 +++++++++++++ .../lib/pages/musicbill/status_views.dart | 82 +++++++ apps/flutter/lib/player_controller/index.dart | 26 ++- .../player_controller/player_controller.dart | 19 +- apps/flutter/lib/states/route.dart | 18 ++ .../lib/widgets/player_bottom_spacer.dart | 13 +- apps/flutter/lib/widgets/safe_tooltip.dart | 149 ++++++++++++ 14 files changed, 1052 insertions(+), 53 deletions(-) create mode 100644 apps/flutter/lib/pages/musicbill/bottom_toolbar.dart create mode 100644 apps/flutter/lib/pages/musicbill/music_list_content.dart create mode 100644 apps/flutter/lib/pages/musicbill/music_list_item.dart create mode 100644 apps/flutter/lib/pages/musicbill/music_option_menu.dart create mode 100644 apps/flutter/lib/pages/musicbill/musicbill_header.dart create mode 100644 apps/flutter/lib/pages/musicbill/status_views.dart create mode 100644 apps/flutter/lib/states/route.dart create mode 100644 apps/flutter/lib/widgets/safe_tooltip.dart diff --git a/apps/flutter/lib/app.dart b/apps/flutter/lib/app.dart index 1792997a..874cf9ef 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -6,6 +6,7 @@ import 'package:get_it/get_it.dart'; import 'package:provider/provider.dart'; import './states/playlist.dart'; import './states/playqueue.dart'; +import './states/route.dart'; import './pages/home/index.dart'; import './server_management/index.dart'; import './states/musicbill.dart' as musicbill_state; @@ -44,6 +45,7 @@ class _AppContentState extends State { ChangeNotifierProvider.value(value: playlistState), ChangeNotifierProvider.value(value: playqueueState), ChangeNotifierProvider.value(value: audioState), + ChangeNotifierProvider.value(value: routeState), ], child: MaterialApp( theme: appTheme, @@ -75,12 +77,7 @@ class _AppContentState extends State { } }, ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: PlayerControllerContainer(), - ), + PlayerControllerContainer(), ], ), ), diff --git a/apps/flutter/lib/pages/musicbill/actions.dart b/apps/flutter/lib/pages/musicbill/actions.dart index 293d12bf..59e93fb3 100644 --- a/apps/flutter/lib/pages/musicbill/actions.dart +++ b/apps/flutter/lib/pages/musicbill/actions.dart @@ -8,21 +8,51 @@ class Actions extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: EdgeInsetsGeometry.all(10), + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: Row( children: [ - IconButton( - onPressed: musicbill.musicList.isNotEmpty - ? () { - eventBus.fire( - AddMusicListToPlaylistEvent( - musicList: musicbill.musicList, - ), - ); - } - : null, - icon: Icon(Icons.playlist_add), + Material( + color: Colors.transparent, + child: InkWell( + onTap: musicbill.musicList.isNotEmpty + ? () { + eventBus.fire( + AddMusicListToPlaylistEvent( + musicList: musicbill.musicList, + ), + ); + } + : null, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: musicbill.musicList.isNotEmpty + ? Theme.of(context).primaryColor.withValues(alpha: 0.08) + : Colors.grey.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + Icons.playlist_add, + color: musicbill.musicList.isNotEmpty + ? Theme.of(context).primaryColor + : Colors.grey, + size: 20, + ), + ), + ), + ), + const SizedBox(width: 12), + Text( + 'Add all to playlist', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: musicbill.musicList.isNotEmpty + ? Colors.black87 + : Colors.grey, + ), ), ], ), diff --git a/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart b/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart new file mode 100644 index 00000000..288bfa60 --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import '../../states/musicbill.dart'; +import '../../widgets/safe_tooltip.dart'; +import '../../event_bus.dart'; + +/// 音乐清单底部工具栏组件 +/// 提供返回按钮和添加全部到播放列表功能 +class BottomToolbar extends StatelessWidget { + final Musicbill musicbill; + + const BottomToolbar({super.key, required this.musicbill}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 16, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + _buildBackButton(context), + const Spacer(), + _buildAddAllButton(context), + ], + ), + ), + ), + ); + } + + /// 构建返回按钮 + Widget _buildBackButton(BuildContext context) { + return SafeTooltip( + message: 'Back', + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.pop(context), + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon(Icons.arrow_back, color: Colors.black87, size: 20), + ), + ), + ), + ); + } + + /// 构建添加全部按钮 + Widget _buildAddAllButton(BuildContext context) { + final isEnabled = musicbill.musicList.isNotEmpty; + + return SafeTooltip( + message: isEnabled ? 'Add all to playlist' : 'No music to add', + alignment: TooltipAlignment.right, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: isEnabled + ? () { + eventBus.fire( + AddMusicListToPlaylistEvent(musicList: musicbill.musicList), + ); + // 显示提示 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${musicbill.musicList.length} tracks added to playlist', + ), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + ), + ); + } + : null, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isEnabled + ? Theme.of(context).primaryColor + : Colors.grey.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon( + Icons.playlist_add, + color: isEnabled ? Colors.white : Colors.grey, + size: 20, + ), + ), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/pages/musicbill/index.dart b/apps/flutter/lib/pages/musicbill/index.dart index fe3094a0..9edd6514 100644 --- a/apps/flutter/lib/pages/musicbill/index.dart +++ b/apps/flutter/lib/pages/musicbill/index.dart @@ -1,13 +1,19 @@ +import 'dart:async'; import 'package:uuid/uuid.dart'; import 'package:flutter/material.dart'; import '../../utils/get_musicbill_by_id.dart'; import '../../states/musicbill.dart' as musicbill_state; -import '../../event_bus.dart'; -import './actions.dart' as actions; -import '../../widgets/player_bottom_spacer.dart'; +import '../../states/route.dart'; +import './bottom_toolbar.dart'; +import './musicbill_header.dart'; +import './status_views.dart'; +import './music_list_content.dart'; const uuid = Uuid(); +// 底部工具栏的高度:padding (8*2) + button height (36) = 52 +const double _bottomToolbarHeight = 52.0; + class Musicbill extends StatefulWidget { final String id; @@ -21,6 +27,12 @@ class _MusicbillState extends State { @override void initState() { super.initState(); + + // 延迟设置路由,避免在 build 期间调用 setState + WidgetsBinding.instance.addPostFrameCallback((_) { + routeState.setRoute('/musicbill'); + }); + final musicbill = musicbill_state.musicbillState.musicbillList.firstWhere( (m) => m.id == widget.id, ); @@ -36,36 +48,40 @@ class _MusicbillState extends State { } } + @override + void dispose() { + // 延迟重置路由,避免在 build 期间调用 setState + scheduleMicrotask(() { + routeState.setRoute('/'); + }); + super.dispose(); + } + @override Widget build(BuildContext context) { final musicbill = useMusicbillById(context, widget.id); final empty = musicbill.musicList.isEmpty; + return Scaffold( - appBar: AppBar(title: Text(musicbill.name)), body: Column( children: [ - actions.Actions(musicbill: musicbill), - if (empty) - Expanded(child: Center(child: Text("No Music"))) + MusicbillHeader(musicbill: musicbill), + if (musicbill.status == musicbill_state.MusicbillStatus.LOADING && + empty) + const Expanded(child: MusicbillLoadingView()) + else if (musicbill.status == musicbill_state.MusicbillStatus.FAILED && + empty) + Expanded(child: MusicbillErrorView(musicbillId: widget.id)) + else if (empty) + const Expanded(child: MusicbillEmptyView()) else Expanded( - child: ListView.builder( - itemCount: musicbill.musicList.length + 1, - itemBuilder: (context, index) { - if (index == musicbill.musicList.length) { - return const PlayerBottomSpacer(); - } - final music = musicbill.musicList[index]; - return ListTile( - leading: const Icon(Icons.music_note_outlined), - title: Text(music.name), - onTap: () { - eventBus.fire(PlayMusicEvent(music: music)); - }, - ); - }, + child: MusicListContent( + musicbill: musicbill, + bottomToolbarHeight: _bottomToolbarHeight, ), ), + BottomToolbar(musicbill: musicbill), ], ), ); diff --git a/apps/flutter/lib/pages/musicbill/music_list_content.dart b/apps/flutter/lib/pages/musicbill/music_list_content.dart new file mode 100644 index 00000000..a9cf2723 --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/music_list_content.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import '../../states/musicbill.dart'; +import '../../event_bus.dart'; +import './music_list_item.dart'; +import '../../widgets/player_bottom_spacer.dart'; + +class MusicListContent extends StatelessWidget { + final Musicbill musicbill; + final double bottomToolbarHeight; + + const MusicListContent({ + super.key, + required this.musicbill, + required this.bottomToolbarHeight, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8), + itemCount: musicbill.musicList.length + 1, + itemBuilder: (context, index) { + if (index == musicbill.musicList.length) { + // 为底部工具栏和播放器控制器留出空间 + return PlayerBottomSpacer(extraHeight: bottomToolbarHeight); + } + final music = musicbill.musicList[index]; + return MusicListItem( + music: music, + onTap: () { + eventBus.fire(PlayMusicEvent(music: music)); + }, + ); + }, + ); + } +} diff --git a/apps/flutter/lib/pages/musicbill/music_list_item.dart b/apps/flutter/lib/pages/musicbill/music_list_item.dart new file mode 100644 index 00000000..e9baa24b --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/music_list_item.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import '../../models/music.dart'; +import 'music_option_menu.dart'; + +/// 音乐列表项组件 +/// 显示音乐封面、名称和歌手信息 +class MusicListItem extends StatelessWidget { + final Music music; + final VoidCallback onTap; + + const MusicListItem({super.key, required this.music, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + onLongPress: () => _showMusicOptions(context), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + _buildCover(context), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + music.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + _buildArtists(context), + ], + ), + ), + IconButton( + icon: Icon( + Icons.more_horiz, + color: Colors.grey[400], + size: 20, + ), + onPressed: () => _showMusicOptions(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// 显示音乐选项菜单 + void _showMusicOptions(BuildContext context) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + + entry = OverlayEntry( + builder: (context) => MusicOptionMenu( + music: music, + onPlay: onTap, + onClose: () { + entry.remove(); + }, + ), + ); + + overlay.insert(entry); + } + + /// 构建封面 + Widget _buildCover(BuildContext context) { + if (music.cover != null && music.cover!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + music.cover!, + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), + ); + } + + return _buildDefaultCover(context); + } + + /// 构建默认封面 + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.music_note_rounded, + color: Theme.of(context).primaryColor.withValues(alpha: 0.8), + size: 22, + ), + ); + } + + /// 构建歌手信息 + Widget _buildArtists(BuildContext context) { + if (music.singers.isEmpty) { + return const Text( + 'Unknown Artist', + style: TextStyle(fontSize: 10, color: Colors.black54), + ); + } + + final artistNames = music.singers.map((singer) => singer.name).join(', '); + return Text( + artistNames, + style: const TextStyle(fontSize: 10, color: Colors.black54), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/apps/flutter/lib/pages/musicbill/music_option_menu.dart b/apps/flutter/lib/pages/musicbill/music_option_menu.dart new file mode 100644 index 00000000..eecd7052 --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import '../../models/music.dart'; +import '../../event_bus.dart'; + +/// 自定义音乐选项菜单(Overlay 实现,覆盖 PlayerController) +class MusicOptionMenu extends StatefulWidget { + final Music music; + final VoidCallback onPlay; + final VoidCallback onClose; + + const MusicOptionMenu({ + super.key, + required this.music, + required this.onPlay, + required this.onClose, + }); + + @override + State createState() => _MusicOptionMenuState(); +} + +class _MusicOptionMenuState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _slideAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + ); + _slideAnimation = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _fadeAnimation = Tween(begin: 0, end: 0.5).animate(_controller); + + _controller.forward(); + } + + Future _close() async { + await _controller.reverse(); + widget.onClose(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // 背景遮罩 + GestureDetector( + onTap: _close, + child: AnimatedBuilder( + animation: _fadeAnimation, + builder: (context, child) { + return Container( + color: Colors.black.withValues(alpha: _fadeAnimation.value), + ); + }, + ), + ), + // 底部菜单 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SlideTransition( + position: _slideAnimation, + child: Material( + color: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + _buildCover(context), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.music.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + _buildArtists(context), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.play_arrow_rounded), + title: const Text('Play'), + onTap: () { + _close().then((_) => widget.onPlay()); + }, + ), + ListTile( + leading: const Icon(Icons.playlist_add_rounded), + title: const Text('Add to queue'), + onTap: () { + _close(); + eventBus.fire( + AddMusicListToPlaylistEvent( + musicList: [widget.music], + ), + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Added to queue'), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 2), + ), + ); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ], + ); + } + + // 复用 MusicListItem 中的构建逻辑 + Widget _buildCover(BuildContext context) { + if (widget.music.cover != null && widget.music.cover!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + widget.music.cover!, + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), + ); + } + return _buildDefaultCover(context); + } + + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.music_note_rounded, + color: Theme.of(context).primaryColor.withValues(alpha: 0.8), + size: 22, + ), + ); + } + + Widget _buildArtists(BuildContext context) { + if (widget.music.singers.isEmpty) { + return const Text( + 'Unknown Artist', + style: TextStyle(fontSize: 10, color: Colors.black54), + ); + } + final artistNames = widget.music.singers + .map((singer) => singer.name) + .join(', '); + return Text( + artistNames, + style: const TextStyle(fontSize: 10, color: Colors.black54), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/apps/flutter/lib/pages/musicbill/musicbill_header.dart b/apps/flutter/lib/pages/musicbill/musicbill_header.dart new file mode 100644 index 00000000..4fd1b13f --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/musicbill_header.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import '../../states/musicbill.dart'; + +/// 音乐清单详情页头部组件 +/// 显示封面、名称和音乐数量 +class MusicbillHeader extends StatelessWidget { + final Musicbill musicbill; + + const MusicbillHeader({super.key, required this.musicbill}); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1.6, + child: Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.hardEdge, + child: Stack( + children: [ + // 背景封面 + Positioned.fill(child: _buildBackgroundCover(context)), + // 渐变遮罩 + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.0), + Colors.black.withValues(alpha: 0.3), + Colors.black.withValues(alpha: 0.6), + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + ), + // 内容层 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Padding( + padding: const EdgeInsets.all(20), + child: _buildMusicbillDetails(context), + ), + ), + ], + ), + ), + ); + } + + /// 构建背景封面 + Widget _buildBackgroundCover(BuildContext context) { + if (musicbill.cover != null && musicbill.cover!.isNotEmpty) { + return Image.network( + musicbill.cover!, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultBackground(context); + }, + ); + } + return _buildDefaultBackground(context); + } + + /// 构建默认背景 + Widget _buildDefaultBackground(BuildContext context) { + return Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).primaryColor.withValues(alpha: 0.3), + Theme.of(context).primaryColor.withValues(alpha: 0.1), + ], + ), + ), + child: Icon( + Icons.library_music_rounded, + size: 60, + color: Theme.of(context).primaryColor.withValues(alpha: 0.3), + ), + ); + } + + /// 构建音乐清单详细信息 + Widget _buildMusicbillDetails(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildName(context), + const SizedBox(height: 6), + _buildMusicCount(context), + ], + ); + } + + /// 构建名称 + Widget _buildName(BuildContext context) { + return Text( + musicbill.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.white, + shadows: [ + Shadow(color: Colors.black54, blurRadius: 12, offset: Offset(0, 2)), + ], + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ); + } + + /// 构建音乐数量 + Widget _buildMusicCount(BuildContext context) { + return Row( + children: [ + const Icon(Icons.music_note_outlined, size: 14, color: Colors.white), + const SizedBox(width: 6), + Text( + '${musicbill.musicList.length} tracks', + style: const TextStyle( + fontSize: 12, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black45, + blurRadius: 8, + offset: Offset(0, 1), + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/pages/musicbill/status_views.dart b/apps/flutter/lib/pages/musicbill/status_views.dart new file mode 100644 index 00000000..cedae9e0 --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/status_views.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import '../../states/musicbill.dart' as musicbill_state; + +/// 加载中视图 +class MusicbillLoadingView extends StatelessWidget { + const MusicbillLoadingView({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColor, + ), + ), + ); + } +} + +/// 错误视图 +class MusicbillErrorView extends StatelessWidget { + final String musicbillId; + + const MusicbillErrorView({super.key, required this.musicbillId}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 60, color: Colors.red[300]), + const SizedBox(height: 16), + Text( + 'Failed to load music', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Colors.grey[700]), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () { + musicbill_state.musicbillState.reloadMusicbill( + id: musicbillId, + silence: false, + ); + }, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Retry'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + ); + } +} + +/// 空数据视图 +class MusicbillEmptyView extends StatelessWidget { + const MusicbillEmptyView({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.music_note_outlined, size: 80, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'No music yet', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Colors.grey[600]), + ), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/player_controller/index.dart b/apps/flutter/lib/player_controller/index.dart index 6264800b..cc0ea394 100644 --- a/apps/flutter/lib/player_controller/index.dart +++ b/apps/flutter/lib/player_controller/index.dart @@ -1,16 +1,36 @@ import 'package:cicada/player_controller/player_controller.dart'; import 'package:cicada/states/playqueue.dart'; +import 'package:cicada/states/route.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +// musicbill 页面底部工具栏的高度 +const double _musicbillBottomToolbarHeight = 52.0; + class PlayerControllerContainer extends StatelessWidget { const PlayerControllerContainer({super.key}); @override Widget build(BuildContext context) { final currentMusic = context.watch().currentMusic; - return currentMusic == null - ? Container() - : PlayController(playqueueMusic: currentMusic); + final currentRoute = context.watch().currentRoute; + + if (currentMusic == null) { + return Container(); + } + + // 根据路由计算底部偏移量 + final bottomOffset = currentRoute == '/musicbill' + ? _musicbillBottomToolbarHeight + : 0.0; + + return AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + left: 0, + right: 0, + bottom: bottomOffset, + child: PlayController(playqueueMusic: currentMusic), + ); } } diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index 11029d9c..fdc26367 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -4,6 +4,13 @@ import 'package:flutter/material.dart'; import './actions.dart' as actions; class PlayController extends StatelessWidget { + static const double kContentHeight = 54.0; + static const double kMargin = 6.0; + + /// 播放器总高度(包含上下边距,但不包含 SafeArea) + /// 实际高度通常还需要加上 MediaQuery.of(context).padding.bottom + static const double kTotalHeight = kContentHeight + kMargin * 2; + final PlayqueueMusic playqueueMusic; const PlayController({super.key, required this.playqueueMusic}); @@ -15,10 +22,10 @@ class PlayController extends StatelessWidget { return Container( margin: EdgeInsets.fromLTRB( - 6, - 6, - 6, - 6 + MediaQuery.of(context).padding.bottom, + kMargin, + kMargin, + kMargin, + kMargin + MediaQuery.of(context).padding.bottom, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), @@ -36,8 +43,8 @@ class PlayController extends StatelessWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( - height: 54, - padding: const EdgeInsets.all(6), + height: kContentHeight, + padding: const EdgeInsets.all(kMargin), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.95), borderRadius: BorderRadius.circular(12), diff --git a/apps/flutter/lib/states/route.dart b/apps/flutter/lib/states/route.dart new file mode 100644 index 00000000..cd368f11 --- /dev/null +++ b/apps/flutter/lib/states/route.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// 路由状态管理 +/// 用于跟踪当前路由,以便 Player Controller 可以根据路由调整位置 +class RouteState extends ChangeNotifier { + String _currentRoute = '/'; + + String get currentRoute => _currentRoute; + + void setRoute(String route) { + if (_currentRoute != route) { + _currentRoute = route; + notifyListeners(); + } + } +} + +final routeState = RouteState(); diff --git a/apps/flutter/lib/widgets/player_bottom_spacer.dart b/apps/flutter/lib/widgets/player_bottom_spacer.dart index 938c0e31..4efd1ba2 100644 --- a/apps/flutter/lib/widgets/player_bottom_spacer.dart +++ b/apps/flutter/lib/widgets/player_bottom_spacer.dart @@ -5,14 +5,21 @@ import '../states/playqueue.dart'; /// 播放器底部留白组件 /// 当有音乐播放显示播放器时,提供相应高度的留白,防止内容被遮挡 class PlayerBottomSpacer extends StatelessWidget { - const PlayerBottomSpacer({super.key}); + /// 额外的高度,用于页面有自己的底部工具栏时 + final double extraHeight; + + const PlayerBottomSpacer({super.key, this.extraHeight = 0}); @override Widget build(BuildContext context) { final currentMusic = context.watch().currentMusic; - if (currentMusic == null) return const SizedBox.shrink(); // 播放器高度 54 + margin bottom 8 + extra spacing 20 - return const SizedBox(height: 82); + final playerHeight = currentMusic == null ? 0.0 : 82.0; + final totalHeight = playerHeight + extraHeight; + + if (totalHeight == 0) return const SizedBox.shrink(); + + return SizedBox(height: totalHeight); } } diff --git a/apps/flutter/lib/widgets/safe_tooltip.dart b/apps/flutter/lib/widgets/safe_tooltip.dart new file mode 100644 index 00000000..ec5a782f --- /dev/null +++ b/apps/flutter/lib/widgets/safe_tooltip.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; + +enum TooltipAlignment { center, left, right } + +/// 安全的 Tooltip 组件 +/// 使用 Overlay 确保显示在最顶层,避免被 PlayerController 遮挡 +/// 并支持鼠标悬停和长按触发 +class SafeTooltip extends StatefulWidget { + final String message; + final Widget child; + final TooltipAlignment alignment; + + const SafeTooltip({ + super.key, + required this.message, + required this.child, + this.alignment = TooltipAlignment.center, + }); + + @override + State createState() => _SafeTooltipState(); +} + +class _SafeTooltipState extends State { + OverlayEntry? _overlayEntry; + bool _isVisible = false; + + void _showTooltip() { + if (_isVisible) return; + _isVisible = true; + + final overlay = Overlay.of(context, rootOverlay: true); + final renderBox = context.findRenderObject() as RenderBox; + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + final screenSize = MediaQuery.of(context).size; + + // 计算水平位置 + double? left; + double? right; + + switch (widget.alignment) { + case TooltipAlignment.left: + left = position.dx; + if (left < 16) left = 16; + break; + case TooltipAlignment.right: + // 右对齐:计算距离屏幕右侧的距离 + right = screenSize.width - (position.dx + size.width); + if (right < 16) right = 16; + break; + case TooltipAlignment.center: + // 居中对齐仍然需要估算宽度 + // Tooltip 的估计宽度(根据文本长度动态计算) + const tooltipPadding = 12.0 * 2; + const charWidth = 7.0; + final tooltipWidth = + (widget.message.length * charWidth + tooltipPadding).clamp( + 60.0, + 200.0, + ); + + left = position.dx + size.width / 2 - tooltipWidth / 2; + + // 边界检测 + if (left < 16) left = 16; + if (left + tooltipWidth > screenSize.width - 16) { + left = screenSize.width - tooltipWidth - 16; + } + break; + } + + _overlayEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + Positioned( + left: left, + right: right, + bottom: screenSize.height - position.dy + 10, + child: IgnorePointer( + child: Material( + color: Colors.transparent, + elevation: 1000, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: widget.alignment == TooltipAlignment.right + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Container( + constraints: BoxConstraints(minWidth: 60, maxWidth: 200), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + widget.message, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + + overlay.insert(_overlayEntry!); + + // 1.5秒后自动隐藏 + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted && _isVisible) { + _hideTooltip(); + } + }); + } + + void _hideTooltip() { + if (!_isVisible) return; + _isVisible = false; + _overlayEntry?.remove(); + _overlayEntry = null; + } + + @override + void dispose() { + _hideTooltip(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => _showTooltip(), + onExit: (_) => _hideTooltip(), + child: GestureDetector(onLongPress: _showTooltip, child: widget.child), + ); + } +} From b542edf9a9a4ae79c79385f1c861ff0c876b5931 Mon Sep 17 00:00:00 2001 From: mebtte Date: Fri, 23 Jan 2026 23:18:19 +0800 Subject: [PATCH 13/50] improve homepage --- apps/flutter/lib/pages/home/index.dart | 30 +++- .../lib/pages/home/musicbill_list.dart | 87 +++++------ .../lib/pages/home/user_info_card.dart | 85 +++++------ apps/flutter/lib/pages/musicbill/index.dart | 140 ++++++++++++++++-- .../pages/musicbill/music_list_content.dart | 31 ++-- .../lib/pages/musicbill/musicbill_header.dart | 63 +++++--- .../player_controller/player_controller.dart | 13 +- 7 files changed, 281 insertions(+), 168 deletions(-) diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index 09b714a3..daa4f83a 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -1,6 +1,7 @@ import '../../states/musicbill.dart'; import '../../states/server.dart'; import './user_info_card.dart'; +import './musicbill_list_header.dart'; import './musicbill_list.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -14,10 +15,33 @@ class Home extends StatelessWidget { final currentUser = context.watch().currentUser; final currentServer = context.watch().currentServer; + final headerHeight = MediaQuery.of(context).size.width; + return Scaffold( - body: Column( - children: [ - UserInfoCard(user: currentUser, server: currentServer), + body: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: false, + stretch: true, + expandedHeight: headerHeight, + toolbarHeight: 0, + collapsedHeight: 0, + backgroundColor: Colors.transparent, + automaticallyImplyLeading: false, + flexibleSpace: FlexibleSpaceBar( + background: UserInfoCard( + user: currentUser, + server: currentServer, + ), + stretchModes: const [StretchMode.zoomBackground], + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + SliverToBoxAdapter( + child: MusicbillListHeader( + count: musicbillState.musicbillList.length, + ), + ), MusicbillList( musicbillList: musicbillState.musicbillList, isLoading: musicbillState.loading, diff --git a/apps/flutter/lib/pages/home/musicbill_list.dart b/apps/flutter/lib/pages/home/musicbill_list.dart index 9008cb1b..7dbcdd12 100644 --- a/apps/flutter/lib/pages/home/musicbill_list.dart +++ b/apps/flutter/lib/pages/home/musicbill_list.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../states/musicbill.dart'; import './musicbill_card.dart'; -import './musicbill_list_header.dart'; + import '../../widgets/player_bottom_spacer.dart'; /// 音乐清单列表组件 @@ -18,68 +18,49 @@ class MusicbillList extends StatelessWidget { @override Widget build(BuildContext context) { - return Expanded( - child: Column( - children: [ - MusicbillListHeader(count: musicbillList.length), - if (isLoading) - _buildLoadingState() - else if (musicbillList.isEmpty) - _buildEmptyState(context) - else - _buildList(context), - ], - ), - ); - } + if (isLoading) { + return const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ); + } - /// 构建列表 - Widget _buildList(BuildContext context) { - return Expanded( - child: ListView.builder( - padding: EdgeInsets.only( - left: 16, - right: 16, - bottom: MediaQuery.of(context).padding.bottom, - ), - itemCount: musicbillList.length + 1, - itemBuilder: (context, index) { + if (musicbillList.isEmpty) { + return SliverFillRemaining(child: _buildEmptyStateContent(context)); + } + + return SliverPadding( + padding: EdgeInsets.only( + left: 16, + right: 16, + bottom: MediaQuery.of(context).padding.bottom, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { if (index == musicbillList.length) { return const PlayerBottomSpacer(); } final musicbill = musicbillList[index]; return MusicbillCard(musicbill: musicbill); - }, + }, childCount: musicbillList.length + 1), ), ); } - /// 构建加载状态 - Widget _buildLoadingState() { - return const Expanded(child: Center(child: CircularProgressIndicator())); - } - - /// 构建空状态 - Widget _buildEmptyState(BuildContext context) { - return Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.library_music_outlined, - size: 80, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - 'No musicbills yet', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Colors.grey[600]), - ), - ], - ), + /// 构建空状态内容 + Widget _buildEmptyStateContent(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.library_music_outlined, size: 80, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'No musicbills yet', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Colors.grey[600]), + ), + ], ), ); } diff --git a/apps/flutter/lib/pages/home/user_info_card.dart b/apps/flutter/lib/pages/home/user_info_card.dart index 26334d58..915f5399 100644 --- a/apps/flutter/lib/pages/home/user_info_card.dart +++ b/apps/flutter/lib/pages/home/user_info_card.dart @@ -16,12 +16,10 @@ class UserInfoCard extends StatelessWidget { } return AspectRatio( - aspectRatio: 1.6, + aspectRatio: 1.0, child: Container( - margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.white, - boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), @@ -35,7 +33,26 @@ class UserInfoCard extends StatelessWidget { children: [ // 背景头像(从右侧逐渐显现) Positioned.fill(child: _buildBackgroundAvatar(context)), - // 渐变遮罩(从上到下,顶部透明到底部不透明) + // 顶部渐变遮罩 + Positioned( + top: 0, + left: 0, + right: 0, + height: 120, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.5), + Colors.transparent, + ], + ), + ), + ), + ), + // 底部渐变遮罩(从上到下,融入背景) Positioned.fill( child: Container( decoration: BoxDecoration( @@ -43,11 +60,12 @@ class UserInfoCard extends StatelessWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black.withValues(alpha: 0.0), - Colors.black.withValues(alpha: 0.3), - Colors.black.withValues(alpha: 0.6), + Theme.of( + context, + ).scaffoldBackgroundColor.withValues(alpha: 0.0), + Theme.of(context).scaffoldBackgroundColor, ], - stops: const [0.0, 0.5, 1.0], + stops: const [0.5, 1.0], ), ), ), @@ -125,10 +143,6 @@ class UserInfoCard extends StatelessWidget { _buildNickname(context), const SizedBox(height: 2), _buildUsername(context), - if (server != null) ...[ - const SizedBox(height: 6), - _buildServerInfo(context), - ], ], ); } @@ -138,12 +152,10 @@ class UserInfoCard extends StatelessWidget { return Text( user!.nickname, style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: Colors.white, - shadows: [ - Shadow(color: Colors.black54, blurRadius: 12, offset: Offset(0, 2)), - ], + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.black87, + height: 1.2, ), overflow: TextOverflow.ellipsis, ); @@ -152,42 +164,13 @@ class UserInfoCard extends StatelessWidget { /// 构建用户名 Widget _buildUsername(BuildContext context) { return Text( - '@${user!.username}', - style: const TextStyle( + '${user!.username}@${server!.hostname}', + style: TextStyle( fontSize: 14, - color: Colors.white, - shadows: [ - Shadow(color: Colors.black54, blurRadius: 10, offset: Offset(0, 1)), - ], + color: Colors.black.withValues(alpha: 0.6), + fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ); } - - /// 构建服务器信息 - Widget _buildServerInfo(BuildContext context) { - return Row( - children: [ - const Icon(Icons.cloud_outlined, size: 14, color: Colors.white), - const SizedBox(width: 6), - Expanded( - child: Text( - server!.hostname, - style: const TextStyle( - fontSize: 12, - color: Colors.white, - shadows: [ - Shadow( - color: Colors.black45, - blurRadius: 8, - offset: Offset(0, 1), - ), - ], - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } } diff --git a/apps/flutter/lib/pages/musicbill/index.dart b/apps/flutter/lib/pages/musicbill/index.dart index 9edd6514..1317d6b5 100644 --- a/apps/flutter/lib/pages/musicbill/index.dart +++ b/apps/flutter/lib/pages/musicbill/index.dart @@ -24,9 +24,14 @@ class Musicbill extends StatefulWidget { } class _MusicbillState extends State { + late ScrollController _scrollController; + bool _showTitle = false; + @override void initState() { super.initState(); + _scrollController = ScrollController(); + _scrollController.addListener(_onScroll); // 延迟设置路由,避免在 build 期间调用 setState WidgetsBinding.instance.addPostFrameCallback((_) { @@ -48,8 +53,27 @@ class _MusicbillState extends State { } } + void _onScroll() { + if (!mounted) return; + // Header 宽高比为 1.6 + final headerHeight = MediaQuery.of(context).size.width / 1.6; + // 当滚动超过 Header 高度的一半时显示标题,或者完全滚出时 + // 这里设定为 Header 底部接近 Toolbar 底部时 + final triggerOffset = + headerHeight - kToolbarHeight - MediaQuery.of(context).padding.top; + + if (_scrollController.hasClients) { + if (_scrollController.offset > triggerOffset) { + if (!_showTitle) setState(() => _showTitle = true); + } else { + if (_showTitle) setState(() => _showTitle = false); + } + } + } + @override void dispose() { + _scrollController.dispose(); // 延迟重置路由,避免在 build 期间调用 setState scheduleMicrotask(() { routeState.setRoute('/'); @@ -62,26 +86,110 @@ class _MusicbillState extends State { final musicbill = useMusicbillById(context, widget.id); final empty = musicbill.musicList.isEmpty; + final headerHeight = MediaQuery.of(context).size.width; + return Scaffold( - body: Column( + body: Stack( children: [ - MusicbillHeader(musicbill: musicbill), - if (musicbill.status == musicbill_state.MusicbillStatus.LOADING && - empty) - const Expanded(child: MusicbillLoadingView()) - else if (musicbill.status == musicbill_state.MusicbillStatus.FAILED && - empty) - Expanded(child: MusicbillErrorView(musicbillId: widget.id)) - else if (empty) - const Expanded(child: MusicbillEmptyView()) - else - Expanded( - child: MusicListContent( - musicbill: musicbill, - bottomToolbarHeight: _bottomToolbarHeight, + CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar( + expandedHeight: headerHeight, + toolbarHeight: 0, + collapsedHeight: 0, + pinned: false, + stretch: true, + backgroundColor: Colors.transparent, + automaticallyImplyLeading: false, + flexibleSpace: FlexibleSpaceBar( + background: MusicbillHeader(musicbill: musicbill), + stretchModes: const [StretchMode.zoomBackground], + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + if (musicbill.status == musicbill_state.MusicbillStatus.LOADING && + empty) + const SliverFillRemaining(child: MusicbillLoadingView()) + else if (musicbill.status == + musicbill_state.MusicbillStatus.FAILED && + empty) + SliverFillRemaining( + child: MusicbillErrorView(musicbillId: widget.id), + ) + else if (empty) + const SliverFillRemaining(child: MusicbillEmptyView()) + else + MusicListContent( + musicbill: musicbill, + bottomToolbarHeight: _bottomToolbarHeight, + ), + ], + ), + + // 顶部 AppBar (仅标题) + Positioned( + top: 0, + left: 0, + right: 0, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + color: _showTitle ? Colors.white : Colors.transparent, + child: SafeArea( + bottom: false, + child: SizedBox( + height: kToolbarHeight, + child: Center( + child: AnimatedOpacity( + opacity: _showTitle ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (musicbill.cover != null && + musicbill.cover!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + musicbill.cover!, + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const SizedBox(), + ), + ), + ), + Flexible( + child: Text( + musicbill.name, + style: const TextStyle( + color: Colors.black, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), ), ), - BottomToolbar(musicbill: musicbill), + ), + + // 底部工具栏 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: BottomToolbar(musicbill: musicbill), + ), ], ), ); diff --git a/apps/flutter/lib/pages/musicbill/music_list_content.dart b/apps/flutter/lib/pages/musicbill/music_list_content.dart index a9cf2723..09636415 100644 --- a/apps/flutter/lib/pages/musicbill/music_list_content.dart +++ b/apps/flutter/lib/pages/musicbill/music_list_content.dart @@ -16,22 +16,23 @@ class MusicListContent extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( + return SliverPadding( padding: const EdgeInsets.only(left: 16, right: 16, top: 8), - itemCount: musicbill.musicList.length + 1, - itemBuilder: (context, index) { - if (index == musicbill.musicList.length) { - // 为底部工具栏和播放器控制器留出空间 - return PlayerBottomSpacer(extraHeight: bottomToolbarHeight); - } - final music = musicbill.musicList[index]; - return MusicListItem( - music: music, - onTap: () { - eventBus.fire(PlayMusicEvent(music: music)); - }, - ); - }, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == musicbill.musicList.length) { + // 为底部工具栏和播放器控制器留出空间 + return PlayerBottomSpacer(extraHeight: bottomToolbarHeight); + } + final music = musicbill.musicList[index]; + return MusicListItem( + music: music, + onTap: () { + eventBus.fire(PlayMusicEvent(music: music)); + }, + ); + }, childCount: musicbill.musicList.length + 1), + ), ); } } diff --git a/apps/flutter/lib/pages/musicbill/musicbill_header.dart b/apps/flutter/lib/pages/musicbill/musicbill_header.dart index 4fd1b13f..7477937d 100644 --- a/apps/flutter/lib/pages/musicbill/musicbill_header.dart +++ b/apps/flutter/lib/pages/musicbill/musicbill_header.dart @@ -11,9 +11,8 @@ class MusicbillHeader extends StatelessWidget { @override Widget build(BuildContext context) { return AspectRatio( - aspectRatio: 1.6, + aspectRatio: 1.0, child: Container( - margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.white, boxShadow: [ @@ -29,7 +28,26 @@ class MusicbillHeader extends StatelessWidget { children: [ // 背景封面 Positioned.fill(child: _buildBackgroundCover(context)), - // 渐变遮罩 + // 顶部渐变遮罩 + Positioned( + top: 0, + left: 0, + right: 0, + height: 120, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.5), + Colors.transparent, + ], + ), + ), + ), + ), + // 底部渐变遮罩 (融入背景) Positioned.fill( child: Container( decoration: BoxDecoration( @@ -37,11 +55,12 @@ class MusicbillHeader extends StatelessWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black.withValues(alpha: 0.0), - Colors.black.withValues(alpha: 0.3), - Colors.black.withValues(alpha: 0.6), + Theme.of( + context, + ).scaffoldBackgroundColor.withValues(alpha: 0.0), + Theme.of(context).scaffoldBackgroundColor, ], - stops: const [0.0, 0.5, 1.0], + stops: const [0.5, 1.0], ), ), ), @@ -119,12 +138,10 @@ class MusicbillHeader extends StatelessWidget { return Text( musicbill.name, style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: Colors.white, - shadows: [ - Shadow(color: Colors.black54, blurRadius: 12, offset: Offset(0, 2)), - ], + fontSize: 24, // Slightly larger for better impact + fontWeight: FontWeight.w800, + color: Colors.black87, // Changed to dark + height: 1.2, ), overflow: TextOverflow.ellipsis, maxLines: 2, @@ -135,20 +152,18 @@ class MusicbillHeader extends StatelessWidget { Widget _buildMusicCount(BuildContext context) { return Row( children: [ - const Icon(Icons.music_note_outlined, size: 14, color: Colors.white), + Icon( + Icons.music_note_rounded, + size: 16, + color: Theme.of(context).primaryColor, + ), const SizedBox(width: 6), Text( '${musicbill.musicList.length} tracks', - style: const TextStyle( - fontSize: 12, - color: Colors.white, - shadows: [ - Shadow( - color: Colors.black45, - blurRadius: 8, - offset: Offset(0, 1), - ), - ], + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.black.withValues(alpha: 0.6), // Darker grey ), ), ], diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index fdc26367..8416c5c0 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -20,6 +20,8 @@ class PlayController extends StatelessWidget { final music = playqueueMusic.music; final cover = music.cover; + const borderRadius = kContentHeight / 2; + return Container( margin: EdgeInsets.fromLTRB( kMargin, @@ -28,7 +30,7 @@ class PlayController extends StatelessWidget { kMargin + MediaQuery.of(context).padding.bottom, ), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(borderRadius), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.15), @@ -39,7 +41,7 @@ class PlayController extends StatelessWidget { ], ), child: ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( @@ -47,7 +49,7 @@ class PlayController extends StatelessWidget { padding: const EdgeInsets.all(kMargin), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.95), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(borderRadius), border: Border.all( color: Colors.white.withValues(alpha: 0.2), width: 1, @@ -57,8 +59,7 @@ class PlayController extends StatelessWidget { children: [ // 封面 if (cover != null) - ClipRRect( - borderRadius: BorderRadius.circular(6), + ClipOval( child: AspectRatio( aspectRatio: 1, child: Image.network( @@ -131,7 +132,7 @@ class PlayController extends StatelessWidget { height: 42, decoration: BoxDecoration( color: Theme.of(context).primaryColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), + shape: BoxShape.circle, ), child: Icon( Icons.music_note, From f0d82fc3e72ed3fad64d37e86fd84cf3e8ddf205 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 26 Jan 2026 22:39:15 +0800 Subject: [PATCH 14/50] rotate cover while playing --- apps/flutter/lib/player_controller/cover.dart | 87 +++++++++++++++++++ apps/flutter/lib/player_controller/info.dart | 45 ++++++++++ .../player_controller/player_controller.dart | 73 +--------------- 3 files changed, 136 insertions(+), 69 deletions(-) create mode 100644 apps/flutter/lib/player_controller/cover.dart create mode 100644 apps/flutter/lib/player_controller/info.dart diff --git a/apps/flutter/lib/player_controller/cover.dart b/apps/flutter/lib/player_controller/cover.dart new file mode 100644 index 00000000..1de82c61 --- /dev/null +++ b/apps/flutter/lib/player_controller/cover.dart @@ -0,0 +1,87 @@ +import 'package:cicada/states/audio.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class RotatingCover extends StatefulWidget { + final String? coverUrl; + + const RotatingCover({super.key, this.coverUrl}); + + @override + State createState() => _RotatingCoverState(); +} + +class _RotatingCoverState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 20), + vsync: this, + ); + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _controller.forward(from: 0); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final playing = context.select((value) => value.playing); + + if (playing) { + if (!_controller.isAnimating) { + _controller.forward(from: _controller.value); + } + } else { + if (_controller.isAnimating) { + _controller.stop(); + } + } + + return RotationTransition( + turns: _controller, + child: widget.coverUrl != null + ? ClipOval( + child: AspectRatio( + aspectRatio: 1, + child: Image.network( + widget.coverUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), + ), + ) + : _buildDefaultCover(context), + ); + } + + /// 构建默认封面 + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.music_note, + color: Theme.of(context).primaryColor, + size: 20, + ), + ); + } +} diff --git a/apps/flutter/lib/player_controller/info.dart b/apps/flutter/lib/player_controller/info.dart new file mode 100644 index 00000000..bce38901 --- /dev/null +++ b/apps/flutter/lib/player_controller/info.dart @@ -0,0 +1,45 @@ +import 'package:cicada/models/music.dart'; +import 'package:flutter/material.dart'; + +class MusicInfo extends StatelessWidget { + final Music music; + + const MusicInfo({super.key, required this.music}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + music.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Colors.black87, + height: 1.2, + decoration: TextDecoration.none, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + music.singers.isEmpty + ? 'Unknown singers' + : music.singers.map((s) => s.name).join(', '), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.normal, + color: Colors.black54, + height: 1.2, + decoration: TextDecoration.none, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index 8416c5c0..b29a4298 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -2,6 +2,8 @@ import 'dart:ui'; import 'package:cicada/states/playqueue.dart'; import 'package:flutter/material.dart'; import './actions.dart' as actions; +import './cover.dart'; +import './info.dart'; class PlayController extends StatelessWidget { static const double kContentHeight = 54.0; @@ -18,8 +20,6 @@ class PlayController extends StatelessWidget { @override Widget build(BuildContext context) { final music = playqueueMusic.music; - final cover = music.cover; - const borderRadius = kContentHeight / 2; return Container( @@ -58,60 +58,12 @@ class PlayController extends StatelessWidget { child: Row( children: [ // 封面 - if (cover != null) - ClipOval( - child: AspectRatio( - aspectRatio: 1, - child: Image.network( - cover, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(context); - }, - ), - ), - ) - else - _buildDefaultCover(context), + RotatingCover(coverUrl: music.cover), const SizedBox(width: 8), // 歌曲信息 - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - music.name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - color: Colors.black87, - height: 1.2, - decoration: TextDecoration.none, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - music.singers.isEmpty - ? 'Unknown singers' - : music.singers.map((s) => s.name).join(', '), - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.normal, - color: Colors.black54, - height: 1.2, - decoration: TextDecoration.none, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), + Expanded(child: MusicInfo(music: music)), const SizedBox(width: 4), @@ -124,21 +76,4 @@ class PlayController extends StatelessWidget { ), ); } - - /// 构建默认封面 - Widget _buildDefaultCover(BuildContext context) { - return Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - Icons.music_note, - color: Theme.of(context).primaryColor, - size: 20, - ), - ); - } } From e278e7fb0ff2788de0f4cb21c3a258f09d890968 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 26 Jan 2026 22:53:04 +0800 Subject: [PATCH 15/50] search page --- apps/flutter/lib/pages/home/index.dart | 38 +++++ apps/flutter/lib/pages/search/index.dart | 157 ++++++++++++++++++ apps/flutter/lib/player_controller/index.dart | 11 +- 3 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 apps/flutter/lib/pages/search/index.dart diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index daa4f83a..6d0373e1 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -1,5 +1,6 @@ import '../../states/musicbill.dart'; import '../../states/server.dart'; +import '../search/index.dart'; import './user_info_card.dart'; import './musicbill_list_header.dart'; import './musicbill_list.dart'; @@ -36,6 +37,43 @@ class Home extends StatelessWidget { stretchModes: const [StretchMode.zoomBackground], ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const SearchPage()), + ); + }, + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + const Icon(Icons.search, size: 20, color: Colors.black38), + const SizedBox(width: 12), + const Text( + 'Search music, singer, ...', + style: TextStyle(color: Colors.black38, fontSize: 14), + ), + ], + ), + ), + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 16)), SliverToBoxAdapter( child: MusicbillListHeader( diff --git a/apps/flutter/lib/pages/search/index.dart b/apps/flutter/lib/pages/search/index.dart new file mode 100644 index 00000000..ed6e76c3 --- /dev/null +++ b/apps/flutter/lib/pages/search/index.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../states/route.dart'; +import '../../player_controller/player_controller.dart'; + +class SearchPage extends StatefulWidget { + const SearchPage({super.key}); + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State + with SingleTickerProviderStateMixin { + late final TextEditingController _textController; + late final TabController _tabController; + + final List _tabs = ['Music', 'Singer', 'Lyric', 'Playlist']; + static const double _bottomToolbarHeight = 60.0; + + @override + void initState() { + super.initState(); + _textController = TextEditingController(); + _tabController = TabController(length: _tabs.length, vsync: this); + + WidgetsBinding.instance.addPostFrameCallback((_) { + routeState.setRoute('/search'); + }); + } + + @override + void dispose() { + _textController.dispose(); + _tabController.dispose(); + scheduleMicrotask(() { + routeState.setRoute('/'); + }); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 底部工具栏 + 播放器高度 + 安全区域 + final bottomPadding = + _bottomToolbarHeight + + PlayController.kTotalHeight + + MediaQuery.of(context).padding.bottom; + + return Scaffold( + body: Stack( + children: [ + Column( + children: [ + // Top TabBar + SafeArea( + child: TabBar( + controller: _tabController, + labelColor: Theme.of(context).primaryColor, + unselectedLabelColor: Colors.black54, + indicatorColor: Theme.of(context).primaryColor, + labelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + tabs: _tabs.map((t) => Tab(text: t)).toList(), + ), + ), + // Content + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: bottomPadding), + child: TabBarView( + controller: _tabController, + children: _tabs.map((t) { + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: 20, + itemBuilder: (context, index) { + return ListTile(title: Text('Result $index for $t')); + }, + ); + }).toList(), + ), + ), + ), + ], + ), + + // Bottom Toolbar (Back + Search) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: + _bottomToolbarHeight + MediaQuery.of(context).padding.bottom, + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + 8 + MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -1), + blurRadius: 10, + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 16), + Expanded( + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(20), + ), + child: TextField( + controller: _textController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search...', + border: InputBorder.none, + hintStyle: TextStyle(color: Colors.black38), + contentPadding: EdgeInsets.only(bottom: 10), + isDense: false, + ), + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + ), + textInputAction: TextInputAction.search, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/player_controller/index.dart b/apps/flutter/lib/player_controller/index.dart index cc0ea394..825496c0 100644 --- a/apps/flutter/lib/player_controller/index.dart +++ b/apps/flutter/lib/player_controller/index.dart @@ -6,6 +6,8 @@ import 'package:provider/provider.dart'; // musicbill 页面底部工具栏的高度 const double _musicbillBottomToolbarHeight = 52.0; +// search 页面底部工具栏的高度 +const double _searchBottomToolbarHeight = 60.0; class PlayerControllerContainer extends StatelessWidget { const PlayerControllerContainer({super.key}); @@ -20,9 +22,12 @@ class PlayerControllerContainer extends StatelessWidget { } // 根据路由计算底部偏移量 - final bottomOffset = currentRoute == '/musicbill' - ? _musicbillBottomToolbarHeight - : 0.0; + double bottomOffset = 0.0; + if (currentRoute == '/musicbill') { + bottomOffset = _musicbillBottomToolbarHeight; + } else if (currentRoute == '/search') { + bottomOffset = _searchBottomToolbarHeight; + } return AnimatedPositioned( duration: const Duration(milliseconds: 300), From 54fb50e83100c44f97330c7d45a0c9da91faada6 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 26 Jan 2026 22:58:09 +0800 Subject: [PATCH 16/50] music search --- apps/flutter/lib/pages/search/index.dart | 23 ++-- apps/flutter/lib/pages/search/music_tab.dart | 111 ++++++++++++++++++ apps/flutter/lib/server/api/search_music.dart | 34 ++++++ 3 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 apps/flutter/lib/pages/search/music_tab.dart create mode 100644 apps/flutter/lib/server/api/search_music.dart diff --git a/apps/flutter/lib/pages/search/index.dart b/apps/flutter/lib/pages/search/index.dart index ed6e76c3..2f4cfc28 100644 --- a/apps/flutter/lib/pages/search/index.dart +++ b/apps/flutter/lib/pages/search/index.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../states/route.dart'; import '../../player_controller/player_controller.dart'; +import './music_tab.dart'; class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -18,6 +19,8 @@ class _SearchPageState extends State final List _tabs = ['Music', 'Singer', 'Lyric', 'Playlist']; static const double _bottomToolbarHeight = 60.0; + String _searchKeyword = ''; + @override void initState() { super.initState(); @@ -72,15 +75,12 @@ class _SearchPageState extends State padding: EdgeInsets.only(bottom: bottomPadding), child: TabBarView( controller: _tabController, - children: _tabs.map((t) { - return ListView.builder( - padding: EdgeInsets.zero, - itemCount: 20, - itemBuilder: (context, index) { - return ListTile(title: Text('Result $index for $t')); - }, - ); - }).toList(), + children: [ + MusicTab(keyword: _searchKeyword), + Center(child: Text('Search Singer: $_searchKeyword')), + Center(child: Text('Search Lyric: $_searchKeyword')), + Center(child: Text('Search Playlist: $_searchKeyword')), + ], ), ), ), @@ -143,6 +143,11 @@ class _SearchPageState extends State fontSize: 16, ), textInputAction: TextInputAction.search, + onSubmitted: (value) { + setState(() { + _searchKeyword = value; + }); + }, ), ), ), diff --git a/apps/flutter/lib/pages/search/music_tab.dart b/apps/flutter/lib/pages/search/music_tab.dart new file mode 100644 index 00000000..361a286c --- /dev/null +++ b/apps/flutter/lib/pages/search/music_tab.dart @@ -0,0 +1,111 @@ +import 'package:cicada/event_bus.dart'; +import 'package:cicada/models/music.dart'; +import 'package:cicada/server/api/search_music.dart'; +import 'package:flutter/material.dart'; +import '../musicbill/music_list_item.dart'; + +class MusicTab extends StatefulWidget { + final String keyword; + + const MusicTab({super.key, required this.keyword}); + + @override + State createState() => _MusicTabState(); +} + +class _MusicTabState extends State { + bool _loading = false; + List _musicList = []; + String? _error; + + @override + void initState() { + super.initState(); + _search(); + } + + @override + void didUpdateWidget(covariant MusicTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.keyword != widget.keyword) { + _search(); + } + } + + Future _search() async { + if (widget.keyword.isEmpty) { + if (mounted) { + setState(() { + _musicList = []; + _loading = false; + _error = null; + }); + } + return; + } + + if (mounted) { + setState(() { + _loading = true; + _error = null; + }); + } + + try { + final response = await searchMusic(keyword: widget.keyword); + if (mounted) { + setState(() { + _musicList = response.musicList; + _loading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center(child: Text('Error: $_error')); + } + if (_musicList.isEmpty) { + if (widget.keyword.isEmpty) { + return const Center( + child: Text( + 'Type to search...', + style: TextStyle(color: Colors.black54), + ), + ); + } + return const Center( + child: Text( + 'No results found', + style: TextStyle(color: Colors.black54), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + itemCount: _musicList.length, + itemBuilder: (context, index) { + final music = _musicList[index]; + return MusicListItem( + music: music, + onTap: () { + eventBus.fire(PlayMusicEvent(music: music)); + }, + ); + }, + ); + } +} diff --git a/apps/flutter/lib/server/api/search_music.dart b/apps/flutter/lib/server/api/search_music.dart new file mode 100644 index 00000000..73aa1677 --- /dev/null +++ b/apps/flutter/lib/server/api/search_music.dart @@ -0,0 +1,34 @@ +import '../../models/music.dart'; +import '../../server/request.dart'; + +class SearchMusicResponse { + final int total; + final List musicList; + + SearchMusicResponse({required this.total, required this.musicList}); + + factory SearchMusicResponse.fromJson(Map json) => + SearchMusicResponse( + total: json['total'], + musicList: (json['musicList'] as List) + .map((e) => Music.fromJson(e)) + .toList(), + ); +} + +Future searchMusic({ + required String keyword, + int page = 1, + int pageSize = 50, +}) async { + final response = await httpGet( + path: '/api/music/search', + query: { + 'keyword': keyword, + 'page': page.toString(), + 'pageSize': pageSize.toString(), + }, + withToken: true, + ); + return SearchMusicResponse.fromJson(response); +} From 6b5f1af4729cae2877238ff7ba277acc7e80580a Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 26 Jan 2026 23:04:25 +0800 Subject: [PATCH 17/50] lyric search --- apps/flutter/lib/pages/search/index.dart | 7 +- apps/flutter/lib/pages/search/lyric_tab.dart | 112 +++++++++ .../search/music_with_lyric_list_item.dart | 221 ++++++++++++++++++ apps/flutter/lib/server/api/search_lyric.dart | 127 ++++++++++ 4 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 apps/flutter/lib/pages/search/lyric_tab.dart create mode 100644 apps/flutter/lib/pages/search/music_with_lyric_list_item.dart create mode 100644 apps/flutter/lib/server/api/search_lyric.dart diff --git a/apps/flutter/lib/pages/search/index.dart b/apps/flutter/lib/pages/search/index.dart index 2f4cfc28..b1e97110 100644 --- a/apps/flutter/lib/pages/search/index.dart +++ b/apps/flutter/lib/pages/search/index.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../states/route.dart'; import '../../player_controller/player_controller.dart'; import './music_tab.dart'; +import './lyric_tab.dart'; class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -16,7 +17,7 @@ class _SearchPageState extends State late final TextEditingController _textController; late final TabController _tabController; - final List _tabs = ['Music', 'Singer', 'Lyric', 'Playlist']; + final List _tabs = ['Music', 'Singer', 'Lyric', 'Musicbill']; static const double _bottomToolbarHeight = 60.0; String _searchKeyword = ''; @@ -78,8 +79,8 @@ class _SearchPageState extends State children: [ MusicTab(keyword: _searchKeyword), Center(child: Text('Search Singer: $_searchKeyword')), - Center(child: Text('Search Lyric: $_searchKeyword')), - Center(child: Text('Search Playlist: $_searchKeyword')), + LyricTab(keyword: _searchKeyword), + Center(child: Text('Search Musicbill: $_searchKeyword')), ], ), ), diff --git a/apps/flutter/lib/pages/search/lyric_tab.dart b/apps/flutter/lib/pages/search/lyric_tab.dart new file mode 100644 index 00000000..e65a0244 --- /dev/null +++ b/apps/flutter/lib/pages/search/lyric_tab.dart @@ -0,0 +1,112 @@ +import 'package:cicada/event_bus.dart'; +import 'package:cicada/server/api/search_lyric.dart'; +import 'package:flutter/material.dart'; +import 'music_with_lyric_list_item.dart'; + +class LyricTab extends StatefulWidget { + final String keyword; + + const LyricTab({super.key, required this.keyword}); + + @override + State createState() => _LyricTabState(); +} + +class _LyricTabState extends State { + bool _loading = false; + List _results = []; + String? _error; + + @override + void initState() { + super.initState(); + _search(); + } + + @override + void didUpdateWidget(covariant LyricTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.keyword != widget.keyword) { + _search(); + } + } + + Future _search() async { + if (widget.keyword.isEmpty) { + if (mounted) { + setState(() { + _results = []; + _loading = false; + _error = null; + }); + } + return; + } + + if (mounted) { + setState(() { + _loading = true; + _error = null; + }); + } + + try { + final results = await searchMusicByLyric(keyword: widget.keyword); + if (mounted) { + setState(() { + _results = results; + _loading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center(child: Text('Error: $_error')); + } + if (_results.isEmpty) { + if (widget.keyword.isEmpty) { + return const Center( + child: Text( + 'Type to search...', + style: TextStyle(color: Colors.black54), + ), + ); + } + return const Center( + child: Text( + 'No results found', + style: TextStyle(color: Colors.black54), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + itemCount: _results.length, + itemBuilder: (context, index) { + final item = _results[index]; + return MusicWithLyricListItem( + music: item.music, + snippetLines: item.snippetLines, + keyword: widget.keyword, + onTap: () { + eventBus.fire(PlayMusicEvent(music: item.music)); + }, + ); + }, + ); + } +} diff --git a/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart new file mode 100644 index 00000000..ce6fd4bb --- /dev/null +++ b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import '../../models/music.dart'; +import '../musicbill/music_option_menu.dart'; + +class MusicWithLyricListItem extends StatelessWidget { + final Music music; + final List snippetLines; + final String keyword; + final VoidCallback onTap; + + const MusicWithLyricListItem({ + super.key, + required this.music, + required this.snippetLines, + required this.keyword, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + onLongPress: () => _showMusicOptions(context), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildCover(context), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + music.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + _buildArtists(context), + ], + ), + ), + IconButton( + icon: Icon( + Icons.more_horiz, + color: Colors.grey[400], + size: 20, + ), + onPressed: () => _showMusicOptions(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + if (snippetLines.isNotEmpty) ...[ + Divider(color: Colors.grey[200], height: 16), + ...snippetLines.map((line) => _buildLyricLine(context, line)), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildLyricLine(BuildContext context, String line) { + if (keyword.isEmpty) { + return Text( + line, + style: const TextStyle(fontSize: 12, color: Colors.black54), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + final lowerLine = line.toLowerCase(); + final lowerKeyword = keyword.toLowerCase(); + final index = lowerLine.indexOf(lowerKeyword); + + if (index == -1) { + return Text( + line, + style: const TextStyle(fontSize: 12, color: Colors.black54), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + // Simple highlighting + final spans = []; + int currentIndex = 0; + + // Find all occurrences + var searchIndex = lowerLine.indexOf(lowerKeyword); + while (searchIndex != -1) { + if (searchIndex > currentIndex) { + spans.add(TextSpan(text: line.substring(currentIndex, searchIndex))); + } + spans.add( + TextSpan( + text: line.substring(searchIndex, searchIndex + keyword.length), + style: TextStyle(color: Theme.of(context).primaryColor), + ), + ); + currentIndex = searchIndex + keyword.length; + searchIndex = lowerLine.indexOf(lowerKeyword, currentIndex); + } + + if (currentIndex < line.length) { + spans.add(TextSpan(text: line.substring(currentIndex))); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: RichText( + text: TextSpan( + style: const TextStyle(fontSize: 12, color: Colors.black54), + children: spans, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } + + void _showMusicOptions(BuildContext context) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + + entry = OverlayEntry( + builder: (context) => MusicOptionMenu( + music: music, + onPlay: onTap, + onClose: () { + entry.remove(); + }, + ), + ); + + overlay.insert(entry); + } + + Widget _buildCover(BuildContext context) { + if (music.cover != null && music.cover!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + music.cover!, + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), + ); + } + return _buildDefaultCover(context); + } + + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.music_note_rounded, + color: Theme.of(context).primaryColor.withValues(alpha: 0.8), + size: 22, + ), + ); + } + + Widget _buildArtists(BuildContext context) { + if (music.singers.isEmpty) { + return const Text( + 'Unknown Artist', + style: TextStyle(fontSize: 10, color: Colors.black54), + ); + } + final artistNames = music.singers.map((singer) => singer.name).join(', '); + return Text( + artistNames, + style: const TextStyle(fontSize: 10, color: Colors.black54), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/apps/flutter/lib/server/api/search_lyric.dart b/apps/flutter/lib/server/api/search_lyric.dart new file mode 100644 index 00000000..82ac9df5 --- /dev/null +++ b/apps/flutter/lib/server/api/search_lyric.dart @@ -0,0 +1,127 @@ +import 'package:cicada/models/music.dart'; +import '../../server/request.dart'; + +class MusicWithLyricSnippet { + final Music music; + final String snippet; + + /// The specific lines to show, if we want detailed control + final List snippetLines; + + MusicWithLyricSnippet({ + required this.music, + required this.snippet, + required this.snippetLines, + }); +} + +class _RawMusicWithLyrics { + final Music music; + final List<_RawLyric> lyrics; + + _RawMusicWithLyrics({required this.music, required this.lyrics}); + + factory _RawMusicWithLyrics.fromJson(Map json) { + return _RawMusicWithLyrics( + music: Music.fromJson(json), + lyrics: + (json['lyrics'] as List?) + ?.map((e) => _RawLyric.fromJson(e)) + .toList() ?? + [], + ); + } +} + +class _RawLyric { + final String lrc; + _RawLyric({required this.lrc}); + factory _RawLyric.fromJson(Map json) => + _RawLyric(lrc: json['lrc'] ?? ''); +} + +class _LrcLine { + final String content; + final String raw; + + _LrcLine(this.content, this.raw); +} + +Future> searchMusicByLyric({ + required String keyword, + int page = 1, + int pageSize = 20, +}) async { + final response = await httpGet( + path: '/api/music/search_by_lyric', + query: { + 'keyword': keyword, + 'page': page.toString(), + 'pageSize': pageSize.toString(), + }, + withToken: true, + ); + + final List listCallback = response['musicList'] ?? []; + final rawList = listCallback.map((e) => _RawMusicWithLyrics.fromJson(e)); + + final List results = []; + final lowerKeyword = keyword.toLowerCase(); + + for (final item in rawList) { + String snippet = ''; + List snippetLines = []; + + for (final lyric in item.lyrics) { + // Simple LRC parsing + final lines = lyric.lrc.split('\n'); + final parsedLines = <_LrcLine>[]; + + for (final line in lines) { + final match = RegExp(r'^\[.*?\](.*)$').firstMatch(line); + if (match != null) { + parsedLines.add(_LrcLine(match.group(1) ?? '', line)); + } else if (line.trim().isNotEmpty) { + // Fallback for lines without timestamp? PWA filters strictly by type text/lyric. + // If strict LRC, maybe ignore. But let's include if unsure or just skip. + // Standard LRC has timestamps. + // parsedLines.add(_LrcLine(line, line)); + } + } + + // Find match + int matchIndex = -1; + for (int i = 0; i < parsedLines.length; i++) { + if (parsedLines[i].content.toLowerCase().contains(lowerKeyword)) { + matchIndex = i; + break; + } + } + + if (matchIndex != -1) { + // Extract context: -2 to +2 + final start = (matchIndex - 2).clamp(0, parsedLines.length - 1); + final end = (matchIndex + 2).clamp(0, parsedLines.length - 1); + + final contextLines = <_LrcLine>[]; + for (int i = start; i <= end; i++) { + contextLines.add(parsedLines[i]); + } + + snippet = contextLines.map((e) => e.raw).join('\n'); + snippetLines = contextLines.map((e) => e.content).toList(); + break; // Found a match in one of the lyrics versions + } + } + + results.add( + MusicWithLyricSnippet( + music: item.music, + snippet: snippet, + snippetLines: snippetLines, + ), + ); + } + + return results; +} From 1693167a3e57767bbad65c9d7ebc59edcfac8c65 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 26 Jan 2026 23:13:00 +0800 Subject: [PATCH 18/50] improve search page --- apps/flutter/lib/pages/home/index.dart | 2 +- apps/flutter/lib/pages/search/index.dart | 9 +++++++-- apps/flutter/lib/player_controller/index.dart | 5 +++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index 6d0373e1..89e41cb8 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -65,7 +65,7 @@ class Home extends StatelessWidget { const Icon(Icons.search, size: 20, color: Colors.black38), const SizedBox(width: 12), const Text( - 'Search music, singer, ...', + 'Search', style: TextStyle(color: Colors.black38, fontSize: 14), ), ], diff --git a/apps/flutter/lib/pages/search/index.dart b/apps/flutter/lib/pages/search/index.dart index b1e97110..6efc745c 100644 --- a/apps/flutter/lib/pages/search/index.dart +++ b/apps/flutter/lib/pages/search/index.dart @@ -45,11 +45,16 @@ class _SearchPageState extends State @override Widget build(BuildContext context) { + final isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; + // 底部工具栏 + 播放器高度 + 安全区域 + // 键盘弹出时,播放器隐藏,不需要预留播放器高度和底部安全区域 final bottomPadding = _bottomToolbarHeight + - PlayController.kTotalHeight + - MediaQuery.of(context).padding.bottom; + (isKeyboardOpen + ? 0.0 + : (PlayController.kTotalHeight + + MediaQuery.of(context).padding.bottom)); return Scaffold( body: Stack( diff --git a/apps/flutter/lib/player_controller/index.dart b/apps/flutter/lib/player_controller/index.dart index 825496c0..cfe1b204 100644 --- a/apps/flutter/lib/player_controller/index.dart +++ b/apps/flutter/lib/player_controller/index.dart @@ -14,6 +14,11 @@ class PlayerControllerContainer extends StatelessWidget { @override Widget build(BuildContext context) { + // 键盘弹出时不渲染 + if (MediaQuery.of(context).viewInsets.bottom > 0) { + return const SizedBox.shrink(); + } + final currentMusic = context.watch().currentMusic; final currentRoute = context.watch().currentRoute; From 195485bac2f05bf5e1f8b0576efe5870d5c2335b Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 27 Jan 2026 21:31:38 +0800 Subject: [PATCH 19/50] upload music play record --- apps/flutter/lib/audio_handler.dart | 32 +++++++++++++++++++ apps/flutter/lib/pages/search/index.dart | 2 +- .../server/base/upload_music_play_record.dart | 15 +++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 apps/flutter/lib/server/base/upload_music_play_record.dart diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 868bd2fc..0ab446b7 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -2,13 +2,20 @@ import 'package:audio_service/audio_service.dart'; import 'package:cicada/states/audio.dart'; import 'package:just_audio/just_audio.dart'; import './states/playqueue.dart'; +import './server/base/upload_music_play_record.dart'; class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { PlayqueueMusic? lastQueueMusic; final player = AudioPlayer(); + final _playTimer = Stopwatch(); MyAudioHandler() { player.playerStateStream.listen((PlayerState state) { + if (state.playing) { + _playTimer.start(); + } else { + _playTimer.stop(); + } audioState.updatePlaying(state.playing); playbackState.add( PlaybackState( @@ -63,6 +70,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { Future seek(Duration position) => player.seek(position); Future playQueueMusic(PlayqueueMusic queueMusic) async { + _playTimer.reset(); var duration = await player.setAudioSource( AudioSource.uri(Uri.parse(queueMusic.music.asset)), ); @@ -80,11 +88,35 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { mediaItem.add(item); } + Future _uploadPlayRecord(PlayqueueMusic queueMusic) async { + final duration = player.duration; + final playedMilliseconds = _playTimer.elapsedMilliseconds; + + if (duration == null || duration.inMilliseconds == 0) return; + + final percent = (playedMilliseconds / duration.inMilliseconds).clamp( + 0.0, + 1.0, + ); + + try { + await uploadMusicPlayRecord( + musicId: queueMusic.music.id, + percent: percent, + ); + } catch (e) { + print('Failed to upload play record: $e'); + } + } + void subscribe() { playqueueState.addListener(() { final currentQueueMusic = playqueueState.currentMusic; if (currentQueueMusic != null && currentQueueMusic.pid != lastQueueMusic?.pid) { + if (lastQueueMusic != null) { + _uploadPlayRecord(lastQueueMusic!); + } lastQueueMusic = currentQueueMusic; playQueueMusic(currentQueueMusic); } diff --git a/apps/flutter/lib/pages/search/index.dart b/apps/flutter/lib/pages/search/index.dart index 6efc745c..74970758 100644 --- a/apps/flutter/lib/pages/search/index.dart +++ b/apps/flutter/lib/pages/search/index.dart @@ -111,7 +111,7 @@ class _SearchPageState extends State color: Colors.white, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), offset: const Offset(0, -1), blurRadius: 10, ), diff --git a/apps/flutter/lib/server/base/upload_music_play_record.dart b/apps/flutter/lib/server/base/upload_music_play_record.dart new file mode 100644 index 00000000..3a84c3ab --- /dev/null +++ b/apps/flutter/lib/server/base/upload_music_play_record.dart @@ -0,0 +1,15 @@ +import '../request.dart'; +import '../../states/server.dart'; + +Future uploadMusicPlayRecord({ + required String musicId, + required double percent, +}) async { + final token = serverState.currentUser?.token; + if (token == null) return; + + await httpPost( + path: '/base/music_play_record', + data: {'token': token, 'musicId': musicId, 'percent': percent}, + ); +} From 7290df45b1f6e8354c5882d0b150537ae746802f Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 27 Jan 2026 21:55:05 +0800 Subject: [PATCH 20/50] play indicator --- apps/flutter/lib/app.dart | 5 +- apps/flutter/lib/main.dart | 5 +- apps/flutter/lib/player_controller/cover.dart | 99 +++++++++++++++---- .../player_controller/player_controller.dart | 5 +- apps/flutter/lib/theme.dart | 8 +- 5 files changed, 99 insertions(+), 23 deletions(-) diff --git a/apps/flutter/lib/app.dart b/apps/flutter/lib/app.dart index 874cf9ef..0072f3b7 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -1,8 +1,9 @@ +import 'package:audio_service/audio_service.dart'; import 'package:cicada/audio_handler.dart'; import 'package:cicada/player_controller/index.dart'; import 'package:cicada/states/audio.dart'; import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; + import 'package:provider/provider.dart'; import './states/playlist.dart'; import './states/playqueue.dart'; @@ -34,7 +35,7 @@ class _AppContentState extends State { @override void reassemble() { super.reassemble(); - GetIt.instance.get().stop(); + context.read().stop(); } @override diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart index b05d9f40..29180f0c 100644 --- a/apps/flutter/lib/main.dart +++ b/apps/flutter/lib/main.dart @@ -36,7 +36,10 @@ void main() async { runApp( MultiProvider( - providers: [ChangeNotifierProvider.value(value: serverState)], + providers: [ + ChangeNotifierProvider.value(value: serverState), + Provider.value(value: audioHandler), + ], child: App(), ), ); diff --git a/apps/flutter/lib/player_controller/cover.dart b/apps/flutter/lib/player_controller/cover.dart index 1de82c61..1a0ef861 100644 --- a/apps/flutter/lib/player_controller/cover.dart +++ b/apps/flutter/lib/player_controller/cover.dart @@ -1,6 +1,7 @@ import 'package:cicada/states/audio.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:audio_service/audio_service.dart'; class RotatingCover extends StatefulWidget { final String? coverUrl; @@ -49,30 +50,92 @@ class _RotatingCoverState extends State } } - return RotationTransition( - turns: _controller, - child: widget.coverUrl != null - ? ClipOval( - child: AspectRatio( - aspectRatio: 1, - child: Image.network( - widget.coverUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(context); - }, - ), - ), - ) - : _buildDefaultCover(context), + final audioHandler = context.read(); + + return StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + final mediaItem = snapshot.data; + final duration = mediaItem?.duration ?? Duration.zero; + + return StreamBuilder( + stream: audioHandler.playbackState, + builder: (context, snapshot) { + final playbackState = snapshot.data; + final processingState = + playbackState?.processingState ?? AudioProcessingState.idle; + + return StreamBuilder( + stream: Stream.periodic(const Duration(milliseconds: 200), (_) { + if (playbackState == null) return Duration.zero; + final now = DateTime.now(); + final updateTime = playbackState.updateTime; + final position = + playbackState.updatePosition + + (now.difference(updateTime)) * playbackState.speed; + return position; + }), + builder: (context, snapshot) { + final position = snapshot.data ?? Duration.zero; + double value = 0.0; + if (duration.inMilliseconds > 0) { + value = (position.inMilliseconds / duration.inMilliseconds) + .clamp(0.0, 1.0); + } + + return Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 42, + height: 42, + child: CircularProgressIndicator( + value: value, + strokeWidth: 2, + backgroundColor: Theme.of( + context, + ).primaryColor.withValues(alpha: 0.3), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColor, + ), + ), + ), + SizedBox( + width: 40, + height: 40, + child: RotationTransition( + turns: _controller, + child: widget.coverUrl != null + ? ClipOval( + child: AspectRatio( + aspectRatio: 1, + child: Image.network( + widget.coverUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), + ), + ) + : _buildDefaultCover(context), + ), + ), + ], + ); + }, + ); + }, + ); + }, ); } /// 构建默认封面 Widget _buildDefaultCover(BuildContext context) { return Container( - width: 42, - height: 42, + width: 40, + height: 40, decoration: BoxDecoration( color: Theme.of(context).primaryColor.withValues(alpha: 0.1), shape: BoxShape.circle, diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index b29a4298..a1b1fef6 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -46,7 +46,10 @@ class PlayController extends StatelessWidget { filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( height: kContentHeight, - padding: const EdgeInsets.all(kMargin), + padding: const EdgeInsets.symmetric( + horizontal: kMargin, + vertical: 2, + ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.95), borderRadius: BorderRadius.circular(borderRadius), diff --git a/apps/flutter/lib/theme.dart b/apps/flutter/lib/theme.dart index 976f5433..bfa32e25 100644 --- a/apps/flutter/lib/theme.dart +++ b/apps/flutter/lib/theme.dart @@ -2,7 +2,13 @@ import 'package:flutter/material.dart'; final appTheme = ThemeData( useMaterial3: true, - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + colorScheme: ColorScheme.fromSeed( + seedColor: const Color.fromARGB(255, 44, 182, 125), + ), + primaryColor: const Color.fromARGB(255, 44, 182, 125), + tabBarTheme: const TabBarThemeData( + indicatorColor: Color.fromARGB(255, 44, 182, 125), + ), textTheme: const TextTheme( // 最大的标题 (例如页面 AppBar 标题) headlineLarge: TextStyle( From 654a47c6ebe44bc6a029ba004ab05956b5e8518f Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 27 Jan 2026 23:00:52 +0800 Subject: [PATCH 21/50] search intermeidate --- apps/flutter/lib/app.dart | 2 +- apps/flutter/lib/models/music.dart | 10 +- apps/flutter/lib/models/singer.dart | 6 +- apps/flutter/lib/pages/home/index.dart | 6 +- apps/flutter/lib/pages/search/index.dart | 25 +- .../lib/pages/search_intermediate/index.dart | 392 ++++++++++++++++++ apps/flutter/lib/player_controller/cover.dart | 2 - apps/flutter/lib/player_controller/index.dart | 5 + .../lib/server/api/get_exploration.dart | 76 ++++ 9 files changed, 504 insertions(+), 20 deletions(-) create mode 100644 apps/flutter/lib/pages/search_intermediate/index.dart create mode 100644 apps/flutter/lib/server/api/get_exploration.dart diff --git a/apps/flutter/lib/app.dart b/apps/flutter/lib/app.dart index 0072f3b7..d1a82e0e 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -1,5 +1,5 @@ import 'package:audio_service/audio_service.dart'; -import 'package:cicada/audio_handler.dart'; + import 'package:cicada/player_controller/index.dart'; import 'package:cicada/states/audio.dart'; import 'package:flutter/material.dart'; diff --git a/apps/flutter/lib/models/music.dart b/apps/flutter/lib/models/music.dart index eb42b017..47a132bc 100644 --- a/apps/flutter/lib/models/music.dart +++ b/apps/flutter/lib/models/music.dart @@ -19,10 +19,12 @@ class Music { factory Music.fromJson(Map json) => Music( id: json['id'], name: json['name'], - asset: prefixServerOrigin(json['asset'])!, + asset: prefixServerOrigin(json['asset']) ?? '', cover: prefixServerOrigin(json['cover']), - singers: (json['singers'] as List) - .map((json) => Singer.fromJson(json)) - .toList(), + singers: + (json['singers'] as List?) + ?.map((json) => Singer.fromJson(json)) + .toList() ?? + [], ); } diff --git a/apps/flutter/lib/models/singer.dart b/apps/flutter/lib/models/singer.dart index 2d0070b9..2b58e25a 100644 --- a/apps/flutter/lib/models/singer.dart +++ b/apps/flutter/lib/models/singer.dart @@ -17,6 +17,10 @@ class Singer { id: json['id'], name: json['name'], avatar: prefixServerOrigin(json['avatar']), - aliases: List.from(json['aliases']), + aliases: + (json['aliases'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], ); } diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index 89e41cb8..21718ea8 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -1,6 +1,6 @@ import '../../states/musicbill.dart'; import '../../states/server.dart'; -import '../search/index.dart'; +import '../search_intermediate/index.dart'; import './user_info_card.dart'; import './musicbill_list_header.dart'; import './musicbill_list.dart'; @@ -43,7 +43,9 @@ class Home extends StatelessWidget { child: GestureDetector( onTap: () { Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const SearchPage()), + MaterialPageRoute( + builder: (context) => const SearchIntermediatePage(), + ), ); }, child: Container( diff --git a/apps/flutter/lib/pages/search/index.dart b/apps/flutter/lib/pages/search/index.dart index 74970758..986ea389 100644 --- a/apps/flutter/lib/pages/search/index.dart +++ b/apps/flutter/lib/pages/search/index.dart @@ -6,7 +6,9 @@ import './music_tab.dart'; import './lyric_tab.dart'; class SearchPage extends StatefulWidget { - const SearchPage({super.key}); + final String? initialKeyword; + + const SearchPage({super.key, this.initialKeyword}); @override State createState() => _SearchPageState(); @@ -25,9 +27,14 @@ class _SearchPageState extends State @override void initState() { super.initState(); - _textController = TextEditingController(); + _textController = TextEditingController(text: widget.initialKeyword ?? ''); _tabController = TabController(length: _tabs.length, vsync: this); + // 如果有初始关键词,立即执行搜索 + if (widget.initialKeyword != null && widget.initialKeyword!.isNotEmpty) { + _searchKeyword = widget.initialKeyword!; + } + WidgetsBinding.instance.addPostFrameCallback((_) { routeState.setRoute('/search'); }); @@ -38,7 +45,7 @@ class _SearchPageState extends State _textController.dispose(); _tabController.dispose(); scheduleMicrotask(() { - routeState.setRoute('/'); + // routeState.setRoute('/'); }); super.dispose(); } @@ -136,7 +143,11 @@ class _SearchPageState extends State ), child: TextField( controller: _textController, - autofocus: true, + readOnly: true, + autofocus: false, + onTap: () { + Navigator.of(context).pop(); + }, decoration: const InputDecoration( hintText: 'Search...', border: InputBorder.none, @@ -148,12 +159,6 @@ class _SearchPageState extends State color: Colors.black87, fontSize: 16, ), - textInputAction: TextInputAction.search, - onSubmitted: (value) { - setState(() { - _searchKeyword = value; - }); - }, ), ), ), diff --git a/apps/flutter/lib/pages/search_intermediate/index.dart b/apps/flutter/lib/pages/search_intermediate/index.dart new file mode 100644 index 00000000..4dbfe02d --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/index.dart @@ -0,0 +1,392 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../server/api/get_exploration.dart'; +import '../search/index.dart'; +import '../../states/route.dart'; +import '../../widgets/player_bottom_spacer.dart'; + +class SearchIntermediatePage extends StatefulWidget { + const SearchIntermediatePage({super.key}); + + @override + State createState() => _SearchIntermediatePageState(); +} + +class _SearchIntermediatePageState extends State { + bool _isLoading = true; + String? _errorMessage; + ExplorationData? _explorationData; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + + static const double _bottomToolbarHeight = 60.0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + routeState.setRoute('/search_intermediate'); + }); + _loadData(); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + scheduleMicrotask(() { + routeState.setRoute('/'); + }); + super.dispose(); + } + + Future _loadData() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final data = await getExploration(); + setState(() { + _explorationData = data; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + + void _onSearch() { + final keyword = _searchController.text.trim(); + if (keyword.isNotEmpty) { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => SearchPage(initialKeyword: keyword), + ), + ) + .then((_) { + // 从搜索页返回时,恢复路由状态并聚焦输入框 + routeState.setRoute('/search_intermediate'); + _searchFocusNode.requestFocus(); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + _buildBody(), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildBottomToolbar(), + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败: $_errorMessage'), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadData, child: const Text('重试')), + ], + ), + ); + } + + if (_explorationData == null) { + return const Center(child: Text('暂无数据')); + } + + final allItems = _getAllItems(); + + return SafeArea( + bottom: false, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => allItems[index], + childCount: allItems.length, + ), + ), + ), + SliverToBoxAdapter( + child: PlayerBottomSpacer(extraHeight: _bottomToolbarHeight), + ), + // 额外添加一些底部 padding,确保最后的内容不被贴底的工具栏遮挡 + // 因为 PlayerBottomSpacer 只是避让播放器的高度 + // 如果播放器不显示,BottomSpacer 是 0,但我们还有 BottomToolbar + // 实际上 PlayerBottomSpacer 的 extraHeight 参数就是用来处理这个的 + // 但是我们需要确保即使没播放器,也要避让 BottomToolbar + const SliverToBoxAdapter( + child: SizedBox(height: 16), // 额外的安全间距 + ), + ], + ), + ); + } + + List _getAllItems() { + final allItems = []; + final data = _explorationData!; + + for (final music in data.musicList) { + allItems.add(_buildMusicCard(music)); + } + for (final singer in data.singerList) { + allItems.add(_buildSingerCard(singer)); + } + for (final musicbill in data.publicMusicbillList) { + allItems.add(_buildMusicbillCard(musicbill)); + } + allItems.shuffle(); + return allItems; + } + + Widget _buildBottomToolbar() { + return Container( + height: _bottomToolbarHeight + MediaQuery.of(context).padding.bottom, + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 8 + MediaQuery.of(context).padding.bottom, + ), + child: Row( + children: [ + // Back Button + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.pop(context), + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.arrow_back, + color: Colors.black87, + size: 24, + ), + ), + ), + ), + const SizedBox(width: 12), + // Search Box + Expanded( + child: Container( + height: 44, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(22), + ), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + autofocus: true, + decoration: InputDecoration( + hintText: 'Search...', + border: InputBorder.none, + prefixIcon: const Icon(Icons.search, size: 20), + hintStyle: const TextStyle(color: Colors.black38), + contentPadding: const EdgeInsets.symmetric(vertical: 10), + ), + style: const TextStyle(color: Colors.black87, fontSize: 16), + textInputAction: TextInputAction.search, + onSubmitted: (_) => _onSearch(), + ), + ), + ), + ], + ), + ); + } + + // Cards implementation (kept same as before but extracted mostly) + Widget _buildMusicCard(dynamic music) { + return GestureDetector( + onTap: () { + // TODO: 打开音乐详情 + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[200], + ), + child: music.cover != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + music.cover!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.music_note, size: 48), + ); + }, + ), + ) + : const Center(child: Icon(Icons.music_note, size: 48)), + ), + ), + const SizedBox(height: 8), + Text( + music.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + Text( + music.singers.map((s) => s.name).join(', '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ); + } + + Widget _buildSingerCard(ExplorationSinger singer) { + return GestureDetector( + onTap: () { + // TODO: 打开歌手详情 + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[200], + ), + child: singer.avatar != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + singer.avatar!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.person, size: 48), + ); + }, + ), + ) + : const Center(child: Icon(Icons.person, size: 48)), + ), + ), + const SizedBox(height: 8), + Text( + singer.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + Text( + '歌手', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ); + } + + Widget _buildMusicbillCard(ExplorationPublicMusicbill musicbill) { + return GestureDetector( + onTap: () { + // TODO: 打开歌单详情 + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[200], + ), + child: musicbill.cover != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + musicbill.cover!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.queue_music, size: 48), + ); + }, + ), + ) + : const Center(child: Icon(Icons.queue_music, size: 48)), + ), + ), + const SizedBox(height: 8), + Text( + musicbill.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + Text( + '公共歌单', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/player_controller/cover.dart b/apps/flutter/lib/player_controller/cover.dart index 1a0ef861..eb2eb4f8 100644 --- a/apps/flutter/lib/player_controller/cover.dart +++ b/apps/flutter/lib/player_controller/cover.dart @@ -62,8 +62,6 @@ class _RotatingCoverState extends State stream: audioHandler.playbackState, builder: (context, snapshot) { final playbackState = snapshot.data; - final processingState = - playbackState?.processingState ?? AudioProcessingState.idle; return StreamBuilder( stream: Stream.periodic(const Duration(milliseconds: 200), (_) { diff --git a/apps/flutter/lib/player_controller/index.dart b/apps/flutter/lib/player_controller/index.dart index cfe1b204..f280a3e7 100644 --- a/apps/flutter/lib/player_controller/index.dart +++ b/apps/flutter/lib/player_controller/index.dart @@ -9,6 +9,9 @@ const double _musicbillBottomToolbarHeight = 52.0; // search 页面底部工具栏的高度 const double _searchBottomToolbarHeight = 60.0; +// 搜索中间页面底部工具栏高度 +const double _searchIntermediateBottomToolbarHeight = 60.0; + class PlayerControllerContainer extends StatelessWidget { const PlayerControllerContainer({super.key}); @@ -32,6 +35,8 @@ class PlayerControllerContainer extends StatelessWidget { bottomOffset = _musicbillBottomToolbarHeight; } else if (currentRoute == '/search') { bottomOffset = _searchBottomToolbarHeight; + } else if (currentRoute == '/search_intermediate') { + bottomOffset = _searchIntermediateBottomToolbarHeight; } return AnimatedPositioned( diff --git a/apps/flutter/lib/server/api/get_exploration.dart b/apps/flutter/lib/server/api/get_exploration.dart new file mode 100644 index 00000000..7efe978c --- /dev/null +++ b/apps/flutter/lib/server/api/get_exploration.dart @@ -0,0 +1,76 @@ +import '../request.dart'; +import '../../models/music.dart'; +import '../../utils/prefix_server_origin.dart'; + +class ExplorationSinger { + final String id; + final String name; + final String? avatar; + + ExplorationSinger({required this.id, required this.name, this.avatar}); + + factory ExplorationSinger.fromJson(Map json) { + return ExplorationSinger( + id: json['id'], + name: json['name'], + avatar: prefixServerOrigin(json['avatar']), + ); + } +} + +class ExplorationPublicMusicbill { + final String id; + final String name; + final String? cover; + + ExplorationPublicMusicbill({ + required this.id, + required this.name, + this.cover, + }); + + factory ExplorationPublicMusicbill.fromJson(Map json) { + return ExplorationPublicMusicbill( + id: json['id'], + name: json['name'], + cover: prefixServerOrigin(json['cover']), + ); + } +} + +class ExplorationData { + final List musicList; + final List singerList; + final List publicMusicbillList; + + ExplorationData({ + required this.musicList, + required this.singerList, + required this.publicMusicbillList, + }); + + factory ExplorationData.fromJson(Map json) { + return ExplorationData( + musicList: + (json['musicList'] as List?) + ?.map((m) => Music.fromJson(m)) + .toList() ?? + [], + singerList: + (json['singerList'] as List?) + ?.map((s) => ExplorationSinger.fromJson(s)) + .toList() ?? + [], + publicMusicbillList: + (json['publicMusicbillList'] as List?) + ?.map((mb) => ExplorationPublicMusicbill.fromJson(mb)) + .toList() ?? + [], + ); + } +} + +Future getExploration() async { + final data = await httpGet(path: '/api/exploration', withToken: true); + return ExplorationData.fromJson(data); +} From a4b01d9e31dbdf07ab6971d11666d9996e60e35e Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 27 Jan 2026 23:05:00 +0800 Subject: [PATCH 22/50] improve search intermediate --- .../search_intermediate/cards/music_card.dart | 58 ++++ .../cards/musicbill_card.dart | 58 ++++ .../cards/singer_card.dart | 58 ++++ .../search_intermediate/exploration_grid.dart | 65 ++++ .../lib/pages/search_intermediate/index.dart | 284 +----------------- .../search_intermediate/search_toolbar.dart | 89 ++++++ 6 files changed, 339 insertions(+), 273 deletions(-) create mode 100644 apps/flutter/lib/pages/search_intermediate/cards/music_card.dart create mode 100644 apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart create mode 100644 apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart create mode 100644 apps/flutter/lib/pages/search_intermediate/exploration_grid.dart create mode 100644 apps/flutter/lib/pages/search_intermediate/search_toolbar.dart diff --git a/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart b/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart new file mode 100644 index 00000000..c8b38404 --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import '../../../models/music.dart'; + +class MusicCard extends StatelessWidget { + final Music music; + + const MusicCard({super.key, required this.music}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + // TODO: 打开音乐详情 + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[200], + ), + child: music.cover != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + music.cover!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.music_note, size: 48), + ); + }, + ), + ) + : const Center(child: Icon(Icons.music_note, size: 48)), + ), + ), + const SizedBox(height: 8), + Text( + music.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + Text( + music.singers.map((s) => s.name).join(', '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart b/apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart new file mode 100644 index 00000000..95117e3d --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import '../../../server/api/get_exploration.dart'; + +class MusicbillCard extends StatelessWidget { + final ExplorationPublicMusicbill musicbill; + + const MusicbillCard({super.key, required this.musicbill}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + // TODO: 打开歌单详情 + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[200], + ), + child: musicbill.cover != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + musicbill.cover!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.queue_music, size: 48), + ); + }, + ), + ) + : const Center(child: Icon(Icons.queue_music, size: 48)), + ), + ), + const SizedBox(height: 8), + Text( + musicbill.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + Text( + '公共歌单', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart b/apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart new file mode 100644 index 00000000..6b12a9c4 --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import '../../../server/api/get_exploration.dart'; + +class SingerCard extends StatelessWidget { + final ExplorationSinger singer; + + const SingerCard({super.key, required this.singer}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + // TODO: 打开歌手详情 + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[200], + ), + child: singer.avatar != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + singer.avatar!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.person, size: 48), + ); + }, + ), + ) + : const Center(child: Icon(Icons.person, size: 48)), + ), + ), + const SizedBox(height: 8), + Text( + singer.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + Text( + '歌手', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/pages/search_intermediate/exploration_grid.dart b/apps/flutter/lib/pages/search_intermediate/exploration_grid.dart new file mode 100644 index 00000000..0a017fb1 --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/exploration_grid.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import '../../server/api/get_exploration.dart'; +import '../../widgets/player_bottom_spacer.dart'; +import './cards/music_card.dart'; +import './cards/singer_card.dart'; +import './cards/musicbill_card.dart'; + +class ExplorationGrid extends StatelessWidget { + final ExplorationData data; + final double bottomToolbarHeight; + + const ExplorationGrid({ + super.key, + required this.data, + required this.bottomToolbarHeight, + }); + + @override + Widget build(BuildContext context) { + final allItems = _getAllItems(); + + return SafeArea( + bottom: false, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => allItems[index], + childCount: allItems.length, + ), + ), + ), + SliverToBoxAdapter( + child: PlayerBottomSpacer(extraHeight: bottomToolbarHeight), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ), + ); + } + + List _getAllItems() { + final allItems = []; + + for (final music in data.musicList) { + allItems.add(MusicCard(music: music)); + } + for (final singer in data.singerList) { + allItems.add(SingerCard(singer: singer)); + } + for (final musicbill in data.publicMusicbillList) { + allItems.add(MusicbillCard(musicbill: musicbill)); + } + allItems.shuffle(); + return allItems; + } +} diff --git a/apps/flutter/lib/pages/search_intermediate/index.dart b/apps/flutter/lib/pages/search_intermediate/index.dart index 4dbfe02d..fae4439e 100644 --- a/apps/flutter/lib/pages/search_intermediate/index.dart +++ b/apps/flutter/lib/pages/search_intermediate/index.dart @@ -3,7 +3,8 @@ import 'package:flutter/material.dart'; import '../../server/api/get_exploration.dart'; import '../search/index.dart'; import '../../states/route.dart'; -import '../../widgets/player_bottom_spacer.dart'; +import './exploration_grid.dart'; +import './search_toolbar.dart'; class SearchIntermediatePage extends StatefulWidget { const SearchIntermediatePage({super.key}); @@ -87,7 +88,12 @@ class _SearchIntermediatePageState extends State { left: 0, right: 0, bottom: 0, - child: _buildBottomToolbar(), + child: SearchToolbar( + controller: _searchController, + focusNode: _searchFocusNode, + onSubmitted: _onSearch, + height: _bottomToolbarHeight, + ), ), ], ), @@ -116,277 +122,9 @@ class _SearchIntermediatePageState extends State { return const Center(child: Text('暂无数据')); } - final allItems = _getAllItems(); - - return SafeArea( - bottom: false, - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => allItems[index], - childCount: allItems.length, - ), - ), - ), - SliverToBoxAdapter( - child: PlayerBottomSpacer(extraHeight: _bottomToolbarHeight), - ), - // 额外添加一些底部 padding,确保最后的内容不被贴底的工具栏遮挡 - // 因为 PlayerBottomSpacer 只是避让播放器的高度 - // 如果播放器不显示,BottomSpacer 是 0,但我们还有 BottomToolbar - // 实际上 PlayerBottomSpacer 的 extraHeight 参数就是用来处理这个的 - // 但是我们需要确保即使没播放器,也要避让 BottomToolbar - const SliverToBoxAdapter( - child: SizedBox(height: 16), // 额外的安全间距 - ), - ], - ), - ); - } - - List _getAllItems() { - final allItems = []; - final data = _explorationData!; - - for (final music in data.musicList) { - allItems.add(_buildMusicCard(music)); - } - for (final singer in data.singerList) { - allItems.add(_buildSingerCard(singer)); - } - for (final musicbill in data.publicMusicbillList) { - allItems.add(_buildMusicbillCard(musicbill)); - } - allItems.shuffle(); - return allItems; - } - - Widget _buildBottomToolbar() { - return Container( - height: _bottomToolbarHeight + MediaQuery.of(context).padding.bottom, - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - offset: const Offset(0, -2), - ), - ], - ), - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 8 + MediaQuery.of(context).padding.bottom, - ), - child: Row( - children: [ - // Back Button - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.pop(context), - borderRadius: BorderRadius.circular(20), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.arrow_back, - color: Colors.black87, - size: 24, - ), - ), - ), - ), - const SizedBox(width: 12), - // Search Box - Expanded( - child: Container( - height: 44, - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(22), - ), - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - autofocus: true, - decoration: InputDecoration( - hintText: 'Search...', - border: InputBorder.none, - prefixIcon: const Icon(Icons.search, size: 20), - hintStyle: const TextStyle(color: Colors.black38), - contentPadding: const EdgeInsets.symmetric(vertical: 10), - ), - style: const TextStyle(color: Colors.black87, fontSize: 16), - textInputAction: TextInputAction.search, - onSubmitted: (_) => _onSearch(), - ), - ), - ), - ], - ), - ); - } - - // Cards implementation (kept same as before but extracted mostly) - Widget _buildMusicCard(dynamic music) { - return GestureDetector( - onTap: () { - // TODO: 打开音乐详情 - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Colors.grey[200], - ), - child: music.cover != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - music.cover!, - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon(Icons.music_note, size: 48), - ); - }, - ), - ) - : const Center(child: Icon(Icons.music_note, size: 48)), - ), - ), - const SizedBox(height: 8), - Text( - music.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - ), - Text( - music.singers.map((s) => s.name).join(', '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - ); - } - - Widget _buildSingerCard(ExplorationSinger singer) { - return GestureDetector( - onTap: () { - // TODO: 打开歌手详情 - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Colors.grey[200], - ), - child: singer.avatar != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - singer.avatar!, - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon(Icons.person, size: 48), - ); - }, - ), - ) - : const Center(child: Icon(Icons.person, size: 48)), - ), - ), - const SizedBox(height: 8), - Text( - singer.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - ), - Text( - '歌手', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - ); - } - - Widget _buildMusicbillCard(ExplorationPublicMusicbill musicbill) { - return GestureDetector( - onTap: () { - // TODO: 打开歌单详情 - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Colors.grey[200], - ), - child: musicbill.cover != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - musicbill.cover!, - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon(Icons.queue_music, size: 48), - ); - }, - ), - ) - : const Center(child: Icon(Icons.queue_music, size: 48)), - ), - ), - const SizedBox(height: 8), - Text( - musicbill.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - ), - Text( - '公共歌单', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), + return ExplorationGrid( + data: _explorationData!, + bottomToolbarHeight: _bottomToolbarHeight, ); } } diff --git a/apps/flutter/lib/pages/search_intermediate/search_toolbar.dart b/apps/flutter/lib/pages/search_intermediate/search_toolbar.dart new file mode 100644 index 00000000..90aa589b --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/search_toolbar.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +class SearchToolbar extends StatelessWidget { + final TextEditingController controller; + final FocusNode focusNode; + final VoidCallback onSubmitted; + final double height; + + const SearchToolbar({ + super.key, + required this.controller, + required this.focusNode, + required this.onSubmitted, + this.height = 60.0, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: height + MediaQuery.of(context).padding.bottom, + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 8 + MediaQuery.of(context).padding.bottom, + ), + child: Row( + children: [ + // Back Button + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.pop(context), + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.arrow_back, + color: Colors.black87, + size: 24, + ), + ), + ), + ), + const SizedBox(width: 12), + // Search Box + Expanded( + child: Container( + height: 44, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(22), + ), + child: TextField( + controller: controller, + focusNode: focusNode, + autofocus: true, + decoration: InputDecoration( + hintText: 'Search...', + border: InputBorder.none, + prefixIcon: const Icon(Icons.search, size: 20), + hintStyle: const TextStyle(color: Colors.black38), + contentPadding: const EdgeInsets.symmetric(vertical: 10), + ), + style: const TextStyle(color: Colors.black87, fontSize: 16), + textInputAction: TextInputAction.search, + onSubmitted: (_) => onSubmitted(), + ), + ), + ), + ], + ), + ); + } +} From 686435e7e3bdcde6c541de1090665fb425ba3b51 Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 28 Jan 2026 21:41:10 +0800 Subject: [PATCH 23/50] error display --- apps/flutter/lib/pages/home/index.dart | 2 + .../lib/pages/home/musicbill_list.dart | 11 +++ .../lib/pages/musicbill/status_views.dart | 37 +++------ apps/flutter/lib/pages/search/lyric_tab.dart | 3 +- apps/flutter/lib/pages/search/music_tab.dart | 3 +- .../lib/pages/search_intermediate/index.dart | 12 +-- apps/flutter/lib/widgets/error_view.dart | 76 +++++++++++++++++++ 7 files changed, 104 insertions(+), 40 deletions(-) create mode 100644 apps/flutter/lib/widgets/error_view.dart diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index 21718ea8..8db6ddcb 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -85,6 +85,8 @@ class Home extends StatelessWidget { MusicbillList( musicbillList: musicbillState.musicbillList, isLoading: musicbillState.loading, + exception: musicbillState.exception, + onRetry: () => musicbillState.reloadMusicbillList(silence: false), ), ], ), diff --git a/apps/flutter/lib/pages/home/musicbill_list.dart b/apps/flutter/lib/pages/home/musicbill_list.dart index 7dbcdd12..ebc1979a 100644 --- a/apps/flutter/lib/pages/home/musicbill_list.dart +++ b/apps/flutter/lib/pages/home/musicbill_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../states/musicbill.dart'; import './musicbill_card.dart'; +import '../../widgets/error_view.dart'; import '../../widgets/player_bottom_spacer.dart'; /// 音乐清单列表组件 @@ -9,11 +10,15 @@ import '../../widgets/player_bottom_spacer.dart'; class MusicbillList extends StatelessWidget { final List musicbillList; final bool isLoading; + final Exception? exception; + final VoidCallback? onRetry; const MusicbillList({ super.key, required this.musicbillList, this.isLoading = false, + this.exception, + this.onRetry, }); @override @@ -24,6 +29,12 @@ class MusicbillList extends StatelessWidget { ); } + if (exception != null && onRetry != null) { + return SliverFillRemaining( + child: ErrorView(errorMessage: exception.toString(), onRetry: onRetry!), + ); + } + if (musicbillList.isEmpty) { return SliverFillRemaining(child: _buildEmptyStateContent(context)); } diff --git a/apps/flutter/lib/pages/musicbill/status_views.dart b/apps/flutter/lib/pages/musicbill/status_views.dart index cedae9e0..59b63725 100644 --- a/apps/flutter/lib/pages/musicbill/status_views.dart +++ b/apps/flutter/lib/pages/musicbill/status_views.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../widgets/error_view.dart'; import '../../states/musicbill.dart' as musicbill_state; /// 加载中视图 @@ -25,34 +26,14 @@ class MusicbillErrorView extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 60, color: Colors.red[300]), - const SizedBox(height: 16), - Text( - 'Failed to load music', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Colors.grey[700]), - ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: () { - musicbill_state.musicbillState.reloadMusicbill( - id: musicbillId, - silence: false, - ); - }, - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Retry'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - ], - ), + return ErrorView( + title: '加载音乐失败', + onRetry: () { + musicbill_state.musicbillState.reloadMusicbill( + id: musicbillId, + silence: false, + ); + }, ); } } diff --git a/apps/flutter/lib/pages/search/lyric_tab.dart b/apps/flutter/lib/pages/search/lyric_tab.dart index e65a0244..c4dd3775 100644 --- a/apps/flutter/lib/pages/search/lyric_tab.dart +++ b/apps/flutter/lib/pages/search/lyric_tab.dart @@ -1,6 +1,7 @@ import 'package:cicada/event_bus.dart'; import 'package:cicada/server/api/search_lyric.dart'; import 'package:flutter/material.dart'; +import '../../widgets/error_view.dart'; import 'music_with_lyric_list_item.dart'; class LyricTab extends StatefulWidget { @@ -74,7 +75,7 @@ class _LyricTabState extends State { return const Center(child: CircularProgressIndicator()); } if (_error != null) { - return Center(child: Text('Error: $_error')); + return ErrorView(errorMessage: _error, onRetry: _search); } if (_results.isEmpty) { if (widget.keyword.isEmpty) { diff --git a/apps/flutter/lib/pages/search/music_tab.dart b/apps/flutter/lib/pages/search/music_tab.dart index 361a286c..227dcadb 100644 --- a/apps/flutter/lib/pages/search/music_tab.dart +++ b/apps/flutter/lib/pages/search/music_tab.dart @@ -2,6 +2,7 @@ import 'package:cicada/event_bus.dart'; import 'package:cicada/models/music.dart'; import 'package:cicada/server/api/search_music.dart'; import 'package:flutter/material.dart'; +import '../../widgets/error_view.dart'; import '../musicbill/music_list_item.dart'; class MusicTab extends StatefulWidget { @@ -75,7 +76,7 @@ class _MusicTabState extends State { return const Center(child: CircularProgressIndicator()); } if (_error != null) { - return Center(child: Text('Error: $_error')); + return ErrorView(errorMessage: _error, onRetry: _search); } if (_musicList.isEmpty) { if (widget.keyword.isEmpty) { diff --git a/apps/flutter/lib/pages/search_intermediate/index.dart b/apps/flutter/lib/pages/search_intermediate/index.dart index fae4439e..16b14416 100644 --- a/apps/flutter/lib/pages/search_intermediate/index.dart +++ b/apps/flutter/lib/pages/search_intermediate/index.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../server/api/get_exploration.dart'; import '../search/index.dart'; import '../../states/route.dart'; +import '../../widgets/error_view.dart'; import './exploration_grid.dart'; import './search_toolbar.dart'; @@ -106,16 +107,7 @@ class _SearchIntermediatePageState extends State { } if (_errorMessage != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('加载失败: $_errorMessage'), - const SizedBox(height: 16), - ElevatedButton(onPressed: _loadData, child: const Text('重试')), - ], - ), - ); + return ErrorView(errorMessage: _errorMessage, onRetry: _loadData); } if (_explorationData == null) { diff --git a/apps/flutter/lib/widgets/error_view.dart b/apps/flutter/lib/widgets/error_view.dart new file mode 100644 index 00000000..788ca234 --- /dev/null +++ b/apps/flutter/lib/widgets/error_view.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +/// 通用错误视图组件 +/// 用于在数据加载失败时展示错误信息和重试按钮 +class ErrorView extends StatelessWidget { + /// 错误消息(可选) + final String? errorMessage; + + /// 重试回调函数 + final VoidCallback onRetry; + + /// 自定义错误标题(可选,默认为 "加载失败") + final String? title; + + /// 自定义重试按钮文本(可选,默认为 "重试") + final String? retryButtonText; + + const ErrorView({ + super.key, + this.errorMessage, + required this.onRetry, + this.title, + this.retryButtonText, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red[300]), + const SizedBox(height: 16), + Text( + title ?? '加载失败', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey[800], + fontWeight: FontWeight.w600, + ), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 8), + Text( + errorMessage!, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 20), + label: Text(retryButtonText ?? '重试'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 14, + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } +} From 6d9f650210eac91b9059434719169512a2370c3f Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 28 Jan 2026 22:45:07 +0800 Subject: [PATCH 24/50] player page --- apps/flutter/lib/audio_handler.dart | 82 ++++--- apps/flutter/lib/models/lyric.dart | 46 ++++ apps/flutter/lib/pages/player/index.dart | 181 +++++++++++++++ apps/flutter/lib/pages/player/lyric_view.dart | 177 +++++++++++++++ .../lib/pages/player/player_controls.dart | 206 ++++++++++++++++++ .../lib/pages/player/player_header.dart | 49 +++++ .../lib/player_controller/actions.dart | 50 +---- apps/flutter/lib/player_controller/cover.dart | 33 +-- .../player_controller/player_controller.dart | 116 ++++++---- .../show_playlist_dialog.dart | 48 ++++ apps/flutter/lib/server/api/get_lyric.dart | 20 ++ 11 files changed, 873 insertions(+), 135 deletions(-) create mode 100644 apps/flutter/lib/models/lyric.dart create mode 100644 apps/flutter/lib/pages/player/index.dart create mode 100644 apps/flutter/lib/pages/player/lyric_view.dart create mode 100644 apps/flutter/lib/pages/player/player_controls.dart create mode 100644 apps/flutter/lib/pages/player/player_header.dart create mode 100644 apps/flutter/lib/player_controller/show_playlist_dialog.dart create mode 100644 apps/flutter/lib/server/api/get_lyric.dart diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 0ab446b7..7c270098 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -10,6 +10,22 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final _playTimer = Stopwatch(); MyAudioHandler() { + _initStreams(); + } + + void _initStreams() { + // Listen to all relevant streams and update state + // We combine the streams or just listen separately and invoke update + + // Just Audio's position stream is what we need for progress bar + player.positionStream.listen((position) { + _broadcastState(); + }); + + player.bufferedPositionStream.listen((bufferedPosition) { + _broadcastState(); + }); + player.playerStateStream.listen((PlayerState state) { if (state.playing) { _playTimer.start(); @@ -17,36 +33,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { _playTimer.stop(); } audioState.updatePlaying(state.playing); - playbackState.add( - PlaybackState( - controls: [ - if (playqueueState.playqueueIndex > 0) MediaControl.skipToPrevious, - state.playing ? MediaControl.pause : MediaControl.play, - MediaControl.skipToNext, - ], - systemActions: { - MediaAction.play, - MediaAction.pause, - MediaAction.playPause, - MediaAction.seek, - MediaAction.seekForward, - MediaAction.seekBackward, - MediaAction.skipToPrevious, - MediaAction.skipToNext, - }, - processingState: { - ProcessingState.idle: AudioProcessingState.idle, - ProcessingState.loading: AudioProcessingState.loading, - ProcessingState.buffering: AudioProcessingState.buffering, - ProcessingState.ready: AudioProcessingState.ready, - ProcessingState.completed: AudioProcessingState.completed, - }[state.processingState]!, - playing: state.playing, - updatePosition: player.position, - bufferedPosition: player.bufferedPosition, - speed: player.speed, - ), - ); + _broadcastState(); if (state.processingState == ProcessingState.completed) { playqueueState.next(); @@ -54,6 +41,41 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { }); } + void _broadcastState() { + final state = player.playerState; + playbackState.add( + PlaybackState( + controls: [ + if (playqueueState.playqueueIndex > 0) MediaControl.skipToPrevious, + state.playing ? MediaControl.pause : MediaControl.play, + MediaControl.skipToNext, + ], + systemActions: { + MediaAction.play, + MediaAction.pause, + MediaAction.playPause, + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + MediaAction.skipToPrevious, + MediaAction.skipToNext, + }, + processingState: { + ProcessingState.idle: AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[state.processingState]!, + playing: state.playing, + updatePosition: player.position, + bufferedPosition: player.bufferedPosition, + speed: player.speed, + queueIndex: playqueueState.playqueueIndex, + ), + ); + } + @override Future play() => player.play(); diff --git a/apps/flutter/lib/models/lyric.dart b/apps/flutter/lib/models/lyric.dart new file mode 100644 index 00000000..6b091076 --- /dev/null +++ b/apps/flutter/lib/models/lyric.dart @@ -0,0 +1,46 @@ +class LyricLine { + final Duration startTime; + final String content; + + LyricLine({required this.startTime, required this.content}); +} + +class LyricParser { + static List parse(String lrc) { + if (lrc.isEmpty) return []; + + final lines = lrc.split('\n'); + final List lyricLines = []; + final RegExp timeTagRegExp = RegExp(r'\[(\d{2}):(\d{2})\.(\d{2,3})\]'); + + for (var line in lines) { + final matches = timeTagRegExp.allMatches(line); + if (matches.isEmpty) continue; + + final content = line.replaceAll(timeTagRegExp, '').trim(); + if (content.isEmpty) continue; + + for (final match in matches) { + final minutes = int.parse(match.group(1)!); + final seconds = int.parse(match.group(2)!); + final milliseconds = int.parse(match.group(3)!); + + // Normalize milliseconds to 3 digits if it's 2 + final normalizedMilliseconds = match.group(3)!.length == 2 + ? milliseconds * 10 + : milliseconds; + + final startTime = Duration( + minutes: minutes, + seconds: seconds, + milliseconds: normalizedMilliseconds, + ); + + lyricLines.add(LyricLine(startTime: startTime, content: content)); + } + } + + lyricLines.sort((a, b) => a.startTime.compareTo(b.startTime)); + return lyricLines; + } +} diff --git a/apps/flutter/lib/pages/player/index.dart b/apps/flutter/lib/pages/player/index.dart new file mode 100644 index 00000000..a454f683 --- /dev/null +++ b/apps/flutter/lib/pages/player/index.dart @@ -0,0 +1,181 @@ +import 'dart:ui'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/music.dart'; +import '../../models/lyric.dart'; +import '../../server/api/get_lyric.dart'; +import './lyric_view.dart'; +import './player_controls.dart'; +import './player_header.dart'; +import '../../states/playqueue.dart'; +import '../../player_controller/show_playlist_dialog.dart'; + +class PlayerDetailPage extends StatefulWidget { + const PlayerDetailPage({super.key}); + + @override + State createState() => _PlayerDetailPageState(); +} + +class _PlayerDetailPageState extends State { + Music? _currentMusic; + List _lyrics = []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final music = context.read().currentMusic?.music; + if (music != null) { + _updateMusic(music); + } + }); + } + + void _updateMusic(Music music) { + if (_currentMusic?.id == music.id) return; + + setState(() { + _currentMusic = music; + _lyrics = []; + }); + + _loadLyric(music.id); + } + + Future _loadLyric(String id) async { + try { + final lrc = await getLyric(id: id); + if (mounted && _currentMusic?.id == id) { + setState(() { + _lyrics = LyricParser.parse(lrc); + }); + } + } catch (e) { + if (mounted && _currentMusic?.id == id) { + setState(() { + // Optionally show error or empty lyrics + }); + } + } + } + + @override + Widget build(BuildContext context) { + // Only listen to currentMusic changes to avoid unnecessary rebuilds form other playqueue changes + final currentMusic = context.select( + (s) => s.currentMusic?.music, + ); + + if (currentMusic != null && _currentMusic?.id != currentMusic.id) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateMusic(currentMusic); + }); + } + + if (currentMusic == null) { + return const SizedBox.shrink(); + } + + final displayMusic = currentMusic; + // AudioHandler is a singleton/service, it doesn't notify changes itself (streams do) + // So we use read() to avoid rebuilding if it were to notify (which it shouldn't, but safe is better) + final audioHandler = context.read(); + + return Scaffold( + body: Stack( + children: [ + // Background Image and Blur + _PlayerBackground(coverUrl: displayMusic.cover), + + // Content + Column( + children: [ + PlayerHeader(music: displayMusic), + + // Lyrics Area + Expanded( + child: StreamBuilder( + stream: Stream.periodic( + const Duration(milliseconds: 100), + (_) => (audioHandler as dynamic).player.position, + ), + builder: (context, positionSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + + return LyricView( + lyrics: _lyrics, + currentPosition: position, + onTap: () { + // Tap to toggle controls visibility? for now do nothing or standard + }, + ); + }, + ), + ), + + // Controls Area + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ProgressBar(), + const SizedBox(height: 32), + PlayerControls( + onBack: () => Navigator.of(context).pop(), + onPlaylist: () => showPlaylistDialog(context), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} + +class _PlayerBackground extends StatelessWidget { + final String? coverUrl; + + const _PlayerBackground({this.coverUrl}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + child: coverUrl != null + ? Image.network( + coverUrl!, + key: ValueKey(coverUrl), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + key: const ValueKey('error'), + color: Colors.grey[900], + ), + ) + : Container( + key: const ValueKey('default'), + color: Colors.grey[900], + ), + ), + ), + + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container(color: Colors.black.withValues(alpha: 0.5)), + ), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/pages/player/lyric_view.dart b/apps/flutter/lib/pages/player/lyric_view.dart new file mode 100644 index 00000000..ce3797e2 --- /dev/null +++ b/apps/flutter/lib/pages/player/lyric_view.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../models/lyric.dart'; + +class LyricView extends StatefulWidget { + final List lyrics; + final Duration currentPosition; + final VoidCallback? onTap; + + const LyricView({ + super.key, + required this.lyrics, + required this.currentPosition, + this.onTap, + }); + + @override + State createState() => _LyricViewState(); +} + +class _LyricViewState extends State { + late FixedExtentScrollController _scrollController; + int _currentIndex = 0; + bool _isUserScrolling = false; + Timer? _userScrollTimer; + Duration _previousPosition = Duration.zero; + + @override + void initState() { + super.initState(); + _scrollController = FixedExtentScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + _userScrollTimer?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant LyricView oldWidget) { + super.didUpdateWidget(oldWidget); + // Always update when position changes to ensure responsiveness during seeks + if (oldWidget.currentPosition.inMilliseconds != + widget.currentPosition.inMilliseconds) { + _updateCurrentIndex(); + } + } + + void _updateCurrentIndex() { + if (widget.lyrics.isEmpty) return; + + // Detect if this is a seek (large position jump) or normal playback + // Do this BEFORE checking index to ensure seeks are always detected + final positionDiff = + (widget.currentPosition.inMilliseconds - + _previousPosition.inMilliseconds) + .abs(); + final isSeek = positionDiff > 500; // More than 500ms jump = seek + + // Logic to find current line + // Find the last line whose startTime <= currentPosition + int newIndex = -1; + final currentMs = widget.currentPosition.inMilliseconds; + + for (int i = 0; i < widget.lyrics.length; i++) { + if (widget.lyrics[i].startTime.inMilliseconds > currentMs) { + break; + } + newIndex = i; + } + + // If before first line, newIndex remains -1, we can map to 0 but maybe show nothing? + // Let's map to 0 for display + if (newIndex < 0) newIndex = 0; + + if (newIndex != _currentIndex) { + if (mounted) { + setState(() { + _currentIndex = newIndex; + }); + + _scrollToCurrentIndex(instant: isSeek); + } + } + + // Always update previous position + _previousPosition = widget.currentPosition; + } + + void _scrollToCurrentIndex({bool instant = false}) { + if (_isUserScrolling) return; + if (!_scrollController.hasClients) return; + + if (instant) { + // Jump immediately without animation for seeks + _scrollController.jumpToItem(_currentIndex); + } else { + // Smooth animation for normal playback + _scrollController.animateToItem( + _currentIndex, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + if (widget.lyrics.isEmpty) { + return Center( + child: Text( + 'No lyrics', + style: TextStyle(color: Colors.white.withValues(alpha: 0.6)), + ), + ); + } + + return GestureDetector( + onTap: widget.onTap, + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollStartNotification) { + _isUserScrolling = true; + _userScrollTimer?.cancel(); + } else if (notification is ScrollEndNotification) { + _userScrollTimer = Timer(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _isUserScrolling = false; + }); + _scrollToCurrentIndex(); // Resume following + } + }); + } + return false; + }, + child: ListWheelScrollView.useDelegate( + controller: _scrollController, + itemExtent: 40, + diameterRatio: 1.5, + physics: const FixedExtentScrollPhysics(), + perspective: 0.002, + onSelectedItemChanged: (index) { + // We could use this to seek? Maybe later. + }, + childDelegate: ListWheelChildBuilderDelegate( + builder: (context, index) { + final isCurrent = index == _currentIndex; + final line = widget.lyrics[index]; + return Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + color: isCurrent + ? Colors.white + : Colors.white.withValues(alpha: 0.4), + fontSize: isCurrent ? 18 : 14, + fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, + ), + child: Text( + line.content, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + childCount: widget.lyrics.length, + ), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/pages/player/player_controls.dart b/apps/flutter/lib/pages/player/player_controls.dart new file mode 100644 index 00000000..6cf64fb4 --- /dev/null +++ b/apps/flutter/lib/pages/player/player_controls.dart @@ -0,0 +1,206 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PlayerControls extends StatelessWidget { + final VoidCallback? onBack; + final VoidCallback? onPlaylist; + + const PlayerControls({super.key, this.onBack, this.onPlaylist}); + + @override + Widget build(BuildContext context) { + final audioHandler = context.read(); + + return StreamBuilder( + stream: audioHandler.playbackState, + builder: (context, snapshot) { + final playbackState = snapshot.data; + final playing = playbackState?.playing ?? false; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Collapse (Back) + IconButton( + icon: const Icon(Icons.keyboard_arrow_down_rounded), + iconSize: 28, + color: Colors.white, + onPressed: onBack, + ), + + // Previous + IconButton( + icon: const Icon(Icons.skip_previous_rounded), + iconSize: 36, + color: Colors.white, + onPressed: () => audioHandler.skipToPrevious(), + ), + + // Play/Pause + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: IconButton( + icon: Icon( + playing ? Icons.pause_rounded : Icons.play_arrow_rounded, + ), + iconSize: 48, + color: Colors.black, + onPressed: () { + if (playing) { + audioHandler.pause(); + } else { + audioHandler.play(); + } + }, + ), + ), + + // Next + IconButton( + icon: const Icon(Icons.skip_next_rounded), + iconSize: 36, + color: Colors.white, + onPressed: () => audioHandler.skipToNext(), + ), + + // Playlist + IconButton( + icon: const Icon(Icons.queue_music_rounded), + iconSize: 28, + color: Colors.white, + onPressed: onPlaylist, + ), + ], + ); + }, + ); + } +} + +class ProgressBar extends StatelessWidget { + const ProgressBar({super.key}); + + @override + Widget build(BuildContext context) { + final audioHandler = context.read(); + + return StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, mediaSnapshot) { + final duration = mediaSnapshot.data?.duration ?? Duration.zero; + + return StreamBuilder( + stream: audioHandler.playbackState, + builder: (context, playbackSnapshot) { + final position = + playbackSnapshot.data?.updatePosition ?? Duration.zero; + + return _SeekBar( + duration: duration, + position: position, + onChangeEnd: (newPosition) { + audioHandler.seek(newPosition); + }, + ); + }, + ); + }, + ); + } +} + +class _SeekBar extends StatefulWidget { + final Duration duration; + final Duration position; + final ValueChanged? onChangeEnd; + + const _SeekBar({ + required this.duration, + required this.position, + this.onChangeEnd, + }); + + @override + State<_SeekBar> createState() => _SeekBarState(); +} + +class _SeekBarState extends State<_SeekBar> { + double? _dragValue; + + String _formatDuration(Duration? duration) { + if (duration == null) return '--:--'; + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final value = min( + _dragValue ?? widget.position.inMilliseconds.toDouble(), + widget.duration.inMilliseconds.toDouble(), + ); + final max = widget.duration.inMilliseconds.toDouble(); + + return Column( + children: [ + SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 4, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white.withValues(alpha: 0.2), + thumbColor: Colors.white, + overlayColor: Colors.white.withValues(alpha: 0.2), + ), + child: Slider( + min: 0.0, + max: max > 0 ? max : 1.0, + value: value, + onChanged: (value) { + setState(() { + _dragValue = value; + }); + }, + onChangeEnd: (value) { + if (widget.onChangeEnd != null) { + widget.onChangeEnd!(Duration(milliseconds: value.round())); + } + _dragValue = null; + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(Duration(milliseconds: value.round())), + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + Text( + _formatDuration(widget.duration), + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ), + ], + ); + } + + double min(double a, double b) => a < b ? a : b; +} diff --git a/apps/flutter/lib/pages/player/player_header.dart b/apps/flutter/lib/pages/player/player_header.dart new file mode 100644 index 00000000..7ab008ec --- /dev/null +++ b/apps/flutter/lib/pages/player/player_header.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../../models/music.dart'; + +class PlayerHeader extends StatelessWidget { + final Music music; + + const PlayerHeader({super.key, required this.music}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + height: kToolbarHeight, + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + music.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + music.singers.map((s) => s.name).join(' / '), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/flutter/lib/player_controller/actions.dart b/apps/flutter/lib/player_controller/actions.dart index 9c4ca50a..bdafd76a 100644 --- a/apps/flutter/lib/player_controller/actions.dart +++ b/apps/flutter/lib/player_controller/actions.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:provider/provider.dart'; import '../audio_handler.dart'; -import './playqueue.dart'; -import './playlist.dart'; import '../states/audio.dart'; import '../states/playqueue.dart'; +import './show_playlist_dialog.dart'; class Actions extends StatelessWidget { const Actions({super.key}); @@ -39,52 +38,7 @@ class Actions extends StatelessWidget { spacing, IconButton( onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) { - return FractionallySizedBox( - heightFactor: 0.8, - child: Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical( - top: Radius.circular(16), - ), - ), - child: DefaultTabController( - length: 2, - initialIndex: 1, - child: Column( - children: [ - Container( - margin: const EdgeInsets.only(top: 8, bottom: 4), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - const TabBar( - tabs: [ - Tab(text: "播放列表"), - Tab(text: "播放队列"), - ], - ), - Expanded( - child: TabBarView( - children: [Playlist(), Playqueue()], - ), - ), - ], - ), - ), - ), - ); - }, - ); + showPlaylistDialog(context); }, icon: Icon(Icons.list_outlined), iconSize: 20, diff --git a/apps/flutter/lib/player_controller/cover.dart b/apps/flutter/lib/player_controller/cover.dart index eb2eb4f8..90edc8a6 100644 --- a/apps/flutter/lib/player_controller/cover.dart +++ b/apps/flutter/lib/player_controller/cover.dart @@ -103,20 +103,27 @@ class _RotatingCoverState extends State height: 40, child: RotationTransition( turns: _controller, - child: widget.coverUrl != null - ? ClipOval( - child: AspectRatio( - aspectRatio: 1, - child: Image.network( - widget.coverUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(context); - }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + child: widget.coverUrl != null + ? ClipOval( + key: ValueKey(widget.coverUrl), + child: AspectRatio( + aspectRatio: 1, + child: Image.network( + widget.coverUrl!, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) { + return _buildDefaultCover(context); + }, + ), ), - ), - ) - : _buildDefaultCover(context), + ) + : _buildDefaultCover(context), + ), ), ), ], diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index a1b1fef6..66d01b83 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -5,6 +5,8 @@ import './actions.dart' as actions; import './cover.dart'; import './info.dart'; +import '../pages/player/index.dart'; + class PlayController extends StatelessWidget { static const double kContentHeight = 54.0; static const double kMargin = 6.0; @@ -22,57 +24,83 @@ class PlayController extends StatelessWidget { final music = playqueueMusic.music; const borderRadius = kContentHeight / 2; - return Container( - margin: EdgeInsets.fromLTRB( - kMargin, - kMargin, - kMargin, - kMargin + MediaQuery.of(context).padding.bottom, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 16, - offset: Offset(0, -4), - spreadRadius: 0, + return GestureDetector( + onTap: () { + Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + const PlayerDetailPage(), + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + const begin = Offset(0.0, 1.0); + const end = Offset.zero; + const curve = Curves.ease; + + var tween = Tween( + begin: begin, + end: end, + ).chain(CurveTween(curve: curve)); + + return SlideTransition( + position: animation.drive(tween), + child: child, + ); + }, ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - height: kContentHeight, - padding: const EdgeInsets.symmetric( - horizontal: kMargin, - vertical: 2, + ); + }, + child: Container( + margin: EdgeInsets.fromLTRB( + kMargin, + kMargin, + kMargin, + kMargin + MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, -4), + spreadRadius: 0, ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.95), - borderRadius: BorderRadius.circular(borderRadius), - border: Border.all( - color: Colors.white.withValues(alpha: 0.2), - width: 1, + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + height: kContentHeight, + padding: const EdgeInsets.symmetric( + horizontal: kMargin, + vertical: 2, ), - ), - child: Row( - children: [ - // 封面 - RotatingCover(coverUrl: music.cover), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + children: [ + // 封面 + RotatingCover(coverUrl: music.cover), - const SizedBox(width: 8), + const SizedBox(width: 8), - // 歌曲信息 - Expanded(child: MusicInfo(music: music)), + // 歌曲信息 + Expanded(child: MusicInfo(music: music)), - const SizedBox(width: 4), + const SizedBox(width: 4), - // 操作按钮 - actions.Actions(), - ], + // 操作按钮 + actions.Actions(), + ], + ), ), ), ), diff --git a/apps/flutter/lib/player_controller/show_playlist_dialog.dart b/apps/flutter/lib/player_controller/show_playlist_dialog.dart new file mode 100644 index 00000000..e32ed2af --- /dev/null +++ b/apps/flutter/lib/player_controller/show_playlist_dialog.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import './playlist.dart'; +import './playqueue.dart'; + +void showPlaylistDialog(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return FractionallySizedBox( + heightFactor: 0.8, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: DefaultTabController( + length: 2, + initialIndex: 1, + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + const TabBar( + tabs: [ + Tab(text: "播放列表"), + Tab(text: "播放队列"), + ], + ), + Expanded( + child: TabBarView(children: [Playlist(), Playqueue()]), + ), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/apps/flutter/lib/server/api/get_lyric.dart b/apps/flutter/lib/server/api/get_lyric.dart new file mode 100644 index 00000000..2aac2857 --- /dev/null +++ b/apps/flutter/lib/server/api/get_lyric.dart @@ -0,0 +1,20 @@ +import '../../server/request.dart'; + +Future getLyric({required String id}) async { + final response = await httpGet( + path: '/api/lyric_list', + query: {'musicId': id}, + withToken: true, + ); + + // The API returns an array of lyric objects: [{ id: number, lrc: string }] + // We'll use the first lyric if available + if (response is List && response.isNotEmpty) { + final firstLyric = response[0]; + if (firstLyric is Map && firstLyric['lrc'] != null) { + return firstLyric['lrc'] as String; + } + } + + return ''; +} From f0c5b70fdae574c7a220027e6d9e89d513202269 Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 28 Jan 2026 23:16:35 +0800 Subject: [PATCH 25/50] improve player --- apps/flutter/lib/app.dart | 76 +++--- apps/flutter/lib/main.dart | 8 + apps/flutter/lib/pages/player/index.dart | 181 ------------- .../player_controller/player_controller.dart | 30 +-- apps/flutter/lib/widgets/player/index.dart | 246 ++++++++++++++++++ .../{pages => widgets}/player/lyric_view.dart | 0 .../player/player_controls.dart | 0 .../player/player_header.dart | 8 +- 8 files changed, 299 insertions(+), 250 deletions(-) delete mode 100644 apps/flutter/lib/pages/player/index.dart create mode 100644 apps/flutter/lib/widgets/player/index.dart rename apps/flutter/lib/{pages => widgets}/player/lyric_view.dart (100%) rename apps/flutter/lib/{pages => widgets}/player/player_controls.dart (100%) rename apps/flutter/lib/{pages => widgets}/player/player_header.dart (84%) diff --git a/apps/flutter/lib/app.dart b/apps/flutter/lib/app.dart index d1a82e0e..eaf859b0 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -1,13 +1,9 @@ import 'package:audio_service/audio_service.dart'; import 'package:cicada/player_controller/index.dart'; -import 'package:cicada/states/audio.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import './states/playlist.dart'; -import './states/playqueue.dart'; -import './states/route.dart'; import './pages/home/index.dart'; import './server_management/index.dart'; import './states/musicbill.dart' as musicbill_state; @@ -40,47 +36,37 @@ class _AppContentState extends State { @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: musicbill_state.musicbillState), - ChangeNotifierProvider.value(value: playlistState), - ChangeNotifierProvider.value(value: playqueueState), - ChangeNotifierProvider.value(value: audioState), - ChangeNotifierProvider.value(value: routeState), - ], - child: MaterialApp( - theme: appTheme, - home: Stack( - children: [ - // ... (Navigator and PlayerController) - Navigator( - key: GlobalKey(), - onGenerateRoute: (setting) { - switch (setting.name) { - case '/musicbill': - { - final args = setting.arguments as Map; - return MaterialPageRoute( - builder: (_) => - musicbill_page.Musicbill(id: args['id']), - ); - } - case '/profile': - { - return MaterialPageRoute( - builder: (_) => const ProfilePage(), - ); - } - default: - { - return MaterialPageRoute(builder: (_) => Home()); - } - } - }, - ), - PlayerControllerContainer(), - ], - ), + return MaterialApp( + theme: appTheme, + home: Stack( + children: [ + // ... (Navigator and PlayerController) + Navigator( + key: GlobalKey(), + onGenerateRoute: (setting) { + switch (setting.name) { + case '/musicbill': + { + final args = setting.arguments as Map; + return MaterialPageRoute( + builder: (_) => musicbill_page.Musicbill(id: args['id']), + ); + } + case '/profile': + { + return MaterialPageRoute( + builder: (_) => const ProfilePage(), + ); + } + default: + { + return MaterialPageRoute(builder: (_) => Home()); + } + } + }, + ), + PlayerControllerContainer(), + ], ), ); } diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart index 29180f0c..083f3aad 100644 --- a/apps/flutter/lib/main.dart +++ b/apps/flutter/lib/main.dart @@ -11,6 +11,9 @@ import './window_manager.dart'; import './app.dart'; import './states/server.dart'; import './audio_handler.dart'; +import './states/musicbill.dart'; +import './states/audio.dart'; +import './states/route.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -39,6 +42,11 @@ void main() async { providers: [ ChangeNotifierProvider.value(value: serverState), Provider.value(value: audioHandler), + ChangeNotifierProvider.value(value: musicbillState), + ChangeNotifierProvider.value(value: playlistState), + ChangeNotifierProvider.value(value: playqueueState), + ChangeNotifierProvider.value(value: audioState), + ChangeNotifierProvider.value(value: routeState), ], child: App(), ), diff --git a/apps/flutter/lib/pages/player/index.dart b/apps/flutter/lib/pages/player/index.dart deleted file mode 100644 index a454f683..00000000 --- a/apps/flutter/lib/pages/player/index.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'dart:ui'; -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../../models/music.dart'; -import '../../models/lyric.dart'; -import '../../server/api/get_lyric.dart'; -import './lyric_view.dart'; -import './player_controls.dart'; -import './player_header.dart'; -import '../../states/playqueue.dart'; -import '../../player_controller/show_playlist_dialog.dart'; - -class PlayerDetailPage extends StatefulWidget { - const PlayerDetailPage({super.key}); - - @override - State createState() => _PlayerDetailPageState(); -} - -class _PlayerDetailPageState extends State { - Music? _currentMusic; - List _lyrics = []; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - final music = context.read().currentMusic?.music; - if (music != null) { - _updateMusic(music); - } - }); - } - - void _updateMusic(Music music) { - if (_currentMusic?.id == music.id) return; - - setState(() { - _currentMusic = music; - _lyrics = []; - }); - - _loadLyric(music.id); - } - - Future _loadLyric(String id) async { - try { - final lrc = await getLyric(id: id); - if (mounted && _currentMusic?.id == id) { - setState(() { - _lyrics = LyricParser.parse(lrc); - }); - } - } catch (e) { - if (mounted && _currentMusic?.id == id) { - setState(() { - // Optionally show error or empty lyrics - }); - } - } - } - - @override - Widget build(BuildContext context) { - // Only listen to currentMusic changes to avoid unnecessary rebuilds form other playqueue changes - final currentMusic = context.select( - (s) => s.currentMusic?.music, - ); - - if (currentMusic != null && _currentMusic?.id != currentMusic.id) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _updateMusic(currentMusic); - }); - } - - if (currentMusic == null) { - return const SizedBox.shrink(); - } - - final displayMusic = currentMusic; - // AudioHandler is a singleton/service, it doesn't notify changes itself (streams do) - // So we use read() to avoid rebuilding if it were to notify (which it shouldn't, but safe is better) - final audioHandler = context.read(); - - return Scaffold( - body: Stack( - children: [ - // Background Image and Blur - _PlayerBackground(coverUrl: displayMusic.cover), - - // Content - Column( - children: [ - PlayerHeader(music: displayMusic), - - // Lyrics Area - Expanded( - child: StreamBuilder( - stream: Stream.periodic( - const Duration(milliseconds: 100), - (_) => (audioHandler as dynamic).player.position, - ), - builder: (context, positionSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - - return LyricView( - lyrics: _lyrics, - currentPosition: position, - onTap: () { - // Tap to toggle controls visibility? for now do nothing or standard - }, - ); - }, - ), - ), - - // Controls Area - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 48), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const ProgressBar(), - const SizedBox(height: 32), - PlayerControls( - onBack: () => Navigator.of(context).pop(), - onPlaylist: () => showPlaylistDialog(context), - ), - ], - ), - ), - ], - ), - ], - ), - ); - } -} - -class _PlayerBackground extends StatelessWidget { - final String? coverUrl; - - const _PlayerBackground({this.coverUrl}); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - Positioned.fill( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 500), - switchInCurve: Curves.easeIn, - switchOutCurve: Curves.easeOut, - child: coverUrl != null - ? Image.network( - coverUrl!, - key: ValueKey(coverUrl), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - key: const ValueKey('error'), - color: Colors.grey[900], - ), - ) - : Container( - key: const ValueKey('default'), - color: Colors.grey[900], - ), - ), - ), - - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container(color: Colors.black.withValues(alpha: 0.5)), - ), - ), - ], - ); - } -} diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index 66d01b83..680fba11 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -5,7 +5,7 @@ import './actions.dart' as actions; import './cover.dart'; import './info.dart'; -import '../pages/player/index.dart'; +import '../widgets/player/index.dart'; class PlayController extends StatelessWidget { static const double kContentHeight = 54.0; @@ -26,27 +26,13 @@ class PlayController extends StatelessWidget { return GestureDetector( onTap: () { - Navigator.of(context).push( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => - const PlayerDetailPage(), - transitionsBuilder: - (context, animation, secondaryAnimation, child) { - const begin = Offset(0.0, 1.0); - const end = Offset.zero; - const curve = Curves.ease; - - var tween = Tween( - begin: begin, - end: end, - ).chain(CurveTween(curve: curve)); - - return SlideTransition( - position: animation.drive(tween), - child: child, - ); - }, - ), + final topPadding = MediaQuery.of(context).padding.top; + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (context) => PlayerWidget(topPadding: topPadding), ); }, child: Container( diff --git a/apps/flutter/lib/widgets/player/index.dart b/apps/flutter/lib/widgets/player/index.dart new file mode 100644 index 00000000..466447d7 --- /dev/null +++ b/apps/flutter/lib/widgets/player/index.dart @@ -0,0 +1,246 @@ +import 'dart:ui'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/music.dart'; +import '../../models/lyric.dart'; +import '../../server/api/get_lyric.dart'; +import './lyric_view.dart'; +import './player_controls.dart'; +import './player_header.dart'; +import '../../states/playqueue.dart'; +import '../../player_controller/show_playlist_dialog.dart'; + +class PlayerWidget extends StatefulWidget { + final double? topPadding; + + const PlayerWidget({super.key, this.topPadding}); + + @override + State createState() => _PlayerWidgetState(); +} + +class _PlayerWidgetState extends State { + // ... (keep existing state vars) + + Music? _currentMusic; + List _lyrics = []; + double _dragOffset = 0; + bool _isDragging = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final music = context.read().currentMusic?.music; + if (music != null) { + _updateMusic(music); + } + }); + } + + void _updateMusic(Music music) { + if (_currentMusic?.id == music.id) return; + + setState(() { + _currentMusic = music; + _lyrics = []; + }); + + _loadLyric(music.id); + } + + Future _loadLyric(String id) async { + try { + final lrc = await getLyric(id: id); + if (mounted && _currentMusic?.id == id) { + setState(() { + _lyrics = LyricParser.parse(lrc); + }); + } + } catch (e) { + if (mounted && _currentMusic?.id == id) { + setState(() { + // Optionally show error or empty lyrics + }); + } + } + } + + void _handleDragStart(DragStartDetails details) { + // Only start drag if starting from screen edge (within 40px of left or right edge) + final screenWidth = MediaQuery.of(context).size.width; + final startX = details.globalPosition.dx; + final isFromLeftEdge = startX < 40; + final isFromRightEdge = startX > screenWidth - 40; + + if (isFromLeftEdge || isFromRightEdge) { + setState(() { + _isDragging = true; + }); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (!_isDragging) return; + setState(() { + // Positive value for dragging right (from left edge) + // Negative value for dragging left (from right edge) + _dragOffset = _dragOffset + details.delta.dx; + }); + } + + void _handleDragEnd(DragEndDetails details) { + if (!_isDragging) { + return; + } + + final screenWidth = MediaQuery.of(context).size.width; + final threshold = screenWidth * 0.2; // 20% of screen width + final velocity = details.primaryVelocity ?? 0; + + // Dismiss if dragged far enough or with enough velocity + if (_dragOffset.abs() > threshold || velocity.abs() > 500) { + Navigator.of(context).pop(); + } else { + // Reset position with animation + setState(() { + _dragOffset = 0; + _isDragging = false; + }); + } + } + + @override + Widget build(BuildContext context) { + // Only listen to currentMusic changes to avoid unnecessary rebuilds form other playqueue changes + final currentMusic = context.select( + (s) => s.currentMusic?.music, + ); + + if (currentMusic != null && _currentMusic?.id != currentMusic.id) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateMusic(currentMusic); + }); + } + + if (currentMusic == null) { + return const SizedBox.shrink(); + } + + final displayMusic = currentMusic; + // AudioHandler is a singleton/service, it doesn't notify changes itself (streams do) + // So we use read() to avoid rebuilding if it were to notify (which it shouldn't, but safe is better) + final audioHandler = context.read(); + + return GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + child: AnimatedContainer( + duration: _isDragging + ? Duration.zero + : const Duration(milliseconds: 200), + curve: Curves.easeOut, + transform: Matrix4.translationValues(_dragOffset, 0, 0), + child: Scaffold( + body: Stack( + children: [ + // Background Image and Blur + _PlayerBackground(coverUrl: displayMusic.cover), + + // Content + Column( + children: [ + PlayerHeader( + music: displayMusic, + topPadding: widget.topPadding, + ), + + // Lyrics Area + Expanded( + child: StreamBuilder( + stream: Stream.periodic( + const Duration(milliseconds: 100), + (_) => (audioHandler as dynamic).player.position, + ), + builder: (context, positionSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + + return LyricView( + lyrics: _lyrics, + currentPosition: position, + onTap: () { + // Tap to toggle controls visibility? for now do nothing or standard + }, + ); + }, + ), + ), + + // Controls Area + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ProgressBar(), + const SizedBox(height: 32), + PlayerControls( + onBack: () => Navigator.of(context).pop(), + onPlaylist: () => showPlaylistDialog(context), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _PlayerBackground extends StatelessWidget { + final String? coverUrl; + + const _PlayerBackground({this.coverUrl}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + child: coverUrl != null + ? Image.network( + coverUrl!, + key: ValueKey(coverUrl), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + key: const ValueKey('error'), + color: Colors.grey[900], + ), + ) + : Container( + key: const ValueKey('default'), + color: Colors.grey[900], + ), + ), + ), + + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container(color: Colors.black.withValues(alpha: 0.5)), + ), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/pages/player/lyric_view.dart b/apps/flutter/lib/widgets/player/lyric_view.dart similarity index 100% rename from apps/flutter/lib/pages/player/lyric_view.dart rename to apps/flutter/lib/widgets/player/lyric_view.dart diff --git a/apps/flutter/lib/pages/player/player_controls.dart b/apps/flutter/lib/widgets/player/player_controls.dart similarity index 100% rename from apps/flutter/lib/pages/player/player_controls.dart rename to apps/flutter/lib/widgets/player/player_controls.dart diff --git a/apps/flutter/lib/pages/player/player_header.dart b/apps/flutter/lib/widgets/player/player_header.dart similarity index 84% rename from apps/flutter/lib/pages/player/player_header.dart rename to apps/flutter/lib/widgets/player/player_header.dart index 7ab008ec..01e6c9af 100644 --- a/apps/flutter/lib/pages/player/player_header.dart +++ b/apps/flutter/lib/widgets/player/player_header.dart @@ -3,12 +3,16 @@ import '../../models/music.dart'; class PlayerHeader extends StatelessWidget { final Music music; + final double? topPadding; - const PlayerHeader({super.key, required this.music}); + const PlayerHeader({super.key, required this.music, this.topPadding}); @override Widget build(BuildContext context) { - return SafeArea( + final safePadding = topPadding ?? MediaQuery.of(context).padding.top; + + return Padding( + padding: EdgeInsets.only(top: safePadding), child: Container( height: kToolbarHeight, padding: const EdgeInsets.symmetric(horizontal: 4), From a777c08c1b085815948ebccdbc9ff108de4287c9 Mon Sep 17 00:00:00 2001 From: mebtte Date: Sun, 1 Feb 2026 22:28:02 +0800 Subject: [PATCH 26/50] auto play next after error --- apps/flutter/lib/app.dart | 25 ++- apps/flutter/lib/audio_handler.dart | 40 +++-- apps/flutter/lib/event_bus.dart | 6 + .../lib/widgets/play_error_dialog.dart | 162 ++++++++++++++++++ 4 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 apps/flutter/lib/widgets/play_error_dialog.dart diff --git a/apps/flutter/lib/app.dart b/apps/flutter/lib/app.dart index eaf859b0..1a01cc5f 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -1,9 +1,11 @@ +import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:cicada/player_controller/index.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import './event_bus.dart'; import './pages/home/index.dart'; import './server_management/index.dart'; import './states/musicbill.dart' as musicbill_state; @@ -11,6 +13,7 @@ import './user_management/index.dart'; import './states/server.dart'; import './pages/musicbill/index.dart' as musicbill_page; import './pages/profile/index.dart'; +import './widgets/play_error_dialog.dart'; import './theme.dart'; class AppContent extends StatefulWidget { @@ -21,11 +24,22 @@ class AppContent extends StatefulWidget { } class _AppContentState extends State { - // ... initState and reassemble ... + final GlobalKey _navigatorKey = GlobalKey(); + StreamSubscription? _errorSubscription; + @override void initState() { super.initState(); musicbill_state.musicbillState.reloadMusicbillList(silence: false); + _errorSubscription = eventBus.on().listen((event) { + _showPlayError(event); + }); + } + + @override + void dispose() { + _errorSubscription?.cancel(); + super.dispose(); } @override @@ -34,6 +48,13 @@ class _AppContentState extends State { context.read().stop(); } + void _showPlayError(PlayErrorEvent event) { + final navContext = _navigatorKey.currentContext; + if (navContext != null) { + showPlayErrorDialog(navContext, event); + } + } + @override Widget build(BuildContext context) { return MaterialApp( @@ -42,7 +63,7 @@ class _AppContentState extends State { children: [ // ... (Navigator and PlayerController) Navigator( - key: GlobalKey(), + key: _navigatorKey, onGenerateRoute: (setting) { switch (setting.name) { case '/musicbill': diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 7c270098..0b1be521 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -1,6 +1,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:cicada/states/audio.dart'; import 'package:just_audio/just_audio.dart'; +import './event_bus.dart'; import './states/playqueue.dart'; import './server/base/upload_music_play_record.dart'; @@ -93,21 +94,30 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { Future playQueueMusic(PlayqueueMusic queueMusic) async { _playTimer.reset(); - var duration = await player.setAudioSource( - AudioSource.uri(Uri.parse(queueMusic.music.asset)), - ); - player.play(); - - var item = MediaItem( - id: queueMusic.pid, - title: queueMusic.music.name, - artist: queueMusic.music.singers.map((s) => s.name).join(','), - artUri: queueMusic.music.cover == null - ? null - : Uri.parse(queueMusic.music.cover!), - duration: duration, - ); - mediaItem.add(item); + try { + var duration = await player.setAudioSource( + AudioSource.uri(Uri.parse(queueMusic.music.asset)), + ); + player.play(); + + var item = MediaItem( + id: queueMusic.pid, + title: queueMusic.music.name, + artist: queueMusic.music.singers.map((s) => s.name).join(','), + artUri: queueMusic.music.cover == null + ? null + : Uri.parse(queueMusic.music.cover!), + duration: duration, + ); + mediaItem.add(item); + } catch (e) { + eventBus.fire( + PlayErrorEvent( + musicName: queueMusic.music.name, + errorMessage: e.toString(), + ), + ); + } } Future _uploadPlayRecord(PlayqueueMusic queueMusic) async { diff --git a/apps/flutter/lib/event_bus.dart b/apps/flutter/lib/event_bus.dart index 4a7811f1..4d14e329 100644 --- a/apps/flutter/lib/event_bus.dart +++ b/apps/flutter/lib/event_bus.dart @@ -11,4 +11,10 @@ class AddMusicListToPlaylistEvent { AddMusicListToPlaylistEvent({required this.musicList}); } +class PlayErrorEvent { + final String musicName; + final String errorMessage; + PlayErrorEvent({required this.musicName, required this.errorMessage}); +} + EventBus eventBus = EventBus(); diff --git a/apps/flutter/lib/widgets/play_error_dialog.dart b/apps/flutter/lib/widgets/play_error_dialog.dart new file mode 100644 index 00000000..e984b52a --- /dev/null +++ b/apps/flutter/lib/widgets/play_error_dialog.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../event_bus.dart'; +import '../states/playqueue.dart'; + +/// 显示播放失败对话框 +/// 返回 true 表示用户取消了自动播放下一首,false 表示自动播放下一首 +void showPlayErrorDialog(BuildContext context, PlayErrorEvent event) { + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => _PlayErrorDialog(event: event), + ); +} + +class _PlayErrorDialog extends StatefulWidget { + final PlayErrorEvent event; + + const _PlayErrorDialog({required this.event}); + + @override + State<_PlayErrorDialog> createState() => _PlayErrorDialogState(); +} + +class _PlayErrorDialogState extends State<_PlayErrorDialog> { + static const int _countdownSeconds = 10; + late int _remainingSeconds; + Timer? _timer; + + @override + void initState() { + super.initState(); + _remainingSeconds = _countdownSeconds; + _startCountdown(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startCountdown() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + + setState(() { + _remainingSeconds--; + }); + + if (_remainingSeconds <= 0) { + timer.cancel(); + _playNext(); + } + }); + } + + void _playNext() { + Navigator.of(context).pop(); + playqueueState.next(); + } + + void _cancel() { + _timer?.cancel(); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: Colors.grey[900], + title: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red[400], size: 28), + const SizedBox(width: 12), + Expanded( + child: Text( + '播放失败', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '无法播放「${widget.event.musicName}」', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + widget.event.errorMessage, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 13, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.skip_next, color: Colors.blue[400], size: 20), + const SizedBox(width: 8), + Text( + '$_remainingSeconds 秒后自动播放下一首', + style: TextStyle( + color: Colors.blue[400], + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: _cancel, + style: TextButton.styleFrom( + foregroundColor: Colors.white.withValues(alpha: 0.7), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('取消自动播放', style: TextStyle(fontSize: 15)), + ), + ElevatedButton( + onPressed: _playNext, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[600], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('立即播放下一首', style: TextStyle(fontSize: 15)), + ), + ], + ); + } +} From b31963ce30bc294fe7587247aa3970accbba84c4 Mon Sep 17 00:00:00 2001 From: mebtte Date: Sun, 1 Feb 2026 23:11:28 +0800 Subject: [PATCH 27/50] improve player --- apps/flutter/lib/models/music.dart | 13 ++ apps/flutter/lib/states/musicbill.dart | 1 + apps/flutter/lib/widgets/player/index.dart | 78 ++++---- .../lib/widgets/player/lyric_view.dart | 170 ++++++++++++++++-- 4 files changed, 214 insertions(+), 48 deletions(-) diff --git a/apps/flutter/lib/models/music.dart b/apps/flutter/lib/models/music.dart index 47a132bc..5e980d61 100644 --- a/apps/flutter/lib/models/music.dart +++ b/apps/flutter/lib/models/music.dart @@ -1,12 +1,19 @@ import 'package:cicada/models/singer.dart'; import 'package:cicada/utils/prefix_server_origin.dart'; +/// 音乐类型 +enum MusicType { + song, // 歌曲,有歌词 + instrumental, // 纯音乐,无歌词 +} + class Music { final String id; final String name; final String asset; final String? cover; final List singers; + final MusicType type; Music({ required this.id, @@ -14,8 +21,12 @@ class Music { required this.asset, required this.cover, required this.singers, + required this.type, }); + /// 是否是纯音乐 + bool get isInstrumental => type == MusicType.instrumental; + factory Music.fromJson(Map json) => Music( id: json['id'], name: json['name'], @@ -26,5 +37,7 @@ class Music { ?.map((json) => Singer.fromJson(json)) .toList() ?? [], + // 服务端 type: 1=歌曲, 2=纯音乐 + type: json['type'] == 2 ? MusicType.instrumental : MusicType.song, ); } diff --git a/apps/flutter/lib/states/musicbill.dart b/apps/flutter/lib/states/musicbill.dart index 64bba1dc..9ce310eb 100644 --- a/apps/flutter/lib/states/musicbill.dart +++ b/apps/flutter/lib/states/musicbill.dart @@ -72,6 +72,7 @@ class MusicbillState extends ChangeNotifier { name: music.name, cover: music.cover, asset: music.asset, + type: music.type, singers: music.singers .map( (s) => Singer( diff --git a/apps/flutter/lib/widgets/player/index.dart b/apps/flutter/lib/widgets/player/index.dart index 466447d7..6fcf2a8b 100644 --- a/apps/flutter/lib/widgets/player/index.dart +++ b/apps/flutter/lib/widgets/player/index.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../../models/music.dart'; import '../../models/lyric.dart'; import '../../server/api/get_lyric.dart'; +import '../../states/audio.dart'; import './lyric_view.dart'; import './player_controls.dart'; import './player_header.dart'; @@ -21,10 +22,9 @@ class PlayerWidget extends StatefulWidget { } class _PlayerWidgetState extends State { - // ... (keep existing state vars) - Music? _currentMusic; List _lyrics = []; + bool _isLoadingLyric = false; double _dragOffset = 0; bool _isDragging = false; @@ -45,23 +45,33 @@ class _PlayerWidgetState extends State { setState(() { _currentMusic = music; _lyrics = []; + _isLoadingLyric = false; }); - _loadLyric(music.id); + // 纯音乐不加载歌词 + if (!music.isInstrumental) { + _loadLyric(music); + } } - Future _loadLyric(String id) async { + Future _loadLyric(Music music) async { + setState(() { + _isLoadingLyric = true; + }); + try { - final lrc = await getLyric(id: id); - if (mounted && _currentMusic?.id == id) { + final lrc = await getLyric(id: music.id); + if (mounted && _currentMusic?.id == music.id) { setState(() { _lyrics = LyricParser.parse(lrc); + _isLoadingLyric = false; }); } } catch (e) { - if (mounted && _currentMusic?.id == id) { + if (mounted && _currentMusic?.id == music.id) { setState(() { - // Optionally show error or empty lyrics + _lyrics = []; + _isLoadingLyric = false; }); } } @@ -170,6 +180,10 @@ class _PlayerWidgetState extends State { return LyricView( lyrics: _lyrics, currentPosition: position, + isLoading: _isLoadingLyric, + isInstrumental: displayMusic.isInstrumental, + coverUrl: displayMusic.cover, + isPlaying: context.watch().playing, onTap: () { // Tap to toggle controls visibility? for now do nothing or standard }, @@ -211,34 +225,36 @@ class _PlayerBackground extends StatelessWidget { @override Widget build(BuildContext context) { return Stack( + fit: StackFit.expand, children: [ - Positioned.fill( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 500), - switchInCurve: Curves.easeIn, - switchOutCurve: Curves.easeOut, - child: coverUrl != null - ? Image.network( - coverUrl!, - key: ValueKey(coverUrl), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - key: const ValueKey('error'), - color: Colors.grey[900], - ), - ) - : Container( - key: const ValueKey('default'), + // 背景图片 + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + child: coverUrl != null + ? Image.network( + coverUrl!, + key: ValueKey(coverUrl), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + alignment: Alignment.center, + errorBuilder: (_, __, ___) => Container( + key: const ValueKey('error'), color: Colors.grey[900], ), - ), + ) + : Container( + key: const ValueKey('default'), + color: Colors.grey[900], + ), ), - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container(color: Colors.black.withValues(alpha: 0.5)), - ), + // 模糊遮罩层 + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container(color: Colors.black.withValues(alpha: 0.5)), ), ], ); diff --git a/apps/flutter/lib/widgets/player/lyric_view.dart b/apps/flutter/lib/widgets/player/lyric_view.dart index ce3797e2..7ea51409 100644 --- a/apps/flutter/lib/widgets/player/lyric_view.dart +++ b/apps/flutter/lib/widgets/player/lyric_view.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../models/lyric.dart'; @@ -6,20 +7,30 @@ class LyricView extends StatefulWidget { final List lyrics; final Duration currentPosition; final VoidCallback? onTap; + final bool isLoading; + final bool isInstrumental; + final String? coverUrl; + final bool isPlaying; const LyricView({ super.key, required this.lyrics, required this.currentPosition, this.onTap, + this.isLoading = false, + this.isInstrumental = false, + this.coverUrl, + this.isPlaying = false, }); @override State createState() => _LyricViewState(); } -class _LyricViewState extends State { +class _LyricViewState extends State + with SingleTickerProviderStateMixin { late FixedExtentScrollController _scrollController; + late AnimationController _rotationController; int _currentIndex = 0; bool _isUserScrolling = false; Timer? _userScrollTimer; @@ -29,11 +40,20 @@ class _LyricViewState extends State { void initState() { super.initState(); _scrollController = FixedExtentScrollController(); + _rotationController = AnimationController( + duration: const Duration(seconds: 20), // 20秒转一圈 + vsync: this, + ); + // 如果是纯音乐且正在播放,开始旋转 + if (widget.isInstrumental && widget.isPlaying) { + _rotationController.repeat(); + } } @override void dispose() { _scrollController.dispose(); + _rotationController.dispose(); _userScrollTimer?.cancel(); super.dispose(); } @@ -41,6 +61,18 @@ class _LyricViewState extends State { @override void didUpdateWidget(covariant LyricView oldWidget) { super.didUpdateWidget(oldWidget); + + // 控制旋转动画 + if (widget.isInstrumental && widget.isPlaying) { + if (!_rotationController.isAnimating) { + _rotationController.repeat(); + } + } else { + if (_rotationController.isAnimating) { + _rotationController.stop(); + } + } + // Always update when position changes to ensure responsiveness during seeks if (oldWidget.currentPosition.inMilliseconds != widget.currentPosition.inMilliseconds) { @@ -108,16 +140,101 @@ class _LyricViewState extends State { @override Widget build(BuildContext context) { + // 使用 AnimatedSwitcher 实现状态切换的过渡动画 + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _buildContent(), + ); + } + + Widget _buildContent() { + // 加载中状态 + if (widget.isLoading) { + return Center( + key: const ValueKey('loading'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation( + Colors.white.withValues(alpha: 0.6), + ), + ), + ), + const SizedBox(height: 16), + Text( + '歌词加载中...', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 14, + ), + ), + ], + ), + ); + } + + // 纯音乐状态 - 显示旋转的大封面 + if (widget.isInstrumental) { + return Center( + key: const ValueKey('instrumental'), + child: AnimatedBuilder( + animation: _rotationController, + builder: (context, child) { + return Transform.rotate( + angle: _rotationController.value * 2 * math.pi, + child: child, + ); + }, + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 30, + spreadRadius: 5, + ), + ], + ), + child: ClipOval( + child: widget.coverUrl != null + ? Image.network( + widget.coverUrl!, + fit: BoxFit.cover, + width: 220, + height: 220, + errorBuilder: (_, __, ___) => _buildDefaultCover(), + ) + : _buildDefaultCover(), + ), + ), + ), + ); + } + + // 无歌词状态 if (widget.lyrics.isEmpty) { return Center( + key: const ValueKey('empty'), child: Text( - 'No lyrics', + '暂无歌词', style: TextStyle(color: Colors.white.withValues(alpha: 0.6)), ), ); } + // 正常歌词显示 return GestureDetector( + key: const ValueKey('lyrics'), onTap: widget.onTap, child: NotificationListener( onNotification: (notification) { @@ -138,7 +255,7 @@ class _LyricViewState extends State { }, child: ListWheelScrollView.useDelegate( controller: _scrollController, - itemExtent: 40, + itemExtent: 56, // 增加高度以容纳两行歌词 diameterRatio: 1.5, physics: const FixedExtentScrollPhysics(), perspective: 0.002, @@ -150,20 +267,26 @@ class _LyricViewState extends State { final isCurrent = index == _currentIndex; final line = widget.lyrics[index]; return Center( - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 200), - style: TextStyle( - color: isCurrent - ? Colors.white - : Colors.white.withValues(alpha: 0.4), - fontSize: isCurrent ? 18 : 14, - fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, - ), - child: Text( - line.content, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + color: isCurrent + ? Colors.white + : Colors.white.withValues(alpha: 0.4), + fontSize: isCurrent ? 18 : 14, + fontWeight: isCurrent + ? FontWeight.bold + : FontWeight.normal, + height: 1.4, // 增加行高 + ), + child: Text( + line.content, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ), ), ); @@ -174,4 +297,17 @@ class _LyricViewState extends State { ), ); } + + Widget _buildDefaultCover() { + return Container( + width: 220, + height: 220, + color: Colors.grey[800], + child: Icon( + Icons.music_note, + size: 80, + color: Colors.white.withValues(alpha: 0.5), + ), + ); + } } From de93de51bbc27c1b3f778656f62e3e751989fbe3 Mon Sep 17 00:00:00 2001 From: mebtte Date: Sun, 1 Feb 2026 23:17:50 +0800 Subject: [PATCH 28/50] improve player-error --- apps/flutter/lib/audio_handler.dart | 31 +++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 0b1be521..7d2b5d90 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -9,6 +9,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { PlayqueueMusic? lastQueueMusic; final player = AudioPlayer(); final _playTimer = Stopwatch(); + String? _currentLoadingPid; // 跟踪当前正在加载的歌曲,用于处理竞态条件 MyAudioHandler() { _initStreams(); @@ -94,10 +95,25 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { Future playQueueMusic(PlayqueueMusic queueMusic) async { _playTimer.reset(); + + // 记录当前正在加载的歌曲 ID,用于检测竞态条件 + final loadingPid = queueMusic.pid; + _currentLoadingPid = loadingPid; + + // 先停止当前播放,避免干扰 + await player.stop(); + try { var duration = await player.setAudioSource( AudioSource.uri(Uri.parse(queueMusic.music.asset)), ); + + // 检查是否仍是当前要播放的歌曲(用户可能在加载过程中切换了歌曲) + if (_currentLoadingPid != loadingPid) { + // 用户已切换到其他歌曲,忽略此次加载结果 + return; + } + player.play(); var item = MediaItem( @@ -111,12 +127,15 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { ); mediaItem.add(item); } catch (e) { - eventBus.fire( - PlayErrorEvent( - musicName: queueMusic.music.name, - errorMessage: e.toString(), - ), - ); + // 只有当错误发生时仍是当前歌曲才显示错误 + if (_currentLoadingPid == loadingPid) { + eventBus.fire( + PlayErrorEvent( + musicName: queueMusic.music.name, + errorMessage: e.toString(), + ), + ); + } } } From 391bdefc5eb1f2f5cf3654f08ae07cd53456943f Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 2 Feb 2026 22:25:08 +0800 Subject: [PATCH 29/50] improve play option --- .../lib/pages/musicbill/music_list_item.dart | 3 ++- .../lib/pages/musicbill/music_option_menu.dart | 17 +++++++++-------- .../search/music_with_lyric_list_item.dart | 3 ++- .../player_controller/show_playlist_dialog.dart | 1 + 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/flutter/lib/pages/musicbill/music_list_item.dart b/apps/flutter/lib/pages/musicbill/music_list_item.dart index e9baa24b..6458ee26 100644 --- a/apps/flutter/lib/pages/musicbill/music_list_item.dart +++ b/apps/flutter/lib/pages/musicbill/music_list_item.dart @@ -83,12 +83,13 @@ class MusicListItem extends StatelessWidget { late OverlayEntry entry; entry = OverlayEntry( - builder: (context) => MusicOptionMenu( + builder: (_) => MusicOptionMenu( music: music, onPlay: onTap, onClose: () { entry.remove(); }, + parentContext: context, ), ); diff --git a/apps/flutter/lib/pages/musicbill/music_option_menu.dart b/apps/flutter/lib/pages/musicbill/music_option_menu.dart index eecd7052..5f924ee7 100644 --- a/apps/flutter/lib/pages/musicbill/music_option_menu.dart +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -1,18 +1,21 @@ import 'package:flutter/material.dart'; import '../../models/music.dart'; import '../../event_bus.dart'; +import '../../player_controller/show_playlist_dialog.dart'; /// 自定义音乐选项菜单(Overlay 实现,覆盖 PlayerController) class MusicOptionMenu extends StatefulWidget { final Music music; final VoidCallback onPlay; final VoidCallback onClose; + final BuildContext? parentContext; // 用于显示播放队列弹窗 const MusicOptionMenu({ super.key, required this.music, required this.onPlay, required this.onClose, + this.parentContext, }); @override @@ -82,6 +85,7 @@ class _MusicOptionMenuState extends State borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: SafeArea( + top: false, // 不需要顶部安全区域 child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -134,7 +138,7 @@ class _MusicOptionMenuState extends State ), ListTile( leading: const Icon(Icons.playlist_add_rounded), - title: const Text('Add to queue'), + title: const Text('Insert to queue'), onTap: () { _close(); eventBus.fire( @@ -142,13 +146,10 @@ class _MusicOptionMenuState extends State musicList: [widget.music], ), ); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Added to queue'), - behavior: SnackBarBehavior.floating, - duration: Duration(seconds: 2), - ), - ); + // 显示播放队列弹窗 + if (widget.parentContext != null) { + showPlaylistDialog(widget.parentContext!); + } }, ), const SizedBox(height: 16), diff --git a/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart index ce6fd4bb..1a2bdd75 100644 --- a/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart +++ b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart @@ -157,12 +157,13 @@ class MusicWithLyricListItem extends StatelessWidget { late OverlayEntry entry; entry = OverlayEntry( - builder: (context) => MusicOptionMenu( + builder: (_) => MusicOptionMenu( music: music, onPlay: onTap, onClose: () { entry.remove(); }, + parentContext: context, ), ); diff --git a/apps/flutter/lib/player_controller/show_playlist_dialog.dart b/apps/flutter/lib/player_controller/show_playlist_dialog.dart index e32ed2af..63ada2d9 100644 --- a/apps/flutter/lib/player_controller/show_playlist_dialog.dart +++ b/apps/flutter/lib/player_controller/show_playlist_dialog.dart @@ -6,6 +6,7 @@ void showPlaylistDialog(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: true, // 确保弹窗显示在播放控制器之上 backgroundColor: Colors.transparent, builder: (context) { return FractionallySizedBox( From 600cedec189174538cc7f1f3a392e6e3e9aecff7 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 2 Feb 2026 22:44:22 +0800 Subject: [PATCH 30/50] improve playlist/playqueue --- apps/flutter/lib/pages/musicbill/actions.dart | 3 + .../lib/pages/musicbill/bottom_toolbar.dart | 13 +- .../pages/musicbill/music_option_menu.dart | 7 +- .../lib/player_controller/playlist.dart | 158 +++++++++++++++--- .../lib/player_controller/playqueue.dart | 91 +++++++++- .../show_playlist_dialog.dart | 10 +- apps/flutter/lib/states/playlist.dart | 6 + 7 files changed, 247 insertions(+), 41 deletions(-) diff --git a/apps/flutter/lib/pages/musicbill/actions.dart b/apps/flutter/lib/pages/musicbill/actions.dart index 59e93fb3..7ef5db1a 100644 --- a/apps/flutter/lib/pages/musicbill/actions.dart +++ b/apps/flutter/lib/pages/musicbill/actions.dart @@ -1,5 +1,6 @@ import 'package:cicada/event_bus.dart'; import 'package:cicada/states/musicbill.dart'; +import 'package:cicada/player_controller/show_playlist_dialog.dart'; import 'package:flutter/material.dart'; class Actions extends StatelessWidget { @@ -22,6 +23,8 @@ class Actions extends StatelessWidget { musicList: musicbill.musicList, ), ); + // 显示播放列表弹窗,定位到播放列表 tab + showPlaylistDialog(context, initialTabIndex: 0); } : null, borderRadius: BorderRadius.circular(20), diff --git a/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart b/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart index 288bfa60..904722d2 100644 --- a/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart +++ b/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../states/musicbill.dart'; import '../../widgets/safe_tooltip.dart'; import '../../event_bus.dart'; +import '../../player_controller/show_playlist_dialog.dart'; /// 音乐清单底部工具栏组件 /// 提供返回按钮和添加全部到播放列表功能 @@ -76,16 +77,8 @@ class BottomToolbar extends StatelessWidget { eventBus.fire( AddMusicListToPlaylistEvent(musicList: musicbill.musicList), ); - // 显示提示 - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '${musicbill.musicList.length} tracks added to playlist', - ), - duration: const Duration(seconds: 2), - behavior: SnackBarBehavior.floating, - ), - ); + // 显示播放列表弹窗,定位到播放列表 tab + showPlaylistDialog(context, initialTabIndex: 0); } : null, borderRadius: BorderRadius.circular(20), diff --git a/apps/flutter/lib/pages/musicbill/music_option_menu.dart b/apps/flutter/lib/pages/musicbill/music_option_menu.dart index 5f924ee7..8798b021 100644 --- a/apps/flutter/lib/pages/musicbill/music_option_menu.dart +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -146,9 +146,12 @@ class _MusicOptionMenuState extends State musicList: [widget.music], ), ); - // 显示播放队列弹窗 + // 显示播放列表弹窗,定位到播放列表 tab if (widget.parentContext != null) { - showPlaylistDialog(widget.parentContext!); + showPlaylistDialog( + widget.parentContext!, + initialTabIndex: 0, + ); } }, ), diff --git a/apps/flutter/lib/player_controller/playlist.dart b/apps/flutter/lib/player_controller/playlist.dart index 475be483..c50cd480 100644 --- a/apps/flutter/lib/player_controller/playlist.dart +++ b/apps/flutter/lib/player_controller/playlist.dart @@ -1,5 +1,6 @@ import 'package:cicada/event_bus.dart'; import 'package:cicada/states/playlist.dart'; +import 'package:cicada/states/playqueue.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -9,27 +10,144 @@ class Playlist extends StatelessWidget { @override Widget build(BuildContext context) { final playlist = context.watch().playlist; - return playlist.isEmpty - ? Text("Playlist is empty") - : Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: playlist.length, - itemBuilder: (context, index) { - final playlistMusic = playlist[index]; - return ListTile( - leading: const Icon(Icons.music_note_outlined), - title: Text(playlistMusic.music.name), - onTap: () => eventBus.fire( - PlayMusicEvent(music: playlistMusic.music), + final currentMusic = context.watch().currentMusic; + + if (playlist.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.playlist_play_outlined, + size: 48, + color: Colors.grey[300], + ), + const SizedBox(height: 12), + Text( + 'Playlist is empty', + style: TextStyle(color: Colors.grey[500], fontSize: 14), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: playlist.length, + itemBuilder: (context, index) { + // 倒序显示:最新添加的在顶部 + final playlistMusic = playlist[playlist.length - index - 1]; + final displayIndex = playlist.length - index; + final music = playlistMusic.music; + final artistNames = music.singers.map((s) => s.name).join(', '); + final isCurrentPlaying = currentMusic?.music.id == music.id; + final primaryColor = Theme.of(context).primaryColor; + + return InkWell( + onTap: () => eventBus.fire(PlayMusicEvent(music: music)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // 索引 + SizedBox( + width: 28, + child: Text( + '$displayIndex', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isCurrentPlaying ? primaryColor : Colors.grey[400], + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + // 音乐信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + music.name, + style: TextStyle( + fontSize: 14, + fontWeight: isCurrentPlaying + ? FontWeight.w600 + : FontWeight.w400, + color: isCurrentPlaying + ? primaryColor + : Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ); - }, + if (artistNames.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + artistNames, + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), ), - ), - Text("action"), - ], - ); + // 删除按钮 + IconButton( + icon: Icon( + Icons.close_rounded, + size: 18, + color: Colors.grey[400], + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: () => _showDeleteConfirmDialog( + context, + playlistMusic.pid, + music.name, + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _showDeleteConfirmDialog( + BuildContext context, + String pid, + String musicName, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirm Delete'), + content: Text('Remove "$musicName" from playlist?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + context.read().removeMusic(pid); + Navigator.pop(context); + }, + child: Text('Delete', style: TextStyle(color: Colors.red[600])), + ), + ], + ), + ); } } diff --git a/apps/flutter/lib/player_controller/playqueue.dart b/apps/flutter/lib/player_controller/playqueue.dart index 4ad4e0d1..c1b87e29 100644 --- a/apps/flutter/lib/player_controller/playqueue.dart +++ b/apps/flutter/lib/player_controller/playqueue.dart @@ -7,15 +7,96 @@ class Playqueue extends StatelessWidget { @override Widget build(BuildContext context) { - final playqueue = context.watch().playqueue; + final playqueueState = context.watch(); + final playqueue = playqueueState.playqueue; + final currentMusic = playqueueState.currentMusic; + + if (playqueue.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.queue_music_outlined, size: 48, color: Colors.grey[300]), + const SizedBox(height: 12), + Text( + 'Playqueue is empty', + style: TextStyle(color: Colors.grey[500], fontSize: 14), + ), + ], + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), itemCount: playqueue.length, itemBuilder: (context, index) { final playqueueMusic = playqueue[playqueue.length - index - 1]; - return ListTile( - leading: const Icon(Icons.music_note_outlined), - title: Text(playqueueMusic.music.name), - onTap: () {}, + final displayIndex = playqueue.length - index; + final music = playqueueMusic.music; + final artistNames = music.singers.map((s) => s.name).join(', '); + final isCurrentPlaying = currentMusic?.pid == playqueueMusic.pid; + final primaryColor = Theme.of(context).primaryColor; + + return InkWell( + onTap: () { + // TODO: 跳转播放 + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // 索引 + SizedBox( + width: 28, + child: Text( + '$displayIndex', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isCurrentPlaying ? primaryColor : Colors.grey[400], + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + // 音乐信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + music.name, + style: TextStyle( + fontSize: 14, + fontWeight: isCurrentPlaying + ? FontWeight.w600 + : FontWeight.w400, + color: isCurrentPlaying + ? primaryColor + : Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (artistNames.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + artistNames, + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), ); }, ); diff --git a/apps/flutter/lib/player_controller/show_playlist_dialog.dart b/apps/flutter/lib/player_controller/show_playlist_dialog.dart index 63ada2d9..b11905d5 100644 --- a/apps/flutter/lib/player_controller/show_playlist_dialog.dart +++ b/apps/flutter/lib/player_controller/show_playlist_dialog.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import './playlist.dart'; import './playqueue.dart'; -void showPlaylistDialog(BuildContext context) { +/// 显示播放列表/队列弹窗 +/// [initialTabIndex] 0=播放列表, 1=播放队列 +void showPlaylistDialog(BuildContext context, {int initialTabIndex = 1}) { showModalBottomSheet( context: context, isScrollControlled: true, @@ -18,7 +20,7 @@ void showPlaylistDialog(BuildContext context) { ), child: DefaultTabController( length: 2, - initialIndex: 1, + initialIndex: initialTabIndex, child: Column( children: [ Container( @@ -32,8 +34,8 @@ void showPlaylistDialog(BuildContext context) { ), const TabBar( tabs: [ - Tab(text: "播放列表"), - Tab(text: "播放队列"), + Tab(text: "Playlist"), + Tab(text: "Playqueue"), ], ), Expanded( diff --git a/apps/flutter/lib/states/playlist.dart b/apps/flutter/lib/states/playlist.dart index 1605064f..259c7b34 100644 --- a/apps/flutter/lib/states/playlist.dart +++ b/apps/flutter/lib/states/playlist.dart @@ -28,6 +28,12 @@ class PlaylistState extends ChangeNotifier { notifyListeners(); } + /// 从播放列表中移除音乐 + void removeMusic(String pid) { + playlist.removeWhere((m) => m.pid == pid); + notifyListeners(); + } + void Function() subscribe() { final playMusicSubscription = eventBus.on().listen((event) { addMusicList([event.music]); From d359e91a83da1e5cac363bbab421de9ef8d36fd2 Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 3 Feb 2026 21:44:55 +0800 Subject: [PATCH 31/50] fix playqueue updating --- apps/flutter/lib/audio_handler.dart | 5 +- .../pages/musicbill/music_option_menu.dart | 17 +-- apps/flutter/lib/states/playqueue.dart | 10 ++ .../lib/widgets/play_error_dialog.dart | 109 +++++++++++------- 4 files changed, 93 insertions(+), 48 deletions(-) diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 7d2b5d90..08c5af01 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -10,6 +10,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final player = AudioPlayer(); final _playTimer = Stopwatch(); String? _currentLoadingPid; // 跟踪当前正在加载的歌曲,用于处理竞态条件 + ProcessingState? _lastProcessingState; MyAudioHandler() { _initStreams(); @@ -37,9 +38,11 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { audioState.updatePlaying(state.playing); _broadcastState(); - if (state.processingState == ProcessingState.completed) { + if (state.processingState == ProcessingState.completed && + _lastProcessingState != ProcessingState.completed) { playqueueState.next(); } + _lastProcessingState = state.processingState; }); } diff --git a/apps/flutter/lib/pages/musicbill/music_option_menu.dart b/apps/flutter/lib/pages/musicbill/music_option_menu.dart index 8798b021..459047f3 100644 --- a/apps/flutter/lib/pages/musicbill/music_option_menu.dart +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -138,7 +138,7 @@ class _MusicOptionMenuState extends State ), ListTile( leading: const Icon(Icons.playlist_add_rounded), - title: const Text('Insert to queue'), + title: const Text('Insert to playqueue'), onTap: () { _close(); eventBus.fire( @@ -147,12 +147,15 @@ class _MusicOptionMenuState extends State ), ); // 显示播放列表弹窗,定位到播放列表 tab - if (widget.parentContext != null) { - showPlaylistDialog( - widget.parentContext!, - initialTabIndex: 0, - ); - } + Future.delayed(const Duration(milliseconds: 100), () { + if (widget.parentContext != null && + widget.parentContext!.mounted) { + showPlaylistDialog( + widget.parentContext!, + initialTabIndex: 1, + ); + } + }); }, ), const SizedBox(height: 16), diff --git a/apps/flutter/lib/states/playqueue.dart b/apps/flutter/lib/states/playqueue.dart index 193d9c8a..e8040008 100644 --- a/apps/flutter/lib/states/playqueue.dart +++ b/apps/flutter/lib/states/playqueue.dart @@ -84,6 +84,16 @@ class PlayqueueState extends ChangeNotifier { final random = Random(); jump(event.musicList[random.nextInt(event.musicList.length)]); next(); + } else { + final newItems = event.musicList + .map((music) => PlayqueueMusic(pid: uuid.v4(), music: music)) + .toList(); + playqueue = [ + ...playqueue.sublist(0, playqueueIndex + 1), + ...newItems, + ...playqueue.sublist(playqueueIndex + 1), + ]; + notifyListeners(); } }); return () { diff --git a/apps/flutter/lib/widgets/play_error_dialog.dart b/apps/flutter/lib/widgets/play_error_dialog.dart index e984b52a..783ff8c5 100644 --- a/apps/flutter/lib/widgets/play_error_dialog.dart +++ b/apps/flutter/lib/widgets/play_error_dialog.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import '../event_bus.dart'; import '../states/playqueue.dart'; -/// 显示播放失败对话框 -/// 返回 true 表示用户取消了自动播放下一首,false 表示自动播放下一首 +/// Display playback error dialog +/// Returns true indicating the user cancelled autoplaying the next song, false indicates autoplay proceeded void showPlayErrorDialog(BuildContext context, PlayErrorEvent event) { showDialog( context: context, @@ -72,16 +72,17 @@ class _PlayErrorDialogState extends State<_PlayErrorDialog> { Widget build(BuildContext context) { return AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - backgroundColor: Colors.grey[900], + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, title: Row( children: [ - Icon(Icons.error_outline, color: Colors.red[400], size: 28), + Icon(Icons.error_outline_rounded, color: Colors.red[400], size: 28), const SizedBox(width: 12), - Expanded( + const Expanded( child: Text( - '播放失败', + 'Playback Error', style: TextStyle( - color: Colors.white, + color: Colors.black87, fontSize: 20, fontWeight: FontWeight.bold, ), @@ -93,68 +94,96 @@ class _PlayErrorDialogState extends State<_PlayErrorDialog> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '无法播放「${widget.event.musicName}」', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.9), - fontSize: 16, + RichText( + text: TextSpan( + style: const TextStyle(fontSize: 16, color: Colors.black87), + children: [ + const TextSpan(text: 'Unable to play '), + TextSpan( + text: ' "${widget.event.musicName}"', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], ), ), const SizedBox(height: 8), - Text( - widget.event.errorMessage, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 13, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 20), Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.skip_next, color: Colors.blue[400], size: 20), + Icon( + Icons.info_outline_rounded, + size: 16, + color: Colors.grey[600], + ), const SizedBox(width: 8), - Text( - '$_remainingSeconds 秒后自动播放下一首', - style: TextStyle( - color: Colors.blue[400], - fontSize: 14, - fontWeight: FontWeight.w500, + Expanded( + child: Text( + widget.event.errorMessage, + style: TextStyle( + color: Colors.grey[700], + fontSize: 13, + fontFamily: 'monospace', + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, ), ), ], ), ), + const SizedBox(height: 24), + LinearProgressIndicator( + value: 1 - (_remainingSeconds / _countdownSeconds), + backgroundColor: Colors.grey[200], + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(2), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.skip_next_rounded, + color: Theme.of(context).primaryColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Auto skipping in $_remainingSeconds s', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ], ), actions: [ TextButton( onPressed: _cancel, style: TextButton.styleFrom( - foregroundColor: Colors.white.withValues(alpha: 0.7), + foregroundColor: Colors.grey[600], padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), - child: const Text('取消自动播放', style: TextStyle(fontSize: 15)), + child: const Text('Cancel'), ), - ElevatedButton( + FilledButton( onPressed: _playNext, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue[600], - foregroundColor: Colors.white, + style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), - child: const Text('立即播放下一首', style: TextStyle(fontSize: 15)), + child: const Text('Skip Now'), ), ], ); From 96c4fc6d79e50320e176c88eb2221aa5bc59cec7 Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 3 Feb 2026 21:53:09 +0800 Subject: [PATCH 32/50] improve playqueue --- .../lib/player_controller/playqueue.dart | 44 +++++++++++++++++++ apps/flutter/lib/states/playqueue.dart | 28 ++++++++++++ 2 files changed, 72 insertions(+) diff --git a/apps/flutter/lib/player_controller/playqueue.dart b/apps/flutter/lib/player_controller/playqueue.dart index c1b87e29..78aa918f 100644 --- a/apps/flutter/lib/player_controller/playqueue.dart +++ b/apps/flutter/lib/player_controller/playqueue.dart @@ -94,6 +94,50 @@ class Playqueue extends StatelessWidget { ], ), ), + if (playqueue.length - index - 1 > + playqueueState.playqueueIndex) + IconButton( + icon: Icon(Icons.close, size: 18, color: Colors.grey[400]), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove from playqueue'), + content: Text( + 'Are you sure you want to remove "${music.name}" from the playqueue?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + playqueueState.remove(playqueueMusic); + Navigator.of(context).pop(); + }, + child: const Text( + 'Remove', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + }, + ), + if (playqueue.length - index - 1 < + playqueueState.playqueueIndex) + IconButton( + icon: Icon( + Icons.settings_backup_restore, + size: 18, + color: Colors.grey[400], + ), + onPressed: () { + playqueueState.rewind(playqueueMusic); + }, + ), ], ), ), diff --git a/apps/flutter/lib/states/playqueue.dart b/apps/flutter/lib/states/playqueue.dart index e8040008..900a416e 100644 --- a/apps/flutter/lib/states/playqueue.dart +++ b/apps/flutter/lib/states/playqueue.dart @@ -72,6 +72,34 @@ class PlayqueueState extends ChangeNotifier { } } + void remove(PlayqueueMusic item) { + final index = playqueue.indexWhere((element) => element.pid == item.pid); + if (index != -1) { + playqueue = List.from(playqueue)..removeAt(index); + if (index < playqueueIndex) { + playqueueIndex--; + } else if (index == playqueueIndex) { + // If removing current song, logic might be complex (skip to next?), + // but UI only allows removing NEXT songs, so this might not be hit. + // For safety, let's say if we remove current, we stay at current index + // which now points to the next song, effectively skipping. + // But if it was the last song, we might need to handle empty or end of list. + if (playqueueIndex >= playqueue.length) { + playqueueIndex = playqueue.length - 1; + } + } + notifyListeners(); + } + } + + void rewind(PlayqueueMusic item) { + final index = playqueue.indexWhere((element) => element.pid == item.pid); + if (index != -1) { + playqueueIndex = index; + notifyListeners(); + } + } + void Function() subscribe() { final playMusicSubscription = eventBus.on().listen((event) { jump(event.music); From f0fcf37e1a7cb8383d2b849e0299c77f01fb0912 Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 3 Feb 2026 22:51:04 +0800 Subject: [PATCH 33/50] improve playqueue --- apps/flutter/lib/audio_handler.dart | 7 +- apps/flutter/lib/pages/home/index.dart | 2 +- .../lib/pages/home/user_info_card.dart | 11 +- .../lib/player_controller/actions.dart | 33 ++- .../lib/player_controller/playqueue.dart | 189 +++++++++--------- .../show_playlist_dialog.dart | 108 +++++++--- apps/flutter/lib/states/audio.dart | 10 + apps/flutter/lib/states/playqueue.dart | 35 +++- apps/flutter/lib/widgets/error_view.dart | 4 +- 9 files changed, 250 insertions(+), 149 deletions(-) diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 08c5af01..48ad70b9 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -35,7 +35,12 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } else { _playTimer.stop(); } - audioState.updatePlaying(state.playing); + audioState.updateState( + playing: state.playing, + loading: + state.processingState == ProcessingState.loading || + state.processingState == ProcessingState.buffering, + ); _broadcastState(); if (state.processingState == ProcessingState.completed && diff --git a/apps/flutter/lib/pages/home/index.dart b/apps/flutter/lib/pages/home/index.dart index 8db6ddcb..83ffa4ff 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -16,7 +16,7 @@ class Home extends StatelessWidget { final currentUser = context.watch().currentUser; final currentServer = context.watch().currentServer; - final headerHeight = MediaQuery.of(context).size.width; + final headerHeight = MediaQuery.of(context).size.width / 1.5; return Scaffold( body: CustomScrollView( diff --git a/apps/flutter/lib/pages/home/user_info_card.dart b/apps/flutter/lib/pages/home/user_info_card.dart index 915f5399..049f737f 100644 --- a/apps/flutter/lib/pages/home/user_info_card.dart +++ b/apps/flutter/lib/pages/home/user_info_card.dart @@ -16,17 +16,10 @@ class UserInfoCard extends StatelessWidget { } return AspectRatio( - aspectRatio: 1.0, + aspectRatio: 1.5, child: Container( decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], + color: Theme.of(context).scaffoldBackgroundColor, ), clipBehavior: Clip.hardEdge, child: Stack( diff --git a/apps/flutter/lib/player_controller/actions.dart b/apps/flutter/lib/player_controller/actions.dart index bdafd76a..9078bb95 100644 --- a/apps/flutter/lib/player_controller/actions.dart +++ b/apps/flutter/lib/player_controller/actions.dart @@ -11,20 +11,31 @@ class Actions extends StatelessWidget { @override Widget build(BuildContext context) { - final playing = context.watch().playing; + final audioState = context.watch(); + final playing = audioState.playing; final spacing = SizedBox(width: 2); return Row( children: [ - IconButton( - onPressed: () { - final audioHandler = GetIt.instance.get(); - playing ? audioHandler.pause() : audioHandler.play(); - }, - icon: Icon(playing ? Icons.pause : Icons.play_arrow), - iconSize: 20, - padding: EdgeInsets.zero, - constraints: BoxConstraints(minWidth: 32, minHeight: 32), - ), + if (audioState.loading) + SizedBox( + width: 32, + height: 32, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + IconButton( + onPressed: () { + final audioHandler = GetIt.instance.get(); + playing ? audioHandler.pause() : audioHandler.play(); + }, + icon: Icon(playing ? Icons.pause : Icons.play_arrow), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: BoxConstraints(minWidth: 32, minHeight: 32), + ), spacing, IconButton( onPressed: () { diff --git a/apps/flutter/lib/player_controller/playqueue.dart b/apps/flutter/lib/player_controller/playqueue.dart index 78aa918f..3dd4a338 100644 --- a/apps/flutter/lib/player_controller/playqueue.dart +++ b/apps/flutter/lib/player_controller/playqueue.dart @@ -38,108 +38,115 @@ class Playqueue extends StatelessWidget { final isCurrentPlaying = currentMusic?.pid == playqueueMusic.pid; final primaryColor = Theme.of(context).primaryColor; - return InkWell( - onTap: () { - // TODO: 跳转播放 - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - // 索引 - SizedBox( - width: 28, - child: Text( - '$displayIndex', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: isCurrentPlaying ? primaryColor : Colors.grey[400], - ), - textAlign: TextAlign.center, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // 索引 + SizedBox( + width: 28, + child: Text( + '$displayIndex', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isCurrentPlaying ? primaryColor : Colors.grey[400], ), + textAlign: TextAlign.center, ), - const SizedBox(width: 12), - // 音乐信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - music.name, - style: TextStyle( - fontSize: 14, - fontWeight: isCurrentPlaying - ? FontWeight.w600 - : FontWeight.w400, - color: isCurrentPlaying - ? primaryColor - : Colors.black87, + ), + const SizedBox(width: 12), + // 音乐信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + music.name, + style: TextStyle( + fontSize: 14, + fontWeight: isCurrentPlaying + ? FontWeight.w600 + : FontWeight.w400, + color: isCurrentPlaying + ? primaryColor + : Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), + if (playqueueMusic.isUserAdded) ...[ + const SizedBox(width: 4), + Tooltip( + message: 'Added by you', + child: Icon( + Icons.person_outline_rounded, + size: 14, + color: primaryColor, + ), + ), + ], + ], + ), + if (artistNames.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + artistNames, + style: TextStyle(fontSize: 11, color: Colors.grey[500]), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (artistNames.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - artistNames, - style: TextStyle( - fontSize: 11, - color: Colors.grey[500], - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], ], - ), + ], ), - if (playqueue.length - index - 1 > - playqueueState.playqueueIndex) - IconButton( - icon: Icon(Icons.close, size: 18, color: Colors.grey[400]), - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Remove from playqueue'), - content: Text( - 'Are you sure you want to remove "${music.name}" from the playqueue?', + ), + if (playqueue.length - index - 1 > playqueueState.playqueueIndex) + IconButton( + icon: Icon(Icons.close, size: 18, color: Colors.grey[400]), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove from playqueue'), + content: Text( + 'Are you sure you want to remove "${music.name}" from the playqueue?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - playqueueState.remove(playqueueMusic); - Navigator.of(context).pop(); - }, - child: const Text( - 'Remove', - style: TextStyle(color: Colors.red), - ), + TextButton( + onPressed: () { + playqueueState.remove(playqueueMusic); + Navigator.of(context).pop(); + }, + child: const Text( + 'Remove', + style: TextStyle(color: Colors.red), ), - ], - ), - ); - }, - ), - if (playqueue.length - index - 1 < - playqueueState.playqueueIndex) - IconButton( - icon: Icon( - Icons.settings_backup_restore, - size: 18, - color: Colors.grey[400], - ), - onPressed: () { - playqueueState.rewind(playqueueMusic); - }, + ), + ], + ), + ); + }, + ), + if (playqueue.length - index - 1 < playqueueState.playqueueIndex) + IconButton( + icon: Icon( + Icons.settings_backup_restore, + size: 18, + color: Colors.grey[400], ), - ], - ), + onPressed: () { + playqueueState.rewind(playqueueMusic); + }, + ), + ], ), ); }, diff --git a/apps/flutter/lib/player_controller/show_playlist_dialog.dart b/apps/flutter/lib/player_controller/show_playlist_dialog.dart index b11905d5..dd9e612b 100644 --- a/apps/flutter/lib/player_controller/show_playlist_dialog.dart +++ b/apps/flutter/lib/player_controller/show_playlist_dialog.dart @@ -2,9 +2,12 @@ import 'package:flutter/material.dart'; import './playlist.dart'; import './playqueue.dart'; +// 记录上次打开的 tab 索引,默认为 1 (Playqueue) +int _lastTabIndex = 1; + /// 显示播放列表/队列弹窗 -/// [initialTabIndex] 0=播放列表, 1=播放队列 -void showPlaylistDialog(BuildContext context, {int initialTabIndex = 1}) { +/// [initialTabIndex] 指定初始 tab,如果不指定则使用上次打开的 tab +void showPlaylistDialog(BuildContext context, {int? initialTabIndex}) { showModalBottomSheet( context: context, isScrollControlled: true, @@ -18,34 +21,85 @@ void showPlaylistDialog(BuildContext context, {int initialTabIndex = 1}) { color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - child: DefaultTabController( - length: 2, - initialIndex: initialTabIndex, - child: Column( - children: [ - Container( - margin: const EdgeInsets.only(top: 8, bottom: 4), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - const TabBar( - tabs: [ - Tab(text: "Playlist"), - Tab(text: "Playqueue"), - ], - ), - Expanded( - child: TabBarView(children: [Playlist(), Playqueue()]), - ), - ], - ), + child: _PlaylistDialogContent( + initialIndex: initialTabIndex ?? _lastTabIndex, ), ), ); }, ); } + +class _PlaylistDialogContent extends StatefulWidget { + final int initialIndex; + + const _PlaylistDialogContent({required this.initialIndex}); + + @override + State<_PlaylistDialogContent> createState() => _PlaylistDialogContentState(); +} + +class _PlaylistDialogContentState extends State<_PlaylistDialogContent> + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 2, + vsync: this, + initialIndex: widget.initialIndex, + ); + _tabController.addListener(_handleTabSelection); + } + + void _handleTabSelection() { + //而在 Android 或者其它平台,滑动切换 Tab 时,indexIsChanging 也是 true。 + // 这里我们只需要最终选中的 index + if (!_tabController.indexIsChanging) { + _lastTabIndex = _tabController.index; + } else { + // 点击 TabBar 切换时 indexIsChanging 为 true,但也应该记录? + // 实际上 TabController.index 在动画开始时就会更新为目标 index + _lastTabIndex = _tabController.index; + } + } + + @override + void dispose() { + _tabController.removeListener(_handleTabSelection); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: "Playlist"), + Tab(text: "Playqueue"), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [const Playlist(), const Playqueue()], + ), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/states/audio.dart b/apps/flutter/lib/states/audio.dart index e9f06b5d..afac247b 100644 --- a/apps/flutter/lib/states/audio.dart +++ b/apps/flutter/lib/states/audio.dart @@ -2,7 +2,17 @@ import 'package:flutter/foundation.dart'; class AudioState extends ChangeNotifier { bool playing = false; + bool loading = false; + void updateState({required bool playing, required bool loading}) { + this.playing = playing; + this.loading = loading; + notifyListeners(); + } + + // Keep the old method for backward compatibility if needed, or remove it if I check all usages. + // Usage in audio_handler.dart: audioState.updatePlaying(state.playing); + // I will replace usage in audio_handler.dart next. void updatePlaying(bool p) { playing = p; notifyListeners(); diff --git a/apps/flutter/lib/states/playqueue.dart b/apps/flutter/lib/states/playqueue.dart index 900a416e..9c7d1b76 100644 --- a/apps/flutter/lib/states/playqueue.dart +++ b/apps/flutter/lib/states/playqueue.dart @@ -12,8 +12,13 @@ final uuid = Uuid(); class PlayqueueMusic { final String pid; final Music music; + final bool isUserAdded; - PlayqueueMusic({required this.pid, required this.music}); + PlayqueueMusic({ + required this.pid, + required this.music, + this.isUserAdded = false, + }); } class PlayqueueState extends ChangeNotifier { @@ -22,8 +27,12 @@ class PlayqueueState extends ChangeNotifier { PlayqueueMusic? get currentMusic => playqueue.safeGet(playqueueIndex); - void jump(Music music) { - final playqueueMusic = PlayqueueMusic(pid: uuid.v4(), music: music); + void jump(Music music, {bool isUserAdded = false}) { + final playqueueMusic = PlayqueueMusic( + pid: uuid.v4(), + music: music, + isUserAdded: isUserAdded, + ); if (playqueueIndex == -1) { playqueue = [playqueueMusic, ...playqueue]; } else { @@ -63,7 +72,7 @@ class PlayqueueState extends ChangeNotifier { } else { final random = Random(); final playlistMusic = playlist[random.nextInt(playlist.length)]; - jump(playlistMusic.music); + jump(playlistMusic.music, isUserAdded: false); next(); } } else { @@ -100,9 +109,12 @@ class PlayqueueState extends ChangeNotifier { } } + // Actually, rewind works for both forward and backward if it just sets the index. + // I will just use rewind (or rename it to proper 'jumpTo' but 'rewind' exists). + void Function() subscribe() { final playMusicSubscription = eventBus.on().listen((event) { - jump(event.music); + jump(event.music, isUserAdded: false); next(); }); final addMusicListToPlaylistSubscription = eventBus @@ -110,11 +122,20 @@ class PlayqueueState extends ChangeNotifier { .listen((event) { if (currentMusic == null) { final random = Random(); - jump(event.musicList[random.nextInt(event.musicList.length)]); + jump( + event.musicList[random.nextInt(event.musicList.length)], + isUserAdded: false, + ); next(); } else { final newItems = event.musicList - .map((music) => PlayqueueMusic(pid: uuid.v4(), music: music)) + .map( + (music) => PlayqueueMusic( + pid: uuid.v4(), + music: music, + isUserAdded: true, // User manually added + ), + ) .toList(); playqueue = [ ...playqueue.sublist(0, playqueueIndex + 1), diff --git a/apps/flutter/lib/widgets/error_view.dart b/apps/flutter/lib/widgets/error_view.dart index 788ca234..fe0443d0 100644 --- a/apps/flutter/lib/widgets/error_view.dart +++ b/apps/flutter/lib/widgets/error_view.dart @@ -34,7 +34,7 @@ class ErrorView extends StatelessWidget { Icon(Icons.error_outline, size: 64, color: Colors.red[300]), const SizedBox(height: 16), Text( - title ?? '加载失败', + title ?? 'Failed to load', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey[800], fontWeight: FontWeight.w600, @@ -56,7 +56,7 @@ class ErrorView extends StatelessWidget { FilledButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh, size: 20), - label: Text(retryButtonText ?? '重试'), + label: Text(retryButtonText ?? 'Retry'), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 32, From 777469507451bbf365659a7a6158df76cb0052ea Mon Sep 17 00:00:00 2001 From: mebtte Date: Thu, 5 Feb 2026 22:09:22 +0800 Subject: [PATCH 34/50] add version display --- .vscode/launch.json | 5 ++++- apps/flutter/env.json | 3 +++ apps/flutter/lib/pages/profile/index.dart | 10 ++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 apps/flutter/env.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 49d94ce6..c14f8ef3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,7 +24,10 @@ "program": "${workspaceFolder}/apps/flutter/lib/main.dart", "cwd": "${workspaceFolder}/apps/flutter", "deviceId": "macos", - "flutterMode": "debug" + "flutterMode": "debug", + "toolArgs": [ + "--dart-define-from-file=${workspaceFolder}/apps/flutter/env.json" + ] } ] } diff --git a/apps/flutter/env.json b/apps/flutter/env.json new file mode 100644 index 00000000..042fd9f4 --- /dev/null +++ b/apps/flutter/env.json @@ -0,0 +1,3 @@ +{ + "VERSION": "0.0.0" +} diff --git a/apps/flutter/lib/pages/profile/index.dart b/apps/flutter/lib/pages/profile/index.dart index 0b1011a1..a7740c1f 100644 --- a/apps/flutter/lib/pages/profile/index.dart +++ b/apps/flutter/lib/pages/profile/index.dart @@ -78,6 +78,11 @@ class ProfilePage extends StatelessWidget { /// 构建用户信息列表 Widget _buildUserInfo(BuildContext context, User user, Server? server) { + const appVersion = String.fromEnvironment( + 'VERSION', + defaultValue: 'Unknown', + ); + return Column( children: [ ListTile( @@ -98,6 +103,11 @@ class ProfilePage extends StatelessWidget { ? const Icon(Icons.check_circle, color: Colors.green) : const Icon(Icons.cancel, color: Colors.grey), ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('App Version'), + subtitle: const Text(appVersion), + ), ], ); } From a253957dbb5ea56d656009942f1e6e3cdd98bd4c Mon Sep 17 00:00:00 2001 From: mebtte Date: Thu, 5 Feb 2026 22:13:09 +0800 Subject: [PATCH 35/50] improve app version --- .vscode/launch.json | 5 +---- apps/flutter/env.json | 3 --- apps/flutter/lib/constants/index.dart | 3 ++- apps/flutter/lib/pages/profile/index.dart | 8 ++------ 4 files changed, 5 insertions(+), 14 deletions(-) delete mode 100644 apps/flutter/env.json diff --git a/.vscode/launch.json b/.vscode/launch.json index c14f8ef3..49d94ce6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,10 +24,7 @@ "program": "${workspaceFolder}/apps/flutter/lib/main.dart", "cwd": "${workspaceFolder}/apps/flutter", "deviceId": "macos", - "flutterMode": "debug", - "toolArgs": [ - "--dart-define-from-file=${workspaceFolder}/apps/flutter/env.json" - ] + "flutterMode": "debug" } ] } diff --git a/apps/flutter/env.json b/apps/flutter/env.json deleted file mode 100644 index 042fd9f4..00000000 --- a/apps/flutter/env.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "VERSION": "0.0.0" -} diff --git a/apps/flutter/lib/constants/index.dart b/apps/flutter/lib/constants/index.dart index 4b0f4e1d..8ef18bd7 100644 --- a/apps/flutter/lib/constants/index.dart +++ b/apps/flutter/lib/constants/index.dart @@ -1 +1,2 @@ -final TOKEN_HEADER_KEY = "x-cicada-token"; +const TOKEN_HEADER_KEY = "x-cicada-token"; +const VERSION = String.fromEnvironment('VERSION', defaultValue: 'DEV'); diff --git a/apps/flutter/lib/pages/profile/index.dart b/apps/flutter/lib/pages/profile/index.dart index a7740c1f..f0c429f7 100644 --- a/apps/flutter/lib/pages/profile/index.dart +++ b/apps/flutter/lib/pages/profile/index.dart @@ -1,3 +1,4 @@ +import 'package:cicada/constants/index.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../states/server.dart'; @@ -78,11 +79,6 @@ class ProfilePage extends StatelessWidget { /// 构建用户信息列表 Widget _buildUserInfo(BuildContext context, User user, Server? server) { - const appVersion = String.fromEnvironment( - 'VERSION', - defaultValue: 'Unknown', - ); - return Column( children: [ ListTile( @@ -106,7 +102,7 @@ class ProfilePage extends StatelessWidget { ListTile( leading: const Icon(Icons.info_outline), title: const Text('App Version'), - subtitle: const Text(appVersion), + subtitle: const Text(VERSION), ), ], ); From af21fb4ccf39436de81e8f22ac6a02eafbdd1690 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 9 Feb 2026 21:25:57 +0800 Subject: [PATCH 36/50] android support --- .../android/app/src/main/AndroidManifest.xml | 24 +++++++++ .../kotlin/com/example/cicada/MainActivity.kt | 4 +- apps/flutter/lib/audio_handler.dart | 34 +++++++++++- apps/flutter/lib/main.dart | 19 ++++++- apps/flutter/pubspec.lock | 54 +++++++++++++++++-- apps/flutter/pubspec.yaml | 6 ++- .../flutter/generated_plugin_registrant.cc | 3 ++ .../windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 136 insertions(+), 9 deletions(-) diff --git a/apps/flutter/android/app/src/main/AndroidManifest.xml b/apps/flutter/android/app/src/main/AndroidManifest.xml index 34c3ca47..0003d3b8 100644 --- a/apps/flutter/android/app/src/main/AndroidManifest.xml +++ b/apps/flutter/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,10 @@ + + + + + + + + + + + + + + + + + + + _initPlayer() async { + // 配置音频会话为音乐类型 + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); } void _initStreams() { @@ -53,10 +77,12 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { void _broadcastState() { final state = player.playerState; + final hasPrevious = playqueueState.playqueueIndex > 0; + playbackState.add( PlaybackState( controls: [ - if (playqueueState.playqueueIndex > 0) MediaControl.skipToPrevious, + if (hasPrevious) MediaControl.skipToPrevious, state.playing ? MediaControl.pause : MediaControl.play, MediaControl.skipToNext, ], @@ -70,6 +96,10 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { MediaAction.skipToPrevious, MediaAction.skipToNext, }, + // 在紧凑视图(系统媒体控制面板)中显示的按钮索引 + androidCompactActionIndices: hasPrevious + ? const [0, 1, 2] // previous, play/pause, next + : const [0, 1], // play/pause, next processingState: { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, @@ -82,6 +112,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { bufferedPosition: player.bufferedPosition, speed: player.speed, queueIndex: playqueueState.playqueueIndex, + updateTime: DateTime.now(), ), ); } @@ -134,6 +165,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { duration: duration, ); mediaItem.add(item); + _broadcastState(); // 确保通知更新 } catch (e) { // 只有当错误发生时仍是当前歌曲才显示错误 if (_currentLoadingPid == loadingPid) { diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart index 083f3aad..8a5e735d 100644 --- a/apps/flutter/lib/main.dart +++ b/apps/flutter/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import './states/playlist.dart'; import './utils/preference.dart'; @@ -28,7 +29,23 @@ void main() async { initializeWindow(); } - final audioHandler = await AudioService.init(builder: () => MyAudioHandler()); + // 请求通知权限 (Android 13+) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + await Permission.notification.request(); + } + + final audioHandler = await AudioService.init( + builder: () => MyAudioHandler(), + config: AudioServiceConfig( + androidNotificationChannelId: 'com.mebtte.cicada.audio', + androidNotificationChannelName: 'Cicada Audio', + androidStopForegroundOnPause: false, + androidNotificationIcon: 'mipmap/ic_launcher', + androidShowNotificationBadge: true, + preloadArtwork: true, + androidNotificationClickStartsActivity: true, + ), + ); audioHandler.subscribe(); GetIt.instance.registerSingleton(audioHandler); diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock index 22eb221f..c38858e1 100644 --- a/apps/flutter/pubspec.lock +++ b/apps/flutter/pubspec.lock @@ -42,13 +42,13 @@ packages: source: hosted version: "0.1.4" audio_session: - dependency: transitive + dependency: "direct main" description: name: audio_session - sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.1.25" boolean_selector: dependency: transitive description: @@ -384,6 +384,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/apps/flutter/pubspec.yaml b/apps/flutter/pubspec.yaml index ba34462a..6ff1cb94 100644 --- a/apps/flutter/pubspec.yaml +++ b/apps/flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: cicada -description: "Client for cicada." -publish_to: "none" +description: 'Client for cicada.' +publish_to: 'none' version: 0.1.0 environment: @@ -8,6 +8,7 @@ environment: dependencies: audio_service: ^0.18.18 + audio_session: ^0.1.21 dio: ^5.9.0 event_bus: ^2.0.1 flutter: @@ -15,6 +16,7 @@ dependencies: flutter_svg: ^2.2.0 get_it: ^8.2.0 just_audio: ^0.10.4 + permission_handler: ^11.3.1 provider: ^6.1.5 shared_preferences: ^2.5.3 uuid: ^4.5.1 diff --git a/apps/flutter/windows/flutter/generated_plugin_registrant.cc b/apps/flutter/windows/flutter/generated_plugin_registrant.cc index c6fe39a5..d014922e 100644 --- a/apps/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/apps/flutter/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); WindowManagerPluginRegisterWithRegistrar( diff --git a/apps/flutter/windows/flutter/generated_plugins.cmake b/apps/flutter/windows/flutter/generated_plugins.cmake index 5e3bc3d8..c73b76dd 100644 --- a/apps/flutter/windows/flutter/generated_plugins.cmake +++ b/apps/flutter/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows screen_retriever_windows window_manager ) From 525c465ea8d79b0aba7a8013e1483d50fde4f93c Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 9 Feb 2026 21:53:38 +0800 Subject: [PATCH 37/50] timeout as play-error --- apps/flutter/ios/Podfile.lock | 6 +++++ .../ios/Runner.xcodeproj/project.pbxproj | 18 +++++++++++++ apps/flutter/lib/audio_handler.dart | 25 ++++++++++++++++--- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/flutter/ios/Podfile.lock b/apps/flutter/ios/Podfile.lock index 6760d616..66af7815 100644 --- a/apps/flutter/ios/Podfile.lock +++ b/apps/flutter/ios/Podfile.lock @@ -11,6 +11,8 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -24,6 +26,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - just_audio (from `.symlinks/plugins/just_audio/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -38,6 +41,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/just_audio/darwin" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite_darwin: @@ -49,6 +54,7 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 diff --git a/apps/flutter/ios/Runner.xcodeproj/project.pbxproj b/apps/flutter/ios/Runner.xcodeproj/project.pbxproj index 26ed750c..1117ced9 100644 --- a/apps/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, AA1627D9F4559A7406269F89 /* [CP] Embed Pods Frameworks */, + CAC0CCFCDB6F83242364ABE1 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -362,6 +363,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + CAC0CCFCDB6F83242364ABE1 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 00580377..31712a21 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:cicada/states/audio.dart'; @@ -143,9 +145,15 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { await player.stop(); try { - var duration = await player.setAudioSource( - AudioSource.uri(Uri.parse(queueMusic.music.asset)), - ); + // 设置 60 秒超时 + var duration = await player + .setAudioSource(AudioSource.uri(Uri.parse(queueMusic.music.asset))) + .timeout( + const Duration(seconds: 60), + onTimeout: () { + throw TimeoutException('Loading timeout after 60 seconds'); + }, + ); // 检查是否仍是当前要播放的歌曲(用户可能在加载过程中切换了歌曲) if (_currentLoadingPid != loadingPid) { @@ -166,6 +174,17 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { ); mediaItem.add(item); _broadcastState(); // 确保通知更新 + } on TimeoutException { + // 加载超时 + if (_currentLoadingPid == loadingPid) { + eventBus.fire( + PlayErrorEvent( + musicName: queueMusic.music.name, + errorMessage: + 'Loading timeout, please check your network connection', + ), + ); + } } catch (e) { // 只有当错误发生时仍是当前歌曲才显示错误 if (_currentLoadingPid == loadingPid) { From cb9a21cd8d69f938c6e78e2378e3cee5e148b879 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 9 Feb 2026 22:21:56 +0800 Subject: [PATCH 38/50] add image cache --- .../lib/pages/home/musicbill_card.dart | 18 ++-- .../lib/pages/home/user_info_card.dart | 11 +-- apps/flutter/lib/pages/musicbill/index.dart | 12 ++- .../lib/pages/musicbill/music_list_item.dart | 18 ++-- .../pages/musicbill/music_option_menu.dart | 18 ++-- .../lib/pages/musicbill/musicbill_header.dart | 14 +-- .../search/music_with_lyric_list_item.dart | 18 ++-- .../search_intermediate/cards/music_card.dart | 19 ++-- .../cards/musicbill_card.dart | 19 ++-- .../cards/singer_card.dart | 19 ++-- apps/flutter/lib/player_controller/cover.dart | 17 ++-- .../lib/utils/get_resized_image_url.dart | 14 +++ apps/flutter/lib/widgets/cached_image.dart | 88 +++++++++++++++++++ apps/flutter/lib/widgets/player/index.dart | 15 +++- .../lib/widgets/player/lyric_view.dart | 12 ++- .../lib/widgets/player/player_controls.dart | 47 +++++++--- apps/flutter/pubspec.lock | 32 +++++++ apps/flutter/pubspec.yaml | 1 + 18 files changed, 282 insertions(+), 110 deletions(-) create mode 100644 apps/flutter/lib/utils/get_resized_image_url.dart create mode 100644 apps/flutter/lib/widgets/cached_image.dart diff --git a/apps/flutter/lib/pages/home/musicbill_card.dart b/apps/flutter/lib/pages/home/musicbill_card.dart index cb66307b..fd881ea9 100644 --- a/apps/flutter/lib/pages/home/musicbill_card.dart +++ b/apps/flutter/lib/pages/home/musicbill_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../states/musicbill.dart'; +import '../../widgets/cached_image.dart'; /// 音乐清单卡片组件 /// 显示单个音乐清单的信息(封面、名称) @@ -77,17 +78,14 @@ class MusicbillCard extends StatelessWidget { /// 构建封面 Widget _buildCover(BuildContext context) { if (musicbill.cover != null && musicbill.cover!.isNotEmpty) { - return ClipRRect( + return CachedImage( + imageUrl: musicbill.cover, + width: 44, + height: 44, + size: 88, // 2x for high DPI screens borderRadius: BorderRadius.circular(8), - child: Image.network( - musicbill.cover!, - width: 44, - height: 44, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(context); - }, - ), + placeholder: _buildDefaultCover(context), + errorWidget: _buildDefaultCover(context), ); } diff --git a/apps/flutter/lib/pages/home/user_info_card.dart b/apps/flutter/lib/pages/home/user_info_card.dart index 049f737f..1db51aea 100644 --- a/apps/flutter/lib/pages/home/user_info_card.dart +++ b/apps/flutter/lib/pages/home/user_info_card.dart @@ -1,5 +1,7 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../states/server.dart'; +import '../../utils/get_resized_image_url.dart'; /// 用户信息卡片组件 /// 显示用户头像、昵称、用户名和服务器信息 @@ -91,14 +93,13 @@ class UserInfoCard extends StatelessWidget { /// 构建背景头像 Widget _buildBackgroundAvatar(BuildContext context) { if (user!.avatar != null && user!.avatar!.isNotEmpty) { - return Image.network( - user!.avatar!, + return CachedNetworkImage( + imageUrl: getResizedImageUrl(user!.avatar!, 400), // Background avatar width: double.infinity, height: double.infinity, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultBackground(context); - }, + placeholder: (_, __) => _buildDefaultBackground(context), + errorWidget: (_, __, ___) => _buildDefaultBackground(context), ); } return _buildDefaultBackground(context); diff --git a/apps/flutter/lib/pages/musicbill/index.dart b/apps/flutter/lib/pages/musicbill/index.dart index 1317d6b5..3c741328 100644 --- a/apps/flutter/lib/pages/musicbill/index.dart +++ b/apps/flutter/lib/pages/musicbill/index.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:uuid/uuid.dart'; import 'package:flutter/material.dart'; import '../../utils/get_musicbill_by_id.dart'; +import '../../utils/get_resized_image_url.dart'; import '../../states/musicbill.dart' as musicbill_state; import '../../states/route.dart'; import './bottom_toolbar.dart'; @@ -152,13 +154,15 @@ class _MusicbillState extends State { padding: const EdgeInsets.only(right: 8), child: ClipRRect( borderRadius: BorderRadius.circular(4), - child: Image.network( - musicbill.cover!, + child: CachedNetworkImage( + imageUrl: getResizedImageUrl( + musicbill.cover!, + 40, + ), // 20px * 2 width: 20, height: 20, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - const SizedBox(), + errorWidget: (_, __, ___) => const SizedBox(), ), ), ), diff --git a/apps/flutter/lib/pages/musicbill/music_list_item.dart b/apps/flutter/lib/pages/musicbill/music_list_item.dart index 6458ee26..4335256e 100644 --- a/apps/flutter/lib/pages/musicbill/music_list_item.dart +++ b/apps/flutter/lib/pages/musicbill/music_list_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../models/music.dart'; +import '../../widgets/cached_image.dart'; import 'music_option_menu.dart'; /// 音乐列表项组件 @@ -99,17 +100,14 @@ class MusicListItem extends StatelessWidget { /// 构建封面 Widget _buildCover(BuildContext context) { if (music.cover != null && music.cover!.isNotEmpty) { - return ClipRRect( + return CachedImage( + imageUrl: music.cover, + width: 44, + height: 44, + size: 88, // 2x for high DPI screens borderRadius: BorderRadius.circular(8), - child: Image.network( - music.cover!, - width: 44, - height: 44, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(context); - }, - ), + placeholder: _buildDefaultCover(context), + errorWidget: _buildDefaultCover(context), ); } diff --git a/apps/flutter/lib/pages/musicbill/music_option_menu.dart b/apps/flutter/lib/pages/musicbill/music_option_menu.dart index 459047f3..119c2865 100644 --- a/apps/flutter/lib/pages/musicbill/music_option_menu.dart +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../models/music.dart'; import '../../event_bus.dart'; import '../../player_controller/show_playlist_dialog.dart'; +import '../../widgets/cached_image.dart'; /// 自定义音乐选项菜单(Overlay 实现,覆盖 PlayerController) class MusicOptionMenu extends StatefulWidget { @@ -172,17 +173,14 @@ class _MusicOptionMenuState extends State // 复用 MusicListItem 中的构建逻辑 Widget _buildCover(BuildContext context) { if (widget.music.cover != null && widget.music.cover!.isNotEmpty) { - return ClipRRect( + return CachedImage( + imageUrl: widget.music.cover, + width: 44, + height: 44, + size: 88, // 2x for high DPI screens borderRadius: BorderRadius.circular(8), - child: Image.network( - widget.music.cover!, - width: 44, - height: 44, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(context); - }, - ), + placeholder: _buildDefaultCover(context), + errorWidget: _buildDefaultCover(context), ); } return _buildDefaultCover(context); diff --git a/apps/flutter/lib/pages/musicbill/musicbill_header.dart b/apps/flutter/lib/pages/musicbill/musicbill_header.dart index 7477937d..be1aa02e 100644 --- a/apps/flutter/lib/pages/musicbill/musicbill_header.dart +++ b/apps/flutter/lib/pages/musicbill/musicbill_header.dart @@ -1,5 +1,7 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../states/musicbill.dart'; +import '../../utils/get_resized_image_url.dart'; /// 音乐清单详情页头部组件 /// 显示封面、名称和音乐数量 @@ -84,14 +86,16 @@ class MusicbillHeader extends StatelessWidget { /// 构建背景封面 Widget _buildBackgroundCover(BuildContext context) { if (musicbill.cover != null && musicbill.cover!.isNotEmpty) { - return Image.network( - musicbill.cover!, + return CachedNetworkImage( + imageUrl: getResizedImageUrl( + musicbill.cover!, + 800, + ), // Large background cover width: double.infinity, height: double.infinity, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultBackground(context); - }, + placeholder: (_, __) => _buildDefaultBackground(context), + errorWidget: (_, __, ___) => _buildDefaultBackground(context), ); } return _buildDefaultBackground(context); diff --git a/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart index 1a2bdd75..4d85daae 100644 --- a/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart +++ b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../models/music.dart'; +import '../../widgets/cached_image.dart'; import '../musicbill/music_option_menu.dart'; class MusicWithLyricListItem extends StatelessWidget { @@ -172,17 +173,14 @@ class MusicWithLyricListItem extends StatelessWidget { Widget _buildCover(BuildContext context) { if (music.cover != null && music.cover!.isNotEmpty) { - return ClipRRect( + return CachedImage( + imageUrl: music.cover, + width: 44, + height: 44, + size: 88, // 2x for high DPI screens borderRadius: BorderRadius.circular(8), - child: Image.network( - music.cover!, - width: 44, - height: 44, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(context); - }, - ), + placeholder: _buildDefaultCover(context), + errorWidget: _buildDefaultCover(context), ); } return _buildDefaultCover(context); diff --git a/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart b/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart index c8b38404..9404c7b5 100644 --- a/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart +++ b/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../models/music.dart'; +import '../../../widgets/cached_image.dart'; class MusicCard extends StatelessWidget { final Music music; @@ -22,17 +23,15 @@ class MusicCard extends StatelessWidget { color: Colors.grey[200], ), child: music.cover != null - ? ClipRRect( + ? CachedImage( + imageUrl: music.cover, + size: 200, // Fixed size for exploration cards borderRadius: BorderRadius.circular(8), - child: Image.network( - music.cover!, - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon(Icons.music_note, size: 48), - ); - }, + placeholder: const Center( + child: Icon(Icons.music_note, size: 48), + ), + errorWidget: const Center( + child: Icon(Icons.music_note, size: 48), ), ) : const Center(child: Icon(Icons.music_note, size: 48)), diff --git a/apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart b/apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart index 95117e3d..895f86e0 100644 --- a/apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart +++ b/apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../server/api/get_exploration.dart'; +import '../../../widgets/cached_image.dart'; class MusicbillCard extends StatelessWidget { final ExplorationPublicMusicbill musicbill; @@ -22,17 +23,15 @@ class MusicbillCard extends StatelessWidget { color: Colors.grey[200], ), child: musicbill.cover != null - ? ClipRRect( + ? CachedImage( + imageUrl: musicbill.cover, + size: 200, // Fixed size for exploration cards borderRadius: BorderRadius.circular(8), - child: Image.network( - musicbill.cover!, - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon(Icons.queue_music, size: 48), - ); - }, + placeholder: const Center( + child: Icon(Icons.queue_music, size: 48), + ), + errorWidget: const Center( + child: Icon(Icons.queue_music, size: 48), ), ) : const Center(child: Icon(Icons.queue_music, size: 48)), diff --git a/apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart b/apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart index 6b12a9c4..99bc9a81 100644 --- a/apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart +++ b/apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../server/api/get_exploration.dart'; +import '../../../widgets/cached_image.dart'; class SingerCard extends StatelessWidget { final ExplorationSinger singer; @@ -22,17 +23,15 @@ class SingerCard extends StatelessWidget { color: Colors.grey[200], ), child: singer.avatar != null - ? ClipRRect( + ? CachedImage( + imageUrl: singer.avatar, + size: 200, // Fixed size for exploration cards borderRadius: BorderRadius.circular(8), - child: Image.network( - singer.avatar!, - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon(Icons.person, size: 48), - ); - }, + placeholder: const Center( + child: Icon(Icons.person, size: 48), + ), + errorWidget: const Center( + child: Icon(Icons.person, size: 48), ), ) : const Center(child: Icon(Icons.person, size: 48)), diff --git a/apps/flutter/lib/player_controller/cover.dart b/apps/flutter/lib/player_controller/cover.dart index 90edc8a6..1891232f 100644 --- a/apps/flutter/lib/player_controller/cover.dart +++ b/apps/flutter/lib/player_controller/cover.dart @@ -1,4 +1,6 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:cicada/states/audio.dart'; +import 'package:cicada/utils/get_resized_image_url.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:audio_service/audio_service.dart'; @@ -112,13 +114,16 @@ class _RotatingCoverState extends State key: ValueKey(widget.coverUrl), child: AspectRatio( aspectRatio: 1, - child: Image.network( - widget.coverUrl!, + child: CachedNetworkImage( + imageUrl: getResizedImageUrl( + widget.coverUrl!, + 80, + ), // 40px * 2 fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) { - return _buildDefaultCover(context); - }, + placeholder: (context, url) => + _buildDefaultCover(context), + errorWidget: (context, url, error) => + _buildDefaultCover(context), ), ), ) diff --git a/apps/flutter/lib/utils/get_resized_image_url.dart b/apps/flutter/lib/utils/get_resized_image_url.dart new file mode 100644 index 00000000..18424db8 --- /dev/null +++ b/apps/flutter/lib/utils/get_resized_image_url.dart @@ -0,0 +1,14 @@ +/// 获取指定尺寸的图片 URL +/// +/// 服务端支持通过 `?size=xxx` 参数来获取裁剪后的图片 +/// [url] - 原始图片 URL +/// [size] - 期望的图片尺寸(宽高,取最大值) +String getResizedImageUrl(String url, int size) { + if (url.isEmpty) return url; + + // 处理已经有参数的 URL + if (url.contains('?')) { + return '$url&size=$size'; + } + return '$url?size=$size'; +} diff --git a/apps/flutter/lib/widgets/cached_image.dart b/apps/flutter/lib/widgets/cached_image.dart new file mode 100644 index 00000000..8a8c3b99 --- /dev/null +++ b/apps/flutter/lib/widgets/cached_image.dart @@ -0,0 +1,88 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import '../utils/get_resized_image_url.dart'; + +/// 带缓存的网络图片组件 +/// +/// 支持通过 [size] 参数请求服务端裁剪后的图片,减少传输数据量 +class CachedImage extends StatelessWidget { + final String? imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final Widget? placeholder; + final Widget? errorWidget; + final BorderRadius? borderRadius; + + /// 图片裁剪尺寸(可选) + /// + /// 如果指定了这个值,会在 URL 后添加 `?size=xxx` 参数 + /// 服务端会返回相应尺寸的裁剪图片 + /// + /// 建议设置为显示尺寸的 2 倍以支持高分屏 + final int? size; + + const CachedImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.placeholder, + this.errorWidget, + this.borderRadius, + this.size, + }); + + @override + Widget build(BuildContext context) { + if (imageUrl == null || imageUrl!.isEmpty) { + return _buildPlaceholder(); + } + + // 应用裁剪参数 + String finalUrl = imageUrl!; + if (size != null) { + finalUrl = getResizedImageUrl(imageUrl!, size!); + } + + Widget image = CachedNetworkImage( + imageUrl: finalUrl, + width: width, + height: height, + fit: fit, + placeholder: (context, url) => placeholder ?? _buildPlaceholder(), + errorWidget: (context, url, error) => errorWidget ?? _buildErrorWidget(), + fadeInDuration: const Duration(milliseconds: 200), + fadeOutDuration: const Duration(milliseconds: 200), + ); + + if (borderRadius != null) { + image = ClipRRect(borderRadius: borderRadius!, child: image); + } + + return image; + } + + Widget _buildPlaceholder() { + return Container( + width: width, + height: height, + color: Colors.grey[800], + child: const Center( + child: Icon(Icons.music_note, color: Colors.grey, size: 32), + ), + ); + } + + Widget _buildErrorWidget() { + return Container( + width: width, + height: height, + color: Colors.grey[800], + child: const Center( + child: Icon(Icons.broken_image, color: Colors.grey, size: 32), + ), + ); + } +} diff --git a/apps/flutter/lib/widgets/player/index.dart b/apps/flutter/lib/widgets/player/index.dart index 6fcf2a8b..4d540c47 100644 --- a/apps/flutter/lib/widgets/player/index.dart +++ b/apps/flutter/lib/widgets/player/index.dart @@ -1,11 +1,13 @@ import 'dart:ui'; import 'package:audio_service/audio_service.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/music.dart'; import '../../models/lyric.dart'; import '../../server/api/get_lyric.dart'; import '../../states/audio.dart'; +import '../../utils/get_resized_image_url.dart'; import './lyric_view.dart'; import './player_controls.dart'; import './player_header.dart'; @@ -233,14 +235,21 @@ class _PlayerBackground extends StatelessWidget { switchInCurve: Curves.easeIn, switchOutCurve: Curves.easeOut, child: coverUrl != null - ? Image.network( - coverUrl!, + ? CachedNetworkImage( + imageUrl: getResizedImageUrl( + coverUrl!, + 800, + ), // Full screen background key: ValueKey(coverUrl), fit: BoxFit.cover, width: double.infinity, height: double.infinity, alignment: Alignment.center, - errorBuilder: (_, __, ___) => Container( + placeholder: (_, __) => Container( + key: const ValueKey('loading'), + color: Colors.grey[900], + ), + errorWidget: (_, __, ___) => Container( key: const ValueKey('error'), color: Colors.grey[900], ), diff --git a/apps/flutter/lib/widgets/player/lyric_view.dart b/apps/flutter/lib/widgets/player/lyric_view.dart index 7ea51409..3bc37ebf 100644 --- a/apps/flutter/lib/widgets/player/lyric_view.dart +++ b/apps/flutter/lib/widgets/player/lyric_view.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../models/lyric.dart'; +import '../../utils/get_resized_image_url.dart'; class LyricView extends StatefulWidget { final List lyrics; @@ -207,12 +209,16 @@ class _LyricViewState extends State ), child: ClipOval( child: widget.coverUrl != null - ? Image.network( - widget.coverUrl!, + ? CachedNetworkImage( + imageUrl: getResizedImageUrl( + widget.coverUrl!, + 440, + ), // 220px * 2 fit: BoxFit.cover, width: 220, height: 220, - errorBuilder: (_, __, ___) => _buildDefaultCover(), + placeholder: (_, __) => _buildDefaultCover(), + errorWidget: (_, __, ___) => _buildDefaultCover(), ) : _buildDefaultCover(), ), diff --git a/apps/flutter/lib/widgets/player/player_controls.dart b/apps/flutter/lib/widgets/player/player_controls.dart index 6cf64fb4..79df64ea 100644 --- a/apps/flutter/lib/widgets/player/player_controls.dart +++ b/apps/flutter/lib/widgets/player/player_controls.dart @@ -17,6 +17,10 @@ class PlayerControls extends StatelessWidget { builder: (context, snapshot) { final playbackState = snapshot.data; final playing = playbackState?.playing ?? false; + final processingState = playbackState?.processingState; + final isLoading = + processingState == AudioProcessingState.loading || + processingState == AudioProcessingState.buffering; return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -39,6 +43,8 @@ class PlayerControls extends StatelessWidget { // Play/Pause Container( + width: 72, + height: 72, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white, @@ -50,20 +56,33 @@ class PlayerControls extends StatelessWidget { ), ], ), - child: IconButton( - icon: Icon( - playing ? Icons.pause_rounded : Icons.play_arrow_rounded, - ), - iconSize: 48, - color: Colors.black, - onPressed: () { - if (playing) { - audioHandler.pause(); - } else { - audioHandler.play(); - } - }, - ), + child: isLoading + ? const Center( + child: SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + strokeWidth: 3, + color: Colors.black, + ), + ), + ) + : IconButton( + icon: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + iconSize: 48, + color: Colors.black, + onPressed: () { + if (playing) { + audioHandler.pause(); + } else { + audioHandler.play(); + } + }, + ), ), // Next diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock index c38858e1..c1cf2938 100644 --- a/apps/flutter/pubspec.lock +++ b/apps/flutter/pubspec.lock @@ -57,6 +57,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -320,6 +344,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: diff --git a/apps/flutter/pubspec.yaml b/apps/flutter/pubspec.yaml index 6ff1cb94..1a1e7c1f 100644 --- a/apps/flutter/pubspec.yaml +++ b/apps/flutter/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: audio_service: ^0.18.18 audio_session: ^0.1.21 + cached_network_image: ^3.4.1 dio: ^5.9.0 event_bus: ^2.0.1 flutter: From 8776cdde0ac2f707be377f143eb000808520a2ed Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 9 Feb 2026 22:28:39 +0800 Subject: [PATCH 39/50] add music cache --- .../android/app/src/main/AndroidManifest.xml | 3 +- apps/flutter/lib/audio_handler.dart | 10 ++- apps/flutter/lib/main.dart | 4 + .../lib/utils/audio_cache_manager.dart | 87 +++++++++++++++++++ apps/flutter/pubspec.lock | 2 +- apps/flutter/pubspec.yaml | 1 + 6 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 apps/flutter/lib/utils/audio_cache_manager.dart diff --git a/apps/flutter/android/app/src/main/AndroidManifest.xml b/apps/flutter/android/app/src/main/AndroidManifest.xml index 0003d3b8..1faf21a1 100644 --- a/apps/flutter/android/app/src/main/AndroidManifest.xml +++ b/apps/flutter/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> MyAudioHandler(), config: AudioServiceConfig( diff --git a/apps/flutter/lib/utils/audio_cache_manager.dart b/apps/flutter/lib/utils/audio_cache_manager.dart new file mode 100644 index 00000000..e41dad81 --- /dev/null +++ b/apps/flutter/lib/utils/audio_cache_manager.dart @@ -0,0 +1,87 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:just_audio/just_audio.dart'; + +/// 音频缓存管理器 +/// +/// 管理音频文件的本地缓存,避免重复下载 +class AudioCacheManager { + static AudioCacheManager? _instance; + static AudioCacheManager get instance => _instance ??= AudioCacheManager._(); + + AudioCacheManager._(); + + Directory? _cacheDir; + + /// 初始化缓存目录 + Future init() async { + final appDir = await getApplicationCacheDirectory(); + _cacheDir = Directory('${appDir.path}/audio_cache'); + if (!await _cacheDir!.exists()) { + await _cacheDir!.create(recursive: true); + } + } + + /// 获取缓存目录 + Directory get cacheDir { + if (_cacheDir == null) { + throw StateError('AudioCacheManager not initialized. Call init() first.'); + } + return _cacheDir!; + } + + /// 根据音乐 ID 获取缓存文件路径 + File getCacheFile(String musicId) { + return File('${cacheDir.path}/$musicId.cache'); + } + + /// 检查音乐是否已缓存 + Future isCached(String musicId) async { + final file = getCacheFile(musicId); + return await file.exists(); + } + + /// 获取缓存的音频源 + /// + /// 如果已缓存,返回本地文件源;否则返回带缓存的网络源 + AudioSource getAudioSource(String musicId, String url) { + final cacheFile = getCacheFile(musicId); + + // 使用 LockCachingAudioSource 来缓存音频 + // 如果缓存文件存在,它会直接从缓存读取 + // 如果不存在,它会边下载边播放,同时保存到缓存文件 + return LockCachingAudioSource(Uri.parse(url), cacheFile: cacheFile); + } + + /// 清除所有缓存 + Future clearCache() async { + if (await cacheDir.exists()) { + await cacheDir.delete(recursive: true); + await cacheDir.create(recursive: true); + } + } + + /// 获取缓存大小(字节) + Future getCacheSize() async { + if (!await cacheDir.exists()) return 0; + + int totalSize = 0; + await for (final entity in cacheDir.list(recursive: true)) { + if (entity is File) { + totalSize += await entity.length(); + } + } + return totalSize; + } + + /// 获取格式化的缓存大小 + Future getFormattedCacheSize() async { + final bytes = await getCacheSize(); + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } +} diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock index c1cf2938..4c5212d7 100644 --- a/apps/flutter/pubspec.lock +++ b/apps/flutter/pubspec.lock @@ -369,7 +369,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" diff --git a/apps/flutter/pubspec.yaml b/apps/flutter/pubspec.yaml index 1a1e7c1f..26505b6c 100644 --- a/apps/flutter/pubspec.yaml +++ b/apps/flutter/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: flutter_svg: ^2.2.0 get_it: ^8.2.0 just_audio: ^0.10.4 + path_provider: ^2.1.5 permission_handler: ^11.3.1 provider: ^6.1.5 shared_preferences: ^2.5.3 From 2874364252a92e2200953098986be48712e574a1 Mon Sep 17 00:00:00 2001 From: mebtte Date: Mon, 9 Feb 2026 22:40:32 +0800 Subject: [PATCH 40/50] fix add-all-to-playlist --- apps/flutter/lib/event_bus.dart | 5 +++++ .../lib/pages/musicbill/music_option_menu.dart | 4 +--- apps/flutter/lib/states/playlist.dart | 6 ++++++ apps/flutter/lib/states/playqueue.dart | 13 +++++++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/flutter/lib/event_bus.dart b/apps/flutter/lib/event_bus.dart index 4d14e329..08a6f29f 100644 --- a/apps/flutter/lib/event_bus.dart +++ b/apps/flutter/lib/event_bus.dart @@ -11,6 +11,11 @@ class AddMusicListToPlaylistEvent { AddMusicListToPlaylistEvent({required this.musicList}); } +class InsertToPlayqueueEvent { + List musicList; + InsertToPlayqueueEvent({required this.musicList}); +} + class PlayErrorEvent { final String musicName; final String errorMessage; diff --git a/apps/flutter/lib/pages/musicbill/music_option_menu.dart b/apps/flutter/lib/pages/musicbill/music_option_menu.dart index 119c2865..825cdb99 100644 --- a/apps/flutter/lib/pages/musicbill/music_option_menu.dart +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -143,9 +143,7 @@ class _MusicOptionMenuState extends State onTap: () { _close(); eventBus.fire( - AddMusicListToPlaylistEvent( - musicList: [widget.music], - ), + InsertToPlayqueueEvent(musicList: [widget.music]), ); // 显示播放列表弹窗,定位到播放列表 tab Future.delayed(const Duration(milliseconds: 100), () { diff --git a/apps/flutter/lib/states/playlist.dart b/apps/flutter/lib/states/playlist.dart index 259c7b34..98830442 100644 --- a/apps/flutter/lib/states/playlist.dart +++ b/apps/flutter/lib/states/playlist.dart @@ -43,9 +43,15 @@ class PlaylistState extends ChangeNotifier { .listen((event) { addMusicList(event.musicList); }); + final insertToPlayqueueSubscription = eventBus + .on() + .listen((event) { + addMusicList(event.musicList); + }); return () { playMusicSubscription.cancel(); addMusicListToPlaylistSubscription.cancel(); + insertToPlayqueueSubscription.cancel(); }; } } diff --git a/apps/flutter/lib/states/playqueue.dart b/apps/flutter/lib/states/playqueue.dart index 9c7d1b76..19204b49 100644 --- a/apps/flutter/lib/states/playqueue.dart +++ b/apps/flutter/lib/states/playqueue.dart @@ -119,6 +119,18 @@ class PlayqueueState extends ChangeNotifier { }); final addMusicListToPlaylistSubscription = eventBus .on() + .listen((event) { + if (currentMusic == null) { + final random = Random(); + jump( + event.musicList[random.nextInt(event.musicList.length)], + isUserAdded: false, + ); + next(); + } + }); + final insertToPlayqueueSubscription = eventBus + .on() .listen((event) { if (currentMusic == null) { final random = Random(); @@ -148,6 +160,7 @@ class PlayqueueState extends ChangeNotifier { return () { playMusicSubscription.cancel(); addMusicListToPlaylistSubscription.cancel(); + insertToPlayqueueSubscription.cancel(); }; } } From da41341291f95f8be7d6b286bb63cc07eca581b8 Mon Sep 17 00:00:00 2001 From: mebtte Date: Sun, 8 Mar 2026 17:01:01 +0800 Subject: [PATCH 41/50] flutter upgrade --- apps/flutter/pubspec.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock index 4c5212d7..44edd0e6 100644 --- a/apps/flutter/pubspec.lock +++ b/apps/flutter/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -308,18 +308,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -705,10 +705,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: @@ -798,5 +798,5 @@ packages: source: hosted version: "6.6.1" sdks: - dart: ">=3.8.1 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.29.0" From 4fa165f73088dd633e6c5974bf380900c5fd892f Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 17 Mar 2026 22:30:22 +0800 Subject: [PATCH 42/50] add agents.md --- AGENTS.md | 13 +++++++++++++ apps/cli/AGENTS.md | 1 + apps/flutter/AGENTS.md | 1 + apps/pwa/AGENTS.md | 1 + 4 files changed, 16 insertions(+) create mode 100644 AGENTS.md create mode 100644 apps/cli/AGENTS.md create mode 100644 apps/flutter/AGENTS.md create mode 100644 apps/pwa/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1423fdb0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# AGENTS.md + +## Project Overview + +Cicada is a multi-user music service for self-hosting, which has three apps: + +- cli: it uses for starting a server and managing the data, which is powered by node.js +- flutter: it's a client for cross-platform, which is powered by flutter +- pwa: it's a client for web + +## Rules + +- The variants prefer lower camel case diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md new file mode 100644 index 00000000..e516510f --- /dev/null +++ b/apps/cli/AGENTS.md @@ -0,0 +1 @@ +# AGENTS.md diff --git a/apps/flutter/AGENTS.md b/apps/flutter/AGENTS.md new file mode 100644 index 00000000..e516510f --- /dev/null +++ b/apps/flutter/AGENTS.md @@ -0,0 +1 @@ +# AGENTS.md diff --git a/apps/pwa/AGENTS.md b/apps/pwa/AGENTS.md new file mode 100644 index 00000000..e516510f --- /dev/null +++ b/apps/pwa/AGENTS.md @@ -0,0 +1 @@ +# AGENTS.md From 8ebcbad81845c14c3bbd6dd8d4f5315f87730bb0 Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 18 Mar 2026 22:39:22 +0800 Subject: [PATCH 43/50] update AGENTS.md --- AGENTS.md | 13 +++++++------ apps/cli/AGENTS.md | 6 ++++++ apps/pwa/AGENTS.md | 8 ++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1423fdb0..63199480 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,14 @@ # AGENTS.md -## Project Overview +## Overview -Cicada is a multi-user music service for self-hosting, which has three apps: +Cicada is a multi-user music service for self-hosting, and it has three apps: -- cli: it uses for starting a server and managing the data, which is powered by node.js -- flutter: it's a client for cross-platform, which is powered by flutter -- pwa: it's a client for web +- cli: it uses for starting a server and managing the data and it is powered by node.js +- flutter: it's a client for cross-platform and it is powered by flutter +- pwa: it's a client for web and it is powered by react ## Rules -- The variants prefer lower camel case +- Variants prefer lower camel case +- Filenames prefer snake case diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index e516510f..86a3c3a8 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -1 +1,7 @@ # AGENTS.md + +`cli` is a terminal tool to start a server and manage the data. + +## Overview + +- use `sqlite` as database diff --git a/apps/pwa/AGENTS.md b/apps/pwa/AGENTS.md index e516510f..ae9e3425 100644 --- a/apps/pwa/AGENTS.md +++ b/apps/pwa/AGENTS.md @@ -1 +1,9 @@ # AGENTS.md + +`pwa` is a web client and it is powered by react. + +## Overview + +- use `webpack` as bundler +- use `zustand` as global state manager +- From 2a82eaf2897c80c4f5e62fa050a6cc76f3212d11 Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 18 Mar 2026 22:53:13 +0800 Subject: [PATCH 44/50] add frequence limit to login --- .../base_app/controllers/login.ts | 22 +++++++++++++++++++ .../base_app/controllers/login_with_2fa.ts | 22 +++++++++++++++++++ .../start_server/constants/exception.ts | 2 ++ apps/cli/src/i18n/en.ts | 2 ++ apps/cli/src/i18n/zh_hans.ts | 2 ++ shared/constants/exception.ts | 2 ++ 6 files changed, 52 insertions(+) diff --git a/apps/cli/src/commands/start_server/base_app/controllers/login.ts b/apps/cli/src/commands/start_server/base_app/controllers/login.ts index dd993583..96d112d7 100644 --- a/apps/cli/src/commands/start_server/base_app/controllers/login.ts +++ b/apps/cli/src/commands/start_server/base_app/controllers/login.ts @@ -8,6 +8,17 @@ import { UNUSED_2FA_SECRET_PREFIX } from '@/constants'; import * as captcha from '@/platform/captcha'; import { Context } from '../constants'; +const USER_LOGIN_INTERVAL = 3000; +const userLastLoginTime = new Map(); + +function clearExpiredUserLastLoginTime(now: number) { + for (const [username, lastLoginTime] of userLastLoginTime) { + if (now - lastLoginTime >= USER_LOGIN_INTERVAL) { + userLastLoginTime.delete(username); + } + } +} + export default async (ctx: Context) => { const { username, password, captchaId, captchaValue } = ctx.request.body as { [key in keyof RequestBody]: unknown; @@ -26,6 +37,17 @@ export default async (ctx: Context) => { return ctx.except(ExceptionCode.WRONG_PARAMETER); } + const now = Date.now(); + clearExpiredUserLastLoginTime(now); + const lastLoginTime = userLastLoginTime.get(username); + if ( + typeof lastLoginTime === 'number' && + now - lastLoginTime < USER_LOGIN_INTERVAL + ) { + return ctx.except(ExceptionCode.LOGIN_TOO_FREQUENT); + } + userLastLoginTime.set(username, now); + const captchaVerified = await captcha.verify({ id: captchaId, value: captchaValue, diff --git a/apps/cli/src/commands/start_server/base_app/controllers/login_with_2fa.ts b/apps/cli/src/commands/start_server/base_app/controllers/login_with_2fa.ts index f7a5efa2..177f6852 100644 --- a/apps/cli/src/commands/start_server/base_app/controllers/login_with_2fa.ts +++ b/apps/cli/src/commands/start_server/base_app/controllers/login_with_2fa.ts @@ -8,6 +8,17 @@ import { UNUSED_2FA_SECRET_PREFIX } from '@/constants'; import * as twoFA from '@/platform/2fa'; import { Context } from '../constants'; +const USER_LOGIN_WITH_2FA_INTERVAL = 3000; +const userLastLoginWith2FATime = new Map(); + +function clearExpiredUserLastLoginWith2FATime(now: number) { + for (const [username, lastLoginWith2FATime] of userLastLoginWith2FATime) { + if (now - lastLoginWith2FATime >= USER_LOGIN_WITH_2FA_INTERVAL) { + userLastLoginWith2FATime.delete(username); + } + } +} + export default async (ctx: Context) => { const { username, password, twoFAToken } = ctx.request.body as { [key in keyof RequestBody]: unknown; @@ -24,6 +35,17 @@ export default async (ctx: Context) => { return ctx.except(ExceptionCode.WRONG_PARAMETER); } + const now = Date.now(); + clearExpiredUserLastLoginWith2FATime(now); + const lastLoginWith2FATime = userLastLoginWith2FATime.get(username); + if ( + typeof lastLoginWith2FATime === 'number' && + now - lastLoginWith2FATime < USER_LOGIN_WITH_2FA_INTERVAL + ) { + return ctx.except(ExceptionCode.LOGIN_WITH_2FA_TOO_FREQUENT); + } + userLastLoginWith2FATime.set(username, now); + const user = await getUserByUsername(username, [ UserProperty.ID, UserProperty.PASSWORD, diff --git a/apps/cli/src/commands/start_server/constants/exception.ts b/apps/cli/src/commands/start_server/constants/exception.ts index ce6cb1b6..211ae00f 100644 --- a/apps/cli/src/commands/start_server/constants/exception.ts +++ b/apps/cli/src/commands/start_server/constants/exception.ts @@ -53,4 +53,6 @@ export const EXCEPTION_CODE_MAP_KEY: Record = { [ExceptionCode.TWO_FA_ENABLED_ALREADY]: '2fa_enabled_already', [ExceptionCode.NEED_2FA]: 'need_2fa', [ExceptionCode.NO_NEED_TO_2FA]: 'no_need_to_2fa', + [ExceptionCode.LOGIN_TOO_FREQUENT]: 'login_too_frequent', + [ExceptionCode.LOGIN_WITH_2FA_TOO_FREQUENT]: 'login_with_2fa_too_frequent', }; diff --git a/apps/cli/src/i18n/en.ts b/apps/cli/src/i18n/en.ts index 8befb8bc..6244abae 100644 --- a/apps/cli/src/i18n/en.ts +++ b/apps/cli/src/i18n/en.ts @@ -43,4 +43,6 @@ export default { '2fa_enabled_already': '2FA enabled already', need_2fa: 'need 2FA', no_need_to_2fa: 'no need to 2FA', + login_too_frequent: 'login too frequent', + login_with_2fa_too_frequent: 'login with 2FA too frequent', }; diff --git a/apps/cli/src/i18n/zh_hans.ts b/apps/cli/src/i18n/zh_hans.ts index 7a3b92cd..b4a40c29 100644 --- a/apps/cli/src/i18n/zh_hans.ts +++ b/apps/cli/src/i18n/zh_hans.ts @@ -44,6 +44,8 @@ const zhHans: { '2fa_enabled_already': '2FA 早已启用', need_2fa: '需要 2FA', no_need_to_2fa: '无需 2FA', + login_too_frequent: 'login 请求过于频繁', + login_with_2fa_too_frequent: 'login_with_2fa 请求过于频繁', }; export default zhHans; diff --git a/shared/constants/exception.ts b/shared/constants/exception.ts index 74307b15..dac655c7 100644 --- a/shared/constants/exception.ts +++ b/shared/constants/exception.ts @@ -39,4 +39,6 @@ export enum ExceptionCode { TWO_FA_ENABLED_ALREADY = 'two_fa_enabled_already', NEED_2FA = 'need_2fa', NO_NEED_TO_2FA = 'no_need_to_2fa', + LOGIN_TOO_FREQUENT = 'login_too_frequent', + LOGIN_WITH_2FA_TOO_FREQUENT = 'login_with_2fa_too_frequent', } From 28962a5b09c1f88acf1c136a7b57b8638e7267a7 Mon Sep 17 00:00:00 2001 From: mebtte Date: Wed, 18 Mar 2026 22:59:34 +0800 Subject: [PATCH 45/50] loading for update --- apps/pwa/src/i18n/en.ts | 1 + apps/pwa/src/i18n/zh_hans.ts | 1 + apps/pwa/src/updater.tsx | 88 +++++++++++++++++++++++++----------- 3 files changed, 64 insertions(+), 26 deletions(-) diff --git a/apps/pwa/src/i18n/en.ts b/apps/pwa/src/i18n/en.ts index 823309f8..4911e3ee 100644 --- a/apps/pwa/src/i18n/en.ts +++ b/apps/pwa/src/i18n/en.ts @@ -103,6 +103,7 @@ export default { add: 'add', image_select_placeholder: 'select a image with jpeg/png format', pwa_update_question: 'do you want to reload to switch new version now ?', + pwa_update_try_later: 'update timed out, please try again later', music: 'music', public_musicbill: 'public musicbill', pick_from_playlist_randomly: 'pick from playlist randomly', diff --git a/apps/pwa/src/i18n/zh_hans.ts b/apps/pwa/src/i18n/zh_hans.ts index 05473700..ed791e58 100644 --- a/apps/pwa/src/i18n/zh_hans.ts +++ b/apps/pwa/src/i18n/zh_hans.ts @@ -102,6 +102,7 @@ const zhCN: { add: '添加', image_select_placeholder: '选择 jpeg/png 格式的图片', pwa_update_question: '检查到新版本, 是否马上加载?', + pwa_update_try_later: '版本更新超时, 请稍后再试', music: '音乐', public_musicbill: '公开乐单', pick_from_playlist_randomly: '随机从播放列表选取', diff --git a/apps/pwa/src/updater.tsx b/apps/pwa/src/updater.tsx index 871bd39d..d2bdddbf 100644 --- a/apps/pwa/src/updater.tsx +++ b/apps/pwa/src/updater.tsx @@ -1,8 +1,8 @@ import notice from '@/utils/notice'; +import { useState } from 'react'; import styled from 'styled-components'; import IconButton from '@/components/icon_button'; import { MdCheck, MdClose } from 'react-icons/md'; -import sleep from '#/utils/sleep'; import definition from './definition'; import { t } from './i18n'; import upperCaseFirstLetter from './style/upper_case_first_letter'; @@ -26,6 +26,66 @@ const VersionUpdater = styled.div` } } `; + +const UPDATE_TIMEOUT = 1000 * 10; + +function VersionUpdateNotice({ + getNoticeId, + wb, +}: { + getNoticeId: () => string; + wb: { + addEventListener: (type: 'controlling', listener: () => void) => void; + removeEventListener: (type: 'controlling', listener: () => void) => void; + messageSkipWaiting: () => void; + }; +}) { + const [updating, setUpdating] = useState(false); + + return ( + +
{t('pwa_update_question')}
+
+ { + if (updating) { + return; + } + + setUpdating(true); + + const onControlling = () => { + window.clearTimeout(timeoutTimer); + wb.removeEventListener('controlling', onControlling); + window.location.reload(); + }; + const timeoutTimer = window.setTimeout(() => { + wb.removeEventListener('controlling', onControlling); + notice.close(getNoticeId()); + notice.error(t('pwa_update_try_later')); + setUpdating(false); + }, UPDATE_TIMEOUT); + + wb.addEventListener('controlling', onControlling); + wb.messageSkipWaiting(); + }} + > + + + notice.close(getNoticeId())} + > + + +
+
+ ); +} + if ('serviceWorker' in navigator) { if (definition.WITH_SW) { import('workbox-window').then(({ Workbox }) => { @@ -41,31 +101,7 @@ if ('serviceWorker' in navigator) { let updateNoticeId: string = ''; wb.addEventListener('waiting', () => { updateNoticeId = notice.info( - -
{t('pwa_update_question')}
-
- { - wb.messageSkipWaiting(); - return Promise.race([ - new Promise((resolve) => - wb.addEventListener('controlling', resolve), - ), - sleep(5000), - ]).then(() => window.location.reload()); - }} - > - - - notice.close(updateNoticeId)} - > - - -
-
, + updateNoticeId} wb={wb} />, { duration: 0, closable: false }, ); }); From 5f921b811a4c367496f956eb66ca7e80613da217 Mon Sep 17 00:00:00 2001 From: mebtte Date: Fri, 27 Mar 2026 22:10:58 +0800 Subject: [PATCH 46/50] auto next volume --- .../ios/Flutter/AppFrameworkInfo.plist | 2 - apps/flutter/ios/Podfile.lock | 9 +- apps/flutter/ios/Runner/AppDelegate.swift | 7 +- apps/flutter/ios/Runner/Info.plist | 119 ++++---- apps/flutter/lib/audio_handler.dart | 255 ++++++++++++++---- apps/flutter/lib/pages/musicbill/index.dart | 29 +- apps/flutter/lib/pages/profile/index.dart | 46 +++- apps/flutter/lib/states/musicbill.dart | 11 + apps/flutter/lib/states/playqueue.dart | 11 +- .../lib/utils/audio_cache_manager.dart | 9 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 - apps/flutter/macos/Podfile.lock | 9 +- apps/flutter/pubspec.lock | 172 ++++++++---- 13 files changed, 468 insertions(+), 213 deletions(-) diff --git a/apps/flutter/ios/Flutter/AppFrameworkInfo.plist b/apps/flutter/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..391a902b 100644 --- a/apps/flutter/ios/Flutter/AppFrameworkInfo.plist +++ b/apps/flutter/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/apps/flutter/ios/Podfile.lock b/apps/flutter/ios/Podfile.lock index 66af7815..a87afb57 100644 --- a/apps/flutter/ios/Podfile.lock +++ b/apps/flutter/ios/Podfile.lock @@ -8,9 +8,6 @@ PODS: - just_audio (0.0.1): - Flutter - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - shared_preferences_foundation (0.0.1): @@ -25,7 +22,6 @@ DEPENDENCIES: - audio_session (from `.symlinks/plugins/audio_session/ios`) - Flutter (from `Flutter`) - just_audio (from `.symlinks/plugins/just_audio/darwin`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -39,8 +35,6 @@ EXTERNAL SOURCES: :path: Flutter just_audio: :path: ".symlinks/plugins/just_audio/darwin" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" shared_preferences_foundation: @@ -53,9 +47,8 @@ SPEC CHECKSUMS: audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/apps/flutter/ios/Runner/AppDelegate.swift b/apps/flutter/ios/Runner/AppDelegate.swift index 62666446..c30b367e 100644 --- a/apps/flutter/ios/Runner/AppDelegate.swift +++ b/apps/flutter/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/apps/flutter/ios/Runner/Info.plist b/apps/flutter/ios/Runner/Info.plist index 8c9f5554..33799b45 100644 --- a/apps/flutter/ios/Runner/Info.plist +++ b/apps/flutter/ios/Runner/Info.plist @@ -1,53 +1,74 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Cicada - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Cicada - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Cicada + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Cicada + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + audio + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 761655da..7820eef7 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -1,25 +1,33 @@ import 'dart:async'; +import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:cicada/states/audio.dart'; +import 'package:cicada/states/playlist.dart'; import 'package:just_audio/just_audio.dart'; import './event_bus.dart'; -import './states/playqueue.dart'; import './server/base/upload_music_play_record.dart'; +import './states/playqueue.dart'; import './utils/audio_cache_manager.dart'; class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { PlayqueueMusic? lastQueueMusic; final player = AudioPlayer(); + // ignore: deprecated_member_use + final _playlist = ConcatenatingAudioSource(children: [], useLazyPreparation: true); final _playTimer = Stopwatch(); - String? _currentLoadingPid; // 跟踪当前正在加载的歌曲,用于处理竞态条件 + final _random = Random(); + String? _currentLoadingPid; ProcessingState? _lastProcessingState; + bool _shouldPlayAfterLoad = false; + bool _syncingFromPlayerIndex = false; + int _loadedQueueBaseIndex = -1; + int _loadedQueueLength = 0; MyAudioHandler() { _initPlayer(); _initStreams(); - // 设置初始 playbackState,确保 Android 前台服务正确初始化 playbackState.add( PlaybackState( controls: [MediaControl.play, MediaControl.skipToNext], @@ -38,16 +46,11 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } Future _initPlayer() async { - // 配置音频会话为音乐类型 final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration.music()); } void _initStreams() { - // Listen to all relevant streams and update state - // We combine the streams or just listen separately and invoke update - - // Just Audio's position stream is what we need for progress bar player.positionStream.listen((position) { _broadcastState(); }); @@ -62,6 +65,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } else { _playTimer.stop(); } + audioState.updateState( playing: state.playing, loading: @@ -76,6 +80,45 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } _lastProcessingState = state.processingState; }); + + player.currentIndexStream.listen((relativeIndex) { + if (relativeIndex == null || _loadedQueueBaseIndex < 0) { + return; + } + + final absoluteIndex = _loadedQueueBaseIndex + relativeIndex; + if (absoluteIndex == playqueueState.playqueueIndex) { + return; + } + + final previousQueueMusic = lastQueueMusic; + + _syncingFromPlayerIndex = true; + playqueueState.setCurrentIndex(absoluteIndex); + _syncingFromPlayerIndex = false; + + final currentQueueMusic = playqueueState.currentMusic; + if (previousQueueMusic != null && + currentQueueMusic != null && + previousQueueMusic.pid != currentQueueMusic.pid) { + unawaited(_uploadPlayRecord(previousQueueMusic)); + } + lastQueueMusic = currentQueueMusic; + unawaited(_appendUpcomingTrackIfNeeded()); + }); + + player.sequenceStateStream.listen((sequenceState) { + final currentSource = sequenceState.currentSource; + if (currentSource != null && currentSource.tag is MediaItem) { + final currentTaggedMediaItem = currentSource.tag as MediaItem; + mediaItem.add( + currentTaggedMediaItem.copyWith( + duration: player.duration, + ), + ); + } + _broadcastState(); + }); } void _broadcastState() { @@ -99,10 +142,9 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { MediaAction.skipToPrevious, MediaAction.skipToNext, }, - // 在紧凑视图(系统媒体控制面板)中显示的按钮索引 androidCompactActionIndices: hasPrevious - ? const [0, 1, 2] // previous, play/pause, next - : const [0, 1], // play/pause, next + ? const [0, 1, 2] + : const [0, 1], processingState: { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, @@ -120,42 +162,99 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { ); } - @override - Future play() => player.play(); + MediaItem _buildMediaItem(PlayqueueMusic queueMusic, {Duration? duration}) { + return MediaItem( + id: queueMusic.pid, + title: queueMusic.music.name, + artist: queueMusic.music.singers.map((s) => s.name).join(','), + artUri: queueMusic.music.cover == null + ? null + : Uri.parse(queueMusic.music.cover!), + duration: duration, + ); + } - @override - Future pause() => player.pause(); + List _buildSources(List queue) { + return queue.map((queueMusic) { + return AudioCacheManager.instance.getAudioSource( + queueMusic.music.id, + queueMusic.music.asset, + tag: _buildMediaItem(queueMusic), + ); + }).toList(); + } - @override - Future skipToPrevious() async => playqueueState.previous(); + Future _appendUpcomingTrackIfNeeded() async { + if (_loadedQueueBaseIndex < 0 || _loadedQueueLength == 0) { + return; + } - @override - Future skipToNext() async => playqueueState.next(); + final currentAbsoluteIndex = playqueueState.playqueueIndex; + final currentRelativeIndex = currentAbsoluteIndex - _loadedQueueBaseIndex; + if (currentRelativeIndex != _loadedQueueLength - 1) { + return; + } - @override - Future seek(Duration position) => player.seek(position); + final nextAbsoluteIndex = currentAbsoluteIndex + 1; + PlayqueueMusic? nextQueueMusic; + if (nextAbsoluteIndex < playqueueState.playqueue.length) { + nextQueueMusic = playqueueState.playqueue[nextAbsoluteIndex]; + } else { + final playlist = playlistState.playlist; + if (playlist.isEmpty) { + return; + } + + final playlistMusic = playlist[_random.nextInt(playlist.length)]; + playqueueState.jump(playlistMusic.music, isUserAdded: false); + if (nextAbsoluteIndex < playqueueState.playqueue.length) { + nextQueueMusic = playqueueState.playqueue[nextAbsoluteIndex]; + } + } + + if (nextQueueMusic == null) { + return; + } + + await _playlist.add( + AudioCacheManager.instance.getAudioSource( + nextQueueMusic.music.id, + nextQueueMusic.music.asset, + tag: _buildMediaItem(nextQueueMusic), + ), + ); + _loadedQueueLength++; + } Future playQueueMusic(PlayqueueMusic queueMusic) async { _playTimer.reset(); + _shouldPlayAfterLoad = true; - // 记录当前正在加载的歌曲 ID,用于检测竞态条件 final loadingPid = queueMusic.pid; _currentLoadingPid = loadingPid; + final queueIndex = playqueueState.playqueueIndex; + if (queueIndex < 0 || queueIndex >= playqueueState.playqueue.length) { + return; + } + final queueSnapshot = List.from( + playqueueState.playqueue.sublist(queueIndex), + ); + _loadedQueueBaseIndex = queueIndex; + _loadedQueueLength = queueSnapshot.length; - // 先停止当前播放,避免干扰 await player.stop(); try { - // 使用缓存管理器获取音频源 - // 如果已缓存,会从本地读取;否则边下载边播放并保存到缓存 - final audioSource = AudioCacheManager.instance.getAudioSource( - queueMusic.music.id, - queueMusic.music.asset, - ); + await _playlist.clear(); + await _playlist.addAll(_buildSources(queueSnapshot)); - // 设置 60 秒超时 - var duration = await player - .setAudioSource(audioSource) + final duration = await player + .setAudioSource( + _playlist, + initialIndex: 0, + initialPosition: Duration.zero, + preload: true, + ) .timeout( const Duration(seconds: 60), onTimeout: () { @@ -163,27 +262,20 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { }, ); - // 检查是否仍是当前要播放的歌曲(用户可能在加载过程中切换了歌曲) if (_currentLoadingPid != loadingPid) { - // 用户已切换到其他歌曲,忽略此次加载结果 return; } - player.play(); + mediaItem.add(_buildMediaItem(queueMusic, duration: duration)); + _broadcastState(); + await _appendUpcomingTrackIfNeeded(); - var item = MediaItem( - id: queueMusic.pid, - title: queueMusic.music.name, - artist: queueMusic.music.singers.map((s) => s.name).join(','), - artUri: queueMusic.music.cover == null - ? null - : Uri.parse(queueMusic.music.cover!), - duration: duration, - ); - mediaItem.add(item); - _broadcastState(); // 确保通知更新 + if (_shouldPlayAfterLoad) { + await player.play(); + } + } on PlayerInterruptedException { + // A newer song load took over; ignore the interrupted request. } on TimeoutException { - // 加载超时 if (_currentLoadingPid == loadingPid) { eventBus.fire( PlayErrorEvent( @@ -193,19 +285,54 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { ), ); } - } catch (e) { - // 只有当错误发生时仍是当前歌曲才显示错误 + } catch (error) { if (_currentLoadingPid == loadingPid) { eventBus.fire( PlayErrorEvent( musicName: queueMusic.music.name, - errorMessage: e.toString(), + errorMessage: error.toString(), ), ); } } } + @override + Future play() { + _shouldPlayAfterLoad = true; + if (player.audioSource != null) { + return player.play(); + } + + final currentQueueMusic = playqueueState.currentMusic; + if (currentQueueMusic == null) { + return Future.value(); + } + + return playQueueMusic(currentQueueMusic); + } + + @override + Future pause() { + _shouldPlayAfterLoad = false; + return player.pause(); + } + + @override + Future skipToPrevious() async { + _shouldPlayAfterLoad = true; + playqueueState.previous(); + } + + @override + Future skipToNext() async { + _shouldPlayAfterLoad = true; + playqueueState.next(); + } + + @override + Future seek(Duration position) => player.seek(position); + Future _uploadPlayRecord(PlayqueueMusic queueMusic) async { final duration = player.duration; final playedMilliseconds = _playTimer.elapsedMilliseconds; @@ -222,22 +349,32 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { musicId: queueMusic.music.id, percent: percent, ); - } catch (e) { - print('Failed to upload play record: $e'); + } catch (error) { + // ignore: avoid_print + print('Failed to upload play record: $error'); } } void subscribe() { playqueueState.addListener(() { + if (_syncingFromPlayerIndex) { + return; + } + final currentQueueMusic = playqueueState.currentMusic; - if (currentQueueMusic != null && - currentQueueMusic.pid != lastQueueMusic?.pid) { - if (lastQueueMusic != null) { - _uploadPlayRecord(lastQueueMusic!); - } - lastQueueMusic = currentQueueMusic; - playQueueMusic(currentQueueMusic); + if (currentQueueMusic == null || + currentQueueMusic.pid == lastQueueMusic?.pid) { + return; } + + final previousQueueMusic = lastQueueMusic; + lastQueueMusic = currentQueueMusic; + + if (previousQueueMusic != null) { + unawaited(_uploadPlayRecord(previousQueueMusic)); + } + + unawaited(playQueueMusic(currentQueueMusic)); }); } } diff --git a/apps/flutter/lib/pages/musicbill/index.dart b/apps/flutter/lib/pages/musicbill/index.dart index 3c741328..760c6e25 100644 --- a/apps/flutter/lib/pages/musicbill/index.dart +++ b/apps/flutter/lib/pages/musicbill/index.dart @@ -43,16 +43,27 @@ class _MusicbillState extends State { final musicbill = musicbill_state.musicbillState.musicbillList.firstWhere( (m) => m.id == widget.id, ); - if (musicbill.status != musicbill_state.MusicbillStatus.LOADING) { - Future.delayed( - Duration.zero, - () => musicbill_state.musicbillState.reloadMusicbill( - id: widget.id, - silence: - musicbill.status == musicbill_state.MusicbillStatus.SUCCESSFUL, - ), - ); + final shouldSilentlyRefresh = musicbill_state.musicbillState + .shouldSilentlyRefreshOnEnter(widget.id); + if (musicbill.status == musicbill_state.MusicbillStatus.LOADING) { + return; } + + final shouldReload = + musicbill.status != musicbill_state.MusicbillStatus.SUCCESSFUL || + shouldSilentlyRefresh; + + if (!shouldReload) { + return; + } + + Future.delayed( + Duration.zero, + () => musicbill_state.musicbillState.reloadMusicbill( + id: widget.id, + silence: musicbill.status == musicbill_state.MusicbillStatus.SUCCESSFUL, + ), + ); } void _onScroll() { diff --git a/apps/flutter/lib/pages/profile/index.dart b/apps/flutter/lib/pages/profile/index.dart index f0c429f7..500e28ec 100644 --- a/apps/flutter/lib/pages/profile/index.dart +++ b/apps/flutter/lib/pages/profile/index.dart @@ -2,6 +2,7 @@ import 'package:cicada/constants/index.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../states/server.dart'; +import '../../widgets/cached_image.dart'; import '../../widgets/player_bottom_spacer.dart'; /// 用户个人资料页面 @@ -38,25 +39,46 @@ class ProfilePage extends StatelessWidget { /// 构建用户头部 Widget _buildUserHeader(BuildContext context, User user, Server? server) { + final avatarUrl = user.avatar; + final hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty; + return Container( padding: const EdgeInsets.all(24), child: Column( children: [ - CircleAvatar( - radius: 50, - backgroundColor: Theme.of( - context, - ).primaryColor.withValues(alpha: 0.1), - backgroundImage: user.avatar != null && user.avatar!.isNotEmpty - ? NetworkImage(user.avatar!) - : null, - child: user.avatar == null || user.avatar!.isEmpty - ? Icon( + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of( + context, + ).primaryColor.withValues(alpha: 0.1), + ), + clipBehavior: Clip.antiAlias, + child: hasAvatar + ? CachedImage( + imageUrl: avatarUrl, + width: 100, + height: 100, + size: 200, + fit: BoxFit.cover, + placeholder: Icon( + Icons.person, + size: 50, + color: Theme.of(context).primaryColor, + ), + errorWidget: Icon( + Icons.person, + size: 50, + color: Theme.of(context).primaryColor, + ), + ) + : Icon( Icons.person, size: 50, color: Theme.of(context).primaryColor, - ) - : null, + ), ), const SizedBox(height: 16), Text( diff --git a/apps/flutter/lib/states/musicbill.dart b/apps/flutter/lib/states/musicbill.dart index 9ce310eb..01a99e8a 100644 --- a/apps/flutter/lib/states/musicbill.dart +++ b/apps/flutter/lib/states/musicbill.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; enum MusicbillStatus { INITIAL, LOADING, SUCCESSFUL, FAILED } +const musicbillRefreshInterval = Duration(minutes: 5); + class Musicbill { String id; String name; @@ -28,6 +30,7 @@ class MusicbillState extends ChangeNotifier { bool loading = false; Exception? exception; List musicbillList = []; + final Map _musicbillLastEnteredAt = {}; void reloadMusicbillList({required bool silence}) async { exception = null; @@ -106,6 +109,14 @@ class MusicbillState extends ChangeNotifier { notifyListeners(); } } + + bool shouldSilentlyRefreshOnEnter(String id) { + final now = DateTime.now(); + final lastEnteredAt = _musicbillLastEnteredAt[id]; + _musicbillLastEnteredAt[id] = now; + return lastEnteredAt == null || + now.difference(lastEnteredAt) > musicbillRefreshInterval; + } } final musicbillState = MusicbillState(); diff --git a/apps/flutter/lib/states/playqueue.dart b/apps/flutter/lib/states/playqueue.dart index 19204b49..8d66200e 100644 --- a/apps/flutter/lib/states/playqueue.dart +++ b/apps/flutter/lib/states/playqueue.dart @@ -59,6 +59,14 @@ class PlayqueueState extends ChangeNotifier { } } + void setCurrentIndex(int index) { + if (index < -1 || index >= playqueue.length || index == playqueueIndex) { + return; + } + playqueueIndex = index; + notifyListeners(); + } + void next() { final nextPlayqueueIndex = playqueueIndex + 1; if (nextPlayqueueIndex >= playqueue.length) { @@ -104,8 +112,7 @@ class PlayqueueState extends ChangeNotifier { void rewind(PlayqueueMusic item) { final index = playqueue.indexWhere((element) => element.pid == item.pid); if (index != -1) { - playqueueIndex = index; - notifyListeners(); + setCurrentIndex(index); } } diff --git a/apps/flutter/lib/utils/audio_cache_manager.dart b/apps/flutter/lib/utils/audio_cache_manager.dart index e41dad81..89be9c20 100644 --- a/apps/flutter/lib/utils/audio_cache_manager.dart +++ b/apps/flutter/lib/utils/audio_cache_manager.dart @@ -44,13 +44,18 @@ class AudioCacheManager { /// 获取缓存的音频源 /// /// 如果已缓存,返回本地文件源;否则返回带缓存的网络源 - AudioSource getAudioSource(String musicId, String url) { + AudioSource getAudioSource(String musicId, String url, {dynamic tag}) { final cacheFile = getCacheFile(musicId); // 使用 LockCachingAudioSource 来缓存音频 // 如果缓存文件存在,它会直接从缓存读取 // 如果不存在,它会边下载边播放,同时保存到缓存文件 - return LockCachingAudioSource(Uri.parse(url), cacheFile: cacheFile); + // ignore: experimental_member_use + return LockCachingAudioSource( + Uri.parse(url), + cacheFile: cacheFile, + tag: tag, + ); } /// 清除所有缓存 diff --git a/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index e2a309d6..5d6cab8a 100644 --- a/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,6 @@ import Foundation import audio_service import audio_session import just_audio -import path_provider_foundation import screen_retriever_macos import shared_preferences_foundation import sqflite_darwin @@ -18,7 +17,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/apps/flutter/macos/Podfile.lock b/apps/flutter/macos/Podfile.lock index b8a0a88f..04ee862b 100644 --- a/apps/flutter/macos/Podfile.lock +++ b/apps/flutter/macos/Podfile.lock @@ -8,9 +8,6 @@ PODS: - just_audio (0.0.1): - Flutter - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): @@ -27,7 +24,6 @@ DEPENDENCIES: - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) @@ -42,8 +38,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral just_audio: :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos shared_preferences_foundation: @@ -58,9 +52,8 @@ SPEC CHECKSUMS: audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 window_manager: b729e31d38fb04905235df9ea896128991cad99e diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock index 44edd0e6..3210b04e 100644 --- a/apps/flutter/pubspec.lock +++ b/apps/flutter/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" audio_service: dependency: "direct main" description: @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -109,26 +117,26 @@ packages: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" dio: dependency: "direct main" description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.9.2" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" event_bus: dependency: "direct main" description: @@ -149,10 +157,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -194,10 +202,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -212,18 +220,34 @@ packages: dependency: "direct main" description: name: get_it - sha256: a4292e7cf67193f8e7c1258203104eb2a51ec8b3a04baa14695f4064c144297b + sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 + url: "https://pub.dev" + source: hosted + version: "8.3.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "1.0.2" http: dependency: transitive description: name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" http_parser: dependency: transitive description: @@ -244,18 +268,18 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" just_audio: dependency: "direct main" description: name: just_audio - sha256: "679637a3ec5b6e00f36472f5a3663667df00ee4822cbf5dafca0f568c710960a" + sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" url: "https://pub.dev" source: hosted - version: "0.10.4" + version: "0.10.5" just_audio_platform_interface: dependency: transitive description: @@ -276,10 +300,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: @@ -304,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -336,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nested: dependency: transitive description: @@ -344,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" octo_image: dependency: transitive description: @@ -380,18 +428,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.23" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -468,10 +516,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -492,10 +540,18 @@ packages: dependency: "direct main" description: name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "2.2.0" rxdart: dependency: transitive description: @@ -548,26 +604,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -580,10 +636,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -609,18 +665,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "1.10.2" sqflite: dependency: transitive description: @@ -633,10 +681,10 @@ packages: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2+3" sqflite_common: dependency: transitive description: @@ -721,18 +769,18 @@ packages: dependency: "direct main" description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "7076216a10d5c390315fbe536a30f1254c341e7543e6c4c8a815e591307772b1" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_graphics_codec: dependency: transitive description: @@ -745,10 +793,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.0" vector_math: dependency: transitive description: @@ -797,6 +845,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" From adf5bd5e351fe79cbe313de771a9e53240c27d82 Mon Sep 17 00:00:00 2001 From: mebtte Date: Fri, 27 Mar 2026 22:43:10 +0800 Subject: [PATCH 47/50] fix auto next mistakenly --- apps/flutter/ios/Runner/Info.plist | 5 + apps/flutter/lib/audio_handler.dart | 138 +++++++++++++++--- .../lib/player_controller/actions.dart | 4 +- .../lib/widgets/play_error_dialog.dart | 6 +- 4 files changed, 130 insertions(+), 23 deletions(-) diff --git a/apps/flutter/ios/Runner/Info.plist b/apps/flutter/ios/Runner/Info.plist index 33799b45..4c975423 100644 --- a/apps/flutter/ios/Runner/Info.plist +++ b/apps/flutter/ios/Runner/Info.plist @@ -26,6 +26,11 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 7820eef7..66f58968 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -5,6 +5,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:cicada/states/audio.dart'; import 'package:cicada/states/playlist.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:just_audio/just_audio.dart'; import './event_bus.dart'; import './server/base/upload_music_play_record.dart'; @@ -15,7 +16,10 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { PlayqueueMusic? lastQueueMusic; final player = AudioPlayer(); // ignore: deprecated_member_use - final _playlist = ConcatenatingAudioSource(children: [], useLazyPreparation: true); + final _playlist = ConcatenatingAudioSource( + children: [], + useLazyPreparation: true, + ); final _playTimer = Stopwatch(); final _random = Random(); String? _currentLoadingPid; @@ -24,6 +28,8 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { bool _syncingFromPlayerIndex = false; int _loadedQueueBaseIndex = -1; int _loadedQueueLength = 0; + List _loadedQueuePids = const []; + bool _mutatingPlayqueueFromHandler = false; MyAudioHandler() { _initPlayer(); @@ -102,6 +108,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { currentQueueMusic != null && previousQueueMusic.pid != currentQueueMusic.pid) { unawaited(_uploadPlayRecord(previousQueueMusic)); + _playTimer.reset(); } lastQueueMusic = currentQueueMusic; unawaited(_appendUpcomingTrackIfNeeded()); @@ -112,9 +119,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { if (currentSource != null && currentSource.tag is MediaItem) { final currentTaggedMediaItem = currentSource.tag as MediaItem; mediaItem.add( - currentTaggedMediaItem.copyWith( - duration: player.duration, - ), + currentTaggedMediaItem.copyWith(duration: player.duration), ); } _broadcastState(); @@ -184,6 +189,32 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { }).toList(); } + void _syncAudioServiceQueue(List queueSnapshot) { + queue.add( + queueSnapshot + .map((queueMusic) => _buildMediaItem(queueMusic)) + .toList(growable: false), + ); + } + + Future _handleLoadFailure(String loadingPid) async { + if (_currentLoadingPid != loadingPid) { + return; + } + + _currentLoadingPid = null; + _shouldPlayAfterLoad = false; + + try { + await player.stop(); + } catch (_) { + // Swallow stop errors so the UI can recover from the original failure. + } + + audioState.updateState(playing: false, loading: false); + _broadcastState(); + } + Future _appendUpcomingTrackIfNeeded() async { if (_loadedQueueBaseIndex < 0 || _loadedQueueLength == 0) { return; @@ -206,7 +237,12 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } final playlistMusic = playlist[_random.nextInt(playlist.length)]; - playqueueState.jump(playlistMusic.music, isUserAdded: false); + _mutatingPlayqueueFromHandler = true; + try { + playqueueState.jump(playlistMusic.music, isUserAdded: false); + } finally { + _mutatingPlayqueueFromHandler = false; + } if (nextAbsoluteIndex < playqueueState.playqueue.length) { nextQueueMusic = playqueueState.playqueue[nextAbsoluteIndex]; } @@ -223,24 +259,36 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { tag: _buildMediaItem(nextQueueMusic), ), ); - _loadedQueueLength++; + _loadedQueuePids = playqueueState.playqueue + .map((queueMusic) => queueMusic.pid) + .toList(growable: false); + _loadedQueueLength = _loadedQueuePids.length; + _syncAudioServiceQueue(playqueueState.playqueue); } - Future playQueueMusic(PlayqueueMusic queueMusic) async { - _playTimer.reset(); - _shouldPlayAfterLoad = true; - + Future _loadCurrentQueue({ + required PlayqueueMusic queueMusic, + required Duration initialPosition, + required bool resetPlayTimer, + required bool shouldPlayAfterLoad, + }) async { + if (resetPlayTimer) { + _playTimer.reset(); + } + _shouldPlayAfterLoad = shouldPlayAfterLoad; final loadingPid = queueMusic.pid; _currentLoadingPid = loadingPid; final queueIndex = playqueueState.playqueueIndex; if (queueIndex < 0 || queueIndex >= playqueueState.playqueue.length) { return; } - final queueSnapshot = List.from( - playqueueState.playqueue.sublist(queueIndex), - ); - _loadedQueueBaseIndex = queueIndex; + final queueSnapshot = List.from(playqueueState.playqueue); + _loadedQueueBaseIndex = 0; _loadedQueueLength = queueSnapshot.length; + _loadedQueuePids = queueSnapshot + .map((queueMusic) => queueMusic.pid) + .toList(growable: false); + _syncAudioServiceQueue(queueSnapshot); await player.stop(); @@ -251,8 +299,8 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final duration = await player .setAudioSource( _playlist, - initialIndex: 0, - initialPosition: Duration.zero, + initialIndex: queueIndex, + initialPosition: initialPosition, preload: true, ) .timeout( @@ -273,10 +321,14 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { if (_shouldPlayAfterLoad) { await player.play(); } + if (_currentLoadingPid == loadingPid) { + _currentLoadingPid = null; + } } on PlayerInterruptedException { // A newer song load took over; ignore the interrupted request. } on TimeoutException { if (_currentLoadingPid == loadingPid) { + await _handleLoadFailure(loadingPid); eventBus.fire( PlayErrorEvent( musicName: queueMusic.music.name, @@ -287,6 +339,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } } catch (error) { if (_currentLoadingPid == loadingPid) { + await _handleLoadFailure(loadingPid); eventBus.fire( PlayErrorEvent( musicName: queueMusic.music.name, @@ -297,6 +350,29 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } } + Future playQueueMusic(PlayqueueMusic queueMusic) { + return _loadCurrentQueue( + queueMusic: queueMusic, + initialPosition: Duration.zero, + resetPlayTimer: true, + shouldPlayAfterLoad: true, + ); + } + + Future _reloadCurrentQueue() { + final currentQueueMusic = playqueueState.currentMusic; + if (currentQueueMusic == null) { + return Future.value(); + } + + return _loadCurrentQueue( + queueMusic: currentQueueMusic, + initialPosition: player.position, + resetPlayTimer: false, + shouldPlayAfterLoad: player.playing, + ); + } + @override Future play() { _shouldPlayAfterLoad = true; @@ -321,12 +397,27 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { @override Future skipToPrevious() async { _shouldPlayAfterLoad = true; + if (player.audioSource != null && player.hasPrevious) { + await player.seekToPrevious(); + if (!player.playing) { + await player.play(); + } + return; + } playqueueState.previous(); } @override Future skipToNext() async { _shouldPlayAfterLoad = true; + await _appendUpcomingTrackIfNeeded(); + if (player.audioSource != null && player.hasNext) { + await player.seekToNext(); + if (!player.playing) { + await player.play(); + } + return; + } playqueueState.next(); } @@ -357,13 +448,22 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { void subscribe() { playqueueState.addListener(() { - if (_syncingFromPlayerIndex) { + if (_syncingFromPlayerIndex || _mutatingPlayqueueFromHandler) { return; } final currentQueueMusic = playqueueState.currentMusic; - if (currentQueueMusic == null || - currentQueueMusic.pid == lastQueueMusic?.pid) { + if (currentQueueMusic == null) { + return; + } + + final currentQueuePids = playqueueState.playqueue + .map((queueMusic) => queueMusic.pid) + .toList(growable: false); + if (currentQueueMusic.pid == lastQueueMusic?.pid) { + if (!listEquals(_loadedQueuePids, currentQueuePids)) { + unawaited(_reloadCurrentQueue()); + } return; } diff --git a/apps/flutter/lib/player_controller/actions.dart b/apps/flutter/lib/player_controller/actions.dart index 9078bb95..3de07459 100644 --- a/apps/flutter/lib/player_controller/actions.dart +++ b/apps/flutter/lib/player_controller/actions.dart @@ -3,7 +3,6 @@ import 'package:get_it/get_it.dart'; import 'package:provider/provider.dart'; import '../audio_handler.dart'; import '../states/audio.dart'; -import '../states/playqueue.dart'; import './show_playlist_dialog.dart'; class Actions extends StatelessWidget { @@ -39,7 +38,8 @@ class Actions extends StatelessWidget { spacing, IconButton( onPressed: () { - playqueueState.next(); + final audioHandler = GetIt.instance.get(); + audioHandler.skipToNext(); }, icon: Icon(Icons.skip_next), iconSize: 20, diff --git a/apps/flutter/lib/widgets/play_error_dialog.dart b/apps/flutter/lib/widgets/play_error_dialog.dart index 783ff8c5..89963278 100644 --- a/apps/flutter/lib/widgets/play_error_dialog.dart +++ b/apps/flutter/lib/widgets/play_error_dialog.dart @@ -1,7 +1,8 @@ import 'dart:async'; +import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../event_bus.dart'; -import '../states/playqueue.dart'; /// Display playback error dialog /// Returns true indicating the user cancelled autoplaying the next song, false indicates autoplay proceeded @@ -59,8 +60,9 @@ class _PlayErrorDialogState extends State<_PlayErrorDialog> { } void _playNext() { + final audioHandler = context.read(); Navigator.of(context).pop(); - playqueueState.next(); + audioHandler.skipToNext(); } void _cancel() { From b28dc2043cf57dced43d3557b5169764be8235be Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 31 Mar 2026 23:01:07 +0800 Subject: [PATCH 48/50] add music to musicbill --- apps/flutter/lib/audio_handler.dart | 111 ++++- apps/flutter/lib/constants/exception.dart | 2 + .../pages/home/create_musicbill_dialog.dart | 7 +- .../lib/pages/home/musicbill_card.dart | 23 +- .../musicbill/add_to_musicbill_sheet.dart | 420 ++++++++++++++++++ .../lib/pages/musicbill/music_list_item.dart | 27 +- .../pages/musicbill/music_option_menu.dart | 44 +- .../musicbill/show_music_option_menu.dart | 28 ++ .../search/music_with_lyric_list_item.dart | 29 +- .../player_controller/player_controller.dart | 2 +- .../lib/player_controller/playlist.dart | 27 ++ .../lib/player_controller/playqueue.dart | 222 +++++---- .../server/api/add_music_to_musicbill.dart | 12 + .../flutter/lib/server/api/get_musicbill.dart | 12 +- .../lib/server/api/get_musicbill_list.dart | 12 +- .../api/remove_music_from_musicbill.dart | 12 + apps/flutter/lib/server/request.dart | 21 + apps/flutter/lib/states/musicbill.dart | 250 ++++++++--- apps/flutter/lib/widgets/musicbill_cover.dart | 102 +++++ apps/flutter/lib/widgets/player/index.dart | 11 + .../lib/widgets/player/player_controls.dart | 67 ++- .../lib/widgets/player/player_header.dart | 2 +- 22 files changed, 1179 insertions(+), 264 deletions(-) create mode 100644 apps/flutter/lib/constants/exception.dart create mode 100644 apps/flutter/lib/pages/musicbill/add_to_musicbill_sheet.dart create mode 100644 apps/flutter/lib/pages/musicbill/show_music_option_menu.dart create mode 100644 apps/flutter/lib/server/api/add_music_to_musicbill.dart create mode 100644 apps/flutter/lib/server/api/remove_music_from_musicbill.dart create mode 100644 apps/flutter/lib/widgets/musicbill_cover.dart diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 66f58968..91c40f95 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -30,6 +30,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { int _loadedQueueLength = 0; List _loadedQueuePids = const []; bool _mutatingPlayqueueFromHandler = false; + Future _queueStructureSync = Future.value(); MyAudioHandler() { _initPlayer(); @@ -179,14 +180,16 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { ); } + AudioSource _buildSource(PlayqueueMusic queueMusic) { + return AudioCacheManager.instance.getAudioSource( + queueMusic.music.id, + queueMusic.music.asset, + tag: _buildMediaItem(queueMusic), + ); + } + List _buildSources(List queue) { - return queue.map((queueMusic) { - return AudioCacheManager.instance.getAudioSource( - queueMusic.music.id, - queueMusic.music.asset, - tag: _buildMediaItem(queueMusic), - ); - }).toList(); + return queue.map(_buildSource).toList(); } void _syncAudioServiceQueue(List queueSnapshot) { @@ -252,13 +255,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { return; } - await _playlist.add( - AudioCacheManager.instance.getAudioSource( - nextQueueMusic.music.id, - nextQueueMusic.music.asset, - tag: _buildMediaItem(nextQueueMusic), - ), - ); + await _playlist.add(_buildSource(nextQueueMusic)); _loadedQueuePids = playqueueState.playqueue .map((queueMusic) => queueMusic.pid) .toList(growable: false); @@ -266,6 +263,90 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { _syncAudioServiceQueue(playqueueState.playqueue); } + Future _syncCurrentQueueStructure({ + required List queueSnapshot, + required String currentPid, + }) async { + if (_currentLoadingPid != null || + player.audioSource == null || + _loadedQueueBaseIndex != 0 || + _playlist.children.length != _loadedQueuePids.length || + playqueueState.currentMusic?.pid != currentPid || + lastQueueMusic?.pid != currentPid) { + return false; + } + + final targetPids = queueSnapshot + .map((queueMusic) => queueMusic.pid) + .toList(growable: false); + if (listEquals(_loadedQueuePids, targetPids)) { + return true; + } + + final workingPids = List.from(_loadedQueuePids); + + try { + for ( + var targetIndex = 0; + targetIndex < queueSnapshot.length; + targetIndex++ + ) { + final targetQueueMusic = queueSnapshot[targetIndex]; + final targetPid = targetQueueMusic.pid; + + if (targetIndex < workingPids.length && + workingPids[targetIndex] == targetPid) { + continue; + } + + final existingIndex = workingPids.indexOf(targetPid); + if (existingIndex == -1) { + await _playlist.insert(targetIndex, _buildSource(targetQueueMusic)); + workingPids.insert(targetIndex, targetPid); + continue; + } + + await _playlist.move(existingIndex, targetIndex); + final movedPid = workingPids.removeAt(existingIndex); + workingPids.insert(targetIndex, movedPid); + } + + while (workingPids.length > queueSnapshot.length) { + await _playlist.removeAt(workingPids.length - 1); + workingPids.removeLast(); + } + } catch (_) { + return false; + } + + _loadedQueuePids = targetPids; + _loadedQueueLength = targetPids.length; + _syncAudioServiceQueue(queueSnapshot); + return true; + } + + void _scheduleCurrentQueueStructureSync(String currentPid) { + _queueStructureSync = _queueStructureSync.catchError((_) {}).then(( + _, + ) async { + if (playqueueState.currentMusic?.pid != currentPid || + lastQueueMusic?.pid != currentPid) { + return; + } + + final queueSnapshot = List.from(playqueueState.playqueue); + final synced = await _syncCurrentQueueStructure( + queueSnapshot: queueSnapshot, + currentPid: currentPid, + ); + if (!synced && + playqueueState.currentMusic?.pid == currentPid && + lastQueueMusic?.pid == currentPid) { + await _reloadCurrentQueue(); + } + }); + } + Future _loadCurrentQueue({ required PlayqueueMusic queueMusic, required Duration initialPosition, @@ -462,7 +543,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { .toList(growable: false); if (currentQueueMusic.pid == lastQueueMusic?.pid) { if (!listEquals(_loadedQueuePids, currentQueuePids)) { - unawaited(_reloadCurrentQueue()); + _scheduleCurrentQueueStructureSync(currentQueueMusic.pid); } return; } diff --git a/apps/flutter/lib/constants/exception.dart b/apps/flutter/lib/constants/exception.dart new file mode 100644 index 00000000..481a6a3b --- /dev/null +++ b/apps/flutter/lib/constants/exception.dart @@ -0,0 +1,2 @@ +const musicAlreadyExistedInMusicbill = 'music_already_existed_in_musicbill'; +const musicNotExistedInMusicbill = 'music_not_existed_in_musicbill'; diff --git a/apps/flutter/lib/pages/home/create_musicbill_dialog.dart b/apps/flutter/lib/pages/home/create_musicbill_dialog.dart index 0b428595..8b5ced82 100644 --- a/apps/flutter/lib/pages/home/create_musicbill_dialog.dart +++ b/apps/flutter/lib/pages/home/create_musicbill_dialog.dart @@ -48,14 +48,15 @@ class _CreateMusicbillDialogState extends State { try { // 调用 API 创建 musicbill - await createMusicbill(name: name); + final id = await createMusicbill(name: name); // 重新加载列表 - musicbillState.reloadMusicbillList(silence: true); + await musicbillState.reloadMusicbillList(silence: true); + await musicbillState.reloadMusicbill(id: id, silence: true); // 关闭对话框 if (mounted) { - Navigator.of(context).pop(); + Navigator.of(context).pop(id); } } catch (e) { setState(() { diff --git a/apps/flutter/lib/pages/home/musicbill_card.dart b/apps/flutter/lib/pages/home/musicbill_card.dart index fd881ea9..efd89326 100644 --- a/apps/flutter/lib/pages/home/musicbill_card.dart +++ b/apps/flutter/lib/pages/home/musicbill_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import '../../states/musicbill.dart'; -import '../../widgets/cached_image.dart'; +import '../../widgets/musicbill_cover.dart'; /// 音乐清单卡片组件 /// 显示单个音乐清单的信息(封面、名称) @@ -77,19 +77,14 @@ class MusicbillCard extends StatelessWidget { /// 构建封面 Widget _buildCover(BuildContext context) { - if (musicbill.cover != null && musicbill.cover!.isNotEmpty) { - return CachedImage( - imageUrl: musicbill.cover, - width: 44, - height: 44, - size: 88, // 2x for high DPI screens - borderRadius: BorderRadius.circular(8), - placeholder: _buildDefaultCover(context), - errorWidget: _buildDefaultCover(context), - ); - } - - return _buildDefaultCover(context); + return MusicbillCover( + imageUrl: musicbill.cover, + size: 44, + isPublic: musicbill.isPublic, + isShared: musicbill.isShared, + borderRadius: BorderRadius.circular(8), + placeholder: _buildDefaultCover(context), + ); } /// 构建默认封面 diff --git a/apps/flutter/lib/pages/musicbill/add_to_musicbill_sheet.dart b/apps/flutter/lib/pages/musicbill/add_to_musicbill_sheet.dart new file mode 100644 index 00000000..e0494ccf --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/add_to_musicbill_sheet.dart @@ -0,0 +1,420 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/music.dart'; +import '../../server/server_exception.dart'; +import '../../states/musicbill.dart'; +import '../../widgets/cached_image.dart'; +import '../../widgets/error_view.dart'; +import '../../widgets/musicbill_cover.dart'; +import '../home/create_musicbill_dialog.dart'; + +void showAddToMusicbillSheet(BuildContext context, {required Music music}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (context) { + return FractionallySizedBox( + heightFactor: 0.82, + child: DecoratedBox( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: _AddToMusicbillSheetContent(music: music), + ), + ); + }, + ); +} + +class _AddToMusicbillSheetContent extends StatefulWidget { + final Music music; + + const _AddToMusicbillSheetContent({required this.music}); + + @override + State<_AddToMusicbillSheetContent> createState() => + _AddToMusicbillSheetContentState(); +} + +class _AddToMusicbillSheetContentState + extends State<_AddToMusicbillSheetContent> { + final Set _processingMusicbillIds = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final musicbillState = context.read(); + if (musicbillState.musicbillList.isEmpty && !musicbillState.loading) { + musicbillState.reloadMusicbillList(silence: false); + } + }); + } + + Future _handleCreateMusicbill() async { + final createdMusicbillId = await showDialog( + context: context, + useRootNavigator: true, + builder: (context) => const CreateMusicbillDialog(), + ); + if (!mounted || createdMusicbillId == null) { + return; + } + await context.read().reloadMusicbill( + id: createdMusicbillId, + silence: true, + ); + } + + Future _handleMusicbillTap(Musicbill musicbill) async { + final musicbillState = context.read(); + final musicbillId = musicbill.id; + + if (_processingMusicbillIds.contains(musicbillId)) { + return; + } + + switch (musicbill.status) { + case MusicbillStatus.LOADING: + _showMessage('Please wait for the musicbill to finish loading'); + return; + case MusicbillStatus.INITIAL: + case MusicbillStatus.FAILED: + await musicbillState.reloadMusicbill(id: musicbillId, silence: false); + return; + case MusicbillStatus.SUCCESSFUL: + final alreadyAdded = musicbillState.containsMusic( + musicbillId: musicbillId, + musicId: widget.music.id, + ); + + setState(() => _processingMusicbillIds.add(musicbillId)); + try { + if (alreadyAdded) { + await musicbillState.removeMusicFromMusicbill( + musicbillId: musicbillId, + music: widget.music, + ); + } else { + await musicbillState.addMusicToMusicbill( + musicbillId: musicbillId, + music: widget.music, + ); + } + } catch (error) { + if (mounted) { + _showMessage( + error is ServerException + ? error.message + : alreadyAdded + ? 'Failed to remove music from musicbill' + : 'Failed to add music to musicbill', + ); + } + } finally { + if (mounted) { + setState(() => _processingMusicbillIds.remove(musicbillId)); + } + } + } + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + final musicbillState = context.watch(); + final musicbillList = musicbillState.musicbillList; + + return SafeArea( + top: false, + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + _buildCover(context), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.music.name, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: Colors.black87, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + _artistText, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.black54), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'Add to musicbill', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.black87, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton.filledTonal( + onPressed: _handleCreateMusicbill, + tooltip: 'Create musicbill', + icon: const Icon(Icons.add_box_outlined, size: 18), + ), + ], + ), + ), + Expanded( + child: Builder( + builder: (context) { + if (musicbillState.loading && musicbillList.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (musicbillState.exception != null && musicbillList.isEmpty) { + return ErrorView( + title: 'Failed to load musicbills', + errorMessage: musicbillState.exception.toString(), + onRetry: () => + musicbillState.reloadMusicbillList(silence: false), + ); + } + + if (musicbillList.isEmpty) { + return Center( + child: Text( + 'No musicbills yet', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.black54), + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + itemCount: musicbillList.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final musicbill = musicbillList[index]; + return _MusicbillSelectionTile( + musicbill: musicbill, + musicId: widget.music.id, + processing: _processingMusicbillIds.contains( + musicbill.id, + ), + onTap: () => _handleMusicbillTap(musicbill), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } + + String get _artistText { + if (widget.music.singers.isEmpty) { + return 'Unknown Artist'; + } + return widget.music.singers.map((singer) => singer.name).join(', '); + } + + Widget _buildCover(BuildContext context) { + if (widget.music.cover != null && widget.music.cover!.isNotEmpty) { + return CachedImage( + imageUrl: widget.music.cover, + width: 52, + height: 52, + size: 104, + borderRadius: BorderRadius.circular(12), + placeholder: _buildDefaultCover(context), + errorWidget: _buildDefaultCover(context), + ); + } + return _buildDefaultCover(context); + } + + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.music_note_rounded, + color: Theme.of(context).primaryColor, + size: 24, + ), + ); + } +} + +class _MusicbillSelectionTile extends StatelessWidget { + final Musicbill musicbill; + final String musicId; + final bool processing; + final VoidCallback onTap; + + const _MusicbillSelectionTile({ + required this.musicbill, + required this.musicId, + required this.processing, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final containsMusic = musicbill.musicList.any( + (music) => music.id == musicId, + ); + final statusSubtitle = switch (musicbill.status) { + MusicbillStatus.SUCCESSFUL => '${musicbill.musicList.length} tracks', + MusicbillStatus.LOADING => 'Loading musicbill...', + MusicbillStatus.FAILED => 'Tap to retry loading', + MusicbillStatus.INITIAL => null, + }; + final subtitleParts = [if (statusSubtitle != null) statusSubtitle]; + final subtitle = subtitleParts.join(' · '); + + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + _buildLeadingIcon(context, containsMusic), + const SizedBox(width: 12), + _buildCover(context), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + musicbill.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.black54), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildLeadingIcon(BuildContext context, bool containsMusic) { + if (processing || musicbill.status == MusicbillStatus.LOADING) { + return SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Theme.of(context).primaryColor, + ), + ); + } + + if (musicbill.status == MusicbillStatus.SUCCESSFUL) { + return Icon( + containsMusic + ? Icons.check_box_rounded + : Icons.check_box_outline_blank_rounded, + color: containsMusic ? Theme.of(context).primaryColor : Colors.black54, + size: 24, + ); + } + + return Icon(Icons.help_outline_rounded, color: Colors.black45, size: 24); + } + + Widget _buildCover(BuildContext context) { + return MusicbillCover( + imageUrl: musicbill.cover, + size: 44, + isPublic: musicbill.isPublic, + isShared: musicbill.isShared, + borderRadius: BorderRadius.circular(10), + placeholder: _buildDefaultCover(context), + ); + } + + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.library_music_rounded, + color: Theme.of(context).primaryColor, + size: 20, + ), + ); + } +} diff --git a/apps/flutter/lib/pages/musicbill/music_list_item.dart b/apps/flutter/lib/pages/musicbill/music_list_item.dart index 4335256e..585e83b0 100644 --- a/apps/flutter/lib/pages/musicbill/music_list_item.dart +++ b/apps/flutter/lib/pages/musicbill/music_list_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/music.dart'; import '../../widgets/cached_image.dart'; -import 'music_option_menu.dart'; +import 'show_music_option_menu.dart'; /// 音乐列表项组件 /// 显示音乐封面、名称和歌手信息 @@ -31,7 +31,8 @@ class MusicListItem extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onTap, - onLongPress: () => _showMusicOptions(context), + onLongPress: () => + showMusicOptionMenu(context, music: music, onPlay: onTap), child: Padding( padding: const EdgeInsets.all(8), child: Row( @@ -63,7 +64,8 @@ class MusicListItem extends StatelessWidget { color: Colors.grey[400], size: 20, ), - onPressed: () => _showMusicOptions(context), + onPressed: () => + showMusicOptionMenu(context, music: music, onPlay: onTap), padding: EdgeInsets.zero, constraints: const BoxConstraints(), style: const ButtonStyle( @@ -78,25 +80,6 @@ class MusicListItem extends StatelessWidget { ); } - /// 显示音乐选项菜单 - void _showMusicOptions(BuildContext context) { - final overlay = Overlay.of(context, rootOverlay: true); - late OverlayEntry entry; - - entry = OverlayEntry( - builder: (_) => MusicOptionMenu( - music: music, - onPlay: onTap, - onClose: () { - entry.remove(); - }, - parentContext: context, - ), - ); - - overlay.insert(entry); - } - /// 构建封面 Widget _buildCover(BuildContext context) { if (music.cover != null && music.cover!.isNotEmpty) { diff --git a/apps/flutter/lib/pages/musicbill/music_option_menu.dart b/apps/flutter/lib/pages/musicbill/music_option_menu.dart index 825cdb99..63f9e69b 100644 --- a/apps/flutter/lib/pages/musicbill/music_option_menu.dart +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -3,6 +3,7 @@ import '../../models/music.dart'; import '../../event_bus.dart'; import '../../player_controller/show_playlist_dialog.dart'; import '../../widgets/cached_image.dart'; +import './add_to_musicbill_sheet.dart'; /// 自定义音乐选项菜单(Overlay 实现,覆盖 PlayerController) class MusicOptionMenu extends StatefulWidget { @@ -10,6 +11,7 @@ class MusicOptionMenu extends StatefulWidget { final VoidCallback onPlay; final VoidCallback onClose; final BuildContext? parentContext; // 用于显示播放队列弹窗 + final bool showPlaylistAfterInsert; const MusicOptionMenu({ super.key, @@ -17,6 +19,7 @@ class MusicOptionMenu extends StatefulWidget { required this.onPlay, required this.onClose, this.parentContext, + this.showPlaylistAfterInsert = true, }); @override @@ -145,16 +148,37 @@ class _MusicOptionMenuState extends State eventBus.fire( InsertToPlayqueueEvent(musicList: [widget.music]), ); - // 显示播放列表弹窗,定位到播放列表 tab - Future.delayed(const Duration(milliseconds: 100), () { - if (widget.parentContext != null && - widget.parentContext!.mounted) { - showPlaylistDialog( - widget.parentContext!, - initialTabIndex: 1, - ); - } - }); + if (widget.showPlaylistAfterInsert) { + // 显示播放列表弹窗,定位到播放列表 tab + Future.delayed(const Duration(milliseconds: 100), () { + if (widget.parentContext != null && + widget.parentContext!.mounted) { + showPlaylistDialog( + widget.parentContext!, + initialTabIndex: 1, + ); + } + }); + } + }, + ), + ListTile( + leading: const Icon(Icons.library_add_rounded), + title: const Text('Add to musicbill'), + onTap: () async { + final dialogContext = + widget.parentContext != null && + widget.parentContext!.mounted + ? widget.parentContext! + : context; + await _close(); + if (!dialogContext.mounted) { + return; + } + showAddToMusicbillSheet( + dialogContext, + music: widget.music, + ); }, ), const SizedBox(height: 16), diff --git a/apps/flutter/lib/pages/musicbill/show_music_option_menu.dart b/apps/flutter/lib/pages/musicbill/show_music_option_menu.dart new file mode 100644 index 00000000..48f289af --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/show_music_option_menu.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import '../../models/music.dart'; +import './music_option_menu.dart'; + +void showMusicOptionMenu( + BuildContext context, { + required Music music, + required VoidCallback onPlay, + BuildContext? parentContext, + bool showPlaylistAfterInsert = true, +}) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + + entry = OverlayEntry( + builder: (_) => MusicOptionMenu( + music: music, + onPlay: onPlay, + onClose: () { + entry.remove(); + }, + parentContext: parentContext ?? context, + showPlaylistAfterInsert: showPlaylistAfterInsert, + ), + ); + + overlay.insert(entry); +} diff --git a/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart index 4d85daae..ea979f85 100644 --- a/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart +++ b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/music.dart'; import '../../widgets/cached_image.dart'; -import '../musicbill/music_option_menu.dart'; +import '../musicbill/show_music_option_menu.dart'; class MusicWithLyricListItem extends StatelessWidget { final Music music; @@ -37,7 +37,8 @@ class MusicWithLyricListItem extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onTap, - onLongPress: () => _showMusicOptions(context), + onLongPress: () => + showMusicOptionMenu(context, music: music, onPlay: onTap), child: Padding( padding: const EdgeInsets.all(8), child: Column( @@ -72,7 +73,11 @@ class MusicWithLyricListItem extends StatelessWidget { color: Colors.grey[400], size: 20, ), - onPressed: () => _showMusicOptions(context), + onPressed: () => showMusicOptionMenu( + context, + music: music, + onPlay: onTap, + ), padding: EdgeInsets.zero, constraints: const BoxConstraints(), style: const ButtonStyle( @@ -153,24 +158,6 @@ class MusicWithLyricListItem extends StatelessWidget { ); } - void _showMusicOptions(BuildContext context) { - final overlay = Overlay.of(context, rootOverlay: true); - late OverlayEntry entry; - - entry = OverlayEntry( - builder: (_) => MusicOptionMenu( - music: music, - onPlay: onTap, - onClose: () { - entry.remove(); - }, - parentContext: context, - ), - ); - - overlay.insert(entry); - } - Widget _buildCover(BuildContext context) { if (music.cover != null && music.cover!.isNotEmpty) { return CachedImage( diff --git a/apps/flutter/lib/player_controller/player_controller.dart b/apps/flutter/lib/player_controller/player_controller.dart index 680fba11..cb72f2a4 100644 --- a/apps/flutter/lib/player_controller/player_controller.dart +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -84,7 +84,7 @@ class PlayController extends StatelessWidget { const SizedBox(width: 4), // 操作按钮 - actions.Actions(), + const actions.Actions(), ], ), ), diff --git a/apps/flutter/lib/player_controller/playlist.dart b/apps/flutter/lib/player_controller/playlist.dart index c50cd480..af3f843c 100644 --- a/apps/flutter/lib/player_controller/playlist.dart +++ b/apps/flutter/lib/player_controller/playlist.dart @@ -3,6 +3,7 @@ import 'package:cicada/states/playlist.dart'; import 'package:cicada/states/playqueue.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../pages/musicbill/show_music_option_menu.dart'; class Playlist extends StatelessWidget { const Playlist({super.key}); @@ -46,6 +47,13 @@ class Playlist extends StatelessWidget { return InkWell( onTap: () => eventBus.fire(PlayMusicEvent(music: music)), + onLongPress: () => showMusicOptionMenu( + context, + music: music, + onPlay: () => eventBus.fire(PlayMusicEvent(music: music)), + parentContext: context, + showPlaylistAfterInsert: false, + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( @@ -98,6 +106,25 @@ class Playlist extends StatelessWidget { ], ), ), + IconButton( + icon: Icon( + Icons.more_horiz_rounded, + size: 18, + color: Colors.grey[400], + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: () => showMusicOptionMenu( + context, + music: music, + onPlay: () => eventBus.fire(PlayMusicEvent(music: music)), + parentContext: context, + showPlaylistAfterInsert: false, + ), + ), // 删除按钮 IconButton( icon: Icon( diff --git a/apps/flutter/lib/player_controller/playqueue.dart b/apps/flutter/lib/player_controller/playqueue.dart index 3dd4a338..e2a4174f 100644 --- a/apps/flutter/lib/player_controller/playqueue.dart +++ b/apps/flutter/lib/player_controller/playqueue.dart @@ -1,6 +1,7 @@ import '../states/playqueue.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../pages/musicbill/show_music_option_menu.dart'; class Playqueue extends StatelessWidget { const Playqueue({super.key}); @@ -38,115 +39,148 @@ class Playqueue extends StatelessWidget { final isCurrentPlaying = currentMusic?.pid == playqueueMusic.pid; final primaryColor = Theme.of(context).primaryColor; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - // 索引 - SizedBox( - width: 28, - child: Text( - '$displayIndex', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: isCurrentPlaying ? primaryColor : Colors.grey[400], + return InkWell( + onLongPress: () => showMusicOptionMenu( + context, + music: music, + onPlay: () => playqueueState.rewind(playqueueMusic), + parentContext: context, + showPlaylistAfterInsert: false, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // 索引 + SizedBox( + width: 28, + child: Text( + '$displayIndex', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isCurrentPlaying ? primaryColor : Colors.grey[400], + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - const SizedBox(width: 12), - // 音乐信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - music.name, - style: TextStyle( - fontSize: 14, - fontWeight: isCurrentPlaying - ? FontWeight.w600 - : FontWeight.w400, - color: isCurrentPlaying - ? primaryColor - : Colors.black87, + const SizedBox(width: 12), + // 音乐信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + music.name, + style: TextStyle( + fontSize: 14, + fontWeight: isCurrentPlaying + ? FontWeight.w600 + : FontWeight.w400, + color: isCurrentPlaying + ? primaryColor + : Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), - if (playqueueMusic.isUserAdded) ...[ - const SizedBox(width: 4), - Tooltip( - message: 'Added by you', - child: Icon( - Icons.person_outline_rounded, - size: 14, - color: primaryColor, + if (playqueueMusic.isUserAdded) ...[ + const SizedBox(width: 4), + Tooltip( + message: 'Added by you', + child: Icon( + Icons.person_outline_rounded, + size: 14, + color: primaryColor, + ), ), - ), + ], ], - ], - ), - if (artistNames.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - artistNames, - style: TextStyle(fontSize: 11, color: Colors.grey[500]), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ], - ], - ), - ), - if (playqueue.length - index - 1 > playqueueState.playqueueIndex) - IconButton( - icon: Icon(Icons.close, size: 18, color: Colors.grey[400]), - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Remove from playqueue'), - content: Text( - 'Are you sure you want to remove "${music.name}" from the playqueue?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + if (artistNames.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + artistNames, + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], ), - TextButton( - onPressed: () { - playqueueState.remove(playqueueMusic); - Navigator.of(context).pop(); - }, - child: const Text( - 'Remove', - style: TextStyle(color: Colors.red), - ), - ), - ], - ), - ); - }, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), ), - if (playqueue.length - index - 1 < playqueueState.playqueueIndex) IconButton( icon: Icon( - Icons.settings_backup_restore, + Icons.more_horiz_rounded, size: 18, color: Colors.grey[400], ), - onPressed: () { - playqueueState.rewind(playqueueMusic); - }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: () => showMusicOptionMenu( + context, + music: music, + onPlay: () => playqueueState.rewind(playqueueMusic), + parentContext: context, + showPlaylistAfterInsert: false, + ), ), - ], + if (playqueue.length - index - 1 > + playqueueState.playqueueIndex) + IconButton( + icon: Icon(Icons.close, size: 18, color: Colors.grey[400]), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove from playqueue'), + content: Text( + 'Are you sure you want to remove "${music.name}" from the playqueue?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + playqueueState.remove(playqueueMusic); + Navigator.of(context).pop(); + }, + child: const Text( + 'Remove', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + }, + ), + if (playqueue.length - index - 1 < + playqueueState.playqueueIndex) + IconButton( + icon: Icon( + Icons.settings_backup_restore, + size: 18, + color: Colors.grey[400], + ), + onPressed: () { + playqueueState.rewind(playqueueMusic); + }, + ), + ], + ), ), ); }, diff --git a/apps/flutter/lib/server/api/add_music_to_musicbill.dart b/apps/flutter/lib/server/api/add_music_to_musicbill.dart new file mode 100644 index 00000000..75172c2b --- /dev/null +++ b/apps/flutter/lib/server/api/add_music_to_musicbill.dart @@ -0,0 +1,12 @@ +import '../request.dart'; + +Future addMusicToMusicbill({ + required String musicbillId, + required String musicId, +}) async { + await httpPost( + path: "/api/musicbill_music", + data: {"musicbillId": musicbillId, "musicId": musicId}, + withToken: true, + ); +} diff --git a/apps/flutter/lib/server/api/get_musicbill.dart b/apps/flutter/lib/server/api/get_musicbill.dart index 8cd5dae5..af6b8594 100644 --- a/apps/flutter/lib/server/api/get_musicbill.dart +++ b/apps/flutter/lib/server/api/get_musicbill.dart @@ -5,13 +5,23 @@ import '../request.dart'; class Musicbill { String name; String? cover; + bool isPublic; + bool isShared; List musicList; - Musicbill({required this.name, required this.cover, required this.musicList}); + Musicbill({ + required this.name, + required this.cover, + required this.isPublic, + required this.isShared, + required this.musicList, + }); factory Musicbill.fromJson(Map json) => Musicbill( name: json['name'], cover: prefixServerOrigin(json['cover']), + isPublic: json['public'] == true, + isShared: (json['sharedUserList'] as List? ?? const []).isNotEmpty, musicList: (json['musicList'] as List) .map((json) => Music.fromJson(json)) .toList(), diff --git a/apps/flutter/lib/server/api/get_musicbill_list.dart b/apps/flutter/lib/server/api/get_musicbill_list.dart index 87650a7b..ec82e06c 100644 --- a/apps/flutter/lib/server/api/get_musicbill_list.dart +++ b/apps/flutter/lib/server/api/get_musicbill_list.dart @@ -5,13 +5,23 @@ class Musicbill { final String id; final String name; final String? cover; + final bool isPublic; + final bool isShared; - Musicbill({required this.id, required this.name, required this.cover}); + Musicbill({ + required this.id, + required this.name, + required this.cover, + required this.isPublic, + required this.isShared, + }); factory Musicbill.fromJson(Map json) => Musicbill( id: json['id'], name: json['name'], cover: prefixServerOrigin(json['cover']), + isPublic: json['public'] == true, + isShared: (json['sharedUserList'] as List? ?? const []).isNotEmpty, ); } diff --git a/apps/flutter/lib/server/api/remove_music_from_musicbill.dart b/apps/flutter/lib/server/api/remove_music_from_musicbill.dart new file mode 100644 index 00000000..f9bbe782 --- /dev/null +++ b/apps/flutter/lib/server/api/remove_music_from_musicbill.dart @@ -0,0 +1,12 @@ +import '../request.dart'; + +Future removeMusicFromMusicbill({ + required String musicbillId, + required String musicId, +}) async { + await httpDelete( + path: "/api/musicbill_music", + query: {"musicbillId": musicbillId, "musicId": musicId}, + withToken: true, + ); +} diff --git a/apps/flutter/lib/server/request.dart b/apps/flutter/lib/server/request.dart index d1b39759..4a2762c0 100644 --- a/apps/flutter/lib/server/request.dart +++ b/apps/flutter/lib/server/request.dart @@ -74,3 +74,24 @@ Future httpPost({ ); return handleResponse(response); } + +Future httpDelete({ + required String path, + Map? query, + Object? data, + bool withToken = false, + String? origin, +}) async { + final response = await dio.delete( + '${origin ?? serverState.currentServer!.origin}$path', + queryParameters: query, + data: data, + options: Options( + headers: { + ...getTokenHeader(withToken), + "content-type": "application/json", + }, + ), + ); + return handleResponse(response); +} diff --git a/apps/flutter/lib/states/musicbill.dart b/apps/flutter/lib/states/musicbill.dart index 01a99e8a..88c0f31b 100644 --- a/apps/flutter/lib/states/musicbill.dart +++ b/apps/flutter/lib/states/musicbill.dart @@ -1,29 +1,55 @@ +import 'package:cicada/constants/exception.dart'; import 'package:cicada/models/music.dart'; -import 'package:cicada/models/singer.dart'; -import '../server/api/get_musicbill.dart' as get_musicbill; -import '../server/api/get_musicbill_list.dart'; import 'package:flutter/material.dart'; +import 'package:cicada/server/server_exception.dart'; +import '../server/api/add_music_to_musicbill.dart' as add_music_to_musicbill; +import '../server/api/get_musicbill.dart' as get_musicbill; +import '../server/api/get_musicbill_list.dart' as get_musicbill_list; +import '../server/api/remove_music_from_musicbill.dart' + as remove_music_from_musicbill; enum MusicbillStatus { INITIAL, LOADING, SUCCESSFUL, FAILED } const musicbillRefreshInterval = Duration(minutes: 5); class Musicbill { - String id; - String name; - String? cover; - - List musicList = []; - MusicbillStatus status = MusicbillStatus.INITIAL; + final String id; + final String name; + final String? cover; + final bool isPublic; + final bool isShared; + final List musicList; + final MusicbillStatus status; Musicbill({ required this.id, required this.name, required this.cover, + required this.isPublic, + required this.isShared, this.musicList = const [], this.status = MusicbillStatus.INITIAL, }); + + Musicbill copyWith({ + String? name, + String? cover, + bool? isPublic, + bool? isShared, + List? musicList, + MusicbillStatus? status, + }) { + return Musicbill( + id: id, + name: name ?? this.name, + cover: cover ?? this.cover, + isPublic: isPublic ?? this.isPublic, + isShared: isShared ?? this.isShared, + musicList: musicList ?? this.musicList, + status: status ?? this.status, + ); + } } class MusicbillState extends ChangeNotifier { @@ -32,81 +58,67 @@ class MusicbillState extends ChangeNotifier { List musicbillList = []; final Map _musicbillLastEnteredAt = {}; - void reloadMusicbillList({required bool silence}) async { + Future reloadMusicbillList({required bool silence}) async { + final shouldShowLoading = !silence || musicbillList.isEmpty; exception = null; - loading = true; - notifyListeners(); + if (shouldShowLoading) { + loading = true; + notifyListeners(); + } try { - final data = await getMusicbillList(); - musicbillList = data - .map((m) => Musicbill(id: m.id, name: m.name, cover: m.cover)) - .toList(); + final data = await get_musicbill_list.getMusicbillList(); + final previousMusicbillMap = { + for (final musicbill in musicbillList) musicbill.id: musicbill, + }; + musicbillList = data.map((m) { + final previousMusicbill = previousMusicbillMap[m.id]; + return Musicbill( + id: m.id, + name: m.name, + cover: m.cover, + isPublic: m.isPublic, + isShared: m.isShared, + musicList: previousMusicbill?.musicList ?? const [], + status: previousMusicbill?.status ?? MusicbillStatus.INITIAL, + ); + }).toList(); } catch (e) { - exception = e as Exception; + exception = e is Exception ? e : Exception(e.toString()); } loading = false; notifyListeners(); } - void reloadMusicbill({required String id, bool silence = true}) async { - if (!silence) { - musicbillList = musicbillList.map((musicbill) { - if (musicbill.id == id) { - musicbill.status = MusicbillStatus.LOADING; - } - return musicbill; - }).toList(); - notifyListeners(); + Future reloadMusicbill({ + required String id, + bool silence = true, + }) async { + final existingMusicbill = getMusicbillById(id); + if (!silence && existingMusicbill != null) { + _upsertMusicbill( + existingMusicbill.copyWith(status: MusicbillStatus.LOADING), + ); } + try { final newMusicbill = await get_musicbill.getMusicbill(id: id); - musicbillList = musicbillList.map((musicbill) { - if (musicbill.id == id) { - return Musicbill( - id: id, - name: newMusicbill.name, - cover: newMusicbill.cover, - - musicList: newMusicbill.musicList - .map( - (music) => Music( - id: music.id, - name: music.name, - cover: music.cover, - asset: music.asset, - type: music.type, - singers: music.singers - .map( - (s) => Singer( - id: s.id, - name: s.name, - aliases: s.aliases, - avatar: s.avatar, - ), - ) - .toList(), - ), - ) - .toList(), - status: MusicbillStatus.SUCCESSFUL, - ); - } - return musicbill; - }).toList(); - notifyListeners(); + final refreshedMusicbill = Musicbill( + id: id, + name: newMusicbill.name, + cover: newMusicbill.cover, + isPublic: newMusicbill.isPublic, + isShared: newMusicbill.isShared, + musicList: List.from(newMusicbill.musicList), + status: MusicbillStatus.SUCCESSFUL, + ); + _upsertMusicbill(refreshedMusicbill); } catch (e) { - /** - * @todo notification - * @author mebtte - */ - musicbillList = musicbillList.map((musicbill) { - if (musicbill.id == id) { - musicbill.status = MusicbillStatus.FAILED; - } - return musicbill; - }).toList(); - notifyListeners(); + if (existingMusicbill != null) { + _upsertMusicbill( + existingMusicbill.copyWith(status: MusicbillStatus.FAILED), + ); + } } } @@ -117,6 +129,102 @@ class MusicbillState extends ChangeNotifier { return lastEnteredAt == null || now.difference(lastEnteredAt) > musicbillRefreshInterval; } + + Musicbill? getMusicbillById(String id) { + try { + return musicbillList.firstWhere((musicbill) => musicbill.id == id); + } on StateError { + return null; + } + } + + bool containsMusic({required String musicbillId, required String musicId}) { + final musicbill = getMusicbillById(musicbillId); + if (musicbill == null || musicbill.status != MusicbillStatus.SUCCESSFUL) { + return false; + } + return musicbill.musicList.any((music) => music.id == musicId); + } + + Future addMusicToMusicbill({ + required String musicbillId, + required Music music, + }) async { + final musicbill = getMusicbillById(musicbillId); + if (musicbill == null || + musicbill.status != MusicbillStatus.SUCCESSFUL || + containsMusic(musicbillId: musicbillId, musicId: music.id)) { + return; + } + + final previousMusicList = musicbill.musicList; + _upsertMusicbill( + musicbill.copyWith(musicList: [music, ...previousMusicList]), + ); + + try { + await add_music_to_musicbill.addMusicToMusicbill( + musicbillId: musicbillId, + musicId: music.id, + ); + } catch (e) { + if (e is ServerException && e.code == musicAlreadyExistedInMusicbill) { + return; + } + _upsertMusicbill(musicbill.copyWith(musicList: previousMusicList)); + rethrow; + } + } + + Future removeMusicFromMusicbill({ + required String musicbillId, + required Music music, + }) async { + final musicbill = getMusicbillById(musicbillId); + if (musicbill == null || + musicbill.status != MusicbillStatus.SUCCESSFUL || + !containsMusic(musicbillId: musicbillId, musicId: music.id)) { + return; + } + + final previousMusicList = musicbill.musicList; + _upsertMusicbill( + musicbill.copyWith( + musicList: previousMusicList + .where((existingMusic) => existingMusic.id != music.id) + .toList(), + ), + ); + + try { + await remove_music_from_musicbill.removeMusicFromMusicbill( + musicbillId: musicbillId, + musicId: music.id, + ); + } catch (e) { + if (e is ServerException && e.code == musicNotExistedInMusicbill) { + return; + } + _upsertMusicbill(musicbill.copyWith(musicList: previousMusicList)); + rethrow; + } + } + + void _upsertMusicbill(Musicbill nextMusicbill) { + final musicbillIndex = musicbillList.indexWhere( + (musicbill) => musicbill.id == nextMusicbill.id, + ); + + if (musicbillIndex == -1) { + musicbillList = [nextMusicbill, ...musicbillList]; + } else { + final nextMusicbillList = List.from(musicbillList); + nextMusicbillList[musicbillIndex] = nextMusicbill; + musicbillList = nextMusicbillList; + } + + notifyListeners(); + } } final musicbillState = MusicbillState(); diff --git a/apps/flutter/lib/widgets/musicbill_cover.dart b/apps/flutter/lib/widgets/musicbill_cover.dart new file mode 100644 index 00000000..d21c5157 --- /dev/null +++ b/apps/flutter/lib/widgets/musicbill_cover.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import './cached_image.dart'; + +class MusicbillCover extends StatelessWidget { + final String? imageUrl; + final double size; + final bool isPublic; + final bool isShared; + final BorderRadius borderRadius; + final Widget? placeholder; + + const MusicbillCover({ + super.key, + required this.imageUrl, + required this.size, + required this.isPublic, + required this.isShared, + required this.borderRadius, + this.placeholder, + }); + + @override + Widget build(BuildContext context) { + final badges = []; + + if (isPublic) { + badges.add( + const _StatusBadge( + icon: Icons.public_rounded, + backgroundColor: Color(0xFF63D1FA), + tooltip: 'Public musicbill', + ), + ); + } + + if (isShared) { + badges.add( + const _StatusBadge( + icon: Icons.people_alt_outlined, + backgroundColor: Color(0xFFEABEC8), + tooltip: 'Shared musicbill', + ), + ); + } + + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CachedImage( + imageUrl: imageUrl, + width: size, + height: size, + size: (size * 2).round(), + borderRadius: borderRadius, + placeholder: placeholder, + errorWidget: placeholder, + ), + ), + if (badges.isNotEmpty) + Positioned( + top: -4, + right: -4, + child: Wrap(spacing: 4, children: badges), + ), + ], + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + final IconData icon; + final Color backgroundColor; + final String tooltip; + + const _StatusBadge({ + required this.icon, + required this.backgroundColor, + required this.tooltip, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + child: Icon(icon, size: 10, color: Colors.white), + ), + ); + } +} diff --git a/apps/flutter/lib/widgets/player/index.dart b/apps/flutter/lib/widgets/player/index.dart index 4d540c47..c81ae385 100644 --- a/apps/flutter/lib/widgets/player/index.dart +++ b/apps/flutter/lib/widgets/player/index.dart @@ -3,6 +3,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../event_bus.dart'; import '../../models/music.dart'; import '../../models/lyric.dart'; import '../../server/api/get_lyric.dart'; @@ -12,6 +13,7 @@ import './lyric_view.dart'; import './player_controls.dart'; import './player_header.dart'; import '../../states/playqueue.dart'; +import '../../pages/musicbill/add_to_musicbill_sheet.dart'; import '../../player_controller/show_playlist_dialog.dart'; class PlayerWidget extends StatefulWidget { @@ -204,6 +206,15 @@ class _PlayerWidgetState extends State { const SizedBox(height: 32), PlayerControls( onBack: () => Navigator.of(context).pop(), + onAddToMusicbill: () => showAddToMusicbillSheet( + context, + music: displayMusic, + ), + onInsertToPlayqueue: () { + eventBus.fire( + InsertToPlayqueueEvent(musicList: [displayMusic]), + ); + }, onPlaylist: () => showPlaylistDialog(context), ), ], diff --git a/apps/flutter/lib/widgets/player/player_controls.dart b/apps/flutter/lib/widgets/player/player_controls.dart index 79df64ea..809a2225 100644 --- a/apps/flutter/lib/widgets/player/player_controls.dart +++ b/apps/flutter/lib/widgets/player/player_controls.dart @@ -4,9 +4,17 @@ import 'package:provider/provider.dart'; class PlayerControls extends StatelessWidget { final VoidCallback? onBack; + final VoidCallback? onAddToMusicbill; + final VoidCallback? onInsertToPlayqueue; final VoidCallback? onPlaylist; - const PlayerControls({super.key, this.onBack, this.onPlaylist}); + const PlayerControls({ + super.key, + this.onBack, + this.onAddToMusicbill, + this.onInsertToPlayqueue, + this.onPlaylist, + }); @override Widget build(BuildContext context) { @@ -23,21 +31,27 @@ class PlayerControls extends StatelessWidget { processingState == AudioProcessingState.buffering; return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Collapse (Back) - IconButton( + _ControlButton( icon: const Icon(Icons.keyboard_arrow_down_rounded), iconSize: 28, - color: Colors.white, + tooltip: 'Collapse', onPressed: onBack, ), + _ControlButton( + icon: const Icon(Icons.library_add_rounded), + iconSize: 22, + tooltip: 'Add to musicbill', + onPressed: onAddToMusicbill, + ), + // Previous - IconButton( + _ControlButton( icon: const Icon(Icons.skip_previous_rounded), iconSize: 36, - color: Colors.white, onPressed: () => audioHandler.skipToPrevious(), ), @@ -86,18 +100,24 @@ class PlayerControls extends StatelessWidget { ), // Next - IconButton( + _ControlButton( icon: const Icon(Icons.skip_next_rounded), iconSize: 36, - color: Colors.white, onPressed: () => audioHandler.skipToNext(), ), + _ControlButton( + icon: const Icon(Icons.playlist_add_rounded), + iconSize: 22, + tooltip: 'Insert to playqueue', + onPressed: onInsertToPlayqueue, + ), + // Playlist - IconButton( + _ControlButton( icon: const Icon(Icons.queue_music_rounded), iconSize: 28, - color: Colors.white, + tooltip: 'Open playlist', onPressed: onPlaylist, ), ], @@ -223,3 +243,30 @@ class _SeekBarState extends State<_SeekBar> { double min(double a, double b) => a < b ? a : b; } + +class _ControlButton extends StatelessWidget { + final Widget icon; + final double iconSize; + final VoidCallback? onPressed; + final String? tooltip; + + const _ControlButton({ + required this.icon, + required this.iconSize, + required this.onPressed, + this.tooltip, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: icon, + iconSize: iconSize, + color: Colors.white, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + tooltip: tooltip, + onPressed: onPressed, + ); + } +} diff --git a/apps/flutter/lib/widgets/player/player_header.dart b/apps/flutter/lib/widgets/player/player_header.dart index 01e6c9af..2834b9c6 100644 --- a/apps/flutter/lib/widgets/player/player_header.dart +++ b/apps/flutter/lib/widgets/player/player_header.dart @@ -15,7 +15,7 @@ class PlayerHeader extends StatelessWidget { padding: EdgeInsets.only(top: safePadding), child: Container( height: kToolbarHeight, - padding: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ Expanded( From a11333df89c715d2a800bdcce428a36f1655de38 Mon Sep 17 00:00:00 2001 From: mebtte Date: Tue, 31 Mar 2026 23:22:02 +0800 Subject: [PATCH 49/50] still playing when inserting music to playqueue --- apps/flutter/lib/audio_handler.dart | 90 +++++++++++++++++-- .../pages/musicbill/music_option_menu.dart | 22 +++-- apps/flutter/lib/states/playqueue.dart | 47 ++++++---- apps/flutter/lib/widgets/player/index.dart | 11 +-- 4 files changed, 139 insertions(+), 31 deletions(-) diff --git a/apps/flutter/lib/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 91c40f95..a966d378 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; +import 'package:cicada/models/music.dart'; import 'package:cicada/states/audio.dart'; import 'package:cicada/states/playlist.dart'; import 'package:flutter/foundation.dart' show listEquals; @@ -180,16 +181,27 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { ); } - AudioSource _buildSource(PlayqueueMusic queueMusic) { + AudioSource _buildSource( + PlayqueueMusic queueMusic, { + required bool useCache, + }) { + final tag = _buildMediaItem(queueMusic); + if (!useCache) { + return AudioSource.uri(Uri.parse(queueMusic.music.asset), tag: tag); + } return AudioCacheManager.instance.getAudioSource( queueMusic.music.id, queueMusic.music.asset, - tag: _buildMediaItem(queueMusic), + tag: tag, ); } List _buildSources(List queue) { - return queue.map(_buildSource).toList(); + final seenMusicIds = {}; + return queue.map((queueMusic) { + final useCache = seenMusicIds.add(queueMusic.music.id); + return _buildSource(queueMusic, useCache: useCache); + }).toList(); } void _syncAudioServiceQueue(List queueSnapshot) { @@ -255,7 +267,15 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { return; } - await _playlist.add(_buildSource(nextQueueMusic)); + final firstOccurrenceIndex = playqueueState.playqueue.indexWhere( + (queueMusic) => queueMusic.music.id == nextQueueMusic!.music.id, + ); + await _playlist.add( + _buildSource( + nextQueueMusic, + useCache: firstOccurrenceIndex == nextAbsoluteIndex, + ), + ); _loadedQueuePids = playqueueState.playqueue .map((queueMusic) => queueMusic.pid) .toList(growable: false); @@ -301,7 +321,16 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final existingIndex = workingPids.indexOf(targetPid); if (existingIndex == -1) { - await _playlist.insert(targetIndex, _buildSource(targetQueueMusic)); + final firstOccurrenceIndex = queueSnapshot.indexWhere( + (queueMusic) => queueMusic.music.id == targetQueueMusic.music.id, + ); + await _playlist.insert( + targetIndex, + _buildSource( + targetQueueMusic, + useCache: firstOccurrenceIndex == targetIndex, + ), + ); workingPids.insert(targetIndex, targetPid); continue; } @@ -395,6 +424,7 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { return; } + lastQueueMusic = queueMusic; mediaItem.add(_buildMediaItem(queueMusic, duration: duration)); _broadcastState(); await _appendUpcomingTrackIfNeeded(); @@ -440,6 +470,52 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { ); } + Future insertMusicListToPlayqueue(List musicList) async { + if (musicList.isEmpty) { + return; + } + + playlistState.addMusicList(musicList); + + final currentQueueMusic = playqueueState.currentMusic; + if (currentQueueMusic == null) { + _mutatingPlayqueueFromHandler = true; + try { + playqueueState.jump(musicList.first, isUserAdded: false); + playqueueState.next(); + if (musicList.length > 1) { + playqueueState.insertAfterCurrent( + musicList.sublist(1), + isUserAdded: true, + ); + } + } finally { + _mutatingPlayqueueFromHandler = false; + } + + final nextQueueMusic = playqueueState.currentMusic; + if (nextQueueMusic != null) { + lastQueueMusic = nextQueueMusic; + await playQueueMusic(nextQueueMusic); + } + return; + } + + final currentPid = currentQueueMusic.pid; + _mutatingPlayqueueFromHandler = true; + try { + playqueueState.insertAfterCurrent(musicList, isUserAdded: true); + } finally { + _mutatingPlayqueueFromHandler = false; + } + + final queueSnapshot = List.from(playqueueState.playqueue); + await _syncCurrentQueueStructure( + queueSnapshot: queueSnapshot, + currentPid: currentPid, + ); + } + Future _reloadCurrentQueue() { final currentQueueMusic = playqueueState.currentMusic; if (currentQueueMusic == null) { @@ -538,6 +614,10 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { return; } + if (lastQueueMusic == null && player.audioSource != null) { + lastQueueMusic = currentQueueMusic; + } + final currentQueuePids = playqueueState.playqueue .map((queueMusic) => queueMusic.pid) .toList(growable: false); diff --git a/apps/flutter/lib/pages/musicbill/music_option_menu.dart b/apps/flutter/lib/pages/musicbill/music_option_menu.dart index 63f9e69b..b0712d2f 100644 --- a/apps/flutter/lib/pages/musicbill/music_option_menu.dart +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -1,6 +1,8 @@ +import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../audio_handler.dart' as cicada_audio; import '../../models/music.dart'; -import '../../event_bus.dart'; import '../../player_controller/show_playlist_dialog.dart'; import '../../widgets/cached_image.dart'; import './add_to_musicbill_sheet.dart'; @@ -143,11 +145,19 @@ class _MusicOptionMenuState extends State ListTile( leading: const Icon(Icons.playlist_add_rounded), title: const Text('Insert to playqueue'), - onTap: () { - _close(); - eventBus.fire( - InsertToPlayqueueEvent(musicList: [widget.music]), - ); + onTap: () async { + final handlerContext = + widget.parentContext != null && + widget.parentContext!.mounted + ? widget.parentContext! + : context; + final audioHandler = + handlerContext.read() + as cicada_audio.MyAudioHandler; + await _close(); + await audioHandler.insertMusicListToPlayqueue([ + widget.music, + ]); if (widget.showPlaylistAfterInsert) { // 显示播放列表弹窗,定位到播放列表 tab Future.delayed(const Duration(milliseconds: 100), () { diff --git a/apps/flutter/lib/states/playqueue.dart b/apps/flutter/lib/states/playqueue.dart index 8d66200e..9f64758d 100644 --- a/apps/flutter/lib/states/playqueue.dart +++ b/apps/flutter/lib/states/playqueue.dart @@ -45,6 +45,37 @@ class PlayqueueState extends ChangeNotifier { notifyListeners(); } + List insertAfterCurrent( + List musicList, { + bool isUserAdded = true, + }) { + final newItems = musicList + .map( + (music) => PlayqueueMusic( + pid: uuid.v4(), + music: music, + isUserAdded: isUserAdded, + ), + ) + .toList(); + + if (newItems.isEmpty) { + return const []; + } + + if (playqueueIndex == -1) { + playqueue = [...newItems, ...playqueue]; + } else { + playqueue = [ + ...playqueue.sublist(0, playqueueIndex + 1), + ...newItems, + ...playqueue.sublist(playqueueIndex + 1), + ]; + } + notifyListeners(); + return newItems; + } + void previous() { final nextPlayqueueIndex = playqueueIndex - 1; if (nextPlayqueueIndex < 0) { @@ -147,21 +178,7 @@ class PlayqueueState extends ChangeNotifier { ); next(); } else { - final newItems = event.musicList - .map( - (music) => PlayqueueMusic( - pid: uuid.v4(), - music: music, - isUserAdded: true, // User manually added - ), - ) - .toList(); - playqueue = [ - ...playqueue.sublist(0, playqueueIndex + 1), - ...newItems, - ...playqueue.sublist(playqueueIndex + 1), - ]; - notifyListeners(); + insertAfterCurrent(event.musicList, isUserAdded: true); } }); return () { diff --git a/apps/flutter/lib/widgets/player/index.dart b/apps/flutter/lib/widgets/player/index.dart index c81ae385..e206545a 100644 --- a/apps/flutter/lib/widgets/player/index.dart +++ b/apps/flutter/lib/widgets/player/index.dart @@ -3,7 +3,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../event_bus.dart'; +import '../../audio_handler.dart' as cicada_audio; import '../../models/music.dart'; import '../../models/lyric.dart'; import '../../server/api/get_lyric.dart'; @@ -146,6 +146,7 @@ class _PlayerWidgetState extends State { // AudioHandler is a singleton/service, it doesn't notify changes itself (streams do) // So we use read() to avoid rebuilding if it were to notify (which it shouldn't, but safe is better) final audioHandler = context.read(); + final appAudioHandler = audioHandler as cicada_audio.MyAudioHandler; return GestureDetector( onHorizontalDragStart: _handleDragStart, @@ -176,7 +177,7 @@ class _PlayerWidgetState extends State { child: StreamBuilder( stream: Stream.periodic( const Duration(milliseconds: 100), - (_) => (audioHandler as dynamic).player.position, + (_) => appAudioHandler.player.position, ), builder: (context, positionSnapshot) { final position = positionSnapshot.data ?? Duration.zero; @@ -211,9 +212,9 @@ class _PlayerWidgetState extends State { music: displayMusic, ), onInsertToPlayqueue: () { - eventBus.fire( - InsertToPlayqueueEvent(musicList: [displayMusic]), - ); + appAudioHandler.insertMusicListToPlayqueue([ + displayMusic, + ]); }, onPlaylist: () => showPlaylistDialog(context), ), From 630772830ef8bda5acea9b7980509ac729697f0e Mon Sep 17 00:00:00 2001 From: mebtte Date: Fri, 3 Apr 2026 22:56:52 +0800 Subject: [PATCH 50/50] fix update_fork_from --- .../update_music/update_fork_from.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/commands/start_server/api_app/controllers/update_music/update_fork_from.ts b/apps/cli/src/commands/start_server/api_app/controllers/update_music/update_fork_from.ts index b6421279..a5298793 100644 --- a/apps/cli/src/commands/start_server/api_app/controllers/update_music/update_fork_from.ts +++ b/apps/cli/src/commands/start_server/api_app/controllers/update_music/update_fork_from.ts @@ -8,11 +8,15 @@ import { getMusicListByIds } from '@/db/music'; import { MusicForkProperty, MusicProperty } from '@/constants/db_definition'; import { Parameter } from './constants'; +function isString(s: unknown): s is string { + return typeof s === 'string'; +} + export default async ({ ctx, music, value }: Parameter) => { if ( !Array.isArray(value) || value.length > 100 || - value.find((v) => typeof v !== 'string') || + !value.every(isString) || value.find((v) => v === music.id) ) { return ctx.error(ExceptionCode.WRONG_PARAMETER); @@ -31,9 +35,11 @@ export default async ({ ctx, music, value }: Parameter) => { return ctx.except(ExceptionCode.NO_NEED_TO_UPDATE); } - const musicList = await getMusicListByIds(value, [MusicProperty.ID]); - if (musicList.length !== value.length) { - return ctx.except(ExceptionCode.MUSIC_NOT_EXISTED); + if (value.length) { + const musicList = await getMusicListByIds(value, [MusicProperty.ID]); + if (musicList.length !== value.length) { + return ctx.except(ExceptionCode.MUSIC_NOT_EXISTED); + } } await Promise.all([ @@ -44,13 +50,15 @@ export default async ({ ctx, music, value }: Parameter) => { `, oldForkFromList.map((f) => f.id), ), - getDB().run( - ` - INSERT INTO music_fork ( musicId, forkFrom ) - VALUES ${value.map(() => '( ?, ? )').join(', ')} - `, - value.map((v) => [music.id, v]).flat(), - ), + value.length + ? getDB().run( + ` + INSERT INTO music_fork ( musicId, forkFrom ) + VALUES ${value.map(() => '( ?, ? )').join(', ')} + `, + value.map((v) => [music.id, v]).flat(), + ) + : null, saveMusicModifyRecord({ musicId: music.id, key: AllowUpdateKey.FORK_FROM,