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/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..63199480 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +## Overview + +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 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 + +- Variants prefer lower camel case +- Filenames prefer snake case diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md new file mode 100644 index 00000000..86a3c3a8 --- /dev/null +++ b/apps/cli/AGENTS.md @@ -0,0 +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/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, 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/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/flutter/android/app/src/main/AndroidManifest.xml b/apps/flutter/android/app/src/main/AndroidManifest.xml index 34c3ca47..1faf21a1 100644 --- a/apps/flutter/android/app/src/main/AndroidManifest.xml +++ b/apps/flutter/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,15 @@ + + + + + + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> + + + + + + + + + + + + + ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/apps/flutter/ios/Podfile.lock b/apps/flutter/ios/Podfile.lock index 6760d616..a87afb57 100644 --- a/apps/flutter/ios/Podfile.lock +++ b/apps/flutter/ios/Podfile.lock @@ -8,9 +8,8 @@ PODS: - just_audio (0.0.1): - Flutter - FlutterMacOS - - path_provider_foundation (0.0.1): + - permission_handler_apple (9.3.0): - Flutter - - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -23,7 +22,7 @@ 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`) @@ -36,8 +35,8 @@ 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: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite_darwin: @@ -48,8 +47,8 @@ SPEC CHECKSUMS: audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e 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/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..4c975423 100644 --- a/apps/flutter/ios/Runner/Info.plist +++ b/apps/flutter/ios/Runner/Info.plist @@ -1,53 +1,79 @@ - - 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 + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + 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/app.dart b/apps/flutter/lib/app.dart index 3461f242..1a01cc5f 100644 --- a/apps/flutter/lib/app.dart +++ b/apps/flutter/lib/app.dart @@ -1,16 +1,20 @@ -import 'package:cicada/audio_handler.dart'; -import 'package:cicada/play_indicator/index.dart'; +import 'dart:async'; +import 'package:audio_service/audio_service.dart'; + +import 'package:cicada/player_controller/index.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'; +import './event_bus.dart'; import './pages/home/index.dart'; import './server_management/index.dart'; 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'; +import './widgets/play_error_dialog.dart'; +import './theme.dart'; class AppContent extends StatefulWidget { const AppContent({super.key}); @@ -20,53 +24,70 @@ class AppContent extends StatefulWidget { } class _AppContentState extends State { + 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 void reassemble() { super.reassemble(); - GetIt.instance.get().stop(); + context.read().stop(); + } + + void _showPlayError(PlayErrorEvent event) { + final navContext = _navigatorKey.currentContext; + if (navContext != null) { + showPlayErrorDialog(navContext, event); + } } @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: musicbill_state.musicbillState), - ChangeNotifierProvider.value(value: playlistState), - ChangeNotifierProvider.value(value: playqueueState), - ], - child: MaterialApp( - home: Column( - 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']), - ); - } - default: - { - return MaterialPageRoute(builder: (_) => Home()); - } + return MaterialApp( + theme: appTheme, + home: Stack( + children: [ + // ... (Navigator and PlayerController) + Navigator( + key: _navigatorKey, + 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()); } - }, - ), - ), - PlayIndicatorContainer(), - ], - ), + } + }, + ), + PlayerControllerContainer(), + ], ), ); } @@ -79,6 +100,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/audio_handler.dart b/apps/flutter/lib/audio_handler.dart index 81a40f83..a966d378 100644 --- a/apps/flutter/lib/audio_handler.dart +++ b/apps/flutter/lib/audio_handler.dart @@ -1,64 +1,141 @@ +import 'dart:async'; +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; import 'package:just_audio/just_audio.dart'; +import './event_bus.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(); + final _random = Random(); + String? _currentLoadingPid; + ProcessingState? _lastProcessingState; + bool _shouldPlayAfterLoad = false; + bool _syncingFromPlayerIndex = false; + int _loadedQueueBaseIndex = -1; + int _loadedQueueLength = 0; + List _loadedQueuePids = const []; + bool _mutatingPlayqueueFromHandler = false; + Future _queueStructureSync = Future.value(); MyAudioHandler() { - player.playerStateStream.listen(_broadcastState); + _initPlayer(); + _initStreams(); + playbackState.add( + PlaybackState( + controls: [MediaControl.play, MediaControl.skipToNext], + systemActions: const { + MediaAction.play, + MediaAction.pause, + MediaAction.playPause, + MediaAction.seek, + MediaAction.skipToPrevious, + MediaAction.skipToNext, + }, + processingState: AudioProcessingState.idle, + playing: false, + ), + ); } - @override - Future play() => player.play(); + Future _initPlayer() async { + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); + } - @override - Future pause() => player.pause(); + void _initStreams() { + player.positionStream.listen((position) { + _broadcastState(); + }); - @override - Future skipToPrevious() async => playqueueState.previous(); + player.bufferedPositionStream.listen((bufferedPosition) { + _broadcastState(); + }); - @override - Future skipToNext() async => playqueueState.next(); + player.playerStateStream.listen((PlayerState state) { + if (state.playing) { + _playTimer.start(); + } else { + _playTimer.stop(); + } - @override - Future seek(Duration position) => player.seek(position); + audioState.updateState( + playing: state.playing, + loading: + state.processingState == ProcessingState.loading || + state.processingState == ProcessingState.buffering, + ); + _broadcastState(); - Future playQueueMusic(PlayqueueMusic queueMusic) async { - var duration = await player.setAudioSource( - AudioSource.uri(Uri.parse(queueMusic.music.asset)), - ); - player.play(); + if (state.processingState == ProcessingState.completed && + _lastProcessingState != ProcessingState.completed) { + playqueueState.next(); + } + _lastProcessingState = state.processingState; + }); - 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); - } + 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; - void listen() { - playqueueState.addListener(() { final currentQueueMusic = playqueueState.currentMusic; - if (currentQueueMusic != null && - currentQueueMusic.pid != lastQueueMusic?.pid) { - lastQueueMusic = currentQueueMusic; - playQueueMusic(currentQueueMusic); + if (previousQueueMusic != null && + currentQueueMusic != null && + previousQueueMusic.pid != currentQueueMusic.pid) { + unawaited(_uploadPlayRecord(previousQueueMusic)); + _playTimer.reset(); } + 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(PlayerState state) { + 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, ], @@ -72,6 +149,9 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { MediaAction.skipToPrevious, MediaAction.skipToNext, }, + androidCompactActionIndices: hasPrevious + ? const [0, 1, 2] + : const [0, 1], processingState: { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, @@ -83,11 +163,479 @@ class MyAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { updatePosition: player.position, bufferedPosition: player.bufferedPosition, speed: player.speed, + queueIndex: playqueueState.playqueueIndex, + updateTime: DateTime.now(), + ), + ); + } + + 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, + ); + } + + 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: tag, + ); + } + + List _buildSources(List queue) { + final seenMusicIds = {}; + return queue.map((queueMusic) { + final useCache = seenMusicIds.add(queueMusic.music.id); + return _buildSource(queueMusic, useCache: useCache); + }).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; + } + + final currentAbsoluteIndex = playqueueState.playqueueIndex; + final currentRelativeIndex = currentAbsoluteIndex - _loadedQueueBaseIndex; + if (currentRelativeIndex != _loadedQueueLength - 1) { + return; + } + + 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)]; + _mutatingPlayqueueFromHandler = true; + try { + playqueueState.jump(playlistMusic.music, isUserAdded: false); + } finally { + _mutatingPlayqueueFromHandler = false; + } + if (nextAbsoluteIndex < playqueueState.playqueue.length) { + nextQueueMusic = playqueueState.playqueue[nextAbsoluteIndex]; + } + } + + if (nextQueueMusic == null) { + return; + } + + 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); + _loadedQueueLength = _loadedQueuePids.length; + _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) { + 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; + } + + 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, + 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); + _loadedQueueBaseIndex = 0; + _loadedQueueLength = queueSnapshot.length; + _loadedQueuePids = queueSnapshot + .map((queueMusic) => queueMusic.pid) + .toList(growable: false); + _syncAudioServiceQueue(queueSnapshot); + + await player.stop(); + + try { + await _playlist.clear(); + await _playlist.addAll(_buildSources(queueSnapshot)); + + final duration = await player + .setAudioSource( + _playlist, + initialIndex: queueIndex, + initialPosition: initialPosition, + preload: true, + ) + .timeout( + const Duration(seconds: 60), + onTimeout: () { + throw TimeoutException('Loading timeout after 60 seconds'); + }, + ); + + if (_currentLoadingPid != loadingPid) { + return; + } + + lastQueueMusic = queueMusic; + mediaItem.add(_buildMediaItem(queueMusic, duration: duration)); + _broadcastState(); + await _appendUpcomingTrackIfNeeded(); + + 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, + errorMessage: + 'Loading timeout, please check your network connection', + ), + ); + } + } catch (error) { + if (_currentLoadingPid == loadingPid) { + await _handleLoadFailure(loadingPid); + eventBus.fire( + PlayErrorEvent( + musicName: queueMusic.music.name, + errorMessage: error.toString(), + ), + ); + } + } + } + + Future playQueueMusic(PlayqueueMusic queueMusic) { + return _loadCurrentQueue( + queueMusic: queueMusic, + initialPosition: Duration.zero, + resetPlayTimer: true, + shouldPlayAfterLoad: true, + ); + } + + 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) { + return Future.value(); + } + + return _loadCurrentQueue( + queueMusic: currentQueueMusic, + initialPosition: player.position, + resetPlayTimer: false, + shouldPlayAfterLoad: player.playing, + ); + } + + @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; + 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(); + } + + @override + Future seek(Duration position) => player.seek(position); + + 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, + ); - if (state.processingState == ProcessingState.completed) { - playqueueState.next(); + try { + await uploadMusicPlayRecord( + musicId: queueMusic.music.id, + percent: percent, + ); + } catch (error) { + // ignore: avoid_print + print('Failed to upload play record: $error'); } } + + void subscribe() { + playqueueState.addListener(() { + if (_syncingFromPlayerIndex || _mutatingPlayqueueFromHandler) { + return; + } + + final currentQueueMusic = playqueueState.currentMusic; + if (currentQueueMusic == null) { + return; + } + + if (lastQueueMusic == null && player.audioSource != null) { + lastQueueMusic = currentQueueMusic; + } + + final currentQueuePids = playqueueState.playqueue + .map((queueMusic) => queueMusic.pid) + .toList(growable: false); + if (currentQueueMusic.pid == lastQueueMusic?.pid) { + if (!listEquals(_loadedQueuePids, currentQueuePids)) { + _scheduleCurrentQueueStructureSync(currentQueueMusic.pid); + } + return; + } + + final previousQueueMusic = lastQueueMusic; + lastQueueMusic = currentQueueMusic; + + if (previousQueueMusic != null) { + unawaited(_uploadPlayRecord(previousQueueMusic)); + } + + unawaited(playQueueMusic(currentQueueMusic)); + }); + } } 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/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/event_bus.dart b/apps/flutter/lib/event_bus.dart index 4a7811f1..08a6f29f 100644 --- a/apps/flutter/lib/event_bus.dart +++ b/apps/flutter/lib/event_bus.dart @@ -11,4 +11,15 @@ class AddMusicListToPlaylistEvent { AddMusicListToPlaylistEvent({required this.musicList}); } +class InsertToPlayqueueEvent { + List musicList; + InsertToPlayqueueEvent({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/main.dart b/apps/flutter/lib/main.dart index f62ce7aa..7e456a4c 100644 --- a/apps/flutter/lib/main.dart +++ b/apps/flutter/lib/main.dart @@ -4,13 +4,18 @@ 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'; +import './utils/audio_cache_manager.dart'; 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(); @@ -25,18 +30,45 @@ void main() async { initializeWindow(); } - final audioHandler = await AudioService.init(builder: () => MyAudioHandler()); - audioHandler.listen(); + // 请求通知权限 (Android 13+) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + await Permission.notification.request(); + } + + // 初始化音频缓存管理器 + await AudioCacheManager.instance.init(); + + 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); await serverState.initialize(); - playlistState.listen(); - playqueueState.listen(); + playlistState.subscribe(); + playqueueState.subscribe(); runApp( MultiProvider( - providers: [ChangeNotifierProvider.value(value: serverState)], + 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/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/models/music.dart b/apps/flutter/lib/models/music.dart index def9c31d..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,15 +21,23 @@ class Music { required this.asset, required this.cover, required this.singers, + required this.type, }); - factory Music.fromJSON(Map json) => Music( + /// 是否是纯音乐 + bool get isInstrumental => type == MusicType.instrumental; + + 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() ?? + [], + // 服务端 type: 1=歌曲, 2=纯音乐 + type: json['type'] == 2 ? MusicType.instrumental : MusicType.song, ); } diff --git a/apps/flutter/lib/models/singer.dart b/apps/flutter/lib/models/singer.dart index 23d98bfe..2b58e25a 100644 --- a/apps/flutter/lib/models/singer.dart +++ b/apps/flutter/lib/models/singer.dart @@ -13,10 +13,14 @@ 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']), - aliases: List.from(json['aliases']), + aliases: + (json['aliases'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], ); } 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..8b5ced82 --- /dev/null +++ b/apps/flutter/lib/pages/home/create_musicbill_dialog.dart @@ -0,0 +1,109 @@ +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 + final id = await createMusicbill(name: name); + + // 重新加载列表 + await musicbillState.reloadMusicbillList(silence: true); + await musicbillState.reloadMusicbill(id: id, silence: true); + + // 关闭对话框 + if (mounted) { + Navigator.of(context).pop(id); + } + } 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..83ffa4ff 100644 --- a/apps/flutter/lib/pages/home/index.dart +++ b/apps/flutter/lib/pages/home/index.dart @@ -1,4 +1,9 @@ import '../../states/musicbill.dart'; +import '../../states/server.dart'; +import '../search_intermediate/index.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'; @@ -7,29 +12,82 @@ 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; + + final headerHeight = MediaQuery.of(context).size.width / 1.5; + 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}, + 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], + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SearchIntermediatePage(), ), ); }, + 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', + style: TextStyle(color: Colors.black38, fontSize: 14), + ), + ], + ), + ), ), ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + SliverToBoxAdapter( + child: MusicbillListHeader( + count: musicbillState.musicbillList.length, + ), + ), + MusicbillList( + musicbillList: musicbillState.musicbillList, + isLoading: musicbillState.loading, + exception: musicbillState.exception, + onRetry: () => musicbillState.reloadMusicbillList(silence: false), + ), ], ), ); 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..efd89326 --- /dev/null +++ b/apps/flutter/lib/pages/home/musicbill_card.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import '../../states/musicbill.dart'; +import '../../widgets/musicbill_cover.dart'; + +/// 音乐清单卡片组件 +/// 显示单个音乐清单的信息(封面、名称) +class MusicbillCard extends StatelessWidget { + final Musicbill musicbill; + + const MusicbillCard({super.key, required this.musicbill}); + + @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: () => 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), + ], + ), + ), + ), + ), + ); + } + + /// 构建封面 + Widget _buildCover(BuildContext context) { + return MusicbillCover( + imageUrl: musicbill.cover, + size: 44, + isPublic: musicbill.isPublic, + isShared: musicbill.isShared, + borderRadius: BorderRadius.circular(8), + placeholder: _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.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 new file mode 100644 index 00000000..ebc1979a --- /dev/null +++ b/apps/flutter/lib/pages/home/musicbill_list.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import '../../states/musicbill.dart'; +import './musicbill_card.dart'; + +import '../../widgets/error_view.dart'; +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 + Widget build(BuildContext context) { + if (isLoading) { + return const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ); + } + + if (exception != null && onRetry != null) { + return SliverFillRemaining( + child: ErrorView(errorMessage: exception.toString(), onRetry: onRetry!), + ); + } + + 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 _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/musicbill_list_header.dart b/apps/flutter/lib/pages/home/musicbill_list_header.dart new file mode 100644 index 00000000..352d4a9e --- /dev/null +++ b/apps/flutter/lib/pages/home/musicbill_list_header.dart @@ -0,0 +1,84 @@ +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.fromLTRB(16, 8, 16, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'My Musicbills', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 18, + color: Colors.black87, + ), + ), + const SizedBox(width: 8), + _buildCountBadge(context), + const Spacer(), + _buildAddButton(context), + ], + ), + ); + } + + /// 构建数量徽章 + Widget _buildCountBadge(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + 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.w600, + fontSize: 12, + ), + ), + ); + } + + /// 构建添加按钮 + Widget _buildAddButton(BuildContext context) { + 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, + ), + ), + ), + ); + } + + /// 显示创建音乐清单对话框 + void _showCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => const CreateMusicbillDialog(), + ); + } +} 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..1db51aea --- /dev/null +++ b/apps/flutter/lib/pages/home/user_info_card.dart @@ -0,0 +1,170 @@ +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'; + +/// 用户信息卡片组件 +/// 显示用户头像、昵称、用户名和服务器信息 +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 AspectRatio( + aspectRatio: 1.5, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + ), + clipBehavior: Clip.hardEdge, + child: Stack( + 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( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of( + context, + ).scaffoldBackgroundColor.withValues(alpha: 0.0), + Theme.of(context).scaffoldBackgroundColor, + ], + stops: const [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 _buildBackgroundAvatar(BuildContext context) { + if (user!.avatar != null && user!.avatar!.isNotEmpty) { + return CachedNetworkImage( + imageUrl: getResizedImageUrl(user!.avatar!, 400), // Background avatar + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + placeholder: (_, __) => _buildDefaultBackground(context), + errorWidget: (_, __, ___) => _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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildNickname(context), + const SizedBox(height: 2), + _buildUsername(context), + ], + ); + } + + /// 构建昵称 + Widget _buildNickname(BuildContext context) { + return Text( + user!.nickname, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.black87, + height: 1.2, + ), + overflow: TextOverflow.ellipsis, + ); + } + + /// 构建用户名 + Widget _buildUsername(BuildContext context) { + return Text( + '${user!.username}@${server!.hostname}', + style: TextStyle( + fontSize: 14, + color: Colors.black.withValues(alpha: 0.6), + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/apps/flutter/lib/pages/musicbill/actions.dart b/apps/flutter/lib/pages/musicbill/actions.dart index 293d12bf..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 { @@ -8,21 +9,53 @@ 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, + ), + ); + // 显示播放列表弹窗,定位到播放列表 tab + showPlaylistDialog(context, initialTabIndex: 0); + } + : 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/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/bottom_toolbar.dart b/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart new file mode 100644 index 00000000..904722d2 --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/bottom_toolbar.dart @@ -0,0 +1,103 @@ +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'; + +/// 音乐清单底部工具栏组件 +/// 提供返回按钮和添加全部到播放列表功能 +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), + ); + // 显示播放列表弹窗,定位到播放列表 tab + showPlaylistDialog(context, initialTabIndex: 0); + } + : 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 57713a42..760c6e25 100644 --- a/apps/flutter/lib/pages/musicbill/index.dart +++ b/apps/flutter/lib/pages/musicbill/index.dart @@ -1,12 +1,21 @@ +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 '../../event_bus.dart'; -import './actions.dart' as actions; +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; @@ -17,51 +26,185 @@ 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((_) { + routeState.setRoute('/musicbill'); + }); + 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() { + 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('/'); + }); + super.dispose(); + } + @override Widget build(BuildContext context) { final musicbill = useMusicbillById(context, widget.id); final empty = musicbill.musicList.isEmpty; + + final headerHeight = MediaQuery.of(context).size.width; + return Scaffold( - appBar: AppBar(title: Text(musicbill.name)), - body: Column( + body: Stack( children: [ - actions.Actions(musicbill: musicbill), - if (empty) - Expanded(child: Center(child: Text("No Music"))) - else - Expanded( - child: ListView.builder( - itemCount: musicbill.musicList.length, - itemBuilder: (context, index) { - final music = musicbill.musicList[index]; - return ListTile( - leading: const Icon(Icons.music_note_outlined), - title: Text(music.name), - onTap: () { - eventBus.fire(PlayMusicEvent(music: music)); - }, - ); - }, + 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: CachedNetworkImage( + imageUrl: getResizedImageUrl( + musicbill.cover!, + 40, + ), // 20px * 2 + width: 20, + height: 20, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => const SizedBox(), + ), + ), + ), + Flexible( + child: Text( + musicbill.name, + style: const TextStyle( + color: Colors.black, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), ), ), + ), + + // 底部工具栏 + 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 new file mode 100644 index 00000000..09636415 --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/music_list_content.dart @@ -0,0 +1,38 @@ +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 SliverPadding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8), + 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/music_list_item.dart b/apps/flutter/lib/pages/musicbill/music_list_item.dart new file mode 100644 index 00000000..585e83b0 --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/music_list_item.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import '../../models/music.dart'; +import '../../widgets/cached_image.dart'; +import 'show_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: () => + showMusicOptionMenu(context, music: music, onPlay: onTap), + 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: () => + showMusicOptionMenu(context, music: music, onPlay: onTap), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// 构建封面 + Widget _buildCover(BuildContext context) { + if (music.cover != null && music.cover!.isNotEmpty) { + return CachedImage( + imageUrl: music.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); + } + + /// 构建默认封面 + 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..b0712d2f --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/music_option_menu.dart @@ -0,0 +1,254 @@ +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 '../../player_controller/show_playlist_dialog.dart'; +import '../../widgets/cached_image.dart'; +import './add_to_musicbill_sheet.dart'; + +/// 自定义音乐选项菜单(Overlay 实现,覆盖 PlayerController) +class MusicOptionMenu extends StatefulWidget { + final Music music; + final VoidCallback onPlay; + final VoidCallback onClose; + final BuildContext? parentContext; // 用于显示播放队列弹窗 + final bool showPlaylistAfterInsert; + + const MusicOptionMenu({ + super.key, + required this.music, + required this.onPlay, + required this.onClose, + this.parentContext, + this.showPlaylistAfterInsert = true, + }); + + @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( + top: false, // 不需要顶部安全区域 + 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('Insert to playqueue'), + 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), () { + 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), + ], + ), + ), + ), + ), + ), + ], + ); + } + + // 复用 MusicListItem 中的构建逻辑 + Widget _buildCover(BuildContext context) { + if (widget.music.cover != null && widget.music.cover!.isNotEmpty) { + return CachedImage( + imageUrl: widget.music.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); + } + + 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..be1aa02e --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/musicbill_header.dart @@ -0,0 +1,176 @@ +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'; + +/// 音乐清单详情页头部组件 +/// 显示封面、名称和音乐数量 +class MusicbillHeader extends StatelessWidget { + final Musicbill musicbill; + + const MusicbillHeader({super.key, required this.musicbill}); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1.0, + child: Container( + 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( + 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( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of( + context, + ).scaffoldBackgroundColor.withValues(alpha: 0.0), + Theme.of(context).scaffoldBackgroundColor, + ], + stops: const [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 CachedNetworkImage( + imageUrl: getResizedImageUrl( + musicbill.cover!, + 800, + ), // Large background cover + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + placeholder: (_, __) => _buildDefaultBackground(context), + errorWidget: (_, __, ___) => _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: 24, // Slightly larger for better impact + fontWeight: FontWeight.w800, + color: Colors.black87, // Changed to dark + height: 1.2, + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ); + } + + /// 构建音乐数量 + Widget _buildMusicCount(BuildContext context) { + return Row( + children: [ + Icon( + Icons.music_note_rounded, + size: 16, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 6), + Text( + '${musicbill.musicList.length} tracks', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.black.withValues(alpha: 0.6), // Darker grey + ), + ), + ], + ); + } +} 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/musicbill/status_views.dart b/apps/flutter/lib/pages/musicbill/status_views.dart new file mode 100644 index 00000000..59b63725 --- /dev/null +++ b/apps/flutter/lib/pages/musicbill/status_views.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import '../../widgets/error_view.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 ErrorView( + title: '加载音乐失败', + onRetry: () { + musicbill_state.musicbillState.reloadMusicbill( + id: musicbillId, + silence: false, + ); + }, + ); + } +} + +/// 空数据视图 +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/pages/profile/index.dart b/apps/flutter/lib/pages/profile/index.dart new file mode 100644 index 00000000..500e28ec --- /dev/null +++ b/apps/flutter/lib/pages/profile/index.dart @@ -0,0 +1,183 @@ +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'; + +/// 用户个人资料页面 +/// 显示用户信息和提供退出登录功能 +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), + const PlayerBottomSpacer(), + ], + ), + ); + } + + /// 构建用户头部 + 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: [ + 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, + ), + ), + 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), + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('App Version'), + subtitle: const Text(VERSION), + ), + ], + ); + } + + /// 构建操作按钮 + 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'), + ), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/pages/search/index.dart b/apps/flutter/lib/pages/search/index.dart new file mode 100644 index 00000000..986ea389 --- /dev/null +++ b/apps/flutter/lib/pages/search/index.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +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 { + final String? initialKeyword; + + const SearchPage({super.key, this.initialKeyword}); + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State + with SingleTickerProviderStateMixin { + late final TextEditingController _textController; + late final TabController _tabController; + + final List _tabs = ['Music', 'Singer', 'Lyric', 'Musicbill']; + static const double _bottomToolbarHeight = 60.0; + + String _searchKeyword = ''; + + @override + void initState() { + super.initState(); + _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'); + }); + } + + @override + void dispose() { + _textController.dispose(); + _tabController.dispose(); + scheduleMicrotask(() { + // routeState.setRoute('/'); + }); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; + + // 底部工具栏 + 播放器高度 + 安全区域 + // 键盘弹出时,播放器隐藏,不需要预留播放器高度和底部安全区域 + final bottomPadding = + _bottomToolbarHeight + + (isKeyboardOpen + ? 0.0 + : (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: [ + MusicTab(keyword: _searchKeyword), + Center(child: Text('Search Singer: $_searchKeyword')), + LyricTab(keyword: _searchKeyword), + Center(child: Text('Search Musicbill: $_searchKeyword')), + ], + ), + ), + ), + ], + ), + + // 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.withValues(alpha: 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, + readOnly: true, + autofocus: false, + onTap: () { + Navigator.of(context).pop(); + }, + 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, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} 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..c4dd3775 --- /dev/null +++ b/apps/flutter/lib/pages/search/lyric_tab.dart @@ -0,0 +1,113 @@ +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 { + 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 ErrorView(errorMessage: _error, onRetry: _search); + } + 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_tab.dart b/apps/flutter/lib/pages/search/music_tab.dart new file mode 100644 index 00000000..227dcadb --- /dev/null +++ b/apps/flutter/lib/pages/search/music_tab.dart @@ -0,0 +1,112 @@ +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 { + 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 ErrorView(errorMessage: _error, onRetry: _search); + } + 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/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..ea979f85 --- /dev/null +++ b/apps/flutter/lib/pages/search/music_with_lyric_list_item.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import '../../models/music.dart'; +import '../../widgets/cached_image.dart'; +import '../musicbill/show_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: () => + showMusicOptionMenu(context, music: music, onPlay: onTap), + 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: () => showMusicOptionMenu( + context, + music: music, + onPlay: onTap, + ), + 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, + ), + ); + } + + Widget _buildCover(BuildContext context) { + if (music.cover != null && music.cover!.isNotEmpty) { + return CachedImage( + imageUrl: music.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); + } + + 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/search_intermediate/cards/music_card.dart b/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart new file mode 100644 index 00000000..9404c7b5 --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/cards/music_card.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import '../../../models/music.dart'; +import '../../../widgets/cached_image.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 + ? CachedImage( + imageUrl: music.cover, + size: 200, // Fixed size for exploration cards + borderRadius: BorderRadius.circular(8), + 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)), + ), + ), + 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..895f86e0 --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/cards/musicbill_card.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import '../../../server/api/get_exploration.dart'; +import '../../../widgets/cached_image.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 + ? CachedImage( + imageUrl: musicbill.cover, + size: 200, // Fixed size for exploration cards + borderRadius: BorderRadius.circular(8), + 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)), + ), + ), + 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..99bc9a81 --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/cards/singer_card.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import '../../../server/api/get_exploration.dart'; +import '../../../widgets/cached_image.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 + ? CachedImage( + imageUrl: singer.avatar, + size: 200, // Fixed size for exploration cards + borderRadius: BorderRadius.circular(8), + 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)), + ), + ), + 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 new file mode 100644 index 00000000..16b14416 --- /dev/null +++ b/apps/flutter/lib/pages/search_intermediate/index.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +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'; + +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: SearchToolbar( + controller: _searchController, + focusNode: _searchFocusNode, + onSubmitted: _onSearch, + height: _bottomToolbarHeight, + ), + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null) { + return ErrorView(errorMessage: _errorMessage, onRetry: _loadData); + } + + if (_explorationData == null) { + return const Center(child: Text('暂无数据')); + } + + 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(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/flutter/lib/play_indicator/index.dart b/apps/flutter/lib/play_indicator/index.dart deleted file mode 100644 index 16235b79..00000000 --- a/apps/flutter/lib/play_indicator/index.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:cicada/play_indicator/play_indicator.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}); - - @override - Widget build(BuildContext context) { - final currentMusic = context.watch().currentMusic; - return currentMusic == null - ? Container() - : PlayIndicator(playqueueMusic: currentMusic); - } -} 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..3de07459 --- /dev/null +++ b/apps/flutter/lib/player_controller/actions.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:provider/provider.dart'; +import '../audio_handler.dart'; +import '../states/audio.dart'; +import './show_playlist_dialog.dart'; + +class Actions extends StatelessWidget { + const Actions({super.key}); + + @override + Widget build(BuildContext context) { + final audioState = context.watch(); + final playing = audioState.playing; + final spacing = SizedBox(width: 2); + return Row( + children: [ + 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: () { + final audioHandler = GetIt.instance.get(); + audioHandler.skipToNext(); + }, + icon: Icon(Icons.skip_next), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: BoxConstraints(minWidth: 32, minHeight: 32), + ), + spacing, + IconButton( + onPressed: () { + showPlaylistDialog(context); + }, + icon: Icon(Icons.list_outlined), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/player_controller/cover.dart b/apps/flutter/lib/player_controller/cover.dart new file mode 100644 index 00000000..1891232f --- /dev/null +++ b/apps/flutter/lib/player_controller/cover.dart @@ -0,0 +1,160 @@ +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'; + +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(); + } + } + + 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; + + 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: 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: CachedNetworkImage( + imageUrl: getResizedImageUrl( + widget.coverUrl!, + 80, + ), // 40px * 2 + fit: BoxFit.cover, + placeholder: (context, url) => + _buildDefaultCover(context), + errorWidget: (context, url, error) => + _buildDefaultCover(context), + ), + ), + ) + : _buildDefaultCover(context), + ), + ), + ), + ], + ); + }, + ); + }, + ); + }, + ); + } + + /// 构建默认封面 + Widget _buildDefaultCover(BuildContext context) { + return Container( + width: 40, + height: 40, + 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/index.dart b/apps/flutter/lib/player_controller/index.dart new file mode 100644 index 00000000..f280a3e7 --- /dev/null +++ b/apps/flutter/lib/player_controller/index.dart @@ -0,0 +1,51 @@ +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; +// search 页面底部工具栏的高度 +const double _searchBottomToolbarHeight = 60.0; + +// 搜索中间页面底部工具栏高度 +const double _searchIntermediateBottomToolbarHeight = 60.0; + +class PlayerControllerContainer extends StatelessWidget { + const PlayerControllerContainer({super.key}); + + @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; + + if (currentMusic == null) { + return Container(); + } + + // 根据路由计算底部偏移量 + double bottomOffset = 0.0; + if (currentRoute == '/musicbill') { + bottomOffset = _musicbillBottomToolbarHeight; + } else if (currentRoute == '/search') { + bottomOffset = _searchBottomToolbarHeight; + } else if (currentRoute == '/search_intermediate') { + bottomOffset = _searchIntermediateBottomToolbarHeight; + } + + 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/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 new file mode 100644 index 00000000..cb72f2a4 --- /dev/null +++ b/apps/flutter/lib/player_controller/player_controller.dart @@ -0,0 +1,96 @@ +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'; + +import '../widgets/player/index.dart'; + +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}); + + @override + Widget build(BuildContext context) { + final music = playqueueMusic.music; + const borderRadius = kContentHeight / 2; + + return GestureDetector( + onTap: () { + final topPadding = MediaQuery.of(context).padding.top; + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (context) => PlayerWidget(topPadding: topPadding), + ); + }, + 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, + ), + ], + ), + 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, + ), + 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), + + // 歌曲信息 + Expanded(child: MusicInfo(music: music)), + + const SizedBox(width: 4), + + // 操作按钮 + const actions.Actions(), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/player_controller/playlist.dart b/apps/flutter/lib/player_controller/playlist.dart new file mode 100644 index 00000000..af3f843c --- /dev/null +++ b/apps/flutter/lib/player_controller/playlist.dart @@ -0,0 +1,180 @@ +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'; +import '../pages/musicbill/show_music_option_menu.dart'; + +class Playlist extends StatelessWidget { + const Playlist({super.key}); + + @override + Widget build(BuildContext context) { + final playlist = context.watch().playlist; + 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)), + 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( + 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, + ), + ], + ], + ), + ), + 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( + 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 new file mode 100644 index 00000000..e2a4174f --- /dev/null +++ b/apps/flutter/lib/player_controller/playqueue.dart @@ -0,0 +1,189 @@ +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}); + + @override + Widget build(BuildContext context) { + 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]; + 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( + 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, + ), + ), + 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, + ), + ], + ], + ), + ), + 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: () => 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/player_controller/show_playlist_dialog.dart b/apps/flutter/lib/player_controller/show_playlist_dialog.dart new file mode 100644 index 00000000..dd9e612b --- /dev/null +++ b/apps/flutter/lib/player_controller/show_playlist_dialog.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import './playlist.dart'; +import './playqueue.dart'; + +// 记录上次打开的 tab 索引,默认为 1 (Playqueue) +int _lastTabIndex = 1; + +/// 显示播放列表/队列弹窗 +/// [initialTabIndex] 指定初始 tab,如果不指定则使用上次打开的 tab +void showPlaylistDialog(BuildContext context, {int? initialTabIndex}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: 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: _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/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/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/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); +} 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 ''; +} diff --git a/apps/flutter/lib/server/api/get_musicbill.dart b/apps/flutter/lib/server/api/get_musicbill.dart index 7bd6d4dd..af6b8594 100644 --- a/apps/flutter/lib/server/api/get_musicbill.dart +++ b/apps/flutter/lib/server/api/get_musicbill.dart @@ -5,15 +5,25 @@ 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( + 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)) + .map((json) => Music.fromJson(json)) .toList(), ); } @@ -24,5 +34,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..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( + 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, ); } @@ -21,6 +31,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/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/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; +} 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); +} 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/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/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}, + ); +} diff --git a/apps/flutter/lib/server/request.dart b/apps/flutter/lib/server/request.dart index 895b6923..4a2762c0 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(); @@ -10,7 +11,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,9 +27,12 @@ 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}\""); + throw ServerException( + code: responseData.code, + message: response.data['message'] ?? responseData.code, + ); } return responseData.data; } @@ -70,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/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/states/audio.dart b/apps/flutter/lib/states/audio.dart new file mode 100644 index 00000000..afac247b --- /dev/null +++ b/apps/flutter/lib/states/audio.dart @@ -0,0 +1,22 @@ +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(); + } +} + +final audioState = AudioState(); diff --git a/apps/flutter/lib/states/musicbill.dart b/apps/flutter/lib/states/musicbill.dart index 64bba1dc..88c0f31b 100644 --- a/apps/flutter/lib/states/musicbill.dart +++ b/apps/flutter/lib/states/musicbill.dart @@ -1,110 +1,230 @@ +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 } -class Musicbill { - String id; - String name; - String? cover; +const musicbillRefreshInterval = Duration(minutes: 5); - List musicList = []; - MusicbillStatus status = MusicbillStatus.INITIAL; +class Musicbill { + 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 { bool loading = false; Exception? exception; 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, - 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), + ); + } + } + } + + bool shouldSilentlyRefreshOnEnter(String id) { + final now = DateTime.now(); + final lastEnteredAt = _musicbillLastEnteredAt[id]; + _musicbillLastEnteredAt[id] = now; + 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/states/playlist.dart b/apps/flutter/lib/states/playlist.dart index c3e34e3a..98830442 100644 --- a/apps/flutter/lib/states/playlist.dart +++ b/apps/flutter/lib/states/playlist.dart @@ -28,7 +28,13 @@ class PlaylistState extends ChangeNotifier { notifyListeners(); } - void Function() listen() { + /// 从播放列表中移除音乐 + void removeMusic(String pid) { + playlist.removeWhere((m) => m.pid == pid); + notifyListeners(); + } + + void Function() subscribe() { final playMusicSubscription = eventBus.on().listen((event) { addMusicList([event.music]); }); @@ -37,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 a8bd7806..9f64758d 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 { @@ -36,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) { @@ -50,6 +90,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) { @@ -63,7 +111,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 { @@ -72,9 +120,39 @@ class PlayqueueState extends ChangeNotifier { } } - void Function() listen() { + 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) { + setCurrentIndex(index); + } + } + + // 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 @@ -82,13 +160,31 @@ 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(); + } + }); + final insertToPlayqueueSubscription = eventBus + .on() + .listen((event) { + if (currentMusic == null) { + final random = Random(); + jump( + event.musicList[random.nextInt(event.musicList.length)], + isUserAdded: false, + ); next(); + } else { + insertAfterCurrent(event.musicList, isUserAdded: true); } }); return () { playMusicSubscription.cancel(); addMusicListToPlaylistSubscription.cancel(); + insertToPlayqueueSubscription.cancel(); }; } } 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/states/server.dart b/apps/flutter/lib/states/server.dart index cae8bac3..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'; @@ -20,16 +21,16 @@ class User { required this.nickname, }); - factory User.fromJSON(Map json) => 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'], nickname: json['nickname'], ); - Map toJSON() => { + Map toJson() => { 'token': token, 'avatar': avatar, 'id': id, @@ -52,21 +53,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 +85,7 @@ class ServerState extends ChangeNotifier { (user) => user.id == selectedUserId, ); - Map toJSON() { + Map toJson() { return { 'selectedServerOrigin': selectedServerOrigin, 'selectedUserId': selectedUserId, @@ -99,7 +100,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/theme.dart b/apps/flutter/lib/theme.dart new file mode 100644 index 00000000..bfa32e25 --- /dev/null +++ b/apps/flutter/lib/theme.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +final appTheme = ThemeData( + useMaterial3: true, + 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( + 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/user_management/index.dart b/apps/flutter/lib/user_management/index.dart index 0d5f273d..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,39 +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) { - print(e); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); } - }, - ), + } catch (e) { + /** + * @todo error handle + * @author mebtte + */ + 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..64ab64b9 --- /dev/null +++ b/apps/flutter/lib/user_management/two_fa_widget.dart @@ -0,0 +1,116 @@ +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({ + super.key, + 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), + ], + ), + ), + ); + } +} 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..89be9c20 --- /dev/null +++ b/apps/flutter/lib/utils/audio_cache_manager.dart @@ -0,0 +1,92 @@ +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, {dynamic tag}) { + final cacheFile = getCacheFile(musicId); + + // 使用 LockCachingAudioSource 来缓存音频 + // 如果缓存文件存在,它会直接从缓存读取 + // 如果不存在,它会边下载边播放,同时保存到缓存文件 + // ignore: experimental_member_use + return LockCachingAudioSource( + Uri.parse(url), + cacheFile: cacheFile, + tag: tag, + ); + } + + /// 清除所有缓存 + 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/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/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"; } 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/error_view.dart b/apps/flutter/lib/widgets/error_view.dart new file mode 100644 index 00000000..fe0443d0 --- /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 ?? 'Failed to load', + 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 ?? 'Retry'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 14, + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } +} 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/play_error_dialog.dart b/apps/flutter/lib/widgets/play_error_dialog.dart new file mode 100644 index 00000000..89963278 --- /dev/null +++ b/apps/flutter/lib/widgets/play_error_dialog.dart @@ -0,0 +1,193 @@ +import 'dart:async'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../event_bus.dart'; + +/// 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, + 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() { + final audioHandler = context.read(); + Navigator.of(context).pop(); + audioHandler.skipToNext(); + } + + void _cancel() { + _timer?.cancel(); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + title: Row( + children: [ + Icon(Icons.error_outline_rounded, color: Colors.red[400], size: 28), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Playback Error', + style: TextStyle( + color: Colors.black87, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline_rounded, + size: 16, + color: Colors.grey[600], + ), + const SizedBox(width: 8), + 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.grey[600], + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _playNext, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Skip Now'), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/widgets/player/index.dart b/apps/flutter/lib/widgets/player/index.dart new file mode 100644 index 00000000..e206545a --- /dev/null +++ b/apps/flutter/lib/widgets/player/index.dart @@ -0,0 +1,283 @@ +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 '../../audio_handler.dart' as cicada_audio; +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'; +import '../../states/playqueue.dart'; +import '../../pages/musicbill/add_to_musicbill_sheet.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 { + Music? _currentMusic; + List _lyrics = []; + bool _isLoadingLyric = false; + 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 = []; + _isLoadingLyric = false; + }); + + // 纯音乐不加载歌词 + if (!music.isInstrumental) { + _loadLyric(music); + } + } + + Future _loadLyric(Music music) async { + setState(() { + _isLoadingLyric = true; + }); + + try { + 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 == music.id) { + setState(() { + _lyrics = []; + _isLoadingLyric = false; + }); + } + } + } + + 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(); + final appAudioHandler = audioHandler as cicada_audio.MyAudioHandler; + + 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), + (_) => appAudioHandler.player.position, + ), + builder: (context, positionSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + + 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 + }, + ); + }, + ), + ), + + // 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(), + onAddToMusicbill: () => showAddToMusicbillSheet( + context, + music: displayMusic, + ), + onInsertToPlayqueue: () { + appAudioHandler.insertMusicListToPlayqueue([ + displayMusic, + ]); + }, + onPlaylist: () => showPlaylistDialog(context), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _PlayerBackground extends StatelessWidget { + final String? coverUrl; + + const _PlayerBackground({this.coverUrl}); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // 背景图片 + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + child: coverUrl != null + ? CachedNetworkImage( + imageUrl: getResizedImageUrl( + coverUrl!, + 800, + ), // Full screen background + key: ValueKey(coverUrl), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + alignment: Alignment.center, + placeholder: (_, __) => Container( + key: const ValueKey('loading'), + color: Colors.grey[900], + ), + errorWidget: (_, __, ___) => Container( + key: const ValueKey('error'), + color: Colors.grey[900], + ), + ) + : Container( + key: const ValueKey('default'), + color: Colors.grey[900], + ), + ), + + // 模糊遮罩层 + 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 new file mode 100644 index 00000000..3bc37ebf --- /dev/null +++ b/apps/flutter/lib/widgets/player/lyric_view.dart @@ -0,0 +1,319 @@ +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; + 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 + with SingleTickerProviderStateMixin { + late FixedExtentScrollController _scrollController; + late AnimationController _rotationController; + int _currentIndex = 0; + bool _isUserScrolling = false; + Timer? _userScrollTimer; + Duration _previousPosition = Duration.zero; + + @override + 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(); + } + + @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) { + _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) { + // 使用 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 + ? CachedNetworkImage( + imageUrl: getResizedImageUrl( + widget.coverUrl!, + 440, + ), // 220px * 2 + fit: BoxFit.cover, + width: 220, + height: 220, + placeholder: (_, __) => _buildDefaultCover(), + errorWidget: (_, __, ___) => _buildDefaultCover(), + ) + : _buildDefaultCover(), + ), + ), + ), + ); + } + + // 无歌词状态 + if (widget.lyrics.isEmpty) { + return Center( + key: const ValueKey('empty'), + child: Text( + '暂无歌词', + style: TextStyle(color: Colors.white.withValues(alpha: 0.6)), + ), + ); + } + + // 正常歌词显示 + return GestureDetector( + key: const ValueKey('lyrics'), + 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: 56, // 增加高度以容纳两行歌词 + 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: 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, + ), + ), + ), + ); + }, + childCount: widget.lyrics.length, + ), + ), + ), + ); + } + + 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), + ), + ); + } +} diff --git a/apps/flutter/lib/widgets/player/player_controls.dart b/apps/flutter/lib/widgets/player/player_controls.dart new file mode 100644 index 00000000..809a2225 --- /dev/null +++ b/apps/flutter/lib/widgets/player/player_controls.dart @@ -0,0 +1,272 @@ +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? onAddToMusicbill; + final VoidCallback? onInsertToPlayqueue; + final VoidCallback? onPlaylist; + + const PlayerControls({ + super.key, + this.onBack, + this.onAddToMusicbill, + this.onInsertToPlayqueue, + 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; + final processingState = playbackState?.processingState; + final isLoading = + processingState == AudioProcessingState.loading || + processingState == AudioProcessingState.buffering; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Collapse (Back) + _ControlButton( + icon: const Icon(Icons.keyboard_arrow_down_rounded), + iconSize: 28, + tooltip: 'Collapse', + onPressed: onBack, + ), + + _ControlButton( + icon: const Icon(Icons.library_add_rounded), + iconSize: 22, + tooltip: 'Add to musicbill', + onPressed: onAddToMusicbill, + ), + + // Previous + _ControlButton( + icon: const Icon(Icons.skip_previous_rounded), + iconSize: 36, + onPressed: () => audioHandler.skipToPrevious(), + ), + + // Play/Pause + Container( + width: 72, + height: 72, + 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: 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 + _ControlButton( + icon: const Icon(Icons.skip_next_rounded), + iconSize: 36, + onPressed: () => audioHandler.skipToNext(), + ), + + _ControlButton( + icon: const Icon(Icons.playlist_add_rounded), + iconSize: 22, + tooltip: 'Insert to playqueue', + onPressed: onInsertToPlayqueue, + ), + + // Playlist + _ControlButton( + icon: const Icon(Icons.queue_music_rounded), + iconSize: 28, + tooltip: 'Open playlist', + 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; +} + +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 new file mode 100644 index 00000000..2834b9c6 --- /dev/null +++ b/apps/flutter/lib/widgets/player/player_header.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import '../../models/music.dart'; + +class PlayerHeader extends StatelessWidget { + final Music music; + final double? topPadding; + + const PlayerHeader({super.key, required this.music, this.topPadding}); + + @override + Widget build(BuildContext context) { + final safePadding = topPadding ?? MediaQuery.of(context).padding.top; + + return Padding( + padding: EdgeInsets.only(top: safePadding), + child: Container( + height: kToolbarHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + 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/widgets/player_bottom_spacer.dart b/apps/flutter/lib/widgets/player_bottom_spacer.dart new file mode 100644 index 00000000..4efd1ba2 --- /dev/null +++ b/apps/flutter/lib/widgets/player_bottom_spacer.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../states/playqueue.dart'; + +/// 播放器底部留白组件 +/// 当有音乐播放显示播放器时,提供相应高度的留白,防止内容被遮挡 +class PlayerBottomSpacer extends StatelessWidget { + /// 额外的高度,用于页面有自己的底部工具栏时 + final double extraHeight; + + const PlayerBottomSpacer({super.key, this.extraHeight = 0}); + + @override + Widget build(BuildContext context) { + final currentMusic = context.watch().currentMusic; + + // 播放器高度 54 + margin bottom 8 + extra spacing 20 + 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), + ); + } +} 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 6b99055d..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: @@ -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: @@ -57,14 +57,38 @@ 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: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -73,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: @@ -85,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: @@ -125,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: @@ -170,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 @@ -188,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: "8.2.0" + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + 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: @@ -220,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: @@ -252,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: @@ -280,30 +328,38 @@ 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: 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: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -312,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: @@ -320,6 +384,22 @@ 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: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: @@ -337,7 +417,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -348,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: @@ -384,14 +464,62 @@ 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: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -412,10 +540,18 @@ packages: dependency: "direct main" description: name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" rxdart: dependency: transitive description: @@ -468,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: @@ -500,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: @@ -529,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: @@ -553,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: @@ -625,10 +753,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" typed_data: dependency: transitive description: @@ -641,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: @@ -665,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: @@ -717,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.8.1 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/apps/flutter/pubspec.yaml b/apps/flutter/pubspec.yaml index ba34462a..26505b6c 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,8 @@ 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: @@ -15,6 +17,8 @@ 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 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 ) diff --git a/apps/pwa/AGENTS.md b/apps/pwa/AGENTS.md new file mode 100644 index 00000000..ae9e3425 --- /dev/null +++ b/apps/pwa/AGENTS.md @@ -0,0 +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 +- 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 }, ); }); diff --git a/package-lock.json b/package-lock.json index a4c011e2..51380bbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -191,9 +191,8 @@ }, "node_modules/@babel/code-frame/node_modules/chalk": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -205,9 +204,8 @@ }, "node_modules/@babel/compat-data": { "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", - "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -333,9 +331,8 @@ }, "node_modules/@babel/helper-define-polyfill-provider": { "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.3.tgz", - "integrity": "sha512-WBrLmuPP47n7PNwsZ57pqam6G/RGo1vw/87b0Blc53tZNGZ4x7YvZ6HgQe2vo1W/FR20OgjeZuGXzudPiXHFug==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", @@ -349,9 +346,8 @@ }, "node_modules/@babel/helper-environment-visitor": { "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -403,9 +399,8 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", - "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-module-imports": "^7.22.15", @@ -441,9 +436,8 @@ }, "node_modules/@babel/helper-remap-async-to-generator": { "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", - "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-environment-visitor": "^7.22.20", @@ -515,9 +509,8 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -532,9 +525,8 @@ }, "node_modules/@babel/helper-wrap-function": { "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", - "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-function-name": "^7.22.5", "@babel/template": "^7.22.15", @@ -572,9 +564,8 @@ }, "node_modules/@babel/highlight/node_modules/chalk": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -775,9 +766,8 @@ }, "node_modules/@babel/plugin-syntax-bigint": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1063,9 +1053,8 @@ }, "node_modules/@babel/plugin-transform-async-generator-functions": { "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.2.tgz", - "integrity": "sha512-BBYVGxbDVHfoeXbOwcagAkOQAm9NxoTdMGfTqghu1GrvadSaw6iW3Je6IcL5PNOw8VwjxqBECXy50/iCQSY/lQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-plugin-utils": "^7.22.5", @@ -1111,9 +1100,8 @@ }, "node_modules/@babel/plugin-transform-block-scoping": { "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.0.tgz", - "integrity": "sha512-cOsrbmIOXmf+5YbL99/S49Y3j46k/T16b9ml8bm9lP6N9US5iQ2yBK7gpui1pg0V/WMcXdkfKbTb7HXq9u+v4g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" }, @@ -1194,9 +1182,8 @@ }, "node_modules/@babel/plugin-transform-destructuring": { "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.0.tgz", - "integrity": "sha512-vaMdgNXFkYrB+8lbgniSYWHsgqK5gjaMNcc84bMIOMRLH0L9AqYq3hwMdvnyqj1OPqea8UtjPEuS/DCenah1wg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" }, @@ -1386,9 +1373,8 @@ }, "node_modules/@babel/plugin-transform-modules-amd": { "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.0.tgz", - "integrity": "sha512-xWT5gefv2HGSm4QHtgc1sYPbseOyf+FFDo2JbpE25GWl5BqTGO9IMwTYJRoIdjsF85GE+VegHxSCUt5EvoYTAw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.23.0", "@babel/helper-plugin-utils": "^7.22.5" @@ -1402,9 +1388,8 @@ }, "node_modules/@babel/plugin-transform-modules-commonjs": { "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.0.tgz", - "integrity": "sha512-32Xzss14/UVc7k9g775yMIvkVK8xwKE0DPdP5JTapr3+Z9w4tzeOuLNY6BXDQR6BdnzIlXnCGAzsk/ICHBLVWQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.23.0", "@babel/helper-plugin-utils": "^7.22.5", @@ -1419,9 +1404,8 @@ }, "node_modules/@babel/plugin-transform-modules-systemjs": { "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.0.tgz", - "integrity": "sha512-qBej6ctXZD2f+DhlOC9yO47yEYgUh5CZNz/aBoH4j/3NOlRfJXJbY7xDQCqQVf9KbrqGzIWER1f23doHGrIHFg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-module-transforms": "^7.23.0", @@ -1559,9 +1543,8 @@ }, "node_modules/@babel/plugin-transform-optional-chaining": { "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.0.tgz", - "integrity": "sha512-sBBGXbLJjxTzLBF5rFWaikMnOGOk/BmK6vVByIdEggZ7Vn6CvWXZyRkkLFK6WE0IF8jSliyOkUN6SScFgzCM0g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", @@ -1920,9 +1903,8 @@ }, "node_modules/@babel/preset-env": { "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.2.tgz", - "integrity": "sha512-BW3gsuDD+rvHL2VO2SjAUNTBe5YrjsTiDyqamPDWY723na3/yPQ65X5oQkFVJZ0o50/2d+svm1rkPoJeR1KxVQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@babel/compat-data": "^7.23.2", @@ -2044,9 +2026,8 @@ }, "node_modules/@babel/preset-react": { "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.22.15.tgz", - "integrity": "sha512-Csy1IJ2uEh/PecCBXXoZGAZBeCATTuePzCSB7dLYWS0vOEj6CNpjxIhW4duWwZodBNueH7QO14WbGn8YyeuN9w==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-validator-option": "^7.22.15", @@ -2064,9 +2045,8 @@ }, "node_modules/@babel/preset-typescript": { "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.23.2.tgz", - "integrity": "sha512-u4UJc1XsS1GhIGteM8rnGiIvf9rJpiVgMEeCnwlLA7WJPC+jcXWJAGxYmeqs5hOZD8BbAfnV5ezBOxQbb4OUxA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-validator-option": "^7.22.15", @@ -2259,9 +2239,8 @@ }, "node_modules/@babel/types": { "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.22.5", "@babel/helper-validator-identifier": "^7.22.20", @@ -2273,9 +2252,8 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -2464,9 +2442,8 @@ }, "node_modules/@eslint/eslintrc": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -2487,9 +2464,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -2502,9 +2478,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -2514,9 +2489,8 @@ }, "node_modules/@eslint/js": { "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -2563,9 +2537,8 @@ }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.1", "debug": "^4.1.1", @@ -2589,15 +2562,13 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, + "license": "ISC", "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -2611,18 +2582,16 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, + "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -2633,9 +2602,8 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -2646,9 +2614,8 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^4.1.0" }, @@ -2658,9 +2625,8 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, + "license": "MIT", "dependencies": { "p-try": "^2.0.0" }, @@ -2673,9 +2639,8 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^2.2.0" }, @@ -2685,27 +2650,24 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/@jest/console": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -2720,9 +2682,8 @@ }, "node_modules/@jest/core": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", "@jest/reporters": "^29.7.0", @@ -2778,9 +2739,8 @@ }, "node_modules/@jest/environment": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", @@ -2793,9 +2753,8 @@ }, "node_modules/@jest/expect": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.7.0", "jest-snapshot": "^29.7.0" @@ -2806,9 +2765,8 @@ }, "node_modules/@jest/expect-utils": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, + "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3" }, @@ -2818,9 +2776,8 @@ }, "node_modules/@jest/fake-timers": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", @@ -2835,9 +2792,8 @@ }, "node_modules/@jest/globals": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -2850,9 +2806,8 @@ }, "node_modules/@jest/reporters": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", "dev": true, + "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^29.7.0", @@ -2893,9 +2848,8 @@ }, "node_modules/@jest/schemas": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, + "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -2905,9 +2859,8 @@ }, "node_modules/@jest/source-map": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "callsites": "^3.0.0", @@ -2919,9 +2872,8 @@ }, "node_modules/@jest/test-result": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", "@jest/types": "^29.6.3", @@ -2934,9 +2886,8 @@ }, "node_modules/@jest/test-sequencer": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", "graceful-fs": "^4.2.9", @@ -2949,9 +2900,8 @@ }, "node_modules/@jest/transform": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", @@ -2975,15 +2925,13 @@ }, "node_modules/@jest/transform/node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/types": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -4354,9 +4302,8 @@ }, "node_modules/@react-three/fiber/node_modules/zustand": { "version": "3.7.2", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", - "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.7.0" }, @@ -4471,24 +4418,21 @@ }, "node_modules/@sinclair/typebox": { "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@sinonjs/commons": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } }, "node_modules/@sinonjs/fake-timers": { "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0" } @@ -4506,9 +4450,8 @@ }, "node_modules/@testing-library/dom": { "version": "9.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", - "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4525,9 +4468,8 @@ }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -4537,18 +4479,16 @@ }, "node_modules/@testing-library/dom/node_modules/aria-query": { "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "deep-equal": "^2.0.5" } }, "node_modules/@testing-library/dom/node_modules/deep-equal": { "version": "2.2.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.2.tgz", - "integrity": "sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA==", "dev": true, + "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.2", @@ -4575,9 +4515,8 @@ }, "node_modules/@testing-library/dom/node_modules/pretty-format": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4589,15 +4528,13 @@ }, "node_modules/@testing-library/dom/node_modules/react-is": { "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@testing-library/react": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.1.0.tgz", - "integrity": "sha512-hcvfZEEyO0xQoZeHmUbuMs7APJCGELpilL7bY+BaJaMP57aWc6q1etFwScnoZDheYjk4ESdlzPdQ33IbsKAK/A==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^9.0.0", @@ -4653,9 +4590,8 @@ }, "node_modules/@types/aria-query": { "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.1", @@ -4804,9 +4740,8 @@ }, "node_modules/@types/graceful-fs": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -4875,9 +4810,8 @@ }, "node_modules/@types/jest": { "version": "29.5.8", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz", - "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -4885,9 +4819,8 @@ }, "node_modules/@types/jsdom": { "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -4906,9 +4839,8 @@ }, "node_modules/@types/json5": { "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/jsonwebtoken": { "version": "8.5.9", @@ -5023,9 +4955,8 @@ }, "node_modules/@types/node": { "version": "18.19.71", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", - "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "undici-types": "~5.26.4" @@ -5213,9 +5144,8 @@ }, "node_modules/@types/tough-cookie": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.3", @@ -5224,8 +5154,6 @@ }, "node_modules/@types/wicg-file-system-access": { "version": "2023.10.7", - "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.7.tgz", - "integrity": "sha512-g49ijasEJvCd7ifmAY2D0wdEtt1xRjBbA33PJTiv8mKBr7DoMsPeISoJ8oQOTopSRi+FBWPpPW5ouDj2QPKtGA==", "dev": true, "license": "MIT" }, @@ -5252,9 +5180,8 @@ }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.19.0.tgz", - "integrity": "sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", @@ -5288,9 +5215,8 @@ }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", - "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "6.19.0", "@typescript-eslint/visitor-keys": "6.19.0" @@ -5305,9 +5231,8 @@ }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", - "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", "dev": true, + "license": "MIT", "engines": { "node": "^16.0.0 || >=18.0.0" }, @@ -5318,9 +5243,8 @@ }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", - "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "6.19.0", "eslint-visitor-keys": "^3.4.1" @@ -5409,9 +5333,8 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.0.tgz", - "integrity": "sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/typescript-estree": "6.19.0", "@typescript-eslint/utils": "6.19.0", @@ -5436,9 +5359,8 @@ }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", - "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", "dev": true, + "license": "MIT", "engines": { "node": "^16.0.0 || >=18.0.0" }, @@ -5449,9 +5371,8 @@ }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", - "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "6.19.0", "@typescript-eslint/visitor-keys": "6.19.0", @@ -5477,9 +5398,8 @@ }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", - "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "6.19.0", "eslint-visitor-keys": "^3.4.1" @@ -5494,18 +5414,16 @@ }, "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/type-utils/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -5515,9 +5433,8 @@ }, "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -5530,9 +5447,8 @@ }, "node_modules/@typescript-eslint/type-utils/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -5545,9 +5461,8 @@ }, "node_modules/@typescript-eslint/type-utils/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@typescript-eslint/types": { "version": "6.6.0", @@ -5619,9 +5534,8 @@ }, "node_modules/@typescript-eslint/utils": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", - "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", @@ -5644,9 +5558,8 @@ }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", - "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "6.19.0", "@typescript-eslint/visitor-keys": "6.19.0" @@ -5661,9 +5574,8 @@ }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", - "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", "dev": true, + "license": "MIT", "engines": { "node": "^16.0.0 || >=18.0.0" }, @@ -5674,9 +5586,8 @@ }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", - "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "6.19.0", "@typescript-eslint/visitor-keys": "6.19.0", @@ -5702,9 +5613,8 @@ }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", - "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "6.19.0", "eslint-visitor-keys": "^3.4.1" @@ -5719,18 +5629,16 @@ }, "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/utils/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -5740,9 +5648,8 @@ }, "node_modules/@typescript-eslint/utils/node_modules/minimatch": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -5755,9 +5662,8 @@ }, "node_modules/@typescript-eslint/utils/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -5770,9 +5676,8 @@ }, "node_modules/@typescript-eslint/utils/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@typescript-eslint/visitor-keys": { "version": "6.6.0", @@ -5792,9 +5697,8 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", @@ -5980,9 +5884,8 @@ }, "node_modules/abab": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/abbrev": { "version": "1.1.1", @@ -6024,9 +5927,8 @@ }, "node_modules/acorn-globals": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", "dev": true, + "license": "MIT", "dependencies": { "acorn": "^8.1.0", "acorn-walk": "^8.0.2" @@ -6042,9 +5944,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -6295,9 +6196,8 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", @@ -6390,9 +6290,8 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -6482,9 +6381,8 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -6515,9 +6413,8 @@ }, "node_modules/babel-jest": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -6554,9 +6451,8 @@ }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", @@ -6570,9 +6466,8 @@ }, "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", @@ -6586,9 +6481,8 @@ }, "node_modules/babel-plugin-jest-hoist": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -6616,9 +6510,8 @@ }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.6.tgz", - "integrity": "sha512-jhHiWVZIlnPbEUKSSNb9YoWcQGdlTLq7z1GHL4AjFxaoOUMuuEVJ+Y4pAaQUGOGk93YsVCKPbqbfw3m0SM6H8Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", "@babel/helper-define-polyfill-provider": "^0.4.3", @@ -6630,9 +6523,8 @@ }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.8.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.6.tgz", - "integrity": "sha512-leDIc4l4tUgU7str5BWLS2h8q2N4Nf6lGZP6UrNDxdtfF2g69eJ5L0H7S8A5Ln/arfFAfHor5InAdZuIOwZdgQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.4.3", "core-js-compat": "^3.33.1" @@ -6643,9 +6535,8 @@ }, "node_modules/babel-plugin-polyfill-regenerator": { "version": "0.5.3", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.3.tgz", - "integrity": "sha512-8sHeDOmXC8csczMrYEOf0UTNa4yE2SxV5JGeT/LP1n0OYVDUUFPxG9vdk2AlDlIit4t+Kf0xCtpgXPBwnn/9pw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.4.3" }, @@ -6655,9 +6546,8 @@ }, "node_modules/babel-plugin-styled-components": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz", - "integrity": "sha512-Xgp9g+A/cG47sUyRwwYxGM4bR/jDRg5N6it/8+HxCnbT5XNKSKDT9xm4oag/osgqjC2It/vH0yXsomOG6k558g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-module-imports": "^7.22.5", @@ -6684,9 +6574,8 @@ }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", @@ -6744,9 +6633,8 @@ }, "node_modules/babel-preset-jest": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "dev": true, + "license": "MIT", "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" @@ -6943,8 +6831,6 @@ }, "node_modules/browserslist": { "version": "4.22.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", - "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", "dev": true, "funding": [ { @@ -6960,6 +6846,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", @@ -7034,18 +6921,16 @@ }, "node_modules/builtins": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", - "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.0.0" } }, "node_modules/builtins/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -7055,9 +6940,8 @@ }, "node_modules/builtins/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -7070,9 +6954,8 @@ }, "node_modules/builtins/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/bytes": { "version": "3.1.2", @@ -7225,8 +7108,6 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001561", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", - "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", "dev": true, "funding": [ { @@ -7241,12 +7122,12 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -7260,8 +7141,7 @@ }, "node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -7274,8 +7154,7 @@ }, "node_modules/chalk/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -7285,21 +7164,18 @@ }, "node_modules/chalk/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "license": "MIT" }, "node_modules/chalk/node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -7309,9 +7185,8 @@ }, "node_modules/char-regex": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -7395,9 +7270,8 @@ }, "node_modules/cjs-module-lexer": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/classnames": { "version": "2.3.2", @@ -7591,9 +7465,8 @@ }, "node_modules/clrc": { "version": "3.1.4", - "resolved": "https://registry.npmjs.org/clrc/-/clrc-3.1.4.tgz", - "integrity": "sha512-lNWVrD2u+PRB9sQimJOjpAUdqRNsgQgx2c+oI00Uqxt4p2Aiusx0MlPLNjXs5t0mqQqz7UlyzqTIHVPIAjAGLg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/co": { "version": "4.6.0", @@ -7616,9 +7489,8 @@ }, "node_modules/collect-v8-coverage": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/color-convert": { "version": "1.9.3", @@ -7647,9 +7519,8 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -7934,9 +7805,8 @@ }, "node_modules/core-js-compat": { "version": "3.33.2", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.33.2.tgz", - "integrity": "sha512-axfo+wxFVxnqf8RvxTzoAlzW4gRoacrHeoFlc9n0x50+7BEyZL/Rt3hicaED1/CEd7I6tPCPVUYcJwCMO5XUYw==", "dev": true, + "license": "MIT", "dependencies": { "browserslist": "^4.22.1" }, @@ -7967,9 +7837,8 @@ }, "node_modules/create-jest": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -8176,15 +8045,13 @@ }, "node_modules/cssom": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cssstyle": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", "dev": true, + "license": "MIT", "dependencies": { "cssom": "~0.3.6" }, @@ -8194,9 +8061,8 @@ }, "node_modules/cssstyle/node_modules/cssom": { "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/csstype": { "version": "3.1.2", @@ -8205,17 +8071,15 @@ }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/data-urls": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", "dev": true, + "license": "MIT", "dependencies": { "abab": "^2.0.6", "whatwg-mimetype": "^3.0.0", @@ -8227,9 +8091,8 @@ }, "node_modules/data-urls/node_modules/tr46": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "dev": true, + "license": "MIT", "dependencies": { "punycode": "^2.1.1" }, @@ -8239,18 +8102,16 @@ }, "node_modules/data-urls/node_modules/webidl-conversions": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" } }, "node_modules/data-urls/node_modules/whatwg-url": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", "dev": true, + "license": "MIT", "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" @@ -8286,8 +8147,7 @@ }, "node_modules/debug/node_modules/ms": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "license": "MIT" }, "node_modules/decamelize": { "version": "1.2.0", @@ -8299,9 +8159,8 @@ }, "node_modules/decimal.js": { "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/decompress-response": { "version": "6.0.0", @@ -8319,9 +8178,8 @@ }, "node_modules/dedent": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", - "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", "dev": true, + "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, @@ -8402,9 +8260,8 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -8452,9 +8309,8 @@ }, "node_modules/detect-newline": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8474,9 +8330,8 @@ }, "node_modules/diff-sequences": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -8521,9 +8376,8 @@ }, "node_modules/dom-accessibility-api": { "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/dom-converter": { "version": "0.2.0", @@ -8571,9 +8425,8 @@ }, "node_modules/domexception": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", "dev": true, + "license": "MIT", "dependencies": { "webidl-conversions": "^7.0.0" }, @@ -8583,9 +8436,8 @@ }, "node_modules/domexception/node_modules/webidl-conversions": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" } @@ -8664,15 +8516,13 @@ }, "node_modules/electron-to-chromium": { "version": "1.4.578", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.578.tgz", - "integrity": "sha512-V0ZhSu1BQZKfG0yNEL6Dadzik8E1vAzfpVOapdSiT9F6yapEJ3Bk+4tZ4SMPdWiUchCgnM/ByYtBzp5ntzDMIA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/emittery": { "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -8853,9 +8703,8 @@ }, "node_modules/es-get-iterator": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -8955,9 +8804,8 @@ }, "node_modules/escodegen": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", @@ -8976,9 +8824,8 @@ }, "node_modules/eslint": { "version": "8.56.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", @@ -9032,9 +8879,8 @@ }, "node_modules/eslint-compat-utils": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.1.2.tgz", - "integrity": "sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -9044,9 +8890,8 @@ }, "node_modules/eslint-config-prettier": { "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9056,8 +8901,6 @@ }, "node_modules/eslint-config-standard": { "version": "17.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", - "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", "dev": true, "funding": [ { @@ -9073,6 +8916,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "engines": { "node": ">=12.0.0" }, @@ -9085,9 +8929,8 @@ }, "node_modules/eslint-config-standard-with-typescript": { "version": "43.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-standard-with-typescript/-/eslint-config-standard-with-typescript-43.0.0.tgz", - "integrity": "sha512-AT0qK01M5bmsWiE3UZvaQO5da1y1n6uQckAKqGNe6zPW5IOzgMLXZxw77nnFm+C11nxAZXsCPrbsgJhSrGfX6Q==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/parser": "^6.4.0", "eslint-config-standard": "17.1.0" @@ -9145,9 +8988,8 @@ }, "node_modules/eslint-plugin-es-x": { "version": "7.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.5.0.tgz", - "integrity": "sha512-ODswlDSO0HJDzXU0XvgZ3lF3lS3XAZEossh15Q2UHjwrJggWeBoKqqEsLTZLXl+dh5eOAozG0zRcYtuE35oTuQ==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.1.2", "@eslint-community/regexpp": "^4.6.0", @@ -9165,9 +9007,8 @@ }, "node_modules/eslint-plugin-import": { "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "array-includes": "^3.1.7", @@ -9216,9 +9057,8 @@ }, "node_modules/eslint-plugin-import/node_modules/json5": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.0" }, @@ -9228,18 +9068,16 @@ }, "node_modules/eslint-plugin-import/node_modules/strip-bom": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, + "license": "MIT", "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", @@ -9249,9 +9087,8 @@ }, "node_modules/eslint-plugin-jest": { "version": "27.6.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.6.3.tgz", - "integrity": "sha512-+YsJFVH6R+tOiO3gCJon5oqn4KWc+mDq2leudk8mrp8RFubLOo9CVyi3cib4L7XMpxExmkmBZQTPDYVBzgpgOA==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/utils": "^5.10.0" }, @@ -9274,9 +9111,8 @@ }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/scope-manager": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0" @@ -9291,9 +9127,8 @@ }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/types": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -9304,9 +9139,8 @@ }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/typescript-estree": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0", @@ -9331,9 +9165,8 @@ }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", @@ -9357,9 +9190,8 @@ }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/visitor-keys": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" @@ -9374,9 +9206,8 @@ }, "node_modules/eslint-plugin-jest/node_modules/eslint-scope": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -9387,18 +9218,16 @@ }, "node_modules/eslint-plugin-jest/node_modules/estraverse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/eslint-plugin-jest/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -9408,9 +9237,8 @@ }, "node_modules/eslint-plugin-jest/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -9423,15 +9251,13 @@ }, "node_modules/eslint-plugin-jest/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/eslint-plugin-n": { "version": "16.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-16.6.2.tgz", - "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", @@ -9458,9 +9284,8 @@ }, "node_modules/eslint-plugin-n/node_modules/globals": { "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -9473,9 +9298,8 @@ }, "node_modules/eslint-plugin-n/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -9485,9 +9309,8 @@ }, "node_modules/eslint-plugin-n/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -9500,9 +9323,8 @@ }, "node_modules/eslint-plugin-n/node_modules/type-fest": { "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -9512,15 +9334,13 @@ }, "node_modules/eslint-plugin-n/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/eslint-plugin-promise": { "version": "6.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", - "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==", "dev": true, + "license": "ISC", "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -9531,9 +9351,8 @@ }, "node_modules/eslint-plugin-react": { "version": "7.33.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", - "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", "dev": true, + "license": "MIT", "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", @@ -9561,9 +9380,8 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -9662,9 +9480,8 @@ }, "node_modules/espree": { "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -9754,9 +9571,8 @@ }, "node_modules/eventin": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/eventin/-/eventin-2.0.4.tgz", - "integrity": "sha512-oC+D1t0vqmpOAppEvC8m4+XjXAyjbmEo94P3AhkeWCOn5mUOUcXvj9hbZufnF2WtfH1T6evpcmX5iFMUXtcSyA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/events": { "version": "3.3.0", @@ -9793,8 +9609,6 @@ }, "node_modules/exit": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", "dev": true, "engines": { "node": ">= 0.8.0" @@ -9810,9 +9624,8 @@ }, "node_modules/expect": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", @@ -10383,9 +10196,8 @@ }, "node_modules/form-data": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -10502,9 +10314,8 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -10589,9 +10400,8 @@ }, "node_modules/get-package-type": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.0.0" } @@ -10833,9 +10643,8 @@ }, "node_modules/hasown": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -10941,9 +10750,8 @@ }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", "dev": true, + "license": "MIT", "dependencies": { "whatwg-encoding": "^2.0.0" }, @@ -10953,8 +10761,6 @@ }, "node_modules/html-entities": { "version": "2.4.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", - "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", "dev": true, "funding": [ { @@ -10965,13 +10771,13 @@ "type": "patreon", "url": "https://patreon.com/mdevils" } - ] + ], + "license": "MIT" }, "node_modules/html-escaper": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/html-minifier-terser": { "version": "6.1.0", @@ -11436,9 +11242,8 @@ }, "node_modules/is-arguments": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -11525,9 +11330,8 @@ }, "node_modules/is-builtin-module": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", "dev": true, + "license": "MIT", "dependencies": { "builtin-modules": "^3.3.0" }, @@ -11551,9 +11355,8 @@ }, "node_modules/is-core-module": { "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dev": true, + "license": "MIT", "dependencies": { "hasown": "^2.0.0" }, @@ -11633,9 +11436,8 @@ }, "node_modules/is-generator-fn": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -11762,9 +11564,8 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-regex": { "version": "1.1.4", @@ -11938,18 +11739,16 @@ }, "node_modules/istanbul-lib-coverage": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.1.tgz", - "integrity": "sha512-opCrKqbthmq3SKZ10mFMQG9dk3fTa3quaOLD35kJa5ejwZHd9xAr+kLuziiZz2cG32s4lMZxNdmdcEQnTDP4+g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=8" } }, "node_modules/istanbul-lib-instrument": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", - "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", @@ -11963,9 +11762,8 @@ }, "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -11975,9 +11773,8 @@ }, "node_modules/istanbul-lib-instrument/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -11990,15 +11787,13 @@ }, "node_modules/istanbul-lib-instrument/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/istanbul-lib-report": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -12010,18 +11805,16 @@ }, "node_modules/istanbul-lib-report/node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/istanbul-lib-report/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -12031,9 +11824,8 @@ }, "node_modules/istanbul-lib-report/node_modules/make-dir": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.5.3" }, @@ -12046,9 +11838,8 @@ }, "node_modules/istanbul-lib-report/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -12061,9 +11852,8 @@ }, "node_modules/istanbul-lib-report/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -12073,15 +11863,13 @@ }, "node_modules/istanbul-lib-report/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/istanbul-lib-source-maps": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", @@ -12093,9 +11881,8 @@ }, "node_modules/istanbul-reports": { "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -12153,9 +11940,8 @@ }, "node_modules/jest": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@jest/core": "^29.7.0", @@ -12180,9 +11966,8 @@ }, "node_modules/jest-changed-files": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", "dev": true, + "license": "MIT", "dependencies": { "execa": "^5.0.0", "jest-util": "^29.7.0", @@ -12194,9 +11979,8 @@ }, "node_modules/jest-circus": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -12225,9 +12009,8 @@ }, "node_modules/jest-cli": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", "@jest/test-result": "^29.7.0", @@ -12258,9 +12041,8 @@ }, "node_modules/jest-config": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/test-sequencer": "^29.7.0", @@ -12303,9 +12085,8 @@ }, "node_modules/jest-diff": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", @@ -12318,9 +12099,8 @@ }, "node_modules/jest-docblock": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, + "license": "MIT", "dependencies": { "detect-newline": "^3.0.0" }, @@ -12330,9 +12110,8 @@ }, "node_modules/jest-each": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -12346,9 +12125,8 @@ }, "node_modules/jest-environment-jsdom": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -12373,9 +12151,8 @@ }, "node_modules/jest-environment-node": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -12390,18 +12167,16 @@ }, "node_modules/jest-get-type": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-haste-map": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", @@ -12424,9 +12199,8 @@ }, "node_modules/jest-leak-detector": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", "dev": true, + "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" @@ -12437,9 +12211,8 @@ }, "node_modules/jest-matcher-utils": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", @@ -12452,9 +12225,8 @@ }, "node_modules/jest-message-util": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", @@ -12472,9 +12244,8 @@ }, "node_modules/jest-mock": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -12486,9 +12257,8 @@ }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -12503,18 +12273,16 @@ }, "node_modules/jest-regex-util": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "graceful-fs": "^4.2.9", @@ -12532,9 +12300,8 @@ }, "node_modules/jest-resolve-dependencies": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", "dev": true, + "license": "MIT", "dependencies": { "jest-regex-util": "^29.6.3", "jest-snapshot": "^29.7.0" @@ -12545,9 +12312,8 @@ }, "node_modules/jest-runner": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", "@jest/environment": "^29.7.0", @@ -12577,9 +12343,8 @@ }, "node_modules/jest-runtime": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -12610,9 +12375,8 @@ }, "node_modules/jest-snapshot": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", @@ -12641,9 +12405,8 @@ }, "node_modules/jest-snapshot/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -12653,9 +12416,8 @@ }, "node_modules/jest-snapshot/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -12668,15 +12430,13 @@ }, "node_modules/jest-snapshot/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/jest-util": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -12691,9 +12451,8 @@ }, "node_modules/jest-validate": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", @@ -12708,9 +12467,8 @@ }, "node_modules/jest-validate/node_modules/camelcase": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -12720,9 +12478,8 @@ }, "node_modules/jest-watcher": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", "dev": true, + "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", @@ -12739,9 +12496,8 @@ }, "node_modules/jest-worker": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -12754,18 +12510,16 @@ }, "node_modules/jest-worker/node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -12813,9 +12567,8 @@ }, "node_modules/js-yaml": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -12877,9 +12630,8 @@ }, "node_modules/jsdom": { "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, + "license": "MIT", "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", @@ -12922,18 +12674,16 @@ }, "node_modules/jsdom/node_modules/@tootallnate/once": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10" } }, "node_modules/jsdom/node_modules/http-proxy-agent": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "dev": true, + "license": "MIT", "dependencies": { "@tootallnate/once": "2", "agent-base": "6", @@ -12945,9 +12695,8 @@ }, "node_modules/jsdom/node_modules/tr46": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "dev": true, + "license": "MIT", "dependencies": { "punycode": "^2.1.1" }, @@ -12957,18 +12706,16 @@ }, "node_modules/jsdom/node_modules/webidl-conversions": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" } }, "node_modules/jsdom/node_modules/whatwg-url": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", "dev": true, + "license": "MIT", "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" @@ -12979,9 +12726,8 @@ }, "node_modules/jsdom/node_modules/ws": { "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -13293,9 +13039,8 @@ }, "node_modules/koa-logger/node_modules/chalk": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -13665,8 +13410,6 @@ }, "node_modules/lodash-es": { "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "license": "MIT" }, "node_modules/lodash.curry": { @@ -13676,9 +13419,8 @@ }, "node_modules/lodash.debounce": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.flow": { "version": "3.5.0", @@ -13977,9 +13719,8 @@ }, "node_modules/lz-string": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "license": "MIT", "bin": { "lz-string": "bin/bin.js" } @@ -15083,8 +14824,7 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "license": "MIT" }, "node_modules/multicast-dns": { "version": "7.2.5", @@ -15521,9 +15261,8 @@ }, "node_modules/nwsapi": { "version": "2.2.7", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", - "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ob1": { "version": "0.76.8", @@ -15550,9 +15289,8 @@ }, "node_modules/object-is": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3" @@ -15923,9 +15661,8 @@ }, "node_modules/parse5": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", "dev": true, + "license": "MIT", "dependencies": { "entities": "^4.4.0" }, @@ -15935,9 +15672,8 @@ }, "node_modules/parse5/node_modules/entities": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -16553,9 +16289,8 @@ }, "node_modules/pretty-format": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -16567,9 +16302,8 @@ }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -16672,9 +16406,8 @@ }, "node_modules/psl": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pstree.remy": { "version": "1.1.8", @@ -16705,8 +16438,6 @@ }, "node_modules/pure-rand": { "version": "6.0.4", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", - "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", "dev": true, "funding": [ { @@ -16717,7 +16448,8 @@ "type": "opencollective", "url": "https://opencollective.com/fast-check" } - ] + ], + "license": "MIT" }, "node_modules/pwa": { "resolved": "apps/pwa", @@ -16725,9 +16457,8 @@ }, "node_modules/qrcode.react": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.1.0.tgz", - "integrity": "sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==", "dev": true, + "license": "ISC", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } @@ -16748,9 +16479,8 @@ }, "node_modules/querystringify": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/queue": { "version": "6.0.2", @@ -17045,9 +16775,8 @@ }, "node_modules/react-lrc": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/react-lrc/-/react-lrc-3.2.1.tgz", - "integrity": "sha512-CRmjtFjjL6Y4RLGSgBxpP7RD9rAJqLLJeRKbC7TYzBFxWFQysOzc2j1dtjGc8rOsOMz6Pfeh4352qUh97dBhZA==", "dev": true, + "license": "MIT", "dependencies": { "clrc": "^3.1.4", "resize-observer-polyfill": "^1.5.1" @@ -17271,9 +17000,8 @@ }, "node_modules/react-select": { "version": "5.7.7", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.7.7.tgz", - "integrity": "sha512-HhashZZJDRlfF/AKj0a0Lnfs3sRdw/46VJIRd8IbB9/Ovr74+ZIwkAdSBjSPXsFMG+u72c5xShqwLSKIJllzqw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", @@ -17353,9 +17081,8 @@ }, "node_modules/react-test-renderer": { "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz", - "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==", "dev": true, + "license": "MIT", "dependencies": { "react-is": "^18.2.0", "react-shallow-renderer": "^16.15.0", @@ -17727,9 +17454,8 @@ }, "node_modules/resolve.exports": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -18003,9 +17729,8 @@ }, "node_modules/saxes": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" }, @@ -18471,9 +18196,8 @@ }, "node_modules/source-map-support": { "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -18601,9 +18325,8 @@ }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", "dev": true, + "license": "MIT", "dependencies": { "internal-slot": "^1.0.4" }, @@ -18673,9 +18396,8 @@ }, "node_modules/string-length": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, + "license": "MIT", "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -18792,9 +18514,8 @@ }, "node_modules/strip-bom": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -18947,9 +18668,8 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.1", @@ -19202,9 +18922,8 @@ }, "node_modules/test-exclude": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, + "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -19372,9 +19091,8 @@ }, "node_modules/tough-cookie": { "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", - "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -19387,9 +19105,8 @@ }, "node_modules/tough-cookie/node_modules/universalify": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4.0.0" } @@ -19418,9 +19135,8 @@ }, "node_modules/ts-node": { "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -19494,9 +19210,8 @@ }, "node_modules/tsutils": { "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", "dev": true, + "license": "MIT", "dependencies": { "tslib": "^1.8.1" }, @@ -19509,9 +19224,8 @@ }, "node_modules/tsutils/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "dev": true, + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -19537,9 +19251,8 @@ }, "node_modules/type-detect": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -19628,8 +19341,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -19710,9 +19421,8 @@ }, "node_modules/undici-types": { "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", @@ -19804,8 +19514,6 @@ }, "node_modules/update-browserslist-db": { "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", "dev": true, "funding": [ { @@ -19821,6 +19529,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.1.1", "picocolors": "^1.0.0" @@ -19842,9 +19551,8 @@ }, "node_modules/url-parse": { "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "dev": true, + "license": "MIT", "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -19930,9 +19638,8 @@ }, "node_modules/v8-to-istanbul": { "version": "9.1.3", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.3.tgz", - "integrity": "sha512-9lDD+EVI2fjFsMWXc6dy5JJzBsVTcQ2fVkfBvncZ6xJWG9wtBhOldG+mHkSL0+V1K/xgZz0JDO5UT5hFwHUghg==", "dev": true, + "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -19944,9 +19651,8 @@ }, "node_modules/v8-to-istanbul/node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/vary": { "version": "1.1.2", @@ -19962,9 +19668,8 @@ }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", "dev": true, + "license": "MIT", "dependencies": { "xml-name-validator": "^4.0.0" }, @@ -20426,9 +20131,8 @@ }, "node_modules/whatwg-encoding": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", "dev": true, + "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" }, @@ -20438,9 +20142,8 @@ }, "node_modules/whatwg-encoding/node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -20454,9 +20157,8 @@ }, "node_modules/whatwg-mimetype": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -20904,9 +20606,8 @@ }, "node_modules/write-file-atomic": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -20943,9 +20644,8 @@ }, "node_modules/xml-name-validator": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=12" } @@ -20974,9 +20674,8 @@ }, "node_modules/xmlchars": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/xss": { "version": "1.0.14", @@ -21085,9 +20784,8 @@ }, "node_modules/zustand": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz", - "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.20.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 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', }