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