{
20 | 'works': instance.works,
21 | 'pagination': instance.pagination,
22 | };
23 |
--------------------------------------------------------------------------------
/lib/core/audio/README.md:
--------------------------------------------------------------------------------
1 | # 音频核心功能
2 |
3 | ## 当前架构
4 |
5 | ### 1. 事件驱动系统
6 | - 基于 RxDart 的事件中心
7 | - 统一的事件定义和处理
8 | - 支持事件过滤和转换
9 |
10 | ### 2. 核心服务 (AudioPlayerService)
11 | - 实现 IAudioPlayerService 接口
12 | - 通过依赖注入管理依赖
13 | - 负责协调各个组件
14 |
15 | ### 3. 状态管理
16 | - PlaybackStateManager 负责状态维护
17 | - 通过 EventHub 发送状态更新
18 | - 支持状态持久化
19 |
20 | ### 4. 通知栏集成
21 | - 基于 audio_service 包
22 | - 响应系统媒体控制
23 | - 支持后台播放
24 |
25 | ### 5. 依赖注入
26 | 通过 GetIt 管理所有依赖:
27 |
28 | void setupServiceLocator() {
29 | // 注册 EventHub
30 | getIt.registerLazySingleton(() => PlaybackEventHub());
31 |
32 | // 注册音频服务
33 | getIt.registerLazySingleton(
34 | () => AudioPlayerService(
35 | eventHub: getIt(),
36 | stateRepository: getIt(),
37 | ),
38 | );
39 | }
40 |
41 |
42 | ## 注意事项
43 |
44 | - 所有状态更新通过 EventHub 传递
45 | - 避免组件间直接调用
46 | - 优先使用依赖注入
47 | - 保持组件职责单一
48 |
--------------------------------------------------------------------------------
/lib/core/audio/utils/track_info_creator.dart:
--------------------------------------------------------------------------------
1 | import 'package:asmrapp/core/audio/models/audio_track_info.dart';
2 | import 'package:asmrapp/data/models/files/child.dart';
3 | import 'package:asmrapp/data/models/works/work.dart';
4 |
5 | class TrackInfoCreator {
6 | static AudioTrackInfo createTrackInfo({
7 | required String title,
8 | required String? artistName,
9 | required String? coverUrl,
10 | required String url,
11 | }) {
12 | return AudioTrackInfo(
13 | title: title,
14 | artist: artistName ?? '',
15 | coverUrl: coverUrl ?? '',
16 | url: url,
17 | );
18 | }
19 |
20 | static AudioTrackInfo createFromFile(Child file, Work work) {
21 | return createTrackInfo(
22 | title: file.title ?? '',
23 | artistName: work.circle?.name,
24 | coverUrl: work.mainCoverUrl,
25 | url: file.mediaDownloadUrl!,
26 | );
27 | }
28 | }
--------------------------------------------------------------------------------
/lib/data/services/interceptors/auth_interceptor.dart:
--------------------------------------------------------------------------------
1 | import 'package:dio/dio.dart';
2 | import 'package:get_it/get_it.dart';
3 | import 'package:asmrapp/data/repositories/auth_repository.dart';
4 | import 'package:asmrapp/utils/logger.dart';
5 |
6 | class AuthInterceptor extends Interceptor {
7 | @override
8 | Future onRequest(
9 | RequestOptions options,
10 | RequestInterceptorHandler handler,
11 | ) async {
12 | try {
13 | final authRepository = GetIt.I();
14 | final authData = await authRepository.getAuthData();
15 |
16 | if (authData?.token != null) {
17 | options.headers['Authorization'] = 'Bearer ${authData!.token}';
18 | }
19 |
20 | handler.next(options);
21 | } catch (e) {
22 | AppLogger.error('AuthInterceptor: 处理请求失败', e);
23 | handler.next(options); // 即使出错也继续请求
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/windows/flutter/generated_plugins.cmake:
--------------------------------------------------------------------------------
1 | #
2 | # Generated file, do not edit.
3 | #
4 |
5 | list(APPEND FLUTTER_PLUGIN_LIST
6 | permission_handler_windows
7 | )
8 |
9 | list(APPEND FLUTTER_FFI_PLUGIN_LIST
10 | )
11 |
12 | set(PLUGIN_BUNDLED_LIBRARIES)
13 |
14 | foreach(plugin ${FLUTTER_PLUGIN_LIST})
15 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
16 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
17 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
18 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
19 | endforeach(plugin)
20 |
21 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
22 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
23 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
24 | endforeach(ffi_plugin)
25 |
--------------------------------------------------------------------------------
/ios/Flutter/AppFrameworkInfo.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | App
9 | CFBundleIdentifier
10 | io.flutter.flutter.app
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | App
15 | CFBundlePackageType
16 | FMWK
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1.0
23 | MinimumOSVersion
24 | 12.0
25 |
26 |
27 |
--------------------------------------------------------------------------------
/lib/widgets/work_card/components/work_footer.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:asmrapp/data/models/works/work.dart';
3 |
4 | class WorkFooter extends StatelessWidget {
5 | final Work work;
6 |
7 | const WorkFooter({
8 | super.key,
9 | required this.work,
10 | });
11 |
12 | @override
13 | Widget build(BuildContext context) {
14 | return Row(
15 | mainAxisAlignment: MainAxisAlignment.spaceBetween,
16 | children: [
17 | Text(
18 | work.release ?? '',
19 | style: Theme.of(context).textTheme.bodySmall?.copyWith(
20 | fontSize: 10,
21 | ),
22 | ),
23 | Text(
24 | '销量 ${work.dlCount ?? 0}',
25 | style: Theme.of(context).textTheme.bodySmall?.copyWith(
26 | fontSize: 10,
27 | ),
28 | ),
29 | ],
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/lib/data/models/auth/auth_resp/user.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'user.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$UserImpl _$$UserImplFromJson(Map json) => _$UserImpl(
10 | loggedIn: json['loggedIn'] as bool?,
11 | name: json['name'] as String?,
12 | group: json['group'] as String?,
13 | email: json['email'],
14 | recommenderUuid: json['recommenderUuid'] as String?,
15 | );
16 |
17 | Map _$$UserImplToJson(_$UserImpl instance) =>
18 | {
19 | 'loggedIn': instance.loggedIn,
20 | 'name': instance.name,
21 | 'group': instance.group,
22 | 'email': instance.email,
23 | 'recommenderUuid': instance.recommenderUuid,
24 | };
25 |
--------------------------------------------------------------------------------
/lib/data/models/mark_lists/mark_lists.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'mark_lists.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$MarkListsImpl _$$MarkListsImplFromJson(Map json) =>
10 | _$MarkListsImpl(
11 | playlists: (json['playlists'] as List?)
12 | ?.map((e) => Playlist.fromJson(e as Map))
13 | .toList(),
14 | pagination: json['pagination'] == null
15 | ? null
16 | : Pagination.fromJson(json['pagination'] as Map),
17 | );
18 |
19 | Map _$$MarkListsImplToJson(_$MarkListsImpl instance) =>
20 | {
21 | 'playlists': instance.playlists,
22 | 'pagination': instance.pagination,
23 | };
24 |
--------------------------------------------------------------------------------
/lib/data/models/playback/playback_state.dart:
--------------------------------------------------------------------------------
1 | import 'package:freezed_annotation/freezed_annotation.dart';
2 | import 'package:asmrapp/data/models/works/work.dart';
3 | import 'package:asmrapp/data/models/files/files.dart';
4 | import 'package:asmrapp/data/models/files/child.dart';
5 | import 'package:asmrapp/core/audio/models/play_mode.dart';
6 |
7 | part 'playback_state.freezed.dart';
8 | part 'playback_state.g.dart';
9 |
10 | @freezed
11 | class PlaybackState with _$PlaybackState {
12 | const factory PlaybackState({
13 | required Work work,
14 | required Files files,
15 | required Child currentFile,
16 | required List playlist,
17 | required int currentIndex,
18 | required PlayMode playMode,
19 | required int position, // 使用毫秒存储
20 | required String timestamp, // ISO8601 格式
21 | }) = _PlaybackState;
22 |
23 | factory PlaybackState.fromJson(Map json) =>
24 | _$PlaybackStateFromJson(json);
25 | }
--------------------------------------------------------------------------------
/lib/core/platform/dummy_lyric_overlay_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:asmrapp/utils/logger.dart';
2 | import 'i_lyric_overlay_controller.dart';
3 |
4 | class DummyLyricOverlayController implements ILyricOverlayController {
5 | static const _tag = 'LyricOverlay';
6 |
7 | @override
8 | Future initialize() async {
9 | }
10 |
11 | @override
12 | Future show() async {
13 |
14 | }
15 |
16 | @override
17 | Future hide() async {
18 |
19 | }
20 |
21 | @override
22 | Future updateLyric(String? text) async {
23 |
24 | }
25 |
26 | @override
27 | Future checkPermission() async {
28 | return true;
29 | }
30 |
31 | @override
32 | Future requestPermission() async {
33 | AppLogger.debug('[$_tag] 请求权限');
34 | return true;
35 | }
36 |
37 | @override
38 | Future dispose() async {
39 |
40 | }
41 |
42 | @override
43 | Future isShowing() async {
44 | return false;
45 | }
46 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Miscellaneous
2 | *.class
3 | *.log
4 | *.pyc
5 | *.swp
6 | .DS_Store
7 | .atom/
8 | .buildlog/
9 | .history
10 | .svn/
11 | migrate_working_dir/
12 |
13 | # IntelliJ related
14 | *.iml
15 | *.ipr
16 | *.iws
17 | .idea/
18 |
19 | # The .vscode folder contains launch configuration and tasks you configure in
20 | # VS Code which you may wish to be included in version control, so this line
21 | # is commented out by default.
22 | #.vscode/
23 |
24 | # Flutter/Dart/Pub related
25 | **/doc/api/
26 | **/ios/Flutter/.last_build_id
27 | .dart_tool/
28 | .flutter-plugins
29 | .flutter-plugins-dependencies
30 | .pub-cache/
31 | .pub/
32 | /build/
33 |
34 | # Symbolication related
35 | app.*.symbols
36 |
37 | # Obfuscation related
38 | app.*.map.json
39 |
40 | # Android Studio will place build artifacts here
41 | /android/app/debug
42 | /android/app/profile
43 | /android/app/release
44 |
45 | # 添加以下内容
46 | **/android/key.properties
47 | **/android/app/upload-keystore.jks
48 |
--------------------------------------------------------------------------------
/lib/data/models/my_lists/my_playlists/my_playlists.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'my_playlists.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$MyPlaylistsImpl _$$MyPlaylistsImplFromJson(Map json) =>
10 | _$MyPlaylistsImpl(
11 | playlists: (json['playlists'] as List?)
12 | ?.map((e) => Playlist.fromJson(e as Map))
13 | .toList(),
14 | pagination: json['pagination'] == null
15 | ? null
16 | : Pagination.fromJson(json['pagination'] as Map),
17 | );
18 |
19 | Map _$$MyPlaylistsImplToJson(_$MyPlaylistsImpl instance) =>
20 | {
21 | 'playlists': instance.playlists,
22 | 'pagination': instance.pagination,
23 | };
24 |
--------------------------------------------------------------------------------
/lib/data/models/works/i18n.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'i18n.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$I18nImpl _$$I18nImplFromJson(Map json) => _$I18nImpl(
10 | enUs: json['en-us'] == null
11 | ? null
12 | : EnUs.fromJson(json['en-us'] as Map),
13 | jaJp: json['ja-jp'] == null
14 | ? null
15 | : JaJp.fromJson(json['ja-jp'] as Map),
16 | zhCn: json['zh-cn'] == null
17 | ? null
18 | : ZhCn.fromJson(json['zh-cn'] as Map),
19 | );
20 |
21 | Map _$$I18nImplToJson(_$I18nImpl instance) =>
22 | {
23 | 'en-us': instance.enUs,
24 | 'ja-jp': instance.jaJp,
25 | 'zh-cn': instance.zhCn,
26 | };
27 |
--------------------------------------------------------------------------------
/lib/presentation/models/filter_state.dart:
--------------------------------------------------------------------------------
1 | class FilterState {
2 | final String orderField;
3 | final bool isDescending;
4 |
5 | const FilterState({
6 | this.orderField = 'create_date',
7 | this.isDescending = true,
8 | });
9 |
10 | bool get showSortDirection => orderField != 'random';
11 |
12 | String get sortValue => orderField == 'random' ? 'desc' : (isDescending ? 'desc' : 'asc');
13 |
14 | FilterState copyWith({
15 | String? orderField,
16 | bool? isDescending,
17 | }) {
18 | return FilterState(
19 | orderField: orderField ?? this.orderField,
20 | isDescending: isDescending ?? this.isDescending,
21 | );
22 | }
23 |
24 | // 用于持久化
25 | Map toJson() => {
26 | 'orderField': orderField,
27 | 'isDescending': isDescending,
28 | };
29 |
30 | // 从持久化恢复
31 | factory FilterState.fromJson(Map json) => FilterState(
32 | orderField: json['orderField'] ?? 'create_date',
33 | isDescending: json['isDescending'] ?? true,
34 | );
35 | }
--------------------------------------------------------------------------------
/web/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Yuro",
3 | "short_name": "Yuro",
4 | "start_url": ".",
5 | "display": "standalone",
6 | "background_color": "#0175C2",
7 | "theme_color": "#0175C2",
8 | "description": "A new Flutter project.",
9 | "orientation": "portrait-primary",
10 | "prefer_related_applications": false,
11 | "icons": [
12 | {
13 | "src": "icons/Icon-192.png",
14 | "sizes": "192x192",
15 | "type": "image/png"
16 | },
17 | {
18 | "src": "icons/Icon-512.png",
19 | "sizes": "512x512",
20 | "type": "image/png"
21 | },
22 | {
23 | "src": "icons/Icon-maskable-192.png",
24 | "sizes": "192x192",
25 | "type": "image/png",
26 | "purpose": "maskable"
27 | },
28 | {
29 | "src": "icons/Icon-maskable-512.png",
30 | "sizes": "512x512",
31 | "type": "image/png",
32 | "purpose": "maskable"
33 | }
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/lib/widgets/work_grid/components/grid_empty.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | class GridEmpty extends StatelessWidget {
4 | final String? message;
5 | final Widget? customWidget;
6 |
7 | const GridEmpty({
8 | super.key,
9 | this.message,
10 | this.customWidget,
11 | });
12 |
13 | @override
14 | Widget build(BuildContext context) {
15 | if (customWidget != null) {
16 | return customWidget!;
17 | }
18 |
19 | return Center(
20 | child: Column(
21 | mainAxisAlignment: MainAxisAlignment.center,
22 | children: [
23 | Icon(
24 | Icons.inbox_outlined,
25 | size: 48,
26 | color: Theme.of(context).colorScheme.outline,
27 | ),
28 | const SizedBox(height: 16),
29 | Text(
30 | message ?? '暂无内容',
31 | style: Theme.of(context).textTheme.bodyLarge?.copyWith(
32 | color: Theme.of(context).colorScheme.outline,
33 | ),
34 | ),
35 | ],
36 | ),
37 | );
38 | }
39 | }
--------------------------------------------------------------------------------
/windows/runner/flutter_window.h:
--------------------------------------------------------------------------------
1 | #ifndef RUNNER_FLUTTER_WINDOW_H_
2 | #define RUNNER_FLUTTER_WINDOW_H_
3 |
4 | #include
5 | #include
6 |
7 | #include
8 |
9 | #include "win32_window.h"
10 |
11 | // A window that does nothing but host a Flutter view.
12 | class FlutterWindow : public Win32Window {
13 | public:
14 | // Creates a new FlutterWindow hosting a Flutter view running |project|.
15 | explicit FlutterWindow(const flutter::DartProject& project);
16 | virtual ~FlutterWindow();
17 |
18 | protected:
19 | // Win32Window:
20 | bool OnCreate() override;
21 | void OnDestroy() override;
22 | LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
23 | LPARAM const lparam) noexcept override;
24 |
25 | private:
26 | // The project to run.
27 | flutter::DartProject project_;
28 |
29 | // The Flutter instance hosted by this window.
30 | std::unique_ptr flutter_controller_;
31 | };
32 |
33 | #endif // RUNNER_FLUTTER_WINDOW_H_
34 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
15 |
18 |
19 |
--------------------------------------------------------------------------------
/lib/data/models/playlists_with_exist_statu/playlists_with_exist_statu.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'playlists_with_exist_statu.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$PlaylistsWithExistStatuImpl _$$PlaylistsWithExistStatuImplFromJson(
10 | Map json) =>
11 | _$PlaylistsWithExistStatuImpl(
12 | playlists: (json['playlists'] as List?)
13 | ?.map((e) => Playlist.fromJson(e as Map))
14 | .toList(),
15 | pagination: json['pagination'] == null
16 | ? null
17 | : Pagination.fromJson(json['pagination'] as Map),
18 | );
19 |
20 | Map _$$PlaylistsWithExistStatuImplToJson(
21 | _$PlaylistsWithExistStatuImpl instance) =>
22 | {
23 | 'playlists': instance.playlists,
24 | 'pagination': instance.pagination,
25 | };
26 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values-night/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
15 |
18 |
19 |
--------------------------------------------------------------------------------
/lib/widgets/work_grid/components/grid_loading.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:shimmer/shimmer.dart';
3 |
4 | class GridLoading extends StatelessWidget {
5 | const GridLoading({super.key});
6 |
7 | @override
8 | Widget build(BuildContext context) {
9 | return Shimmer.fromColors(
10 | baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
11 | highlightColor: Theme.of(context).colorScheme.surface,
12 | child: GridView.builder(
13 | padding: const EdgeInsets.all(16),
14 | gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
15 | crossAxisCount: 2,
16 | childAspectRatio: 0.75,
17 | crossAxisSpacing: 16,
18 | mainAxisSpacing: 16,
19 | ),
20 | itemCount: 6,
21 | itemBuilder: (context, index) {
22 | return Container(
23 | decoration: BoxDecoration(
24 | color: Colors.white,
25 | borderRadius: BorderRadius.circular(8),
26 | ),
27 | );
28 | },
29 | ),
30 | );
31 | }
32 | }
--------------------------------------------------------------------------------
/lib/data/models/works/language_edition.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'language_edition.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$LanguageEditionImpl _$$LanguageEditionImplFromJson(
10 | Map json) =>
11 | _$LanguageEditionImpl(
12 | lang: json['lang'] as String?,
13 | label: json['label'] as String?,
14 | workno: json['workno'] as String?,
15 | editionId: (json['edition_id'] as num?)?.toInt(),
16 | editionType: json['edition_type'] as String?,
17 | displayOrder: (json['display_order'] as num?)?.toInt(),
18 | );
19 |
20 | Map _$$LanguageEditionImplToJson(
21 | _$LanguageEditionImpl instance) =>
22 | {
23 | 'lang': instance.lang,
24 | 'label': instance.label,
25 | 'workno': instance.workno,
26 | 'edition_id': instance.editionId,
27 | 'edition_type': instance.editionType,
28 | 'display_order': instance.displayOrder,
29 | };
30 |
--------------------------------------------------------------------------------
/lib/widgets/common/tag_chip.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | class TagChip extends StatelessWidget {
4 | final String text;
5 | final Color? backgroundColor;
6 | final Color? textColor;
7 | final VoidCallback? onTap;
8 |
9 | const TagChip({
10 | super.key,
11 | required this.text,
12 | this.backgroundColor,
13 | this.textColor,
14 | this.onTap,
15 | });
16 |
17 | @override
18 | Widget build(BuildContext context) {
19 | return InkWell(
20 | onTap: onTap,
21 | borderRadius: BorderRadius.circular(16),
22 | child: Container(
23 | padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
24 | decoration: BoxDecoration(
25 | color: backgroundColor ?? Theme.of(context).colorScheme.surfaceContainerHighest,
26 | borderRadius: BorderRadius.circular(4),
27 | ),
28 | child: Text(
29 | text,
30 | style: Theme.of(context).textTheme.bodyMedium?.copyWith(
31 | color: textColor ?? Theme.of(context).colorScheme.onSurfaceVariant,
32 | fontSize: 13,
33 | ),
34 | ),
35 | ),
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/test/widget_test.dart:
--------------------------------------------------------------------------------
1 | // This is a basic Flutter widget test.
2 | //
3 | // To perform an interaction with a widget in your test, use the WidgetTester
4 | // utility in the flutter_test package. For example, you can send tap and scroll
5 | // gestures. You can also use WidgetTester to find child widgets in the widget
6 | // tree, read text, and verify that the values of widget properties are correct.
7 |
8 | import 'package:flutter/material.dart';
9 | import 'package:flutter_test/flutter_test.dart';
10 |
11 | import 'package:asmrapp/main.dart';
12 |
13 | void main() {
14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async {
15 | // Build our app and trigger a frame.
16 | await tester.pumpWidget(const MyApp());
17 |
18 | // Verify that our counter starts at 0.
19 | expect(find.text('0'), findsOneWidget);
20 | expect(find.text('1'), findsNothing);
21 |
22 | // Tap the '+' icon and trigger a frame.
23 | await tester.tap(find.byIcon(Icons.add));
24 | await tester.pump();
25 |
26 | // Verify that our counter has incremented.
27 | expect(find.text('0'), findsNothing);
28 | expect(find.text('1'), findsOneWidget);
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/macos/Flutter/GeneratedPluginRegistrant.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Generated file. Do not edit.
3 | //
4 |
5 | import FlutterMacOS
6 | import Foundation
7 |
8 | import audio_service
9 | import audio_session
10 | import just_audio
11 | import package_info_plus
12 | import path_provider_foundation
13 | import shared_preferences_foundation
14 | import sqflite_darwin
15 | import wakelock_plus
16 |
17 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
18 | AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
19 | AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
20 | JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
21 | FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
22 | PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
23 | SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
24 | SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
25 | WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
26 | }
27 |
--------------------------------------------------------------------------------
/macos/Runner/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIconFile
10 |
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | $(FLUTTER_BUILD_NAME)
21 | CFBundleVersion
22 | $(FLUTTER_BUILD_NUMBER)
23 | LSMinimumSystemVersion
24 | $(MACOSX_DEPLOYMENT_TARGET)
25 | NSHumanReadableCopyright
26 | $(PRODUCT_COPYRIGHT)
27 | NSMainNibFile
28 | MainMenu
29 | NSPrincipalClass
30 | NSApplication
31 |
32 |
33 |
--------------------------------------------------------------------------------
/lib/data/models/works/other_language_editions_in_db.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'other_language_editions_in_db.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$OtherLanguageEditionsInDbImpl _$$OtherLanguageEditionsInDbImplFromJson(
10 | Map json) =>
11 | _$OtherLanguageEditionsInDbImpl(
12 | id: (json['id'] as num?)?.toInt(),
13 | lang: json['lang'] as String?,
14 | title: json['title'] as String?,
15 | sourceId: json['source_id'] as String?,
16 | isOriginal: json['is_original'] as bool?,
17 | sourceType: json['source_type'] as String?,
18 | );
19 |
20 | Map _$$OtherLanguageEditionsInDbImplToJson(
21 | _$OtherLanguageEditionsInDbImpl instance) =>
22 | {
23 | 'id': instance.id,
24 | 'lang': instance.lang,
25 | 'title': instance.title,
26 | 'source_id': instance.sourceId,
27 | 'is_original': instance.isOriginal,
28 | 'source_type': instance.sourceType,
29 | };
30 |
--------------------------------------------------------------------------------
/lib/widgets/work_grid/components/grid_error.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | class GridError extends StatelessWidget {
4 | final String error;
5 | final VoidCallback? onRetry;
6 |
7 | const GridError({
8 | super.key,
9 | required this.error,
10 | this.onRetry,
11 | });
12 |
13 | @override
14 | Widget build(BuildContext context) {
15 | return Center(
16 | child: Column(
17 | mainAxisAlignment: MainAxisAlignment.center,
18 | children: [
19 | Icon(
20 | Icons.error_outline,
21 | size: 48,
22 | color: Theme.of(context).colorScheme.error,
23 | ),
24 | const SizedBox(height: 16),
25 | Text(
26 | error,
27 | style: Theme.of(context).textTheme.bodyLarge,
28 | textAlign: TextAlign.center,
29 | ),
30 | if (onRetry != null) ...[
31 | const SizedBox(height: 16),
32 | FilledButton.icon(
33 | onPressed: onRetry,
34 | icon: const Icon(Icons.refresh),
35 | label: const Text('重试'),
36 | ),
37 | ],
38 | ],
39 | ),
40 | );
41 | }
42 | }
--------------------------------------------------------------------------------
/lib/widgets/mini_player/mini_player_progress.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get_it/get_it.dart';
3 | import 'package:asmrapp/presentation/viewmodels/player_viewmodel.dart';
4 |
5 | class MiniPlayerProgress extends StatelessWidget {
6 | const MiniPlayerProgress({super.key});
7 |
8 | @override
9 | Widget build(BuildContext context) {
10 | final viewModel = GetIt.I();
11 | return ListenableBuilder(
12 | listenable: viewModel,
13 | builder: (context, _) {
14 | final position = viewModel.position?.inMilliseconds.toDouble() ?? 0.0;
15 | final duration = viewModel.duration?.inMilliseconds.toDouble() ?? 0.0;
16 | final progress = duration > 0 ? position / duration : 0.0;
17 |
18 | return SizedBox(
19 | height: 2,
20 | child: LinearProgressIndicator(
21 | value: progress,
22 | backgroundColor:
23 | Theme.of(context).colorScheme.surfaceContainerHighest,
24 | valueColor: AlwaysStoppedAnimation(
25 | Theme.of(context).colorScheme.primary,
26 | ),
27 | ),
28 | );
29 | },
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/android/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | ## Flutter wrapper
2 | -keep class io.flutter.app.** { *; }
3 | -keep class io.flutter.plugin.** { *; }
4 | -keep class io.flutter.util.** { *; }
5 | -keep class io.flutter.view.** { *; }
6 | -keep class io.flutter.** { *; }
7 | -keep class io.flutter.plugins.** { *; }
8 | -keep class io.flutter.plugin.editing.** { *; }
9 | -dontwarn io.flutter.embedding.**
10 | -keepattributes Signature
11 | -keepattributes *Annotation*
12 |
13 | ## Gson rules
14 | -keepattributes Signature
15 | -keepattributes *Annotation*
16 | -dontwarn sun.misc.**
17 |
18 | ## audio_service plugin
19 | -keep class com.ryanheise.audioservice.** { *; }
20 |
21 | ## Fix Play Store Split
22 | -keep class com.google.android.play.core.splitcompat.** { *; }
23 | -dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
24 |
25 | ## Fix for all Android classes that might be accessed via reflection
26 | -keep class androidx.lifecycle.DefaultLifecycleObserver
27 | -keep class androidx.lifecycle.LifecycleOwner
28 | -keepnames class androidx.lifecycle.LifecycleOwner
29 |
30 | ## Just Audio
31 | -keep class com.google.android.exoplayer2.** { *; }
32 | -dontwarn com.google.android.exoplayer2.**
33 |
34 | ## Cached network image
35 | -keep class com.bumptech.glide.** { *; }
--------------------------------------------------------------------------------
/lib/core/audio/utils/playlist_builder.dart:
--------------------------------------------------------------------------------
1 | import 'package:just_audio/just_audio.dart';
2 | import 'package:asmrapp/data/models/files/child.dart';
3 | import 'package:asmrapp/core/audio/cache/audio_cache_manager.dart';
4 |
5 | class PlaylistBuilder {
6 | static Future> buildAudioSources(List files) async {
7 | return await Future.wait(
8 | files.map((file) async {
9 | return AudioCacheManager.createAudioSource(file.mediaDownloadUrl!);
10 | })
11 | );
12 | }
13 |
14 | static Future updatePlaylist(
15 | ConcatenatingAudioSource playlist,
16 | List sources,
17 | ) async {
18 | await playlist.clear();
19 | await playlist.addAll(sources);
20 | }
21 |
22 | static Future setPlaylistSource({
23 | required AudioPlayer player,
24 | required ConcatenatingAudioSource playlist,
25 | required List files,
26 | required int initialIndex,
27 | required Duration initialPosition,
28 | }) async {
29 | final sources = await buildAudioSources(files);
30 | await updatePlaylist(playlist, sources);
31 |
32 | await player.setAudioSource(
33 | playlist,
34 | initialIndex: initialIndex,
35 | initialPosition: initialPosition,
36 | );
37 | }
38 | }
--------------------------------------------------------------------------------
/lib/core/theme/theme_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:shared_preferences/shared_preferences.dart';
3 |
4 | class ThemeController extends ChangeNotifier {
5 | static const String _themeKey = 'theme_mode';
6 | final SharedPreferences _prefs;
7 |
8 | ThemeController(this._prefs) {
9 | // 从持久化存储加载主题模式
10 | final savedThemeMode = _prefs.getString(_themeKey);
11 | if (savedThemeMode != null) {
12 | _themeMode = ThemeMode.values.firstWhere(
13 | (mode) => mode.toString() == savedThemeMode,
14 | orElse: () => ThemeMode.system,
15 | );
16 | }
17 | }
18 |
19 | ThemeMode _themeMode = ThemeMode.system;
20 |
21 | ThemeMode get themeMode => _themeMode;
22 |
23 | // 切换主题模式
24 | Future setThemeMode(ThemeMode mode) async {
25 | if (_themeMode == mode) return;
26 |
27 | _themeMode = mode;
28 | notifyListeners();
29 |
30 | // 保存到持久化存储
31 | await _prefs.setString(_themeKey, mode.toString());
32 | }
33 |
34 | // 切换到下一个主题模式
35 | Future toggleThemeMode() async {
36 | final modes = ThemeMode.values;
37 | final currentIndex = modes.indexOf(_themeMode);
38 | final nextIndex = (currentIndex + 1) % modes.length;
39 | await setThemeMode(modes[nextIndex]);
40 | }
41 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Yuro
2 |
3 | [English](README_en.md)
4 |
5 | 一个使用 Flutter 构建的 ASMR.ONE 客户端。
6 |
7 | ## 项目概述
8 |
9 | Yuro 旨在通过精美的动画和现代化的用户界面,提供流畅愉悦的 ASMR 聆听体验。
10 |
11 | ## 特性
12 |
13 | - 稳定的后台播放,再也不用担心杀后台了
14 | - 精美的动画效果
15 | - 流畅的播放体验
16 | - 简洁的UI设计
17 | - 全方位的智能缓存机制
18 | - 图片智能缓存:优化封面加载速度,告别重复加载
19 | - 字幕本地缓存:实现快速字幕匹配与加载
20 | - 音频文件缓存:减少重复下载,节省流量开销
21 | - 为服务器减轻压力
22 | - 智能的缓存策略确保资源高效利用
23 | - 懒加载机制避免无效请求
24 | - 合理的缓存清理机制平衡本地存储
25 |
26 | ## 开发准则
27 |
28 | 我们维护了一套完整的开发准则以确保代码质量和一致性:
29 | - [开发准则](docs/guidelines_zh.md)
30 |
31 | ## 项目结构
32 |
33 |
34 | lib/
35 | ├── core/ # 核心功能
36 | ├── data/ # 数据层
37 | ├── domain/ # 领域层
38 | ├── presentation/ # 表现层
39 | └── common/ # 通用功能
40 |
41 |
42 | ## 开始使用
43 |
44 | 1. 克隆仓库
45 | ```bash
46 | git clone [repository-url]
47 | ```
48 |
49 | 2. 安装依赖
50 | ```bash
51 | flutter pub get
52 | ```
53 |
54 | 3. 运行应用
55 | ```bash
56 | flutter run
57 | ```
58 |
59 | ## 功能特性
60 |
61 | - 现代化UI设计
62 | - 流畅的动画效果
63 | - ASMR 播放控制
64 | - 播放列表管理
65 | - 搜索功能
66 | - 收藏功能
67 |
68 | ## 贡献指南
69 |
70 | 在提交贡献之前,请阅读我们的[开发准则](docs/guidelines_zh.md)。
71 |
72 | ## 许可证
73 |
74 | 本项目采用 Creative Commons 非商业性使用-相同方式共享许可证 (CC BY-NC-SA) - 查看 [LICENSE](LICENSE) 文件了解详细信息。该许可证允许他人修改和分享您的作品,但禁止商业用途,要求保留署名,并要求对修改后的作品以相同的许可证发布。
75 |
--------------------------------------------------------------------------------
/lib/core/theme/app_theme.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'app_colors.dart';
3 |
4 | /// 应用主题配置
5 | class AppTheme {
6 | // 禁止实例化
7 | const AppTheme._();
8 |
9 | // 亮色主题
10 | static ThemeData get light => ThemeData(
11 | useMaterial3: true,
12 | brightness: Brightness.light,
13 | colorScheme: AppColors.lightColorScheme,
14 |
15 | // Card主题
16 | cardTheme: const CardTheme(
17 | elevation: 0,
18 | shape: RoundedRectangleBorder(
19 | borderRadius: BorderRadius.all(Radius.circular(12)),
20 | ),
21 | ),
22 |
23 | // AppBar主题
24 | appBarTheme: const AppBarTheme(
25 | centerTitle: true,
26 | elevation: 0,
27 | scrolledUnderElevation: 0,
28 | ),
29 | );
30 |
31 | // 暗色主题
32 | static ThemeData get dark => ThemeData(
33 | useMaterial3: true,
34 | brightness: Brightness.dark,
35 | colorScheme: AppColors.darkColorScheme,
36 |
37 | // Card主题
38 | cardTheme: const CardTheme(
39 | elevation: 0,
40 | shape: RoundedRectangleBorder(
41 | borderRadius: BorderRadius.all(Radius.circular(12)),
42 | ),
43 | ),
44 |
45 | // AppBar主题
46 | appBarTheme: const AppBarTheme(
47 | centerTitle: true,
48 | elevation: 0,
49 | scrolledUnderElevation: 0,
50 | ),
51 | );
52 | }
--------------------------------------------------------------------------------
/lib/widgets/work_card/work_card.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:asmrapp/data/models/works/work.dart';
3 | import 'components/work_cover_image.dart';
4 | import 'components/work_info_section.dart';
5 |
6 | class WorkCard extends StatelessWidget {
7 | final Work work;
8 | final VoidCallback? onTap;
9 |
10 | const WorkCard({
11 | super.key,
12 | required this.work,
13 | this.onTap,
14 | });
15 |
16 | @override
17 | Widget build(BuildContext context) {
18 | final isDark = Theme.of(context).brightness == Brightness.dark;
19 |
20 | return Card(
21 | clipBehavior: Clip.antiAlias,
22 | elevation: isDark ? 0 : 1,
23 | color: isDark
24 | ? Theme.of(context).colorScheme.surfaceVariant
25 | : Theme.of(context).colorScheme.surface,
26 | child: InkWell(
27 | onTap: onTap,
28 | child: Column(
29 | crossAxisAlignment: CrossAxisAlignment.start,
30 | children: [
31 | WorkCoverImage(
32 | imageUrl: work.mainCoverUrl ?? '',
33 | workId: work.id ?? 0,
34 | sourceId: work.sourceId ?? '',
35 | ),
36 | Expanded(
37 | child: WorkInfoSection(work: work),
38 | ),
39 | ],
40 | ),
41 | ),
42 | );
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | asmrapp
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/lib/core/theme/app_colors.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | /// 应用颜色配置
4 | class AppColors {
5 | // 禁止实例化
6 | const AppColors._();
7 |
8 | // 亮色主题颜色
9 | static const ColorScheme lightColorScheme = ColorScheme.light(
10 | // 基础色调
11 | primary: Color(0xFF6750A4),
12 | onPrimary: Colors.white,
13 |
14 | // 表面颜色
15 | surface: Colors.white,
16 | surfaceVariant: Color(0xFFF4F4F4),
17 | onSurface: Colors.black87,
18 | surfaceContainerHighest: Color(0xFFE6E6E6),
19 |
20 | // 背景颜色
21 | background: Colors.white,
22 | onBackground: Colors.black87,
23 |
24 | // 错误状态颜色
25 | error: Color(0xFFB3261E),
26 | errorContainer: Color(0xFFF9DEDC),
27 | onError: Colors.white,
28 | );
29 |
30 | // 暗色主题颜色
31 | static const ColorScheme darkColorScheme = ColorScheme.dark(
32 | // 基础色调
33 | primary: Color(0xFFD0BCFF),
34 | onPrimary: Color(0xFF381E72),
35 |
36 | // 表面颜色
37 | surface: Color(0xFF1C1B1F),
38 | surfaceVariant: Color(0xFF2B2930),
39 | onSurface: Colors.white,
40 | surfaceContainerHighest: Color(0xFF2B2B2B),
41 |
42 | // 背景颜色
43 | background: Color(0xFF1C1B1F),
44 | onBackground: Colors.white,
45 |
46 | // 错误状态颜色
47 | error: Color(0xFFF2B8B5),
48 | errorContainer: Color(0xFF8C1D18),
49 | onError: Color(0xFF601410),
50 | );
51 | }
--------------------------------------------------------------------------------
/lib/data/services/auth_service.dart:
--------------------------------------------------------------------------------
1 | import 'package:asmrapp/data/models/auth/auth_resp/auth_resp.dart';
2 | import 'package:dio/dio.dart';
3 | import '../../utils/logger.dart';
4 |
5 | class AuthService {
6 | final Dio _dio;
7 |
8 | AuthService()
9 | : _dio = Dio(BaseOptions(
10 | baseUrl: 'https://api.asmr.one/api',
11 | ));
12 |
13 | Future login(String name, String password) async {
14 | try {
15 | AppLogger.info('开始登录请求: name=$name');
16 | final response = await _dio.post('/auth/me',
17 | data: {
18 | 'name': name,
19 | 'password': password,
20 | },
21 | );
22 |
23 | AppLogger.info('收到登录响应: statusCode=${response.statusCode}');
24 | AppLogger.info('响应数据: ${response.data}');
25 |
26 | if (response.statusCode == 200) {
27 | final authResp = AuthResp.fromJson(response.data);
28 | AppLogger.info('登录成功: username=${authResp.user?.name}, group=${authResp.user?.group}');
29 | return authResp;
30 | }
31 |
32 | throw Exception('登录失败: ${response.statusCode}');
33 | } on DioException catch (e) {
34 | AppLogger.error('登录请求失败', e);
35 | AppLogger.error('错误详情: ${e.response?.data}');
36 | throw Exception('网络请求失败: ${e.message}');
37 | } catch (e) {
38 | AppLogger.error('登录失败', e);
39 | throw Exception('登录失败: $e');
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/lib/core/audio/storage/playback_state_repository.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 | import 'package:shared_preferences/shared_preferences.dart';
3 | import 'package:asmrapp/utils/logger.dart';
4 | import 'package:asmrapp/data/models/playback/playback_state.dart';
5 | import 'i_playback_state_repository.dart';
6 |
7 | class PlaybackStateRepository implements IPlaybackStateRepository {
8 | static const _key = 'last_playback_state';
9 | final SharedPreferences _prefs;
10 |
11 | PlaybackStateRepository(this._prefs);
12 |
13 | @override
14 | Future saveState(PlaybackState state) async {
15 | try {
16 | final json = state.toJson();
17 | final data = jsonEncode(json);
18 | await _prefs.setString(_key, data);
19 | AppLogger.debug('播放状态已保存');
20 | } catch (e) {
21 | AppLogger.error('保存播放状态失败', e);
22 | rethrow;
23 | }
24 | }
25 |
26 | @override
27 | Future loadState() async {
28 | try {
29 | final data = _prefs.getString(_key);
30 | if (data == null) {
31 | AppLogger.debug('没有找到保存的播放状态');
32 | return null;
33 | }
34 |
35 | final json = jsonDecode(data) as Map;
36 | final state = PlaybackState.fromJson(json);
37 | AppLogger.debug('播放状态已加载');
38 | return state;
39 | } catch (e) {
40 | AppLogger.error('加载播放状态失败', e);
41 | return null;
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/lib/core/audio/events/playback_event_hub.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart/rxdart.dart';
2 | import './playback_event.dart';
3 |
4 | class PlaybackEventHub {
5 | // 统一的事件流,处理所有类型的事件
6 | final _eventSubject = PublishSubject();
7 |
8 | // 分类后的特定事件流
9 | late final Stream playbackState = _eventSubject
10 | .whereType()
11 | .distinct();
12 |
13 | late final Stream trackChange = _eventSubject
14 | .whereType();
15 |
16 | late final Stream contextChange = _eventSubject
17 | .whereType();
18 |
19 | late final Stream playbackProgress = _eventSubject
20 | .whereType()
21 | .distinct((prev, next) => prev.position == next.position);
22 |
23 | late final Stream errors = _eventSubject
24 | .whereType();
25 |
26 | // 添加新的事件流
27 | late final Stream initialState = _eventSubject
28 | .whereType();
29 |
30 | late final Stream requestInitialState = _eventSubject
31 | .whereType();
32 |
33 | // 发送事件
34 | void emit(PlaybackEvent event) => _eventSubject.add(event);
35 |
36 | // 资源释放
37 | void dispose() => _eventSubject.close();
38 | }
--------------------------------------------------------------------------------
/lib/data/repositories/auth_repository.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 | import 'package:shared_preferences/shared_preferences.dart';
3 | import 'package:asmrapp/data/models/auth/auth_resp/auth_resp.dart';
4 | import 'package:asmrapp/utils/logger.dart';
5 |
6 | class AuthRepository {
7 | static const _authDataKey = 'auth_data';
8 | final SharedPreferences _prefs;
9 |
10 | AuthRepository(this._prefs);
11 |
12 | Future saveAuthData(AuthResp authData) async {
13 | try {
14 | final jsonStr = json.encode(authData.toJson());
15 | await _prefs.setString(_authDataKey, jsonStr);
16 | AppLogger.info('保存认证数据成功');
17 | } catch (e) {
18 | AppLogger.error('保存认证数据失败', e);
19 | rethrow;
20 | }
21 | }
22 |
23 | Future getAuthData() async {
24 | try {
25 | final jsonStr = _prefs.getString(_authDataKey);
26 | if (jsonStr == null) return null;
27 |
28 | final authData = AuthResp.fromJson(json.decode(jsonStr));
29 | AppLogger.info('读取认证数据成功: ${authData.user?.name}');
30 | return authData;
31 | } catch (e) {
32 | AppLogger.error('读取认证数据失败', e);
33 | return null;
34 | }
35 | }
36 |
37 | Future clearAuthData() async {
38 | try {
39 | await _prefs.remove(_authDataKey);
40 | AppLogger.info('清除认证数据成功');
41 | } catch (e) {
42 | AppLogger.error('清除认证数据失败', e);
43 | rethrow;
44 | }
45 | }
46 | }
--------------------------------------------------------------------------------
/lib/data/models/works/translation_bonus_lang.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'translation_bonus_lang.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$TranslationBonusLangImpl _$$TranslationBonusLangImplFromJson(
10 | Map json) =>
11 | _$TranslationBonusLangImpl(
12 | price: (json['price'] as num?)?.toInt(),
13 | status: json['status'] as String?,
14 | priceTax: (json['price_tax'] as num?)?.toInt(),
15 | childCount: (json['child_count'] as num?)?.toInt(),
16 | priceInTax: (json['price_in_tax'] as num?)?.toInt(),
17 | recipientMax: (json['recipient_max'] as num?)?.toInt(),
18 | recipientAvailableCount:
19 | (json['recipient_available_count'] as num?)?.toInt(),
20 | );
21 |
22 | Map _$$TranslationBonusLangImplToJson(
23 | _$TranslationBonusLangImpl instance) =>
24 | {
25 | 'price': instance.price,
26 | 'status': instance.status,
27 | 'price_tax': instance.priceTax,
28 | 'child_count': instance.childCount,
29 | 'price_in_tax': instance.priceInTax,
30 | 'recipient_max': instance.recipientMax,
31 | 'recipient_available_count': instance.recipientAvailableCount,
32 | };
33 |
--------------------------------------------------------------------------------
/lib/presentation/layouts/work_layout_strategy.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:asmrapp/data/models/works/work.dart';
3 | import 'package:asmrapp/presentation/layouts/work_layout_config.dart';
4 |
5 | /// 作品布局策略
6 | class WorkLayoutStrategy {
7 | const WorkLayoutStrategy();
8 |
9 | /// 获取设备类型
10 | DeviceType _getDeviceType(BuildContext context) {
11 | return DeviceType.fromWidth(MediaQuery.of(context).size.width);
12 | }
13 |
14 | /// 获取每行的列数
15 | int getColumnsCount(BuildContext context) {
16 | return WorkLayoutConfig.getColumnsCount(_getDeviceType(context));
17 | }
18 |
19 | /// 获取行间距
20 | double getRowSpacing(BuildContext context) {
21 | return WorkLayoutConfig.getSpacing(_getDeviceType(context));
22 | }
23 |
24 | /// 获取列间距
25 | double getColumnSpacing(BuildContext context) {
26 | return WorkLayoutConfig.getSpacing(_getDeviceType(context));
27 | }
28 |
29 | /// 获取内边距
30 | EdgeInsets getPadding(BuildContext context) {
31 | return WorkLayoutConfig.getPadding(_getDeviceType(context));
32 | }
33 |
34 | /// 将作品列表分组为行
35 | List> groupWorksIntoRows(List works, int columnsCount) {
36 | final List> rows = [];
37 | for (var i = 0; i < works.length; i += columnsCount) {
38 | final end = i + columnsCount;
39 | rows.add(works.sublist(i, end > works.length ? works.length : end));
40 | }
41 | return rows;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/windows/runner/main.cpp:
--------------------------------------------------------------------------------
1 | #include
2 | #include
3 | #include
4 |
5 | #include "flutter_window.h"
6 | #include "utils.h"
7 |
8 | int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
9 | _In_ wchar_t *command_line, _In_ int show_command) {
10 | // Attach to console when present (e.g., 'flutter run') or create a
11 | // new console when running with a debugger.
12 | if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
13 | CreateAndAttachConsole();
14 | }
15 |
16 | // Initialize COM, so that it is available for use in the library and/or
17 | // plugins.
18 | ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
19 |
20 | flutter::DartProject project(L"data");
21 |
22 | std::vector command_line_arguments =
23 | GetCommandLineArguments();
24 |
25 | project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
26 |
27 | FlutterWindow window(project);
28 | Win32Window::Point origin(10, 10);
29 | Win32Window::Size size(1280, 720);
30 | if (!window.Create(L"asmrapp", origin, size)) {
31 | return EXIT_FAILURE;
32 | }
33 | window.SetQuitOnClose(true);
34 |
35 | ::MSG msg;
36 | while (::GetMessage(&msg, nullptr, 0, 0)) {
37 | ::TranslateMessage(&msg);
38 | ::DispatchMessage(&msg);
39 | }
40 |
41 | ::CoUninitialize();
42 | return EXIT_SUCCESS;
43 | }
44 |
--------------------------------------------------------------------------------
/lib/widgets/work_row.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:asmrapp/data/models/works/work.dart';
3 | import 'package:asmrapp/widgets/work_card/work_card.dart';
4 |
5 | class WorkRow extends StatelessWidget {
6 | final List works;
7 | final void Function(Work work)? onWorkTap;
8 | final double spacing;
9 |
10 | const WorkRow({
11 | super.key,
12 | required this.works,
13 | this.onWorkTap,
14 | this.spacing = 8.0,
15 | });
16 |
17 | @override
18 | Widget build(BuildContext context) {
19 | return IntrinsicHeight(
20 | child: Row(
21 | crossAxisAlignment: CrossAxisAlignment.stretch,
22 | children: [
23 | // 第一个卡片
24 | Expanded(
25 | child: works.isNotEmpty
26 | ? WorkCard(
27 | work: works[0],
28 | onTap: onWorkTap != null ? () => onWorkTap!(works[0]) : null,
29 | )
30 | : const SizedBox.shrink(),
31 | ),
32 | SizedBox(width: spacing),
33 | // 第二个卡片或占位符
34 | Expanded(
35 | child: works.length > 1
36 | ? WorkCard(
37 | work: works[1],
38 | onTap: onWorkTap != null ? () => onWorkTap!(works[1]) : null,
39 | )
40 | : const SizedBox.shrink(), // 空占位符,保持两列布局
41 | ),
42 | ],
43 | ),
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/lib/data/models/files/child.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'child.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$ChildImpl _$$ChildImplFromJson(Map json) => _$ChildImpl(
10 | type: json['type'] as String?,
11 | title: json['title'] as String?,
12 | children: (json['children'] as List?)
13 | ?.map((e) => Child.fromJson(e as Map))
14 | .toList(),
15 | hash: json['hash'] as String?,
16 | work: json['work'] == null
17 | ? null
18 | : Work.fromJson(json['work'] as Map),
19 | workTitle: json['workTitle'] as String?,
20 | mediaStreamUrl: json['mediaStreamUrl'] as String?,
21 | mediaDownloadUrl: json['mediaDownloadUrl'] as String?,
22 | size: (json['size'] as num?)?.toInt(),
23 | );
24 |
25 | Map _$$ChildImplToJson(_$ChildImpl instance) =>
26 | {
27 | 'type': instance.type,
28 | 'title': instance.title,
29 | 'children': instance.children,
30 | 'hash': instance.hash,
31 | 'work': instance.work,
32 | 'workTitle': instance.workTitle,
33 | 'mediaStreamUrl': instance.mediaStreamUrl,
34 | 'mediaDownloadUrl': instance.mediaDownloadUrl,
35 | 'size': instance.size,
36 | };
37 |
--------------------------------------------------------------------------------
/lib/screens/docs/main_screen.md:
--------------------------------------------------------------------------------
1 | # 应用架构说明
2 |
3 | ## MainScreen 架构
4 |
5 | ### 概述
6 | MainScreen 采用集中式的状态管理架构,作为应用的主要页面容器,它负责:
7 | 1. 管理所有主要页面的 ViewModel
8 | 2. 提供统一的状态管理入口
9 | 3. 确保 ViewModel 的单一实例
10 |
11 | ### 核心原则
12 |
13 | 1. **ViewModel 单一实例**
14 | - 所有页面的 ViewModel 都在 MainScreen 中初始化
15 | - 子页面通过 Provider 获取 ViewModel,不创建自己的实例
16 | - 确保状态的一致性和可预测性
17 |
18 | 2. **状态提供机制**
19 | - 使用 MultiProvider 在顶层提供所有 ViewModel
20 | - 子页面使用 context.read 或 Provider.of 获取 ViewModel
21 | - 避免重复创建 ViewModel 实例
22 |
23 | 3. **生命周期管理**
24 | - MainScreen 负责 ViewModel 的创建和销毁
25 | - 在 initState 中初始化所有 ViewModel
26 | - 在 dispose 中释放所有资源
27 |
28 | ### 子页面开发指南
29 |
30 | 1. **ViewModel 访问** ```dart
31 | // 推荐使用 context.read 获取 ViewModel
32 | final viewModel = context.read();
33 |
34 | // 或者使用 Provider.of(效果相同)
35 | final viewModel = Provider.of(context, listen: false); ```
36 |
37 | 2. **状态监听** ```dart
38 | // 使用 Consumer 监听状态变化
39 | Consumer(
40 | builder: (context, viewModel, child) {
41 | // 使用 viewModel 的状态
42 | },
43 | ) ```
44 |
45 | 3. **注意事项**
46 | - 不要在子页面中创建新的 ViewModel 实例
47 | - 使用 AutomaticKeepAliveClientMixin 保持页面状态
48 | - 在 initState 中进行必要的初始化
49 |
50 | ### 常见问题
51 |
52 | 1. **重复实例问题**
53 | - 症状:状态更新不生效
54 | - 原因:子页面创建了新的 ViewModel 实例
55 | - 解决:使用 MainScreen 提供的 ViewModel
56 |
57 | 2. **状态同步问题**
58 | - 症状:不同页面状态不同步
59 | - 原因:使用了多个 ViewModel 实例
60 | - 解决:确保使用 MainScreen 提供的单一实例
--------------------------------------------------------------------------------
/lib/widgets/work_grid.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:asmrapp/data/models/works/work.dart';
3 | import 'package:asmrapp/widgets/work_row.dart';
4 | import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart';
5 |
6 | class WorkGrid extends StatelessWidget {
7 | final List works;
8 | final void Function(Work work)? onWorkTap;
9 | final WorkLayoutStrategy layoutStrategy;
10 |
11 | const WorkGrid({
12 | super.key,
13 | required this.works,
14 | this.onWorkTap,
15 | this.layoutStrategy = const WorkLayoutStrategy(),
16 | });
17 |
18 | @override
19 | Widget build(BuildContext context) {
20 | final columnsCount = layoutStrategy.getColumnsCount(context);
21 | final rows = layoutStrategy.groupWorksIntoRows(works, columnsCount);
22 | final rowSpacing = layoutStrategy.getRowSpacing(context);
23 | final columnSpacing = layoutStrategy.getColumnSpacing(context);
24 |
25 | return SliverList(
26 | delegate: SliverChildBuilderDelegate(
27 | (context, index) {
28 | if (index >= rows.length) return null;
29 | return Padding(
30 | padding: EdgeInsets.only(
31 | bottom: index < rows.length - 1 ? rowSpacing : 0),
32 | child: WorkRow(
33 | works: rows[index],
34 | onWorkTap: onWorkTap,
35 | spacing: columnSpacing,
36 | ),
37 | );
38 | },
39 | childCount: rows.length,
40 | ),
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/docs/architecture.md:
--------------------------------------------------------------------------------
1 | # ASMR Music App 架构设计
2 |
3 | ## 目录结构
4 |
5 |
6 | lib/
7 | ├── main.dart # 应用程序入口
8 | ├── screens/ # 页面
9 | │ ├── home_screen.dart # 主页(音乐列表)
10 | │ ├── player_screen.dart # 播放页面
11 | │ └── detail_screen.dart # 详情页面
12 | ├── widgets/ # 可重用组件
13 | │ └── drawer_menu.dart # 侧滑菜单
14 | └── models/ # 数据模型(待添加)
15 | └── music.dart # 音乐模型(待添加)
16 |
17 |
18 | ## 主要功能模块
19 |
20 | 1. 主页 (HomeScreen)
21 | - 显示音乐列表
22 | - 搜索功能
23 | - 侧滑菜单访问
24 |
25 | 2. 播放页 (PlayerScreen)
26 | - 音乐播放控制
27 | - 进度条
28 | - 音量控制
29 |
30 | 3. 详情页 (DetailScreen)
31 | - 显示音乐详细信息
32 | - 评论功能(待实现)
33 | - 收藏功能(待实现)
34 |
35 | 4. 侧滑菜单 (DrawerMenu)
36 | - 主页导航
37 | - 收藏列表
38 | - 设置页面
39 |
40 | ## 技术栈
41 |
42 | - Flutter SDK
43 | - Material Design 3
44 | - 路由管理: Flutter 内置导航
45 | - 状态管理: 待定
46 |
47 | ## 开发计划
48 |
49 | 1. 第一阶段:基础框架搭建
50 | - [x] 创建基本页面结构
51 | - [x] 实现页面导航
52 | - [x] 设计侧滑菜单
53 |
54 | 2. 第二阶段:UI 实现
55 | - [ ] 设计并实现音乐列表
56 | - [ ] 设计并实现播放器界面
57 | - [ ] 设计并实现详情页面
58 |
59 | 3. 第三阶段:功能实现
60 | - [ ] 音乐播放功能
61 | - [ ] 搜索功能
62 | - [ ] 收藏功能
63 |
64 | 4. 第四阶段:优化
65 | - [ ] 性能优化
66 | - [ ] UI/UX 改进
67 | - [ ] 代码重构
68 |
69 | ## 注意事项
70 |
71 | 1. 代码规范
72 | - 使用 const 构造函数
73 | - 遵循 Flutter 官方代码风格
74 | - 添加必要的代码注释
75 |
76 | 2. 性能考虑
77 | - 合理使用 StatelessWidget 和 StatefulWidget
78 | - 避免不必要的重建
79 | - 图片资源优化
80 |
81 | 3. 用户体验
82 | - 添加加载状态提示
83 | - 错误处理和提示
84 | - 合理的动画过渡
85 |
--------------------------------------------------------------------------------
/lib/widgets/lyrics/components/lyric_line.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:asmrapp/core/audio/models/subtitle.dart';
3 |
4 | class LyricLine extends StatelessWidget {
5 | final Subtitle subtitle;
6 | final bool isActive;
7 | final double opacity;
8 | final VoidCallback? onTap;
9 |
10 | const LyricLine({
11 | super.key,
12 | required this.subtitle,
13 | this.isActive = false,
14 | this.opacity = 1.0,
15 | this.onTap,
16 | });
17 |
18 | @override
19 | Widget build(BuildContext context) {
20 | return Center(
21 | child: AnimatedOpacity(
22 | duration: const Duration(milliseconds: 300),
23 | opacity: opacity,
24 | child: GestureDetector(
25 | behavior: HitTestBehavior.translucent,
26 | onTap: onTap,
27 | child: Padding(
28 | padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
29 | child: Text(
30 | subtitle.text,
31 | style: Theme.of(context).textTheme.bodyLarge?.copyWith(
32 | fontSize: 20,
33 | height: 1.3,
34 | color: isActive
35 | ? Theme.of(context).colorScheme.primary
36 | : Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
37 | fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
38 | ),
39 | textAlign: TextAlign.center,
40 | ),
41 | ),
42 | ),
43 | ),
44 | );
45 | }
46 | }
--------------------------------------------------------------------------------
/lib/data/models/playlists_with_exist_statu/playlist.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'playlist.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$PlaylistImpl _$$PlaylistImplFromJson(Map json) =>
10 | _$PlaylistImpl(
11 | id: json['id'] as String?,
12 | userName: json['user_name'] as String?,
13 | privacy: (json['privacy'] as num?)?.toInt(),
14 | locale: json['locale'] as String?,
15 | playbackCount: (json['playback_count'] as num?)?.toInt(),
16 | name: json['name'] as String?,
17 | description: json['description'] as String?,
18 | createdAt: json['created_at'] as String?,
19 | updatedAt: json['updated_at'] as String?,
20 | worksCount: (json['works_count'] as num?)?.toInt(),
21 | exist: json['exist'] as bool?,
22 | );
23 |
24 | Map _$$PlaylistImplToJson(_$PlaylistImpl instance) =>
25 | {
26 | 'id': instance.id,
27 | 'user_name': instance.userName,
28 | 'privacy': instance.privacy,
29 | 'locale': instance.locale,
30 | 'playback_count': instance.playbackCount,
31 | 'name': instance.name,
32 | 'description': instance.description,
33 | 'created_at': instance.createdAt,
34 | 'updated_at': instance.updatedAt,
35 | 'works_count': instance.worksCount,
36 | 'exist': instance.exist,
37 | };
38 |
--------------------------------------------------------------------------------
/macos/Podfile:
--------------------------------------------------------------------------------
1 | platform :osx, '10.14'
2 |
3 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
4 | ENV['COCOAPODS_DISABLE_STATS'] = 'true'
5 |
6 | project 'Runner', {
7 | 'Debug' => :debug,
8 | 'Profile' => :release,
9 | 'Release' => :release,
10 | }
11 |
12 | def flutter_root
13 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
14 | unless File.exist?(generated_xcode_build_settings_path)
15 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
16 | end
17 |
18 | File.foreach(generated_xcode_build_settings_path) do |line|
19 | matches = line.match(/FLUTTER_ROOT\=(.*)/)
20 | return matches[1].strip if matches
21 | end
22 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
23 | end
24 |
25 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
26 |
27 | flutter_macos_podfile_setup
28 |
29 | target 'Runner' do
30 | use_frameworks!
31 | use_modular_headers!
32 |
33 | flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
34 | target 'RunnerTests' do
35 | inherit! :search_paths
36 | end
37 | end
38 |
39 | post_install do |installer|
40 | installer.pods_project.targets.each do |target|
41 | flutter_additional_macos_build_settings(target)
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/ios/Podfile:
--------------------------------------------------------------------------------
1 | # Uncomment this line to define a global platform for your project
2 | # platform :ios, '12.0'
3 |
4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true'
6 |
7 | project 'Runner', {
8 | 'Debug' => :debug,
9 | 'Profile' => :release,
10 | 'Release' => :release,
11 | }
12 |
13 | def flutter_root
14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
15 | unless File.exist?(generated_xcode_build_settings_path)
16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
17 | end
18 |
19 | File.foreach(generated_xcode_build_settings_path) do |line|
20 | matches = line.match(/FLUTTER_ROOT\=(.*)/)
21 | return matches[1].strip if matches
22 | end
23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
24 | end
25 |
26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
27 |
28 | flutter_ios_podfile_setup
29 |
30 | target 'Runner' do
31 | use_frameworks!
32 | use_modular_headers!
33 |
34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
35 | target 'RunnerTests' do
36 | inherit! :search_paths
37 | end
38 | end
39 |
40 | post_install do |installer|
41 | installer.pods_project.targets.each do |target|
42 | flutter_additional_ios_build_settings(target)
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/core/platform/wakelock_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:shared_preferences/shared_preferences.dart';
3 |
4 | import 'package:wakelock_plus/wakelock_plus.dart';
5 | import 'package:asmrapp/utils/logger.dart';
6 |
7 | class WakeLockController extends ChangeNotifier {
8 | static const _tag = 'WakeLock';
9 | static const _wakeLockKey = 'wakelock_enabled';
10 | final SharedPreferences _prefs;
11 | bool _enabled = false;
12 |
13 | WakeLockController(this._prefs) {
14 | _loadState();
15 | }
16 |
17 | bool get enabled => _enabled;
18 |
19 | Future _loadState() async {
20 | try {
21 | _enabled = _prefs.getBool(_wakeLockKey) ?? false;
22 | if (_enabled) {
23 | await WakelockPlus.enable();
24 | }
25 | notifyListeners();
26 | } catch (e) {
27 | AppLogger.error('[$_tag] 加载状态失败', e);
28 | }
29 | }
30 |
31 | Future toggle() async {
32 | try {
33 | _enabled = !_enabled;
34 | if (_enabled) {
35 | await WakelockPlus.enable();
36 | } else {
37 | await WakelockPlus.disable();
38 | }
39 | await _prefs.setBool(_wakeLockKey, _enabled);
40 | notifyListeners();
41 | } catch (e) {
42 | AppLogger.error('[$_tag] 切换状态失败', e);
43 | // 恢复状态
44 | _enabled = !_enabled;
45 | notifyListeners();
46 | }
47 | }
48 |
49 | Future dispose() async {
50 | try {
51 | await WakelockPlus.disable();
52 | } catch (e) {
53 | AppLogger.error('[$_tag] 释放失败', e);
54 | }
55 | super.dispose();
56 | }
57 | }
--------------------------------------------------------------------------------
/lib/widgets/detail/work_file_item.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:asmrapp/data/models/files/child.dart';
3 | import 'package:asmrapp/utils/logger.dart';
4 | import 'package:asmrapp/utils/file_size_formatter.dart';
5 |
6 | class WorkFileItem extends StatelessWidget {
7 | final Child file;
8 | final double indentation;
9 | final Function(Child file)? onFileTap;
10 |
11 | const WorkFileItem({
12 | super.key,
13 | required this.file,
14 | required this.indentation,
15 | this.onFileTap,
16 | });
17 |
18 | @override
19 | Widget build(BuildContext context) {
20 | final bool isAudio = file.type?.toLowerCase() == 'audio';
21 | final colorScheme = Theme.of(context).colorScheme;
22 |
23 | return Padding(
24 | padding: EdgeInsets.only(left: indentation),
25 | child: ListTile(
26 | title: Text(
27 | file.title ?? '',
28 | style: TextStyle(
29 | color: colorScheme.onSurface,
30 | ),
31 | ),
32 | subtitle: Text(
33 | FileSizeFormatter.format(file.size),
34 | style: TextStyle(
35 | color: colorScheme.onSurfaceVariant,
36 | ),
37 | ),
38 | leading: Icon(
39 | isAudio ? Icons.audio_file : Icons.insert_drive_file,
40 | color: isAudio ? Colors.green : Colors.blue,
41 | ),
42 | dense: true,
43 | onTap: isAudio ? () {
44 | AppLogger.debug('点击音频文件: ${file.title}');
45 | onFileTap?.call(file);
46 | } : null,
47 | ),
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "size" : "16x16",
5 | "idiom" : "mac",
6 | "filename" : "app_icon_16.png",
7 | "scale" : "1x"
8 | },
9 | {
10 | "size" : "16x16",
11 | "idiom" : "mac",
12 | "filename" : "app_icon_32.png",
13 | "scale" : "2x"
14 | },
15 | {
16 | "size" : "32x32",
17 | "idiom" : "mac",
18 | "filename" : "app_icon_32.png",
19 | "scale" : "1x"
20 | },
21 | {
22 | "size" : "32x32",
23 | "idiom" : "mac",
24 | "filename" : "app_icon_64.png",
25 | "scale" : "2x"
26 | },
27 | {
28 | "size" : "128x128",
29 | "idiom" : "mac",
30 | "filename" : "app_icon_128.png",
31 | "scale" : "1x"
32 | },
33 | {
34 | "size" : "128x128",
35 | "idiom" : "mac",
36 | "filename" : "app_icon_256.png",
37 | "scale" : "2x"
38 | },
39 | {
40 | "size" : "256x256",
41 | "idiom" : "mac",
42 | "filename" : "app_icon_256.png",
43 | "scale" : "1x"
44 | },
45 | {
46 | "size" : "256x256",
47 | "idiom" : "mac",
48 | "filename" : "app_icon_512.png",
49 | "scale" : "2x"
50 | },
51 | {
52 | "size" : "512x512",
53 | "idiom" : "mac",
54 | "filename" : "app_icon_512.png",
55 | "scale" : "1x"
56 | },
57 | {
58 | "size" : "512x512",
59 | "idiom" : "mac",
60 | "filename" : "app_icon_1024.png",
61 | "scale" : "2x"
62 | }
63 | ],
64 | "info" : {
65 | "version" : 1,
66 | "author" : "xcode"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/analysis_options.yaml:
--------------------------------------------------------------------------------
1 | # This file configures the analyzer, which statically analyzes Dart code to
2 | # check for errors, warnings, and lints.
3 | #
4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled
5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
6 | # invoked from the command line by running `flutter analyze`.
7 |
8 | # The following line activates a set of recommended lints for Flutter apps,
9 | # packages, and plugins designed to encourage good coding practices.
10 | include: package:flutter_lints/flutter.yaml
11 |
12 | linter:
13 | # The lint rules applied to this project can be customized in the
14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml`
15 | # included above or to enable additional rules. A list of all available lints
16 | # and their documentation is published at https://dart.dev/lints.
17 | #
18 | # Instead of disabling a lint rule for the entire project in the
19 | # section below, it can also be suppressed for a single line of code
20 | # or a specific dart file by using the `// ignore: name_of_lint` and
21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file
22 | # producing the lint.
23 | rules:
24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule
25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
26 |
27 | # Additional information about this file can be found at
28 | # https://dart.dev/guides/language/analysis-options
29 | analyzer:
30 | exclude:
31 | - "**/*.g.dart"
32 | - "**/*.freezed.dart"
33 | errors:
34 | invalid_annotation_target: ignore
--------------------------------------------------------------------------------
/lib/core/audio/utils/audio_error_handler.dart:
--------------------------------------------------------------------------------
1 | import 'package:asmrapp/utils/logger.dart';
2 |
3 | enum AudioErrorType {
4 | playback, // 播放错误
5 | playlist, // 播放列表错误
6 | state, // 状态错误
7 | context, // 上下文错误
8 | init, // 初始化错误
9 | }
10 |
11 | class AudioError implements Exception {
12 | final AudioErrorType type;
13 | final String message;
14 | final dynamic originalError;
15 |
16 | AudioError(this.type, this.message, [this.originalError]);
17 |
18 | @override
19 | String toString() => '$message${originalError != null ? ': $originalError' : ''}';
20 | }
21 |
22 | class AudioErrorHandler {
23 | static void handleError(
24 | AudioErrorType type,
25 | String operation,
26 | dynamic error, [
27 | StackTrace? stack,
28 | ]) {
29 | final message = _getErrorMessage(type, operation);
30 | AppLogger.error(message, error, stack);
31 | }
32 |
33 | static Never throwError(
34 | AudioErrorType type,
35 | String operation,
36 | dynamic error,
37 | ) {
38 | final message = _getErrorMessage(type, operation);
39 | throw AudioError(type, message, error);
40 | }
41 |
42 | static String _getErrorMessage(AudioErrorType type, String operation) {
43 | switch (type) {
44 | case AudioErrorType.playback:
45 | return '播放操作失败: $operation';
46 | case AudioErrorType.playlist:
47 | return '播放列表操作失败: $operation';
48 | case AudioErrorType.state:
49 | return '状态操作失败: $operation';
50 | case AudioErrorType.context:
51 | return '上下文操作失败: $operation';
52 | case AudioErrorType.init:
53 | return '初始化失败: $operation';
54 | }
55 | }
56 | }
--------------------------------------------------------------------------------
/lib/data/models/mark_lists/playlist.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'playlist.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$PlaylistImpl _$$PlaylistImplFromJson(Map json) =>
10 | _$PlaylistImpl(
11 | id: json['id'] as String?,
12 | userName: json['user_name'] as String?,
13 | privacy: (json['privacy'] as num?)?.toInt(),
14 | locale: json['locale'] as String?,
15 | playbackCount: (json['playback_count'] as num?)?.toInt(),
16 | name: json['name'] as String?,
17 | description: json['description'] as String?,
18 | createdAt: json['created_at'] as String?,
19 | updatedAt: json['updated_at'] as String?,
20 | worksCount: (json['works_count'] as num?)?.toInt(),
21 | latestWorkId: json['latestWorkID'],
22 | mainCoverUrl: json['mainCoverUrl'] as String?,
23 | );
24 |
25 | Map _$$PlaylistImplToJson(_$PlaylistImpl instance) =>
26 | {
27 | 'id': instance.id,
28 | 'user_name': instance.userName,
29 | 'privacy': instance.privacy,
30 | 'locale': instance.locale,
31 | 'playback_count': instance.playbackCount,
32 | 'name': instance.name,
33 | 'description': instance.description,
34 | 'created_at': instance.createdAt,
35 | 'updated_at': instance.updatedAt,
36 | 'works_count': instance.worksCount,
37 | 'latestWorkID': instance.latestWorkId,
38 | 'mainCoverUrl': instance.mainCoverUrl,
39 | };
40 |
--------------------------------------------------------------------------------
/lib/data/models/my_lists/my_playlists/playlist.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'playlist.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$PlaylistImpl _$$PlaylistImplFromJson(Map json) =>
10 | _$PlaylistImpl(
11 | id: json['id'] as String?,
12 | userName: json['user_name'] as String?,
13 | privacy: (json['privacy'] as num?)?.toInt(),
14 | locale: json['locale'] as String?,
15 | playbackCount: (json['playback_count'] as num?)?.toInt(),
16 | name: json['name'] as String?,
17 | description: json['description'] as String?,
18 | createdAt: json['created_at'] as String?,
19 | updatedAt: json['updated_at'] as String?,
20 | worksCount: (json['works_count'] as num?)?.toInt(),
21 | latestWorkId: json['latestWorkID'],
22 | mainCoverUrl: json['mainCoverUrl'] as String?,
23 | );
24 |
25 | Map _$$PlaylistImplToJson(_$PlaylistImpl instance) =>
26 | {
27 | 'id': instance.id,
28 | 'user_name': instance.userName,
29 | 'privacy': instance.privacy,
30 | 'locale': instance.locale,
31 | 'playback_count': instance.playbackCount,
32 | 'name': instance.name,
33 | 'description': instance.description,
34 | 'created_at': instance.createdAt,
35 | 'updated_at': instance.updatedAt,
36 | 'works_count': instance.worksCount,
37 | 'latestWorkID': instance.latestWorkId,
38 | 'mainCoverUrl': instance.mainCoverUrl,
39 | };
40 |
--------------------------------------------------------------------------------
/lib/data/models/playback/playback_state.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'playback_state.dart';
4 |
5 | // **************************************************************************
6 | // JsonSerializableGenerator
7 | // **************************************************************************
8 |
9 | _$PlaybackStateImpl _$$PlaybackStateImplFromJson(Map json) =>
10 | _$PlaybackStateImpl(
11 | work: Work.fromJson(json['work'] as Map),
12 | files: Files.fromJson(json['files'] as Map),
13 | currentFile: Child.fromJson(json['currentFile'] as Map),
14 | playlist: (json['playlist'] as List)
15 | .map((e) => Child.fromJson(e as Map))
16 | .toList(),
17 | currentIndex: (json['currentIndex'] as num).toInt(),
18 | playMode: $enumDecode(_$PlayModeEnumMap, json['playMode']),
19 | position: (json['position'] as num).toInt(),
20 | timestamp: json['timestamp'] as String,
21 | );
22 |
23 | Map _$$PlaybackStateImplToJson(_$PlaybackStateImpl instance) =>
24 | {
25 | 'work': instance.work,
26 | 'files': instance.files,
27 | 'currentFile': instance.currentFile,
28 | 'playlist': instance.playlist,
29 | 'currentIndex': instance.currentIndex,
30 | 'playMode': _$PlayModeEnumMap[instance.playMode]!,
31 | 'position': instance.position,
32 | 'timestamp': instance.timestamp,
33 | };
34 |
35 | const _$PlayModeEnumMap = {
36 | PlayMode.single: 'single',
37 | PlayMode.loop: 'loop',
38 | PlayMode.sequence: 'sequence',
39 | };
40 |
--------------------------------------------------------------------------------
/ios/Runner/Base.lproj/Main.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |