diff --git a/assets/fonts/custom_icon.ttf b/assets/fonts/custom_icon.ttf index ea091ac5..88e7f64f 100644 Binary files a/assets/fonts/custom_icon.ttf and b/assets/fonts/custom_icon.ttf differ diff --git a/assets/images/dm_tip/player_dm_tip_center.svg b/assets/images/dm_tip/player_dm_tip_center.svg new file mode 100644 index 00000000..9c362584 --- /dev/null +++ b/assets/images/dm_tip/player_dm_tip_center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/dm_tip/player_dm_tip_left.svg b/assets/images/dm_tip/player_dm_tip_left.svg new file mode 100644 index 00000000..1173549f --- /dev/null +++ b/assets/images/dm_tip/player_dm_tip_left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/dm_tip/player_dm_tip_right.svg b/assets/images/dm_tip/player_dm_tip_right.svg new file mode 100644 index 00000000..5d5260ce --- /dev/null +++ b/assets/images/dm_tip/player_dm_tip_right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/common/widgets/custom_icon.dart b/lib/common/widgets/custom_icon.dart index 8555a8d3..69d1c893 100644 --- a/lib/common/widgets/custom_icon.dart +++ b/lib/common/widgets/custom_icon.dart @@ -10,19 +10,24 @@ class CustomIcons { static const IconData dyn = _CustomIconData(0xe804); static const IconData fav = _CustomIconData(0xe805); static const IconData live_reserve = _CustomIconData(0xe806); - static const IconData share = _CustomIconData(0xe807); - static const IconData share_line = _CustomIconData(0xe808); - static const IconData share_node = _CustomIconData(0xe809); - static const IconData star_favorite_line = _CustomIconData(0xe80a); - static const IconData star_favorite_solid = _CustomIconData(0xe80b); - static const IconData thumbs_down = _CustomIconData(0xe80c); - static const IconData thumbs_down_outline = _CustomIconData(0xe80d); - static const IconData thumbs_up = _CustomIconData(0xe80e); - static const IconData thumbs_up_fill = _CustomIconData(0xe80f); - static const IconData thumbs_up_line = _CustomIconData(0xe810); - static const IconData thumbs_up_outline = _CustomIconData(0xe811); - static const IconData topic_tag = _CustomIconData(0xe812); - static const IconData watch_later = _CustomIconData(0xe813); + static const IconData player_dm_tip_back = _CustomIconData(0xe807); + static const IconData player_dm_tip_copy = _CustomIconData(0xe808); + static const IconData player_dm_tip_like = _CustomIconData(0xe809); + static const IconData player_dm_tip_like_solid = _CustomIconData(0xe80a); + static const IconData player_dm_tip_recall = _CustomIconData(0xe80b); + static const IconData share = _CustomIconData(0xe80c); + static const IconData share_line = _CustomIconData(0xe80d); + static const IconData share_node = _CustomIconData(0xe80e); + static const IconData star_favorite_line = _CustomIconData(0xe80f); + static const IconData star_favorite_solid = _CustomIconData(0xe810); + static const IconData thumbs_down = _CustomIconData(0xe811); + static const IconData thumbs_down_outline = _CustomIconData(0xe812); + static const IconData thumbs_up = _CustomIconData(0xe813); + static const IconData thumbs_up_fill = _CustomIconData(0xe814); + static const IconData thumbs_up_line = _CustomIconData(0xe815); + static const IconData thumbs_up_outline = _CustomIconData(0xe816); + static const IconData topic_tag = _CustomIconData(0xe817); + static const IconData watch_later = _CustomIconData(0xe818); } class _CustomIconData extends IconData { diff --git a/lib/common/widgets/image/flutter_svg_provider.dart b/lib/common/widgets/image/flutter_svg_provider.dart new file mode 100644 index 00000000..dabe06cd --- /dev/null +++ b/lib/common/widgets/image/flutter_svg_provider.dart @@ -0,0 +1,159 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_svg/flutter_svg.dart'; + +/// https://github.com/yang-f/flutter_svg_provider + +/// Rasterizes given svg picture for displaying in [Image] widget: +/// +/// ```dart +/// Image( +/// width: 32, +/// height: 32, +/// image: Svg('assets/my_icon.svg'), +/// ) +/// ``` +class SvgImageProvider extends ImageProvider { + /// Path to svg file or asset + final String path; + + /// Size in logical pixels to render. + /// Useful for [DecorationImage]. + /// If not specified, will use size from [Image]. + /// If [Image] not specifies size too, will use default size 100x100. + final Size? size; + + /// Color to tint the SVG + final Color? color; + + /// Image scale. + final double? scale; + + /// Width and height can also be specified from [Image] constructor. + /// Default size is 100x100 logical pixels. + /// Different size can be specified in [Image] parameters + const SvgImageProvider( + this.path, { + this.size, + this.scale, + this.color, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + final Color color = this.color ?? Colors.transparent; + final double scale = this.scale ?? configuration.devicePixelRatio ?? 1.0; + final double logicWidth = size?.width ?? configuration.size?.width ?? 100; + final double logicHeight = + size?.height ?? configuration.size?.height ?? 100; + + return SynchronousFuture( + SvgImageKey( + path: path, + scale: scale, + color: color, + pixelWidth: (logicWidth * scale).round(), + pixelHeight: (logicHeight * scale).round(), + ), + ); + } + + @override + ImageStreamCompleter loadImage(SvgImageKey key, ImageDecoderCallback decode) { + return OneFrameImageStreamCompleter( + _loadAsync(key, getFilterColor(color)), + ); + } + + static Future _loadAsync(SvgImageKey key, Color color) async { + final rawSvg = await rootBundle.loadString(key.path); + final pictureInfo = await vg.loadPicture( + SvgStringLoader(rawSvg, theme: SvgTheme(currentColor: color)), + null, + clipViewbox: false, + ); + + try { + final image = pictureInfo.picture.toImageSync( + pictureInfo.size.width.round(), + pictureInfo.size.height.round(), + ); + return ImageInfo(image: image); + } finally { + // Dispose of the Picture to release resources + pictureInfo.picture.dispose(); + } + } + + // Note: == and hashCode not overrided as changes in properties + // (width, height and scale) are not observable from the here. + // [SvgImageKey] instances will be compared instead. + @override + String toString() => '$runtimeType(${describeIdentity(path)})'; + + // Running on web with Colors.transparent may throws the exception `Expected a value of type 'SkDeletable', but got one of type 'Null'`. + static Color getFilterColor(Color? color) { + if (kIsWeb && color == Colors.transparent) { + return const Color(0x01ffffff); + } else { + return color ?? Colors.transparent; + } + } +} + +@immutable +class SvgImageKey { + const SvgImageKey({ + required this.path, + required this.pixelWidth, + required this.pixelHeight, + required this.scale, + this.color, + }); + + /// Path to svg asset. + final String path; + + /// Width in physical pixels. + /// Used when raterizing. + final int pixelWidth; + + /// Height in physical pixels. + /// Used when raterizing. + final int pixelHeight; + + /// Color to tint the SVG + final Color? color; + + /// Used to calculate logical size from physical, i.e. + /// logicalWidth = [pixelWidth] / [scale], + /// logicalHeight = [pixelHeight] / [scale]. + /// Should be equal to [MediaQueryData.devicePixelRatio]. + final double scale; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is SvgImageKey && + other.path == path && + other.pixelWidth == pixelWidth && + other.pixelHeight == pixelHeight && + other.scale == scale && + other.color == color; + } + + @override + int get hashCode => Object.hash( + path, + pixelWidth, + pixelHeight, + scale, + color, + ); +} diff --git a/lib/pages/setting/models/play_settings.dart b/lib/pages/setting/models/play_settings.dart index 6eff199f..34e07caf 100644 --- a/lib/pages/setting/models/play_settings.dart +++ b/lib/pages/setting/models/play_settings.dart @@ -33,6 +33,7 @@ List get playSettings => [ const SettingsModel( settingsType: SettingsType.sw1tch, title: '启用点击弹幕', + subtitle: '点击弹幕悬停,支持点赞、复制、举报操作', leading: Icon(Icons.touch_app_outlined), setKey: SettingBoxKey.enableTapDm, defaultVal: true, diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index 1e846729..065a8847 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -4,8 +4,10 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/gesture/immediate_tap_gesture_recognizer.dart'; import 'package:PiliPlus/common/widgets/gesture/mouse_interactive_viewer.dart'; +import 'package:PiliPlus/common/widgets/image/flutter_svg_provider.dart'; import 'package:PiliPlus/common/widgets/loading_widget.dart'; import 'package:PiliPlus/common/widgets/pair.dart'; import 'package:PiliPlus/common/widgets/progress_bar/audio_video_progress_bar.dart'; @@ -867,14 +869,15 @@ class _PLVideoPlayerState extends State void _onInteractionStart(ScaleStartDetails details) { if (plPlayerController.controlsLock.value) return; // 如果起点太靠上则屏蔽 - if (details.localFocalPoint.dy < 40) return; - if (details.localFocalPoint.dx < 40) return; - if (details.localFocalPoint.dx > maxWidth - 40) return; - if (details.localFocalPoint.dy > maxHeight - 40) return; + final localFocalPoint = details.localFocalPoint; + final dx = localFocalPoint.dx; + final dy = localFocalPoint.dy; + if (dx < 40 || dy < 40) return; + if (dx > maxWidth - 40 || dy > maxHeight - 40) return; if (details.pointerCount > 1) { interacting = true; } - plPlayerController.initialFocalPoint = details.localFocalPoint; + plPlayerController.initialFocalPoint = localFocalPoint; // if (kDebugMode) { // debugPrint("_initialFocalPoint$_initialFocalPoint"); // } @@ -899,10 +902,12 @@ class _PLVideoPlayerState extends State if (_gestureType == null) { if (cumulativeDelta.distance < 1) return; - if (cumulativeDelta.dx.abs() > 3 * cumulativeDelta.dy.abs()) { + final dx = cumulativeDelta.dx.abs(); + final dy = cumulativeDelta.dy.abs(); + if (dx > 3 * dy) { _gestureType = GestureType.horizontal; _showControlsIfNeeded(); - } else if (cumulativeDelta.dy.abs() > 3 * cumulativeDelta.dx.abs()) { + } else if (dy > 3 * dx) { if (!plPlayerController.enableSlideVolumeBrightness && !plPlayerController.enableSlideFS) { return; @@ -1208,10 +1213,12 @@ class _PLVideoPlayerState extends State if (_gestureType == null) { final pan = event.pan; if (pan.distance < 1) return; - if (pan.dx.abs() > 3 * pan.dy.abs()) { + final dx = pan.dx.abs(); + final dy = pan.dy.abs(); + if (dx > 3 * dy) { _gestureType = GestureType.horizontal; _showControlsIfNeeded(); - } else if (pan.dy.abs() > 3 * pan.dx.abs()) { + } else if (dy > 3 * dx) { _gestureType = GestureType.right; } return; @@ -2179,7 +2186,7 @@ class _PLVideoPlayerState extends State } static const _overlaySpacing = 10.0; - static const _overlayWidth = 130.0; + static const _overlayWidth = 118.0; static const _overlayHeight = 35.0; DanmakuItem? _suspendedDm; @@ -2196,7 +2203,10 @@ class _PLVideoPlayerState extends State Widget _dmActionItem(Widget child, {required VoidCallback onTap}) { return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: onTap, + onTap: () { + _removeDmAction(); + onTap(); + }, child: SizedBox( height: _overlayHeight, width: _overlayWidth / 3, @@ -2207,12 +2217,49 @@ class _PLVideoPlayerState extends State ); } + BoxDecoration _getDmTipBg(DanmakuItem item, double dx) { + const offset = 65; + const size = Size(_overlayWidth, _overlayHeight); + if (item.xPosition >= maxWidth - offset) { + return const BoxDecoration( + image: DecorationImage( + filterQuality: FilterQuality.low, + image: SvgImageProvider( + 'assets/images/dm_tip/player_dm_tip_right.svg', + size: size, + ), + ), + ); + } + if (item.xPosition + item.width <= offset) { + return const BoxDecoration( + image: DecorationImage( + filterQuality: FilterQuality.low, + image: SvgImageProvider( + 'assets/images/dm_tip/player_dm_tip_left.svg', + size: size, + ), + ), + ); + } + return const BoxDecoration( + image: DecorationImage( + filterQuality: FilterQuality.low, + image: SvgImageProvider( + 'assets/images/dm_tip/player_dm_tip_center.svg', + size: size, + ), + ), + ); + } + Widget _buildDmAction( DanmakuItem item, Offset offset, ) { + final dx = offset.dx; // fullscreen - if (offset.dx > maxWidth) { + if (dx > maxWidth) { _removeDmAction(); return const Positioned(left: 0, top: 0, child: SizedBox.shrink()); } @@ -2224,28 +2271,24 @@ class _PLVideoPlayerState extends State final right = maxWidth - clampDouble( - offset.dx + _overlayWidth / 2, + dx + _overlayWidth / 2, _overlaySpacing + _overlayWidth, maxWidth - _overlaySpacing, ); + // TODO LiveDanmaku final extra = item.content.extra as VideoDanmaku; + return Positioned( right: right, top: top, - child: Column( - children: [ - const CustomPaint( - painter: _TrianglePainter(Colors.black54), - size: Size(12, 6), - ), - Container( - width: _overlayWidth, - height: _overlayHeight, - decoration: const BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.all(Radius.circular(18)), - ), + child: SizedBox( + width: _overlayWidth, + height: _overlayHeight, + child: DecoratedBox( + decoration: _getDmTipBg(item, dx), + child: Padding( + padding: const EdgeInsets.only(top: 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2254,64 +2297,52 @@ class _PLVideoPlayerState extends State Icon( size: 20, extra.isLike - ? Icons.thumb_up_off_alt_sharp - : Icons.thumb_up_off_alt_outlined, + ? CustomIcons.player_dm_tip_like_solid + : CustomIcons.player_dm_tip_like, color: Colors.white, ), - onTap: () { - _removeDmAction(); - HeaderControl.likeDanmaku( - extra, - plPlayerController.cid!, - ); - }, + onTap: () => HeaderControl.likeDanmaku( + extra, + plPlayerController.cid!, + ), ), _dmActionItem( const Icon( - size: 20, - Icons.copy, + size: 19, + CustomIcons.player_dm_tip_copy, color: Colors.white, ), - onTap: () { - _removeDmAction(); - Utils.copyText(item.content.text); - }, + onTap: () => Utils.copyText(item.content.text), ), if (item.content.selfSend) _dmActionItem( const Icon( size: 20, - Icons.delete, + CustomIcons.player_dm_tip_recall, color: Colors.white, ), - onTap: () { - _removeDmAction(); - HeaderControl.deleteDanmaku( - extra.id, - plPlayerController.cid!, - ); - }, + onTap: () => HeaderControl.deleteDanmaku( + extra.id, + plPlayerController.cid!, + ), ) else _dmActionItem( const Icon( size: 20, - Icons.report_problem_outlined, + CustomIcons.player_dm_tip_back, color: Colors.white, ), - onTap: () { - _removeDmAction(); - HeaderControl.reportDanmaku( - extra, - context, - plPlayerController, - ); - }, + onTap: () => HeaderControl.reportDanmaku( + extra, + context, + plPlayerController, + ), ), ], ), ), - ], + ), ), ); } @@ -2659,27 +2690,3 @@ Widget buildViewPointWidget( ), ); } - -class _TrianglePainter extends CustomPainter { - const _TrianglePainter(this.color); - final Color color; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..style = PaintingStyle.fill; - - final path = Path() - ..moveTo(0, size.height) - ..lineTo(size.width, size.height) - ..lineTo(size.width / 2, 0) - ..close(); - - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant _TrianglePainter oldDelegate) => - color != oldDelegate.color; -} diff --git a/pubspec.yaml b/pubspec.yaml index 68fbc6f9..a6284aad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -326,6 +326,7 @@ flutter: - assets/images/live/ - assets/images/video/ - assets/images/paycoins/ + - assets/images/dm_tip/ - assets/shaders/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware