From ea8e7cb2e26f8a3f392af049dc6d2a1fd353dc21 Mon Sep 17 00:00:00 2001 From: Starfallen <36763490+Starfallan@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:43:41 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=AD=97=E4=BD=93=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AD=97=E4=BD=93=E9=80=89=E6=8B=A9=E5=92=8C?= =?UTF-8?q?=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 2 + lib/pages/setting/models/style_settings.dart | 60 +++++++++ lib/utils/app_font.dart | 127 +++++++++++++++++++ lib/utils/storage_key.dart | 3 + lib/utils/storage_pref.dart | 13 ++ lib/utils/theme_utils.dart | 21 ++- 6 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 lib/utils/app_font.dart diff --git a/lib/main.dart b/lib/main.dart index b5467172d2..5e6af0e76a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/services/service_locator.dart'; import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/calc_window_position.dart'; +import 'package:PiliPlus/utils/app_font.dart'; import 'package:PiliPlus/utils/date_utils.dart'; import 'package:PiliPlus/utils/extension/iterable_ext.dart'; import 'package:PiliPlus/utils/extension/theme_ext.dart'; @@ -96,6 +97,7 @@ void main() async { if (kDebugMode) debugPrint('GStorage init error: $e'); exit(0); } + await AppFont.init(); ScaledWidgetsFlutterBinding.instance.scaleFactor = Pref.uiScale; await Future.wait([_initDownPath(), _initTmpPath()]); Get diff --git a/lib/pages/setting/models/style_settings.dart b/lib/pages/setting/models/style_settings.dart index 9f9b5eff3a..d62ce838e8 100644 --- a/lib/pages/setting/models/style_settings.dart +++ b/lib/pages/setting/models/style_settings.dart @@ -26,6 +26,7 @@ import 'package:PiliPlus/pages/setting/widgets/multi_select_dialog.dart'; import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart'; import 'package:PiliPlus/pages/setting/widgets/slider_dialog.dart'; import 'package:PiliPlus/plugin/pl_player/utils/fullscreen.dart'; +import 'package:PiliPlus/utils/app_font.dart'; import 'package:PiliPlus/utils/extension/file_ext.dart'; import 'package:PiliPlus/utils/extension/num_ext.dart'; import 'package:PiliPlus/utils/extension/theme_ext.dart'; @@ -156,6 +157,12 @@ List get styleSettings => [ onChanged: (value) => Get.forceAppUpdate(), onTap: _showFontWeightDialog, ), + NormalModel( + title: '应用字体', + leading: const Icon(Icons.font_download_outlined), + getSubtitle: () => AppFont.currentFontName ?? '系统字体', + onTap: _showCustomFontDialog, + ), NormalModel( title: '界面缩放', getSubtitle: () => '当前缩放比例:${Pref.uiScale.toStringAsFixed(2)}', @@ -488,6 +495,59 @@ void _showQualityDialog({ }); } +Future _showCustomFontDialog( + BuildContext context, + VoidCallback setState, +) async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('应用字体'), + content: Text( + AppFont.currentFontName == null + ? '当前使用系统字体。' + : '当前字体:${AppFont.currentFontName}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final cleared = await AppFont.clear(); + if (cleared) { + setState(); + Get.forceAppUpdate(); + SmartDialog.showToast('已恢复为系统字体'); + } else { + SmartDialog.showToast('当前已经是系统字体'); + } + }, + child: const Text('系统字体'), + ), + FilledButton( + onPressed: () async { + Navigator.of(context).pop(); + try { + final changed = await AppFont.pickAndApply(); + if (changed) { + setState(); + Get.forceAppUpdate(); + SmartDialog.showToast('自定义字体已应用'); + } + } catch (e) { + SmartDialog.showToast('字体加载失败: $e'); + } + }, + child: const Text('选择字体'), + ), + ], + ), + ); +} + void _showUiScaleDialog( BuildContext context, VoidCallback setState, diff --git a/lib/utils/app_font.dart b/lib/utils/app_font.dart new file mode 100644 index 0000000000..ec64b7066e --- /dev/null +++ b/lib/utils/app_font.dart @@ -0,0 +1,127 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:PiliPlus/utils/path_utils.dart'; +import 'package:PiliPlus/utils/storage.dart'; +import 'package:PiliPlus/utils/storage_key.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as path; + +abstract final class AppFont { + static const List allowedExtensions = ['ttf', 'otf']; + static const String _fontDirName = 'fonts'; + + static String? get currentFontName => Pref.customFontName; + + static Future init() async { + final fontPath = Pref.customFontPath; + final fontFamily = Pref.customFontFamily; + if (fontPath == null || fontFamily == null) { + return; + } + + final file = File(fontPath); + if (!file.existsSync()) { + await clear(); + return; + } + + try { + await _loadFont(fontPath: fontPath, fontFamily: fontFamily); + } catch (_) { + await clear(); + } + } + + static Future pickAndApply() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: allowedExtensions, + withData: true, + ); + if (result == null || result.files.isEmpty) { + return false; + } + + final picked = result.files.single; + final extension = path + .extension(picked.path ?? picked.name) + .replaceFirst('.', '') + .toLowerCase(); + if (!allowedExtensions.contains(extension)) { + throw UnsupportedError('unsupported font file: $extension'); + } + + final fontDir = Directory(path.join(appSupportDirPath, _fontDirName)); + if (!fontDir.existsSync()) { + await fontDir.create(recursive: true); + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final targetPath = path.join(fontDir.path, 'custom_font_$timestamp.$extension'); + final targetFile = File(targetPath); + if (picked.path case final String sourcePath) { + await File(sourcePath).copy(targetPath); + } else if (picked.bytes case final Uint8List bytes) { + await targetFile.writeAsBytes(bytes, flush: true); + } else { + throw StateError('missing font bytes'); + } + + final fontFamily = 'custom_font_$timestamp'; + try { + await _loadFont(fontPath: targetPath, fontFamily: fontFamily); + final previousFontPath = Pref.customFontPath; + await GStorage.setting.put(SettingBoxKey.customFontPath, targetPath); + await GStorage.setting.put(SettingBoxKey.customFontFamily, fontFamily); + await GStorage.setting.put( + SettingBoxKey.customFontName, + path.basename(picked.path ?? picked.name), + ); + if (previousFontPath != null && previousFontPath != targetPath) { + final previousFile = File(previousFontPath); + if (previousFile.existsSync()) { + try { + await previousFile.delete(); + } catch (_) {} + } + } + return true; + } catch (_) { + if (targetFile.existsSync()) { + await targetFile.delete(); + } + rethrow; + } + } + + static Future clear() async { + final fontPath = Pref.customFontPath; + await GStorage.setting.delete(SettingBoxKey.customFontPath); + await GStorage.setting.delete(SettingBoxKey.customFontFamily); + await GStorage.setting.delete(SettingBoxKey.customFontName); + if (fontPath == null || fontPath.isEmpty) { + return false; + } + + final file = File(fontPath); + if (file.existsSync()) { + try { + await file.delete(); + } catch (_) {} + } + return true; + } + + static Future _loadFont({ + required String fontPath, + required String fontFamily, + }) async { + final bytes = await File(fontPath).readAsBytes(); + final loader = FontLoader(fontFamily); + loader.addFont(Future.value(ByteData.sublistView(bytes))); + await loader.load(); + } +} diff --git a/lib/utils/storage_key.dart b/lib/utils/storage_key.dart index df59371acd..558051171e 100644 --- a/lib/utils/storage_key.dart +++ b/lib/utils/storage_key.dart @@ -221,6 +221,9 @@ abstract final class SettingBoxKey { static const String themeMode = 'themeMode', defaultTextScale = 'textScale', + customFontPath = 'customFontPath', + customFontFamily = 'customFontFamily', + customFontName = 'customFontName', dynamicColor = 'dynamicColor', customColor = 'customColor', displayMode = 'displayMode', diff --git a/lib/utils/storage_pref.dart b/lib/utils/storage_pref.dart index 6827cfdc58..81bcf512a0 100644 --- a/lib/utils/storage_pref.dart +++ b/lib/utils/storage_pref.dart @@ -564,6 +564,19 @@ abstract final class Pref { static int get appFontWeight => _setting.get(SettingBoxKey.appFontWeight, defaultValue: -1); + static String? get customFontPath => + _setting.get(SettingBoxKey.customFontPath); + + static String? get customFontFamily { + final value = _setting.get(SettingBoxKey.customFontFamily, defaultValue: ''); + return value is String && value.isNotEmpty ? value : null; + } + + static String? get customFontName { + final value = _setting.get(SettingBoxKey.customFontName, defaultValue: ''); + return value is String && value.isNotEmpty ? value : null; + } + static bool get enableDragSubtitle => _setting.get(SettingBoxKey.enableDragSubtitle, defaultValue: false); diff --git a/lib/utils/theme_utils.dart b/lib/utils/theme_utils.dart index 1715f566b2..860d72150a 100644 --- a/lib/utils/theme_utils.dart +++ b/lib/utils/theme_utils.dart @@ -11,6 +11,7 @@ abstract final class ThemeUtils { required bool isDynamic, bool isDark = false, }) { + final customFontFamily = Pref.customFontFamily; final appFontWeight = Pref.appFontWeight.clamp( -1, FontWeight.values.length - 1, @@ -18,11 +19,15 @@ abstract final class ThemeUtils { final fontWeight = appFontWeight == -1 ? null : FontWeight.values[appFontWeight]; - late final textStyle = TextStyle(fontWeight: fontWeight); + late final textStyle = TextStyle( + fontWeight: fontWeight, + fontFamily: customFontFamily, + ); ThemeData themeData = ThemeData( colorScheme: colorScheme, + fontFamily: customFontFamily, useMaterial3: true, - textTheme: fontWeight == null + textTheme: fontWeight == null && customFontFamily == null ? null : TextTheme( displayLarge: textStyle, @@ -41,7 +46,7 @@ abstract final class ThemeUtils { labelMedium: textStyle, labelSmall: textStyle, ), - tabBarTheme: fontWeight == null + tabBarTheme: fontWeight == null && customFontFamily == null ? null : TabBarThemeData(labelStyle: textStyle), appBarTheme: AppBarTheme( @@ -53,6 +58,7 @@ abstract final class ThemeUtils { titleTextStyle: TextStyle( fontSize: 16, color: colorScheme.onSurface, + fontFamily: customFontFamily, fontWeight: fontWeight, ), ), @@ -88,6 +94,7 @@ abstract final class ThemeUtils { titleTextStyle: TextStyle( fontSize: 18, color: colorScheme.onSurface, + fontFamily: customFontFamily, fontWeight: fontWeight, ), backgroundColor: colorScheme.surface, @@ -130,6 +137,14 @@ abstract final class ThemeUtils { }, ), ); + if (customFontFamily != null) { + themeData = themeData.copyWith( + textTheme: themeData.textTheme.apply(fontFamily: customFontFamily), + primaryTextTheme: themeData.primaryTextTheme.apply( + fontFamily: customFontFamily, + ), + ); + } if (isDark) { if (Pref.isPureBlackTheme) { themeData = darkenTheme(themeData); From beeb75e91b47199bf8c314adad9a41d76a3d0352 Mon Sep 17 00:00:00 2001 From: Starfallen <36763490+Starfallan@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:13:58 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AD=97?= =?UTF-8?q?=E4=BD=93=E7=AE=A1=E7=90=86=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=B8=85?= =?UTF-8?q?=E7=90=86=E5=AD=97=E4=BD=93=E7=9B=AE=E5=BD=95=E7=9A=84=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/utils/app_font.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/utils/app_font.dart b/lib/utils/app_font.dart index ec64b7066e..0d0ab29d8b 100644 --- a/lib/utils/app_font.dart +++ b/lib/utils/app_font.dart @@ -19,6 +19,7 @@ abstract final class AppFont { final fontPath = Pref.customFontPath; final fontFamily = Pref.customFontFamily; if (fontPath == null || fontFamily == null) { + await _cleanupFontDir(); return; } @@ -30,6 +31,7 @@ abstract final class AppFont { try { await _loadFont(fontPath: fontPath, fontFamily: fontFamily); + await _cleanupFontDir(excludePath: fontPath); } catch (_) { await clear(); } @@ -88,6 +90,7 @@ abstract final class AppFont { } catch (_) {} } } + await _cleanupFontDir(excludePath: targetPath); return true; } catch (_) { if (targetFile.existsSync()) { @@ -112,6 +115,7 @@ abstract final class AppFont { await file.delete(); } catch (_) {} } + await _cleanupFontDir(); return true; } @@ -124,4 +128,27 @@ abstract final class AppFont { loader.addFont(Future.value(ByteData.sublistView(bytes))); await loader.load(); } + + static Future _cleanupFontDir({String? excludePath}) async { + final fontDir = Directory(path.join(appSupportDirPath, _fontDirName)); + if (!fontDir.existsSync()) { + return; + } + + await for (final entity in fontDir.list()) { + if (entity is! File) { + continue; + } + if (excludePath != null && path.equals(entity.path, excludePath)) { + continue; + } + final extension = path.extension(entity.path).replaceFirst('.', '').toLowerCase(); + if (!allowedExtensions.contains(extension)) { + continue; + } + try { + await entity.delete(); + } catch (_) {} + } + } } From 27a864d981c1313c6a25e9d2c368e7c653a7e58d Mon Sep 17 00:00:00 2001 From: Starfallen <36763490+Starfallan@users.noreply.github.com> Date: Sun, 8 Mar 2026 10:53:44 +0800 Subject: [PATCH 3/3] tweaks --- lib/pages/setting/models/style_settings.dart | 17 +++++--- lib/utils/app_font.dart | 44 +++++++++++--------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/lib/pages/setting/models/style_settings.dart b/lib/pages/setting/models/style_settings.dart index d62ce838e8..e413f0b3de 100644 --- a/lib/pages/setting/models/style_settings.dart +++ b/lib/pages/setting/models/style_settings.dart @@ -499,9 +499,10 @@ Future _showCustomFontDialog( BuildContext context, VoidCallback setState, ) async { + final pageContext = context; await showDialog( - context: context, - builder: (context) => AlertDialog( + context: pageContext, + builder: (dialogContext) => AlertDialog( title: const Text('应用字体'), content: Text( AppFont.currentFontName == null @@ -510,13 +511,16 @@ Future _showCustomFontDialog( ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('取消'), ), TextButton( onPressed: () async { - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(); final cleared = await AppFont.clear(); + if (!pageContext.mounted) { + return; + } if (cleared) { setState(); Get.forceAppUpdate(); @@ -529,9 +533,12 @@ Future _showCustomFontDialog( ), FilledButton( onPressed: () async { - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(); try { final changed = await AppFont.pickAndApply(); + if (!pageContext.mounted) { + return; + } if (changed) { setState(); Get.forceAppUpdate(); diff --git a/lib/utils/app_font.dart b/lib/utils/app_font.dart index 0d0ab29d8b..2ba4182fab 100644 --- a/lib/utils/app_font.dart +++ b/lib/utils/app_font.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; @@ -64,10 +63,10 @@ abstract final class AppFont { final timestamp = DateTime.now().millisecondsSinceEpoch; final targetPath = path.join(fontDir.path, 'custom_font_$timestamp.$extension'); final targetFile = File(targetPath); - if (picked.path case final String sourcePath) { - await File(sourcePath).copy(targetPath); - } else if (picked.bytes case final Uint8List bytes) { + if (picked.bytes case final Uint8List bytes) { await targetFile.writeAsBytes(bytes, flush: true); + } else if (picked.path case final String sourcePath) { + await File(sourcePath).copy(targetPath); } else { throw StateError('missing font bytes'); } @@ -102,21 +101,23 @@ abstract final class AppFont { static Future clear() async { final fontPath = Pref.customFontPath; + final hadCustomFont = + (fontPath != null && fontPath.isNotEmpty) || + Pref.customFontFamily != null || + Pref.customFontName != null; await GStorage.setting.delete(SettingBoxKey.customFontPath); await GStorage.setting.delete(SettingBoxKey.customFontFamily); await GStorage.setting.delete(SettingBoxKey.customFontName); - if (fontPath == null || fontPath.isEmpty) { - return false; - } - - final file = File(fontPath); - if (file.existsSync()) { - try { - await file.delete(); - } catch (_) {} + if (fontPath != null && fontPath.isNotEmpty) { + final file = File(fontPath); + if (file.existsSync()) { + try { + await file.delete(); + } catch (_) {} + } } - await _cleanupFontDir(); - return true; + final deletedFiles = await _cleanupFontDir(); + return hadCustomFont || deletedFiles; } static Future _loadFont({ @@ -124,17 +125,18 @@ abstract final class AppFont { required String fontFamily, }) async { final bytes = await File(fontPath).readAsBytes(); - final loader = FontLoader(fontFamily); - loader.addFont(Future.value(ByteData.sublistView(bytes))); - await loader.load(); + await (FontLoader(fontFamily) + ..addFont(Future.value(ByteData.sublistView(bytes)))) + .load(); } - static Future _cleanupFontDir({String? excludePath}) async { + static Future _cleanupFontDir({String? excludePath}) async { final fontDir = Directory(path.join(appSupportDirPath, _fontDirName)); if (!fontDir.existsSync()) { - return; + return false; } + var deletedAny = false; await for (final entity in fontDir.list()) { if (entity is! File) { continue; @@ -148,7 +150,9 @@ abstract final class AppFont { } try { await entity.delete(); + deletedAny = true; } catch (_) {} } + return deletedAny; } }