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..e413f0b3de 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,66 @@ void _showQualityDialog({ }); } +Future _showCustomFontDialog( + BuildContext context, + VoidCallback setState, +) async { + final pageContext = context; + await showDialog( + context: pageContext, + builder: (dialogContext) => AlertDialog( + title: const Text('应用字体'), + content: Text( + AppFont.currentFontName == null + ? '当前使用系统字体。' + : '当前字体:${AppFont.currentFontName}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + Navigator.of(dialogContext).pop(); + final cleared = await AppFont.clear(); + if (!pageContext.mounted) { + return; + } + if (cleared) { + setState(); + Get.forceAppUpdate(); + SmartDialog.showToast('已恢复为系统字体'); + } else { + SmartDialog.showToast('当前已经是系统字体'); + } + }, + child: const Text('系统字体'), + ), + FilledButton( + onPressed: () async { + Navigator.of(dialogContext).pop(); + try { + final changed = await AppFont.pickAndApply(); + if (!pageContext.mounted) { + return; + } + 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..2ba4182fab --- /dev/null +++ b/lib/utils/app_font.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +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) { + await _cleanupFontDir(); + return; + } + + final file = File(fontPath); + if (!file.existsSync()) { + await clear(); + return; + } + + try { + await _loadFont(fontPath: fontPath, fontFamily: fontFamily); + await _cleanupFontDir(excludePath: fontPath); + } 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.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'); + } + + 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 (_) {} + } + } + await _cleanupFontDir(excludePath: targetPath); + return true; + } catch (_) { + if (targetFile.existsSync()) { + await targetFile.delete(); + } + rethrow; + } + } + + 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.isNotEmpty) { + final file = File(fontPath); + if (file.existsSync()) { + try { + await file.delete(); + } catch (_) {} + } + } + final deletedFiles = await _cleanupFontDir(); + return hadCustomFont || deletedFiles; + } + + static Future _loadFont({ + required String fontPath, + required String fontFamily, + }) async { + final bytes = await File(fontPath).readAsBytes(); + await (FontLoader(fontFamily) + ..addFont(Future.value(ByteData.sublistView(bytes)))) + .load(); + } + + static Future _cleanupFontDir({String? excludePath}) async { + final fontDir = Directory(path.join(appSupportDirPath, _fontDirName)); + if (!fontDir.existsSync()) { + return false; + } + + var deletedAny = false; + 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(); + deletedAny = true; + } catch (_) {} + } + return deletedAny; + } +} 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);