diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index afc9dc8..621f589 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -45,5 +45,15 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..4495c28 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/i18n/en_US.json b/assets/i18n/en_US.json index b7c690b..be76c97 100644 --- a/assets/i18n/en_US.json +++ b/assets/i18n/en_US.json @@ -40,6 +40,9 @@ "channels": "Channels", "change_title": "Change title", "change_cover": "Change Cover", + "cover": "Cover", + "import_covers": "Import covers", + "import_covers_description": "Choose a folder that contains\ngame covers.\nThe cover name must be the same as the game.", "resync": "Resync", "replace_player": "Replace player", "remove": "Remove", diff --git a/assets/i18n/es_ES.json b/assets/i18n/es_ES.json index f98fede..70e81ec 100644 --- a/assets/i18n/es_ES.json +++ b/assets/i18n/es_ES.json @@ -40,6 +40,9 @@ "channels": "Canales", "change_title": "Cambiar título", "change_cover": "Cambiar Portada", + "cover": "Portada", + "import_covers": "Importar Portadas", + "import_covers_description": "Elige una carpeta que contenga\nportadas de juegos.\nEl nombre de la portada debe ser el mismo que el del juego.", "resync": "Re-sincronizar", "replace_player": "Reemplazar jugador", "remove": "Eliminar", diff --git a/assets/i18n/ja_JP.json b/assets/i18n/ja_JP.json index 0d14465..fc64b40 100644 --- a/assets/i18n/ja_JP.json +++ b/assets/i18n/ja_JP.json @@ -40,6 +40,9 @@ "channels": "チャンネル", "change_title": "タイトルを変更する", "change_cover": "カバーを変更する", + "cover": "カバー", + "import_covers": "カバーをインポートする", + "import_covers_description": "ゲームのカバーをインポートするには、ゲームのフォルダに「cover.png」または「cover.jpg」ファイルを追加します。", "resync": "再同期", "replace_player": "プレイヤーを交換する", "remove": "削除する", diff --git a/assets/i18n/pt_BR.json b/assets/i18n/pt_BR.json index 7bf97bb..e050970 100644 --- a/assets/i18n/pt_BR.json +++ b/assets/i18n/pt_BR.json @@ -40,6 +40,9 @@ "channels": "Canais", "change_title": "Mudar título", "change_cover": "Mudar Capa", + "cover": "Capa", + "import_covers": "Importar Capas", + "import_covers_description": "Escolha um pasta que contenha\nas capas dos jogos.\nO nome da capa deve ser o mesmo do jogo.", "resync": "Ressincronizar", "replace_player": "Substituir player", "remove": "Remover", diff --git a/assets/i18n/ru_RU.json b/assets/i18n/ru_RU.json index 0a2c79a..1f8a14b 100644 --- a/assets/i18n/ru_RU.json +++ b/assets/i18n/ru_RU.json @@ -40,6 +40,9 @@ "channels": "Каналы", "change_title": "Изменить название", "change_cover": "Изменить обложку", + "cover": "Обложка", + "import_covers": "Импортировать обложки", + "import_covers_description": "Выберите папку, содержащую\nобложки игр.\nНазвание обложки должно совпадать с названием игры.", "resync": "Пересинхронизация", "replace_player": "Заменить игрока", "remove": "Удалить", diff --git a/assets/i18n/zh_CN.json b/assets/i18n/zh_CN.json index cd22a2a..f8fd986 100644 --- a/assets/i18n/zh_CN.json +++ b/assets/i18n/zh_CN.json @@ -40,6 +40,9 @@ "channels": "频道", "change_title": "更改标题", "change_cover": "更改封面", + "cover": "封面", + "import_covers": "导入封面", + "import_covers_description": "您可以导入封面,以便在游戏列表中显示。如果您不导入封面,将使用默认封面。", "resync": "重新同步", "replace_player": "替换玩家", "remove": "移除", diff --git a/assets/i18n/zh_TW.json b/assets/i18n/zh_TW.json index 9cfd5eb..6688fa2 100644 --- a/assets/i18n/zh_TW.json +++ b/assets/i18n/zh_TW.json @@ -40,6 +40,9 @@ "channels": "頻道", "change_title": "更改標題", "change_cover": "更改封面", + "cover": "封面", + "import_covers": "導入封面", + "import_covers_description": "您可以導入一個包含遊戲封面的資料夾,Yuno 將會為您的遊戲自動分配封面。", "resync": "重新同步", "replace_player": "替換玩家", "remove": "移除", diff --git a/lib/app/(public)/config/config_page.dart b/lib/app/(public)/config/config_page.dart index fddee55..dda1faa 100644 --- a/lib/app/(public)/config/config_page.dart +++ b/lib/app/(public)/config/config_page.dart @@ -7,6 +7,7 @@ import '../../interactor/atoms/config_atom.dart'; import '../../interactor/atoms/gamepad_atom.dart'; import '../../interactor/services/gamepad_service.dart'; import 'widgets/about_widget.dart'; +import 'widgets/cover_settings_widget.dart'; import 'widgets/feedback_widget.dart'; import 'widgets/platform_widget.dart'; import 'widgets/preferences_widget.dart'; @@ -84,6 +85,10 @@ class _ConfigPageState extends State { icon: const Icon(Icons.settings), label: Text('preferences'.i18n()), ), + NavigationRailDestination( + icon: const Icon(Icons.image), + label: Text('cover'.i18n()), + ), NavigationRailDestination( icon: const Icon(Icons.chat_outlined), label: Text('feedback'.i18n()), @@ -114,6 +119,7 @@ class _ConfigPageState extends State { children: [ PlatformWidget(transitionAnimation: widget.transitionAnimation), const PreferencesWidget(), + const CoverSettingsWidget(), FeedbackWidget(), AboutWidget(), ], diff --git a/lib/app/(public)/config/edit_platform_page.dart b/lib/app/(public)/config/edit_platform_page.dart index 18ad001..3dc62fc 100644 --- a/lib/app/(public)/config/edit_platform_page.dart +++ b/lib/app/(public)/config/edit_platform_page.dart @@ -1,4 +1,3 @@ -import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:localization/localization.dart'; @@ -9,6 +8,7 @@ import 'package:yuno/app/interactor/atoms/platform_atom.dart'; import 'package:yuno/app/interactor/models/embeds/game.dart'; import '../../core/widgets/animated_title_app_bart.dart'; +import '../../interactor/actions/config_action.dart'; import '../../interactor/actions/platform_action.dart'; import '../../interactor/models/embeds/game_category.dart'; import '../../interactor/models/embeds/player.dart'; @@ -178,7 +178,7 @@ class _EditPlatformPageState extends State { initialValue: beautifyPath(platform.folder), readOnly: true, onTap: () async { - final selectedDirectory = await getDirectoryPath(); + final selectedDirectory = await getDirectory(); if (selectedDirectory != null) { setState(() { @@ -189,6 +189,29 @@ class _EditPlatformPageState extends State { } }, ), + const Gap(17), + if (platform.category.id != 'android') + TextFormField( + key: Key(beautifyPath('${platform.folderCover ?? platform.folder}_cover')), + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'import_covers'.i18n(), + suffixIcon: const Icon(Icons.folder), + ), + initialValue: beautifyPath(platform.folderCover ?? platform.folder), + readOnly: true, + onTap: () async { + final selectedDirectory = await getDirectory(); + + if (selectedDirectory != null) { + setState(() { + platform = platform.copyWith( + folderCover: selectedDirectory, + ); + }); + } + }, + ), const Gap(50), ], ), @@ -246,9 +269,4 @@ class _EditPlatformPageState extends State { ), ); } - - String beautifyPath(String dir) { - final path = convertContentUriToFilePath(dir); - return path.replaceAll('/storage/emulated/0', ''); - } } diff --git a/lib/app/(public)/config/widgets/cover_settings_widget.dart b/lib/app/(public)/config/widgets/cover_settings_widget.dart new file mode 100644 index 0000000..d51def5 --- /dev/null +++ b/lib/app/(public)/config/widgets/cover_settings_widget.dart @@ -0,0 +1,76 @@ +import 'dart:ffi'; + +import 'package:asp/asp.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:localization/localization.dart'; + +import '../../../interactor/actions/config_action.dart'; +import '../../../interactor/actions/platform_action.dart'; +import '../../../interactor/atoms/config_atom.dart'; + +class CoverSettingsWidget extends StatelessWidget { + const CoverSettingsWidget({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return RxBuilder( + builder: (context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'cover'.i18n(), + style: theme.textTheme.titleMedium, + ), + const Gap(12), + SwitchListTile( + value: gameConfigState.value.enableIGDB, + onChanged: (v) { + saveConfig(gameConfigState.value.copyWith(enableIGDB: v)); + }, + title: const Text('IGDB'), + ), + const Gap(18), + Text( + 'import_covers'.i18n(), + style: theme.textTheme.titleMedium, + ), + const Gap(12), + Padding( + padding: const EdgeInsets.only(right: 20), + child: TextFormField( + key: Key(beautifyPath(gameConfigState.value.coverFolder ?? '')), + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'folder'.i18n(), + suffixIcon: const Icon(Icons.folder), + ), + initialValue: + beautifyPath(gameConfigState.value.coverFolder ?? ''), + readOnly: true, + onTap: () async { + final selectedDirectory = await getDirectory(); + + if (selectedDirectory != null) { + saveConfig( + gameConfigState.value.copyWith( + coverFolder: selectedDirectory, + ), + ); + } + }, + ), + ), + const Gap(12), + Text( + 'import_covers_description'.i18n(), + style: theme.textTheme.titleSmall, + ), + ], + ); + }, + ); + } +} diff --git a/lib/app/data/repositories/isar/adapters/platform_adapter.dart b/lib/app/data/repositories/isar/adapters/platform_adapter.dart index d5a0c98..5347d1b 100644 --- a/lib/app/data/repositories/isar/adapters/platform_adapter.dart +++ b/lib/app/data/repositories/isar/adapters/platform_adapter.dart @@ -26,6 +26,7 @@ abstract class PlatformAdapter { data.lastUpdate = DateTime.now(); data.playerPackageId = model.player?.app.package; data.playerExtra = model.player?.extra; + data.folderCover = model.folderCover; return data; } @@ -58,6 +59,7 @@ abstract class PlatformAdapter { extra: model.playerExtra, ), games: model.games.map((e) => gameFromData(e)).toList(), + folderCover: model.folderCover, ); } diff --git a/lib/app/data/repositories/isar/db/platform_data.dart b/lib/app/data/repositories/isar/db/platform_data.dart index d305419..273bf42 100644 --- a/lib/app/data/repositories/isar/db/platform_data.dart +++ b/lib/app/data/repositories/isar/db/platform_data.dart @@ -10,6 +10,7 @@ class PlatformData { late String category; String? playerPackageId; String? playerExtra; + String? folderCover; late String folder; late DateTime lastUpdate; List games = []; diff --git a/lib/app/data/repositories/isar/db/platform_data.g.dart b/lib/app/data/repositories/isar/db/platform_data.g.dart index fee9f25..8859f22 100644 --- a/lib/app/data/repositories/isar/db/platform_data.g.dart +++ b/lib/app/data/repositories/isar/db/platform_data.g.dart @@ -27,24 +27,29 @@ const PlatformDataSchema = CollectionSchema( name: r'folder', type: IsarType.string, ), - r'games': PropertySchema( + r'folderCover': PropertySchema( id: 2, + name: r'folderCover', + type: IsarType.string, + ), + r'games': PropertySchema( + id: 3, name: r'games', type: IsarType.objectList, target: r'GameData', ), r'lastUpdate': PropertySchema( - id: 3, + id: 4, name: r'lastUpdate', type: IsarType.dateTime, ), r'playerExtra': PropertySchema( - id: 4, + id: 5, name: r'playerExtra', type: IsarType.string, ), r'playerPackageId': PropertySchema( - id: 5, + id: 6, name: r'playerPackageId', type: IsarType.string, ) @@ -71,6 +76,12 @@ int _platformDataEstimateSize( var bytesCount = offsets.last; bytesCount += 3 + object.category.length * 3; bytesCount += 3 + object.folder.length * 3; + { + final value = object.folderCover; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } bytesCount += 3 + object.games.length * 3; { final offsets = allOffsets[GameData]!; @@ -102,15 +113,16 @@ void _platformDataSerialize( ) { writer.writeString(offsets[0], object.category); writer.writeString(offsets[1], object.folder); + writer.writeString(offsets[2], object.folderCover); writer.writeObjectList( - offsets[2], + offsets[3], allOffsets, GameDataSchema.serialize, object.games, ); - writer.writeDateTime(offsets[3], object.lastUpdate); - writer.writeString(offsets[4], object.playerExtra); - writer.writeString(offsets[5], object.playerPackageId); + writer.writeDateTime(offsets[4], object.lastUpdate); + writer.writeString(offsets[5], object.playerExtra); + writer.writeString(offsets[6], object.playerPackageId); } PlatformData _platformDataDeserialize( @@ -122,17 +134,18 @@ PlatformData _platformDataDeserialize( final object = PlatformData(); object.category = reader.readString(offsets[0]); object.folder = reader.readString(offsets[1]); + object.folderCover = reader.readStringOrNull(offsets[2]); object.games = reader.readObjectList( - offsets[2], + offsets[3], GameDataSchema.deserialize, allOffsets, GameData(), ) ?? []; object.id = id; - object.lastUpdate = reader.readDateTime(offsets[3]); - object.playerExtra = reader.readStringOrNull(offsets[4]); - object.playerPackageId = reader.readStringOrNull(offsets[5]); + object.lastUpdate = reader.readDateTime(offsets[4]); + object.playerExtra = reader.readStringOrNull(offsets[5]); + object.playerPackageId = reader.readStringOrNull(offsets[6]); return object; } @@ -148,6 +161,8 @@ P _platformDataDeserializeProp

( case 1: return (reader.readString(offset)) as P; case 2: + return (reader.readStringOrNull(offset)) as P; + case 3: return (reader.readObjectList( offset, GameDataSchema.deserialize, @@ -155,12 +170,12 @@ P _platformDataDeserializeProp

( GameData(), ) ?? []) as P; - case 3: - return (reader.readDateTime(offset)) as P; case 4: - return (reader.readStringOrNull(offset)) as P; + return (reader.readDateTime(offset)) as P; case 5: return (reader.readStringOrNull(offset)) as P; + case 6: + return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -531,6 +546,160 @@ extension PlatformDataQueryFilter }); } + QueryBuilder + folderCoverIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'folderCover', + )); + }); + } + + QueryBuilder + folderCoverIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'folderCover', + )); + }); + } + + QueryBuilder + folderCoverEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'folderCover', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + folderCoverGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'folderCover', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + folderCoverLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'folderCover', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + folderCoverBetween( + 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'folderCover', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + folderCoverStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'folderCover', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + folderCoverEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'folderCover', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + folderCoverContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'folderCover', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + folderCoverMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'folderCover', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + folderCoverIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'folderCover', + value: '', + )); + }); + } + + QueryBuilder + folderCoverIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'folderCover', + value: '', + )); + }); + } + QueryBuilder gamesLengthEqualTo(int length) { return QueryBuilder.apply(this, (query) { @@ -1077,6 +1246,19 @@ extension PlatformDataQuerySortBy }); } + QueryBuilder sortByFolderCover() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'folderCover', Sort.asc); + }); + } + + QueryBuilder + sortByFolderCoverDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'folderCover', Sort.desc); + }); + } + QueryBuilder sortByLastUpdate() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'lastUpdate', Sort.asc); @@ -1144,6 +1326,19 @@ extension PlatformDataQuerySortThenBy }); } + QueryBuilder thenByFolderCover() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'folderCover', Sort.asc); + }); + } + + QueryBuilder + thenByFolderCoverDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'folderCover', Sort.desc); + }); + } + QueryBuilder thenById() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'id', Sort.asc); @@ -1213,6 +1408,13 @@ extension PlatformDataQueryWhereDistinct }); } + QueryBuilder distinctByFolderCover( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'folderCover', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByLastUpdate() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'lastUpdate'); @@ -1255,6 +1457,12 @@ extension PlatformDataQueryProperty }); } + QueryBuilder folderCoverProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'folderCover'); + }); + } + QueryBuilder, QQueryOperations> gamesProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'games'); diff --git a/lib/app/data/repositories/uno_sync_repository.dart b/lib/app/data/repositories/uno_sync_repository.dart index 14dbbfb..ce8c598 100644 --- a/lib/app/data/repositories/uno_sync_repository.dart +++ b/lib/app/data/repositories/uno_sync_repository.dart @@ -1,7 +1,11 @@ import 'dart:io'; +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:media_store_plus/media_store_plus.dart'; import 'package:path/path.dart'; import 'package:uno/uno.dart'; +import 'package:yuno/app/interactor/actions/platform_action.dart'; import 'package:yuno/app/interactor/models/embeds/game.dart'; import '../../core/constants/env.dart'; @@ -14,49 +18,109 @@ class UnoSyncRepository implements SyncRepository { UnoSyncRepository({required this.uno}); @override - Future syncIGDB(Game game) async { - final response = await uno.post( - 'https://api.igdb.com/v4/games', - headers: { - 'content-type': 'text/plain', - 'client-id': igdbClientId, - 'authorization': 'Bearer $igdbToken', - }, - data: - 'fields artworks,collection,cover.*, first_release_date,genres.*,name,summary; search "${game.name}"; limit 2;', - ); - - // prevent multiples calls - await Future.delayed(const Duration(seconds: 1)); - - if (response.data.isEmpty) { + Future syncLocalFolder(Game game, String coverFolder) async { + try { + final media = MediaStore(); + final documents = await media.getDocumentTree(uriString: coverFolder); + + if (documents == null) { + return game; + } + + final files = documents // + .children + .where( + (doc) { + final name = doc.name?.toLowerCase(); + if (name == null) { + return false; + } + return name.endsWith('.png') || + name.endsWith('.jpg') || + name.endsWith('.jpeg'); + }, + ).toList(); + + if (files.isEmpty) { + return game; + } + + final gameName = basenameWithoutExtension(convertContentUriToFilePath(game.path)); + + final file = files.firstWhereOrNull((doc) { + final name = doc.name!; + return name.startsWith('${gameName}.'); + }); + + if (file == null) { + return game; + } + + final dirPath = await pathProvider.getApplicationDocumentsDirectory(); + final imageName = file.name!; + final pathSeparator = Platform.pathSeparator; + final imageFile = File('${dirPath.path}${pathSeparator}local_$imageName'); + if (imageFile.existsSync()) { + await imageFile.delete(); + } + await media.readFileUsingUri( + uriString: file.uriString, tempFilePath: imageFile.path); + return game.copyWith(image: imageFile.path, isSynced: true); + } catch (e) { + debugPrint(e.toString()); return game; } + } - final json = response.data[0]; + @override + Future syncIGDB(Game game) async { + try { + final response = await uno.post( + 'https://api.igdb.com/v4/games', + headers: { + 'content-type': 'text/plain', + 'client-id': igdbClientId, + 'authorization': 'Bearer $igdbToken', + }, + data: + 'fields artworks,collection,cover.*, first_release_date,genres.*,name,summary; search "${game.name}"; limit 2;', + ); - var image = "https:${json['cover']['url']}"; - image = image.replaceAll('t_thumb', 't_cover_big'); + // prevent multiples calls + await Future.delayed(const Duration(seconds: 1)); - final dirPath = await pathProvider.getApplicationDocumentsDirectory(); - final imageName = basename(image); - final pathSeparator = Platform.pathSeparator; - final imageFile = File('${dirPath.path}$pathSeparator$imageName'); + if (response.data.isEmpty) { + return game; + } - if (!imageFile.existsSync()) { - final imageData = - await uno.get(image, responseType: ResponseType.arraybuffer); - await imageFile.writeAsBytes(imageData.data); - } + final json = response.data[0]; + + var image = "https:${json['cover']['url']}"; + image = image.replaceAll('t_thumb', 't_cover_big'); - final metaGame = game.copyWith( - isSynced: true, - description: json['summary'], - image: imageFile.path, - genre: json['genres'][0]['name'], - ); + final dirPath = await pathProvider.getApplicationDocumentsDirectory(); + final imageName = basename(image); + final pathSeparator = Platform.pathSeparator; + final imageFile = File('${dirPath.path}${pathSeparator}igdb_$imageName'); - return metaGame; + if (!imageFile.existsSync()) { + final imageData = + await uno.get(image, responseType: ResponseType.arraybuffer); + await imageFile.writeAsBytes(imageData.data); + } + + final metaGame = game.copyWith( + isSynced: true, + description: json['summary'], + image: imageFile.path, + genre: json['genres'][0]['name'], + ); + + return metaGame; + } catch (e) { + debugPrint(e.toString()); + return game; + } } @override diff --git a/lib/app/interactor/actions/config_action.dart b/lib/app/interactor/actions/config_action.dart index 658ea25..3aaed43 100644 --- a/lib/app/interactor/actions/config_action.dart +++ b/lib/app/interactor/actions/config_action.dart @@ -8,6 +8,7 @@ import 'package:yuno/injector.dart'; import '../atoms/config_atom.dart'; import '../repositories/config_repository.dart'; +import 'platform_action.dart'; Future saveConfig(GameConfig config) async { final repository = injector.get(); @@ -25,6 +26,11 @@ Future openUrl(Uri uri) async { await repository.openUrl(uri); } +String beautifyPath(String dir) { + final path = convertContentUriToFilePath(dir); + return path.replaceAll('/storage/emulated/0', ''); +} + final _battery = Battery(); StreamSubscription? _batterySubscription; diff --git a/lib/app/interactor/actions/platform_action.dart b/lib/app/interactor/actions/platform_action.dart index 1802d40..17de62a 100644 --- a/lib/app/interactor/actions/platform_action.dart +++ b/lib/app/interactor/actions/platform_action.dart @@ -2,8 +2,10 @@ import 'dart:io'; +import 'package:shared_storage/shared_storage.dart' as shared_storage; import 'package:flutter/material.dart'; import 'package:media_store_plus/media_store_plus.dart'; +import 'package:yuno/app/interactor/atoms/config_atom.dart'; import 'package:yuno/app/interactor/models/platform_model.dart'; import 'package:yuno/app/interactor/repositories/platform_repository.dart'; import 'package:yuno/app/interactor/repositories/sync_repository.dart'; @@ -32,6 +34,12 @@ Future createPlatform(PlatformModel platform) async { await fetchPlatforms(); } +Future getDirectory([String? initialFolder]) async { + final uri = await shared_storage.openDocumentTree( + initialUri: initialFolder == null ? null : Uri.parse(initialFolder)); + return uri?.toString(); +} + Future> _getGames(PlatformModel platform) async { if (platform.category.id == 'android') { return platform.games; @@ -40,6 +48,13 @@ Future> _getGames(PlatformModel platform) async { final games = []; final media = MediaStore(); + await shared_storage.persistedUriPermissions(); + + final canRead = await shared_storage.canRead(Uri.parse(platform.folder)); + if (canRead != true) { + await getDirectory(platform.folder); + } + final documents = await media.getDocumentTree(uriString: platform.folder); if (documents == null) { @@ -86,14 +101,41 @@ Future syncPlatform(PlatformModel platform) async { final color = await getDominatingColor(platform.games[i].image); platform.games[i] = platform.games[i].copyWith(imageColor: color); } else { - try { - var metaGame = await repository.syncIGDB(platform.games[i]); - final color = await getDominatingColor(metaGame.image); - metaGame = metaGame.copyWith(imageColor: color); - platform.games[i] = metaGame; - } catch (e) { - continue; + Game metaGame = platform.games[i]; + + final coverFolder = platform.folderCover ?? platform.folder; + + var canRead = await shared_storage.canRead(Uri.parse(coverFolder)); + if (canRead != true) { + await getDirectory(coverFolder); + } + + metaGame = await repository.syncLocalFolder( + metaGame, + coverFolder, + ); + + if (gameConfigState.value.coverFolder != null && !metaGame.isSynced) { + canRead = await shared_storage + .canRead(Uri.parse(gameConfigState.value.coverFolder!)); + if (canRead != true) { + await getDirectory(gameConfigState.value.coverFolder!); + } + metaGame = await repository.syncLocalFolder( + metaGame, + gameConfigState.value.coverFolder!, + ); } + + if (gameConfigState.value.enableIGDB && !metaGame.isSynced) { + metaGame = await repository.syncIGDB( + platform.games[i], + ); + } + + final color = await getDominatingColor(metaGame.image); + metaGame = metaGame.copyWith(imageColor: color); + platform.games[i] = metaGame; } } diff --git a/lib/app/interactor/actions/player_action.dart b/lib/app/interactor/actions/player_action.dart index 4b1b32f..de92206 100644 --- a/lib/app/interactor/actions/player_action.dart +++ b/lib/app/interactor/actions/player_action.dart @@ -38,14 +38,13 @@ final _defaultAppIntent = { arguments: { 'ROM': convertContentUriToFilePath(g.path), 'LIBRETRO': - '/data/data/com.retroarch/cores/${p.extra}_libretro_android.so', + '/data/data/com.retroarch/cores/${p.extra}_libretro_android.so', 'CONFIGFILE': - '/storage/emulated/0/Android/data/com.retroarch/files/retroarch.cfg', + '/storage/emulated/0/Android/data/com.retroarch/files/retroarch.cfg', 'DATADIR': '/data/data/com.retroarch', 'APK': '/data/app/com.retroarch-1/base.apk', 'SDCARD': '/storage/emulated/0', - 'EXTERNAL': - '/storage/emulated/0/Android/data/com.retroarch/files', + 'EXTERNAL': '/storage/emulated/0/Android/data/com.retroarch/files', 'IME': 'com.android.inputmethod.latin/.LatinIME', }, ); diff --git a/lib/app/interactor/models/game_config.dart b/lib/app/interactor/models/game_config.dart index ee29203..c4a07d9 100644 --- a/lib/app/interactor/models/game_config.dart +++ b/lib/app/interactor/models/game_config.dart @@ -14,28 +14,35 @@ class GameConfig { final bool swapABXY; final bool menuSounds; final LanguageModel? language; + final bool enableIGDB; + final String? coverFolder; GameConfig({ this.themeMode = ThemeMode.system, this.swapABXY = false, this.menuSounds = true, this.language, + this.enableIGDB = true, this.backgroundType = BackgroundType.bubble, + this.coverFolder, }); - GameConfig copyWith({ - ThemeMode? themeMode, - BackgroundType? backgroundType, - bool? swapABXY, - bool? menuSounds, - LanguageModel? language, - }) { + GameConfig copyWith( + {ThemeMode? themeMode, + BackgroundType? backgroundType, + bool? swapABXY, + bool? menuSounds, + LanguageModel? language, + bool? enableIGDB, + String? coverFolder}) { return GameConfig( themeMode: themeMode ?? this.themeMode, backgroundType: backgroundType ?? this.backgroundType, language: language ?? this.language, swapABXY: swapABXY ?? this.swapABXY, menuSounds: menuSounds ?? this.menuSounds, + enableIGDB: enableIGDB ?? this.enableIGDB, + coverFolder: coverFolder ?? this.coverFolder, ); } @@ -45,6 +52,8 @@ class GameConfig { 'backgroundType': backgroundType.name, 'swapABXY': swapABXY, 'menuSounds': menuSounds, + 'enableIGDB': enableIGDB, + if (coverFolder != null) 'coverFolder': coverFolder, if (language != null) 'locale': language!.locale.toString(), }; } @@ -64,6 +73,8 @@ class GameConfig { ), swapABXY: map['swapABXY'] as bool, menuSounds: map['menuSounds'] as bool, + enableIGDB: map['enableIGDB'] ?? true, + coverFolder: map['coverFolder'], ); } diff --git a/lib/app/interactor/models/platform_model.dart b/lib/app/interactor/models/platform_model.dart index 85cf20d..eaa5363 100644 --- a/lib/app/interactor/models/platform_model.dart +++ b/lib/app/interactor/models/platform_model.dart @@ -8,6 +8,7 @@ class PlatformModel { final GameCategory category; final Player? player; final String folder; + final String? folderCover; final List games; final DateTime lastUpdate; @@ -16,6 +17,7 @@ class PlatformModel { required this.id, required this.folder, this.player, + this.folderCover, required this.lastUpdate, required this.category, required this.games, @@ -65,10 +67,12 @@ class PlatformModel { DateTime? lastUpdate, GameCategory? category, String? playerArguments, + String? folderCover, List? games}) { return PlatformModel( id: id, category: category ?? this.category, + folderCover: folderCover ?? this.folderCover, player: player ?? this.player, games: games ?? this.games, folder: folder ?? this.folder, diff --git a/lib/app/interactor/repositories/sync_repository.dart b/lib/app/interactor/repositories/sync_repository.dart index 2e96b8f..fc4b39c 100644 --- a/lib/app/interactor/repositories/sync_repository.dart +++ b/lib/app/interactor/repositories/sync_repository.dart @@ -2,5 +2,6 @@ import 'package:yuno/app/interactor/models/embeds/game.dart'; abstract class SyncRepository { Future syncIGDB(Game game); + Future syncLocalFolder(Game game, String coverFolder); Future syncRAWG(Game game); } diff --git a/pubspec.yaml b/pubspec.yaml index 636a725..b2a29bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.0.9+17 +version: 0.1.0+20 environment: sdk: '>=3.2.4 <4.0.0' @@ -69,6 +69,7 @@ dependencies: battery_plus: ^5.0.2 intl: ^0.18.1 based_battery_indicator: ^1.0.3 + shared_storage: ^0.8.0 install_or_uninstall_app_listener: path: plugins/install_or_uninstall_app_listener localization: ^2.1.1