diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml new file mode 100644 index 00000000..526ff382 --- /dev/null +++ b/.github/workflows/prbuild.yml @@ -0,0 +1,70 @@ +# This is a basic workflow to help you get started with Actions + +name: prbuild + +on: + pull_request: + branches: ["main"] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + + # This workflow contains a single job called "build" + build-and-release-android: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v3 + - name: Flutter action + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.13.0 + channel: stable + - name: Decode keystore + run: | + echo $ENCODED_KEYSTORE | base64 -di > android/app/keystore.jks + env: + ENCODED_KEYSTORE: ${{ secrets.KEYSTORE }} + + - run: flutter pub get + # 打包apk + - name: Collect Apks + run: flutter build apk --release --split-per-abi + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD}} + + # 发布安装包 + - name: Upload Artifact + uses: actions/upload-artifact@v3 + with: + path: "build/app/outputs/flutter-apk/app-*.apk" + + build-and-release-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.13.0 + channel: stable + - name: Install project dependencies + run: flutter pub get + - name: Build artifacts + run: flutter build windows --release + - name: Archive Release + uses: thedoctor0/zip-release@master + with: + type: "zip" + filename: Miru-${{github.ref_name}}-windows.zip + directory: build/windows/runner/Release + # 发布安装包 + - name: Upload Artifact + uses: actions/upload-artifact@v3 + with: + path: "build/windows/runner/Release/Miru-${{github.ref_name}}-windows.zip" diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 2cbc806a..fa329d67 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -112,7 +112,10 @@ "overview": "Overview", "cast": "Cast", "additional-info": "Additional Info", - "get-lastest-data-error": "Failed to get latest data" + "get-lastest-data-error": "Failed to get latest data", + "modify-tmdb-binding": "Modify TMDB Binding", + "no-tmdb-data": "No TMDB data matched, please bind the data yourself", + "tmdb-key-missing": "TMDB API Key missing, please fill in the settings" }, "video": { diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index b4c15942..4acb71db 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -101,7 +101,10 @@ "overview": "概览", "cast": "演员", "additional-info": "附加信息", - "get-lastest-data-error": "获取最新数据失败" + "get-lastest-data-error": "获取最新数据失败", + "modify-tmdb-binding": "修改 TMDB 绑定", + "no-tmdb-data": "未匹配到 TMDB 数据,请自行绑定数据", + "tmdb-key-missing": "TMDB API Key 丢失,请前往设置填写" }, "video": { diff --git a/lib/api/tmdb.dart b/lib/api/tmdb.dart index 67c696e6..c9897547 100644 --- a/lib/api/tmdb.dart +++ b/lib/api/tmdb.dart @@ -8,9 +8,11 @@ class TmdbApi { defaultLanguage: MiruStorage.getSetting(SettingKey.language), ); - static Future getDetail(String keyword, - {int page = 1}) async { - final result = await tmdb.v3.search.queryMulti( + static Future getDetailBySearch( + String keyword, { + int page = 1, + }) async { + final result = await search( keyword, page: page, ); @@ -19,20 +21,28 @@ class TmdbApi { if (results.isEmpty) { return null; } + return getDetail( + results.first["id"], + results.first["media_type"], + ); + } + + static Future getDetail( + int id, + String mediaType, + ) async { late Map data; - final mediaType = results[0]["media_type"]; if (mediaType == "movie") { data = await tmdb.v3.movies.getDetails( - results[0]["id"], + id, appendToResponse: "credits,images", ); } else { data = await tmdb.v3.tv.getDetails( - results[0]["id"], + id, appendToResponse: "credits,images", ); } - return tmdb_model.TMDBDetail( id: data["id"], mediaType: mediaType, @@ -69,7 +79,7 @@ class TmdbApi { ); } - static String? getImageUrl(String path) { - return tmdb.images.getUrl(path); + static String? getImageUrl(String path, {size = ImageSizes.ORIGINAL}) { + return tmdb.images.getUrl(path, size: size); } } diff --git a/lib/models/tmdb.dart b/lib/models/tmdb.dart index 6e645964..2303d2a3 100644 --- a/lib/models/tmdb.dart +++ b/lib/models/tmdb.dart @@ -9,6 +9,7 @@ class TMDB { @Index(unique: true) late int tmdbID; late String data; + late String mediaType; } @JsonSerializable() diff --git a/lib/models/tmdb.g.dart b/lib/models/tmdb.g.dart index 6a2e40c9..beb93939 100644 --- a/lib/models/tmdb.g.dart +++ b/lib/models/tmdb.g.dart @@ -22,8 +22,13 @@ const TMDBSchema = CollectionSchema( name: r'data', type: IsarType.string, ), - r'tmdbID': PropertySchema( + r'mediaType': PropertySchema( id: 1, + name: r'mediaType', + type: IsarType.string, + ), + r'tmdbID': PropertySchema( + id: 2, name: r'tmdbID', type: IsarType.long, ) @@ -63,6 +68,7 @@ int _tMDBEstimateSize( ) { var bytesCount = offsets.last; bytesCount += 3 + object.data.length * 3; + bytesCount += 3 + object.mediaType.length * 3; return bytesCount; } @@ -73,7 +79,8 @@ void _tMDBSerialize( Map> allOffsets, ) { writer.writeString(offsets[0], object.data); - writer.writeLong(offsets[1], object.tmdbID); + writer.writeString(offsets[1], object.mediaType); + writer.writeLong(offsets[2], object.tmdbID); } TMDB _tMDBDeserialize( @@ -85,7 +92,8 @@ TMDB _tMDBDeserialize( final object = TMDB(); object.data = reader.readString(offsets[0]); object.id = id; - object.tmdbID = reader.readLong(offsets[1]); + object.mediaType = reader.readString(offsets[1]); + object.tmdbID = reader.readLong(offsets[2]); return object; } @@ -99,6 +107,8 @@ P _tMDBDeserializeProp

( case 0: return (reader.readString(offset)) as P; case 1: + return (reader.readString(offset)) as P; + case 2: return (reader.readLong(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -523,6 +533,136 @@ extension TMDBQueryFilter on QueryBuilder { }); } + QueryBuilder mediaTypeEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'mediaType', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder mediaTypeGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'mediaType', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder mediaTypeLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'mediaType', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder mediaTypeBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'mediaType', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder mediaTypeStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'mediaType', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder mediaTypeEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'mediaType', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder mediaTypeContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'mediaType', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder mediaTypeMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'mediaType', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder mediaTypeIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'mediaType', + value: '', + )); + }); + } + + QueryBuilder mediaTypeIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'mediaType', + value: '', + )); + }); + } + QueryBuilder tmdbIDEqualTo(int value) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( @@ -593,6 +733,18 @@ extension TMDBQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByMediaType() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'mediaType', Sort.asc); + }); + } + + QueryBuilder sortByMediaTypeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'mediaType', Sort.desc); + }); + } + QueryBuilder sortByTmdbID() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'tmdbID', Sort.asc); @@ -631,6 +783,18 @@ extension TMDBQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByMediaType() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'mediaType', Sort.asc); + }); + } + + QueryBuilder thenByMediaTypeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'mediaType', Sort.desc); + }); + } + QueryBuilder thenByTmdbID() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'tmdbID', Sort.asc); @@ -652,6 +816,13 @@ extension TMDBQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByMediaType( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'mediaType', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByTmdbID() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'tmdbID'); @@ -672,6 +843,12 @@ extension TMDBQueryProperty on QueryBuilder { }); } + QueryBuilder mediaTypeProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'mediaType'); + }); + } + QueryBuilder tmdbIDProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'tmdbID'); diff --git a/lib/pages/detail/controller.dart b/lib/pages/detail/controller.dart index ef7f03d4..f855c390 100644 --- a/lib/pages/detail/controller.dart +++ b/lib/pages/detail/controller.dart @@ -1,6 +1,7 @@ // ignore_for_file: use_build_context_synchronously import 'dart:convert'; +import 'dart:io'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:fluent_ui/fluent_ui.dart' as fluent; @@ -9,6 +10,7 @@ import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:get/get.dart'; import 'package:miru_app/api/tmdb.dart'; import 'package:miru_app/models/index.dart'; +import 'package:miru_app/pages/detail/pages/tmdb_binding.dart'; import 'package:miru_app/pages/home/controller.dart'; import 'package:miru_app/pages/main/controller.dart'; import 'package:miru_app/pages/watch/view.dart'; @@ -49,6 +51,7 @@ class DetailPageController extends GetxController { ExtensionDetail? get detail => data.value; set detail(ExtensionDetail? value) => data.value = value; + TMDBDetail? get tmdbDetail => tmdb.value; set tmdbDetail(TMDBDetail? value) => tmdb.value = value; @@ -62,9 +65,12 @@ class DetailPageController extends GetxController { return bg; } - int _tmdbID = -1; MiruDetail? _miruDetail; + int _tmdbID = -1; + + final _flyoutController = fluent.FlyoutController(); + @override void onInit() { onRefresh(); @@ -90,6 +96,35 @@ class DetailPageController extends GetxController { }) ..launch(extension!.webSite + url); }, + ), + fluent.FlyoutTarget( + controller: _flyoutController, + child: fluent.IconButton( + icon: const Icon(fluent.FluentIcons.more), + onPressed: () { + _flyoutController.showFlyout(builder: (context) { + return SizedBox( + width: 300, + child: Card( + child: fluent.Column( + mainAxisSize: fluent.MainAxisSize.min, + children: [ + if (detail != null) + fluent.ListTile( + title: Text( + 'detail.modify-tmdb-binding'.i18n, + ), + onPressed: () { + router.pop(); + modifyTMDBBinding(); + }, + ) + ], + )), + ); + }); + }, + ), ) ]); super.onInit(); @@ -107,6 +142,38 @@ class DetailPageController extends GetxController { isLoading.value = false; } catch (e) { error.value = e.toString(); + rethrow; + } + } + + // 修改 tmdb 绑定 + modifyTMDBBinding() async { + // 判断是否有 key + if (MiruStorage.getSetting(SettingKey.tmdbKay) == "") { + showPlatformSnackbar( + context: cuurentContext, + content: 'detail.tmdb-key-missing'.i18n, + severity: fluent.InfoBarSeverity.error, + ); + return; + } + + dynamic data; + if (Platform.isAndroid) { + data = await Get.to(TMDBBinding( + title: detail!.title, + )); + } else { + data = await fluent.showDialog( + context: cuurentContext, + builder: (context) => TMDBBinding(title: detail!.title), + ); + } + if (data != null) { + await getRemoteTMDBDetail( + id: data['id'], + mediaType: data['media_type'], + ); } } @@ -160,25 +227,36 @@ class DetailPageController extends GetxController { if (detail == null) { return; } - getRemoteTMDBDetail(); - } - - getRemoteTMDBDetail() async { - tmdbDetail = await TmdbApi.getDetail(detail!.title); if (tmdbDetail == null) { + getRemoteTMDBDetail(); return; } - _tmdbID = tmdbDetail!.id; - DatabaseUtils.putTMDBDetail( + getRemoteTMDBDetail(id: tmdbDetail!.id, mediaType: tmdbDetail!.mediaType); + } + + getRemoteTMDBDetail({int? id, String? mediaType}) async { + if (id != null && mediaType != null) { + tmdbDetail = await TmdbApi.getDetail(id, mediaType); + if (tmdbDetail == null) { + return; + } + } else { + tmdbDetail = await TmdbApi.getDetailBySearch(detail!.title); + if (tmdbDetail == null) { + return; + } + } + _tmdbID = await DatabaseUtils.putTMDBDetail( tmdbDetail!.id, tmdbDetail!, + tmdbDetail!.mediaType, ); // 更新 id await DatabaseUtils.putMiruDetail( package, url, detail!, - tmdbID: tmdbDetail!.id, + tmdbID: _tmdbID, ); } diff --git a/lib/pages/detail/pages/tmdb_binding.dart b/lib/pages/detail/pages/tmdb_binding.dart new file mode 100644 index 00000000..77f2b1c8 --- /dev/null +++ b/lib/pages/detail/pages/tmdb_binding.dart @@ -0,0 +1,233 @@ +import 'dart:io'; + +import 'package:easy_refresh/easy_refresh.dart'; +import 'package:flutter/material.dart'; +import 'package:fluent_ui/fluent_ui.dart' as fluent; +import 'package:get/get.dart'; +import 'package:miru_app/api/tmdb.dart'; +import 'package:miru_app/router/router.dart'; +import 'package:miru_app/utils/i18n.dart'; +import 'package:miru_app/widgets/grid_item_tile.dart'; +import 'package:miru_app/widgets/infinite_scroller.dart'; +import 'package:miru_app/widgets/messenger.dart'; +import 'package:miru_app/widgets/platform_widget.dart'; +import 'package:miru_app/widgets/search_appbar.dart'; +import 'package:tmdb_api/tmdb_api.dart'; + +class TMDBBinding extends StatefulWidget { + const TMDBBinding({ + Key? key, + required this.title, + }) : super(key: key); + final String title; + + @override + State createState() => _TMDBBindingState(); +} + +class _TMDBBindingState extends State { + late final TextEditingController _textEditingController = + TextEditingController(text: widget.title); + final EasyRefreshController _easyRefreshController = EasyRefreshController(); + + int _page = 1; + final List _data = []; + bool _isLoading = true; + late String _keyWord = widget.title; + + Future _onRefresh() async { + setState(() { + _page = 1; + _data.clear(); + }); + await _onLoad(); + } + + Future _onLoad() async { + try { + _isLoading = true; + setState(() {}); + final data = await TmdbApi.search(_keyWord, page: _page); + final result = data["results"] as List; + if (result.isEmpty && mounted) { + showPlatformSnackbar( + context: context, + content: "common.no-more-data".i18n, + severity: fluent.InfoBarSeverity.warning, + ); + } + _data.addAll(result); + _page++; + } catch (e) { + // ignore: use_build_context_synchronously + showPlatformSnackbar( + context: context, + content: e.toString(), + severity: fluent.InfoBarSeverity.error, + ); + } finally { + _isLoading = false; + if (mounted) { + setState(() {}); + } + } + } + + _onSearch(String keyWord) { + _keyWord = keyWord; + if (Platform.isAndroid) { + _easyRefreshController.callRefresh(); + } else { + _onRefresh(); + } + } + + Widget _buildAndroid(BuildContext context) { + return Scaffold( + appBar: SearchAppBar( + title: 'detail.modify-tmdb-binding'.i18n, + onChanged: (value) { + if (value.isEmpty) { + _onSearch(value); + } + }, + onSubmitted: _onSearch, + textEditingController: _textEditingController, + ), + body: InfiniteScroller( + onRefresh: _onRefresh, + easyRefreshController: _easyRefreshController, + onLoad: _onLoad, + child: LayoutBuilder( + builder: (context, constraints) => GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth ~/ 120, + childAspectRatio: 0.7, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _data.length, + itemBuilder: (context, index) { + final item = _data[index]; + return GridItemTile( + title: item['name'] ?? item['original_title'], + cover: TmdbApi.getImageUrl( + item['poster_path'], + size: ImageSizes.POSTER_SIZE_HIGH, + ) ?? + '', + onTap: () { + Get.back( + result: { + 'id': item['id'], + 'media_type': item['media_type'], + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + Widget _buildDesktop(BuildContext context) { + return fluent.Card( + padding: const EdgeInsets.all(0), + backgroundColor: fluent.FluentTheme.of(context).micaBackgroundColor, + child: Column( + mainAxisSize: fluent.MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isLoading) + const SizedBox( + height: 4, + width: double.infinity, + child: fluent.ProgressBar(), + ) + else + const SizedBox(height: 4), + fluent.Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + fluent.IconButton( + icon: const Icon(fluent.FluentIcons.back), + onPressed: () { + router.pop(); + }, + ), + const SizedBox(width: 8), + Text( + 'detail.modify-tmdb-binding'.i18n, + style: fluent.FluentTheme.of(context).typography.subtitle, + ), + const Spacer(), + SizedBox( + width: 300, + child: fluent.TextBox( + onChanged: (value) { + if (value.isEmpty) { + _onSearch(value); + } + }, + onSubmitted: _onSearch, + controller: _textEditingController, + placeholder: 'search.hint-text'.i18n, + ), + ) + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: InfiniteScroller( + onRefresh: _onRefresh, + onLoad: _onLoad, + child: LayoutBuilder( + builder: ((context, constraints) => GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth ~/ 160, + childAspectRatio: 0.6, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _data.length, + itemBuilder: (context, index) { + final item = _data[index]; + return GridItemTile( + title: item['name'] ?? item['original_title'], + cover: TmdbApi.getImageUrl( + item['poster_path'], + size: ImageSizes.POSTER_SIZE_HIGH, + ) ?? + '', + onTap: () { + router.pop({ + 'id': item['id'], + 'media_type': item['media_type'], + }); + }, + ); + }, + )), + ), + ), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return PlatformBuildWidget( + androidBuilder: _buildAndroid, + desktopBuilder: _buildDesktop, + ); + } +} diff --git a/lib/pages/detail/view.dart b/lib/pages/detail/view.dart index be8370c3..88e202bf 100644 --- a/lib/pages/detail/view.dart +++ b/lib/pages/detail/view.dart @@ -5,6 +5,7 @@ import 'package:get/get.dart'; import 'package:miru_app/api/tmdb.dart'; import 'package:miru_app/models/extension.dart'; import 'package:miru_app/pages/detail/controller.dart'; +import 'package:miru_app/pages/detail/pages/tmdb_binding.dart'; import 'package:miru_app/pages/detail/pages/webview.dart'; import 'package:miru_app/pages/detail/widgets/detail_appbar_flexible_space.dart'; import 'package:miru_app/pages/detail/widgets/detail_appbar_title.dart'; @@ -109,6 +110,25 @@ class _DetailPageState extends State { ); }, icon: const Icon(Icons.public), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (context) { + return [ + if (c.detail != null) + PopupMenuItem( + child: ListTile( + title: Text( + 'detail.modify-tmdb-binding'.i18n, + ), + onTap: () { + Get.back(); + c.modifyTMDBBinding(); + }, + ), + ) + ]; + }, ) ], expandedHeight: 400, @@ -124,7 +144,21 @@ class _DetailPageState extends State { if (c.type == ExtensionType.bangumi) Obx(() { if (c.tmdbDetail == null || c.tmdbDetail!.casts.isEmpty) { - return const SizedBox(); + return Column( + children: [ + const SizedBox(height: 100), + Text('detail.no-tmdb-data'.i18n), + const SizedBox(height: 8), + FilledButton( + onPressed: () { + c.modifyTMDBBinding(); + }, + child: Text( + 'detail.modify-tmdb-binding'.i18n, + ), + ) + ], + ); } return ListView.builder( padding: const EdgeInsets.all(0), diff --git a/lib/pages/home/controller.dart b/lib/pages/home/controller.dart index f93f79f2..36c41a9f 100644 --- a/lib/pages/home/controller.dart +++ b/lib/pages/home/controller.dart @@ -1,7 +1,5 @@ import 'package:get/get.dart'; -import 'package:miru_app/models/extension.dart'; -import 'package:miru_app/models/favorite.dart'; -import 'package:miru_app/models/history.dart'; +import 'package:miru_app/models/index.dart'; import 'package:miru_app/utils/database.dart'; class HomePageController extends GetxController { diff --git a/lib/pages/home/widgets/home_favorites.dart b/lib/pages/home/widgets/home_favorites.dart index b7885e9e..a9660253 100644 --- a/lib/pages/home/widgets/home_favorites.dart +++ b/lib/pages/home/widgets/home_favorites.dart @@ -2,8 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:miru_app/models/extension.dart'; -import 'package:miru_app/models/favorite.dart'; +import 'package:miru_app/models/index.dart'; import 'package:miru_app/pages/home/pages/favorites_page.dart'; import 'package:miru_app/router/router.dart'; import 'package:miru_app/utils/extension.dart'; diff --git a/lib/pages/search/pages/search_extension.dart b/lib/pages/search/pages/search_extension.dart index aa80d597..c3e9d9e9 100644 --- a/lib/pages/search/pages/search_extension.dart +++ b/lib/pages/search/pages/search_extension.dart @@ -33,7 +33,7 @@ class _SearchExtensionPageState extends fluent.State { late String _keyWord = widget.keyWord ?? ''; final List _data = []; int _page = 1; - bool _isLoding = true; + bool _isLoading = true; final EasyRefreshController _easyRefreshController = EasyRefreshController(); Future _onRefresh() async { @@ -46,7 +46,7 @@ class _SearchExtensionPageState extends fluent.State { Future _onLoad() async { try { - _isLoding = true; + _isLoading = true; setState(() {}); late List data; if (_keyWord.isEmpty) { @@ -71,7 +71,7 @@ class _SearchExtensionPageState extends fluent.State { severity: fluent.InfoBarSeverity.error, ); } finally { - _isLoding = false; + _isLoading = false; if (mounted) { setState(() {}); } @@ -133,7 +133,7 @@ class _SearchExtensionPageState extends fluent.State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_isLoding) + if (_isLoading) const SizedBox( height: 4, width: double.infinity, diff --git a/lib/pages/search/widgets/search_all_extension.dart b/lib/pages/search/widgets/search_all_extension.dart index 6f821201..f05a4057 100644 --- a/lib/pages/search/widgets/search_all_extension.dart +++ b/lib/pages/search/widgets/search_all_extension.dart @@ -30,6 +30,7 @@ class _SearchAllExtSearchState extends State { if (widget.runtimeList.isEmpty) { return SizedBox( height: 300, + width: double.infinity, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/lib/utils/database.dart b/lib/utils/database.dart index a9310803..afe95256 100644 --- a/lib/utils/database.dart +++ b/lib/utils/database.dart @@ -277,19 +277,21 @@ class DatabaseUtils { static Future putTMDBDetail( int tmdbID, TMDBDetail tmdbDetail, + String mediaType, ) { return db.writeTxn( () => db.tMDBs.putByTmdbID( TMDB() ..data = jsonEncode(tmdbDetail.toJson()) - ..tmdbID = tmdbID, + ..tmdbID = tmdbID + ..mediaType = mediaType, ), ); } // 获取 TMDB 数据 static Future getTMDBDetail(int tmdbID) async { - final tmdb = await db.tMDBs.filter().tmdbIDEqualTo(tmdbID).findFirst(); + final tmdb = await db.tMDBs.filter().idEqualTo(tmdbID).findFirst(); if (tmdb == null) { return null; } diff --git a/lib/utils/miru_storage.dart b/lib/utils/miru_storage.dart index b71a1121..6e7c13c5 100644 --- a/lib/utils/miru_storage.dart +++ b/lib/utils/miru_storage.dart @@ -1,14 +1,26 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:isar/isar.dart'; import 'package:miru_app/models/index.dart'; import 'package:miru_app/utils/miru_directory.dart'; +import 'package:path/path.dart' as p; class MiruStorage { static late final Isar database; static late final Box settings; + static const int _lastDatabaseVersion = 2; + static late String _path; static ensureInitialized() async { - final path = await MiruDirectory.getDirectory; + _path = await MiruDirectory.getDirectory; + // 初始化设置 + await Hive.initFlutter(_path); + settings = await Hive.openBox("settings"); + await _initSettings(); + // 初始化数据库 database = await Isar.open( [ @@ -19,12 +31,76 @@ class MiruStorage { MiruDetailSchema, TMDBSchema, ], - directory: path, + directory: _path, ); - // 初始化设置 - await Hive.initFlutter(path); - settings = await Hive.openBox("settings"); - await _initSettings(); + + // 数据库升级 + await performMigrationIfNeeded(); + } + + static performMigrationIfNeeded() async { + final currentVersion = await getDatabaseVersion(); + debugPrint(currentVersion.toString()); + switch (currentVersion) { + case 1: + await migrateV1ToV2(); + break; + case 2: + return; + default: + throw Exception('Unknown version: $currentVersion'); + } + + // 更新到最新版本 + await settings.put(SettingKey.databaseVersion, _lastDatabaseVersion); + } + + static migrateV1ToV2() async { + // 获取所有的 TMDB 数据 + final tmdbList = await database.tMDBs.where().findAll(); + database.writeTxn(() async { + // 给所有的 TMDB 数据添加 mediaType 字段 + for (final tmdb in tmdbList) { + final tmdbdetail = TMDBDetail.fromJson(jsonDecode(tmdb.data)); + tmdb.mediaType = tmdbdetail.mediaType; + await database.tMDBs.put(tmdb); + } + }); + + // 修改所有 miruDetail 的 tmdbId 字段为本地的 TMDB id + final miruList = await database.miruDetails.where().findAll(); + database.writeTxn(() async { + for (final miru in miruList) { + final tmdb = await database.tMDBs + .where() + .filter() + .tmdbIDEqualTo(miru.tmdbID!) + .findFirst(); + if (tmdb != null) { + miru.tmdbID = tmdb.id; + await database.miruDetails.put(miru); + } + } + }); + } + + // 获取数据库版本 + static Future getDatabaseVersion() async { + // 先获取数据库版本 + final version = await settings.get(SettingKey.databaseVersion); + // 如果没有版本号,并且没有数据库文件说明是第一次使用,返回最新的数据库版本 + if (version == null) { + final path = await MiruDirectory.getDirectory; + final dbPath = p.join(path, 'default.isar'); + if (File(dbPath).existsSync()) { + return 1; + } + // 设置数据库版本并返回最新版本 + await settings.put(SettingKey.databaseVersion, _lastDatabaseVersion); + return _lastDatabaseVersion; + } + // 如果有版本号,返回版本号 + return version; } static _initSettings() async { @@ -62,4 +138,5 @@ class SettingKey { static String novelFontSize = 'NovelFontSize'; static String enableNSFW = 'EnableNSFW'; static String videoPlayer = 'VideoPlayer'; + static String databaseVersion = 'DatabaseVersion'; } diff --git a/lib/widgets/extension_item_card.dart b/lib/widgets/extension_item_card.dart index e5d704b6..aba48f19 100644 --- a/lib/widgets/extension_item_card.dart +++ b/lib/widgets/extension_item_card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:miru_app/pages/detail/view.dart'; import 'package:miru_app/router/router.dart'; -import 'package:miru_app/widgets/cache_network_image.dart'; +import 'package:miru_app/widgets/grid_item_tile.dart'; import 'package:miru_app/widgets/platform_widget.dart'; class ExtensionItemCard extends StatefulWidget { @@ -25,158 +25,40 @@ class ExtensionItemCard extends StatefulWidget { } class _ExtensionItemCardState extends State { - bool _isHover = false; - Widget _buildAndroid(BuildContext context) { return Hero( tag: widget.url, - child: Stack( - children: [ - Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - clipBehavior: Clip.antiAlias, - child: CacheNetWorkImage( - widget.cover, - width: double.infinity, - height: double.infinity, - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - width: 350, - height: 60, - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.8), - ], - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - // 文字只显示一行 - SizedBox( - height: 20, - child: Text( - widget.title, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - ), - ), - ), - if (widget.update != null) - Text( - widget.update!, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - ], - ), - )), - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - clipBehavior: Clip.antiAlias, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - Get.to(DetailPage( - url: widget.url, - package: widget.package, - heroTag: widget.url, - )); - }, - ), - ), - )), - ], + child: GridItemTile( + title: widget.title, + cover: widget.cover, + subtitle: widget.update, + onTap: () { + Get.to(DetailPage( + url: widget.url, + package: widget.package, + heroTag: widget.url, + )); + }, ), ); } Widget _buildDesktop(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - onHover: (event) { - setState(() { - _isHover = true; - }); - }, - onExit: (event) { - setState(() { - _isHover = false; - }); + return GridItemTile( + title: widget.title, + cover: widget.cover, + subtitle: widget.update, + onTap: () { + router.push( + Uri( + path: '/detail', + queryParameters: { + "url": widget.url, + "package": widget.package, + }, + ).toString(), + ); }, - child: Column( - // 居左 - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: GestureDetector( - onTap: () { - router.push( - Uri( - path: '/detail', - queryParameters: { - "url": widget.url, - "package": widget.package, - }, - ).toString(), - ); - }, - child: Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - clipBehavior: Clip.antiAlias, - child: AnimatedScale( - scale: _isHover ? 1.05 : 1, - duration: const Duration(milliseconds: 80), - child: CacheNetWorkImage( - widget.cover, - width: double.infinity, - ), - )), - ), - ), - const SizedBox(height: 8), - // 文字只显示一行 - SizedBox( - height: 20, - child: Text( - widget.title, - overflow: TextOverflow.ellipsis, - ), - ), - if (widget.update != null) - Text( - widget.update.toString(), - style: const TextStyle( - fontSize: 12, - ), - ), - ], - ), ); } diff --git a/lib/widgets/grid_item_tile.dart b/lib/widgets/grid_item_tile.dart new file mode 100644 index 00000000..48a0c8b7 --- /dev/null +++ b/lib/widgets/grid_item_tile.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:miru_app/widgets/cache_network_image.dart'; +import 'package:miru_app/widgets/platform_widget.dart'; + +class GridItemTile extends StatefulWidget { + const GridItemTile({ + Key? key, + required this.title, + required this.cover, + this.subtitle, + this.onTap, + }) : super(key: key); + final String title; + final String cover; + final String? subtitle; + final Function()? onTap; + + @override + State createState() => _GridItemTileState(); +} + +class _GridItemTileState extends State { + bool _isHover = false; + + Widget _buildAndroid(BuildContext context) { + return Stack( + children: [ + Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: CacheNetWorkImage( + widget.cover, + width: double.infinity, + height: double.infinity, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + width: 350, + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.8), + ], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + // 文字只显示一行 + SizedBox( + height: 20, + child: Text( + widget.title, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + ), + ), + ), + if (widget.subtitle != null) + Text( + widget.subtitle!, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ], + ), + )), + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + widget.onTap?.call(); + }, + ), + ), + )), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onHover: (event) { + setState(() { + _isHover = true; + }); + }, + onExit: (event) { + setState(() { + _isHover = false; + }); + }, + child: Column( + // 居左 + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: GestureDetector( + onTap: () { + widget.onTap?.call(); + }, + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedScale( + scale: _isHover ? 1.05 : 1, + duration: const Duration(milliseconds: 80), + child: CacheNetWorkImage( + widget.cover, + width: double.infinity, + ), + )), + ), + ), + const SizedBox(height: 8), + // 文字只显示一行 + SizedBox( + height: 20, + child: Text( + widget.title, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.subtitle != null) + Text( + widget.subtitle.toString(), + style: const TextStyle( + fontSize: 12, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return PlatformBuildWidget( + androidBuilder: _buildAndroid, + desktopBuilder: _buildDesktop, + ); + } +}