From 938af524b484dd7973e70bef04ac5cede30efdb8 Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Fri, 28 Jun 2024 03:02:35 +0300 Subject: [PATCH] feat(yt): description and comments native style --- lib/youtube/pages/yt_playlist_subpage.dart | 28 +++ lib/youtube/widgets/namida_read_more.dart | 46 ++-- lib/youtube/widgets/yt_comment_card.dart | 157 ++++++------- .../widgets/yt_description_widget.dart | 207 ++++++++++++++++++ lib/youtube/youtube_miniplayer.dart | 49 +---- .../yt_miniplayer_comments_subpage.dart | 6 +- pubspec.yaml | 2 +- 7 files changed, 342 insertions(+), 153 deletions(-) create mode 100644 lib/youtube/widgets/yt_description_widget.dart diff --git a/lib/youtube/pages/yt_playlist_subpage.dart b/lib/youtube/pages/yt_playlist_subpage.dart index e6190e99..210067e1 100644 --- a/lib/youtube/pages/yt_playlist_subpage.dart +++ b/lib/youtube/pages/yt_playlist_subpage.dart @@ -6,6 +6,7 @@ import 'package:youtipie/class/result_wrapper/list_wrapper_base.dart'; import 'package:youtipie/class/result_wrapper/playlist_result.dart'; import 'package:youtipie/class/result_wrapper/playlist_result_base.dart'; import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/class/youtipie_feed/playlist_basic_info.dart'; import 'package:youtipie/youtipie.dart'; import 'package:namida/base/youtube_streams_manager.dart'; @@ -370,6 +371,11 @@ class YTHostedPlaylistSubpage extends StatefulWidget with NamidaRouteWidget { required this.playlist, }); + YTHostedPlaylistSubpage.fromId({ + super.key, + required String playlistId, + }) : playlist = _EmptyPlaylistResult(playlistId: playlistId); + @override State createState() => _YTHostedPlaylistSubpageState(); } @@ -705,3 +711,25 @@ class _YTHostedPlaylistSubpageState extends State with ); } } + +/// not meant for usage, just a placeholder instead of nullifying everything +class _EmptyPlaylistResult extends YoutiPiePlaylistResultBase { + _EmptyPlaylistResult({ + required String playlistId, + }) : super( + basicInfo: PlaylistBasicInfo(id: playlistId, title: '', videosCountText: null, videosCount: null, thumbnails: []), + items: [], + cacheKey: null, + continuation: null, + ); + + @override + Future fetchNextFunction(ExecuteDetails? details) async { + return false; + } + + @override + Map toMap() { + return {}; + } +} diff --git a/lib/youtube/widgets/namida_read_more.dart b/lib/youtube/widgets/namida_read_more.dart index e9b7eb7e..8eec9a00 100644 --- a/lib/youtube/widgets/namida_read_more.dart +++ b/lib/youtube/widgets/namida_read_more.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; class NamidaReadMoreText extends StatefulWidget { - final String text; + final TextSpan span; final int lines; final Locale? locale; final Widget Function( - String text, + TextSpan span, int? lines, bool isExpanded, bool exceededMaxLines, @@ -14,7 +14,7 @@ class NamidaReadMoreText extends StatefulWidget { const NamidaReadMoreText({ super.key, - required this.text, + required this.span, required this.lines, this.locale, required this.builder, @@ -30,27 +30,25 @@ class _ReadMoreTextState extends State { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final locale = widget.locale ?? Localizations.maybeLocaleOf(context); - final span = TextSpan(text: widget.text); - final tp = TextPainter( - text: span, - locale: locale, - maxLines: widget.lines, - textDirection: Directionality.of(context), - ); - tp.layout(maxWidth: constraints.maxWidth); - final exceededMaxLines = tp.didExceedMaxLines; - tp.dispose(); - return widget.builder( - widget.text, - _isTextExpanded || !exceededMaxLines ? null : widget.lines, - _isTextExpanded, - exceededMaxLines, - _onReadMoreClicked, - ); - }, + final locale = widget.locale ?? Localizations.maybeLocaleOf(context); + final tp = TextPainter( + text: widget.span, + locale: locale, + maxLines: widget.lines, + textDirection: Directionality.of(context), + ); + bool exceededMaxLines = false; + try { + tp.layout(); + exceededMaxLines = tp.didExceedMaxLines; + } catch (_) {} + tp.dispose(); + return widget.builder( + widget.span, + _isTextExpanded || !exceededMaxLines ? null : widget.lines, + _isTextExpanded, + exceededMaxLines, + _onReadMoreClicked, ); } } diff --git a/lib/youtube/widgets/yt_comment_card.dart b/lib/youtube/widgets/yt_comment_card.dart index ca170123..80a7e03d 100644 --- a/lib/youtube/widgets/yt_comment_card.dart +++ b/lib/youtube/widgets/yt_comment_card.dart @@ -1,11 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:selectable_autolink_text/selectable_autolink_text.dart'; -import 'package:youtipie/class/comments/comment_info_item.dart'; import 'package:namida/controller/navigator_controller.dart'; -import 'package:namida/controller/player_controller.dart'; -import 'package:namida/core/constants.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/icon_fonts/broken_icons.dart'; import 'package:namida/core/translations/language.dart'; @@ -13,13 +9,16 @@ import 'package:namida/core/utils.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/pages/yt_channel_subpage.dart'; import 'package:namida/youtube/widgets/namida_read_more.dart'; +import 'package:namida/youtube/widgets/yt_description_widget.dart'; import 'package:namida/youtube/widgets/yt_shimmer.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; +import 'package:youtipie/class/comments/comment_info_item.dart'; class YTCommentCard extends StatelessWidget { final EdgeInsetsGeometry? margin; + final String? videoId; final CommentInfoItem? comment; - const YTCommentCard({super.key, required this.comment, required this.margin}); + const YTCommentCard({super.key, required this.videoId, required this.comment, required this.margin}); @override Widget build(BuildContext context) { @@ -27,7 +26,7 @@ class YTCommentCard extends StatelessWidget { final author = comment?.author?.displayName; final isArtist = comment?.author?.isArtist ?? false; final uploadedFrom = comment?.publishedTimeText; - final commentTextParsed = comment?.text; + final commentContent = comment?.content; final likeCount = comment?.likesCount; final repliesCount = comment?.repliesCount; final isHearted = comment?.isHearted ?? false; @@ -41,6 +40,7 @@ class YTCommentCard extends StatelessWidget { fontWeight: FontWeight.w400, color: authorTextColor, ); + return Stack( children: [ Padding( @@ -148,88 +148,72 @@ class YTCommentCard extends StatelessWidget { ), const SizedBox(height: 4.0), AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: commentTextParsed == null - ? Column( - children: [ - ...List.filled( - (4 - 1).getRandomNumberBelow(1), - const Padding( - padding: EdgeInsets.only(top: 2.0), - child: NamidaDummyContainer( - width: null, - height: 12.0, - borderRadius: 4.0, - shimmerEnabled: true, - child: null, - ), - ), - ), - ], - ) - : NamidaReadMoreText( - text: commentTextParsed, - lines: 5, - builder: (text, lines, isExpanded, exceededMaxLines, toggle) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableAutoLinkText( - text, - maxLines: lines, - style: context.textTheme.displaySmall?.copyWith( - fontSize: 13.5, - fontWeight: FontWeight.w500, - color: context.theme.colorScheme.onSurface.withAlpha(220), - ), - linkStyle: context.textTheme.displayMedium?.copyWith( - color: context.theme.colorScheme.primary.withAlpha(210), - fontSize: 13.5, + duration: const Duration(milliseconds: 200), + child: commentContent == null + ? Column( + children: [ + ...List.filled( + (4 - 1).getRandomNumberBelow(1), + const Padding( + padding: EdgeInsets.only(top: 2.0), + child: NamidaDummyContainer( + width: null, + height: 12.0, + borderRadius: 4.0, + shimmerEnabled: true, + child: null, ), - highlightedLinkStyle: TextStyle( - color: context.theme.colorScheme.primary.withAlpha(220), - backgroundColor: context.theme.colorScheme.onSurface.withAlpha(40), - fontSize: 13.5, - ), - scrollPhysics: const NeverScrollableScrollPhysics(), - linkRegExpPattern: NamidaLinkRegex.all, - onTap: (url) async { - final dur = NamidaLinkUtils.parseDuration(url); - if (dur != null) { - Player.inst.seek(dur); - } else { - NamidaLinkUtils.openLink(url); - } - }, ), - if (exceededMaxLines) - Padding( - padding: const EdgeInsets.all(8.0), - child: TapDetector( - onTap: toggle, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, + ), + ], + ) + : commentContent.rawText == null + ? const SizedBox() + : YoutubeDescriptionWidget( + videoId: videoId, + content: commentContent, + linkColor: context.theme.colorScheme.primary.withAlpha(210), + childBuilder: (span) { + return NamidaReadMoreText( + span: span, + lines: 5, + builder: (span, lines, isExpanded, exceededMaxLines, toggle) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - isExpanded ? '' : lang.SHOW_MORE, - style: context.textTheme.displaySmall?.copyWith(color: readmoreColor), - ), - const SizedBox(width: 8), - Icon( - isExpanded ? Broken.arrow_up_3 : Broken.arrow_down_2, - size: 18.0, - color: readmoreColor, + Text.rich( + span, + maxLines: lines, ), + if (exceededMaxLines) + Padding( + padding: const EdgeInsets.all(8.0), + child: TapDetector( + onTap: toggle, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + isExpanded ? '' : lang.SHOW_MORE, + style: context.textTheme.displaySmall?.copyWith(color: readmoreColor), + ), + const SizedBox(width: 8), + Icon( + isExpanded ? Broken.arrow_up_3 : Broken.arrow_down_2, + size: 18.0, + color: readmoreColor, + ), + ], + ), + ), + ), ], - ), - ), - ), - ], - ); - }, - ), - ), + ); + }, + ); + }, + )), const SizedBox(height: 8.0), Row( children: [ @@ -289,8 +273,9 @@ class YTCommentCard extends StatelessWidget { icon: Broken.copy, title: lang.COPY, onTap: () { - if (commentTextParsed != null) { - Clipboard.setData(ClipboardData(text: commentTextParsed)); + final rawText = comment?.content.rawText; + if (rawText != null) { + Clipboard.setData(ClipboardData(text: rawText)); } }, ), @@ -325,7 +310,7 @@ class YTCommentCardCompact extends StatelessWidget { final uploaderAvatar = comment?.authorAvatarUrl ?? comment?.author?.avatarThumbnailUrl; final author = comment?.author?.displayName; final uploadedFrom = comment?.publishedTimeText; - final commentTextParsed = comment?.text; + final commentTextParsed = comment?.content.rawText; final likeCount = comment?.likesCount; final repliesCount = comment?.repliesCount; final isHearted = comment?.isHearted ?? false; diff --git a/lib/youtube/widgets/yt_description_widget.dart b/lib/youtube/widgets/yt_description_widget.dart new file mode 100644 index 00000000..2c99af00 --- /dev/null +++ b/lib/youtube/widgets/yt_description_widget.dart @@ -0,0 +1,207 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:vibration/vibration.dart'; +import 'package:youtipie/class/youtipie_description/youtipie_description.dart'; + +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/controller/player_controller.dart'; +import 'package:namida/core/constants.dart'; +import 'package:namida/core/enums.dart'; +import 'package:namida/core/extensions.dart'; +import 'package:namida/core/utils.dart'; +import 'package:namida/ui/widgets/custom_widgets.dart'; +import 'package:namida/youtube/class/youtube_id.dart'; +import 'package:namida/youtube/pages/yt_channel_subpage.dart'; +import 'package:namida/youtube/pages/yt_playlist_subpage.dart'; +import 'package:namida/youtube/widgets/yt_thumbnail.dart'; + +class YoutubeDescriptionWidget extends StatefulWidget { + final String? videoId; + final YoutipieDescription content; + final Color? linkColor; + final Widget Function(TextSpan span)? childBuilder; + + const YoutubeDescriptionWidget({ + super.key, + required this.videoId, + required this.content, + this.childBuilder, + this.linkColor, + }); + + @override + State createState() => _YoutubeDescriptionWidgetState(); +} + +class _YoutubeDescriptionWidgetState extends State { + late final _manager = YoutubeDescriptionWidgetManager(); + + @override + void dispose() { + _manager.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final linkColor = widget.linkColor ?? context.theme.colorScheme.primary.withAlpha(210); + final parts = widget.content.parts; + final TextSpan mainSpan; + if (parts != null && parts.isNotEmpty) { + mainSpan = _manager.buildMainSpan(parts, widget.videoId, linkColor); + } else { + mainSpan = TextSpan(text: widget.content.rawText); + } + + final Widget child; + + if (widget.childBuilder != null) { + child = widget.childBuilder!(mainSpan); + } else { + child = Text.rich( + mainSpan, + textAlign: TextAlign.start, + ); + } + + return SelectionArea(child: child); + } +} + +class YoutubeDescriptionWidgetManager { + YoutubeDescriptionWidgetManager(); + + late final _activeRecognizers = []; + + void dispose() { + _activeRecognizers.loop((item) => item.dispose()); + _activeRecognizers.clear(); + } + + TextSpan buildMainSpan(List styleParts, String? videoId, Color linkColor) { + return TextSpan( + children: styleParts.mapped((sw) => _styleWrapperToSpan(sw, videoId, linkColor)), + ); + } + + Widget? _latestAttachment; + InlineSpan _styleWrapperToSpan(StylesWrapper sw, String? videoId, Color linkColor) { + if (sw.attachementUrl != null) { + _latestAttachment = YoutubeThumbnail( + key: Key(sw.attachementUrl ?? ''), + width: 16.0, + isImportantInCache: true, + customUrl: sw.attachementUrl, + ); + return const TextSpan(); // we combining attachment with the next piece + } + bool addVMargin = false; + bool surroundWithBG = false; + if (_latestAttachment != null) { + surroundWithBG = true; + addVMargin = true; + } + void Function()? onTap; + if (sw.hashtag != null) { + // TODO: onTap for hashtags + } else if (sw.videoId != null) { + surroundWithBG = true; + onTap = () { + if (sw.videoId == videoId && sw.videoStartSeconds != null) { + Player.inst.seek(Duration(seconds: sw.videoStartSeconds!)); + Vibration.vibrate(duration: 10, amplitude: 10); + } else { + Player.inst.playOrPause(0, [YoutubeID(id: sw.videoId!, playlistID: null)], QueueSource.others); + // TODO: seek after playing? + } + }; + } else if (sw.channelId != null) { + onTap = () => NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: sw.channelId!, channel: null)); + } else if (sw.playlistId != null) { + onTap = () => NamidaNavigator.inst.navigateTo(YTHostedPlaylistSubpage.fromId(playlistId: sw.playlistId!)); + } else if (sw.link != null) { + onTap = () => NamidaLinkUtils.openLink(sw.link!); + } + + final colorized = onTap != null || sw.link != null || sw.hashtag != null; + final textStyle = TextStyle( + color: colorized ? linkColor : null, + fontSize: onTap != null ? 13.5 : 14.0, + fontStyle: sw.italic ? FontStyle.italic : FontStyle.normal, + fontWeight: sw.bold + ? FontWeight.w700 + : sw.medium + ? FontWeight.w600 + : FontWeight.w400, + decoration: sw.strikethrough ? TextDecoration.lineThrough : null, + ); + + if (surroundWithBG) { + Widget child = Text(sw.text); + + double vpadding = 2.0; + double hpadding = 4.0; + double br = 4.0; + + if (_latestAttachment != null) { + vpadding += 2.0; + hpadding += 2.0; + br += 4.0; + child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + _latestAttachment!, + child, + ], + ); + _latestAttachment = null; + } + child = ColoredBox( + color: linkColor.withOpacity(0.1), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: hpadding, vertical: vpadding), + child: child, + ), + ); + final radius = br.multipliedRadius; + if (radius > 0) { + child = ClipPath( + clipper: DecorationClipper( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + ), + ), + child: child, + ); + } + if (onTap != null) { + child = TapDetector( + onTap: onTap, + child: child, + ); + } + + if (addVMargin) { + child = Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: child, + ); + } + return WidgetSpan( + child: child, + style: textStyle, + ); + } else { + TapGestureRecognizer? recognizer; + if (onTap != null) { + recognizer = TapGestureRecognizer()..onTap = onTap; + _activeRecognizers.add(recognizer); + } + return TextSpan( + text: sw.text, + style: textStyle, + recognizer: recognizer, + ); + } + } +} diff --git a/lib/youtube/youtube_miniplayer.dart b/lib/youtube/youtube_miniplayer.dart index 706d8d48..3454aadb 100644 --- a/lib/youtube/youtube_miniplayer.dart +++ b/lib/youtube/youtube_miniplayer.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; import 'package:jiffy/jiffy.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:youtipie/class/youtipie_feed/playlist_info_item.dart'; import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; import 'package:youtipie/class/stream_info_item/stream_info_item_short.dart'; +import 'package:youtipie/class/youtipie_feed/playlist_info_item.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/controller/current_color.dart'; import 'package:namida/controller/miniplayer_controller.dart'; @@ -15,7 +15,6 @@ import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/player_controller.dart'; import 'package:namida/controller/settings_controller.dart'; import 'package:namida/controller/video_controller.dart'; -import 'package:namida/core/constants.dart'; import 'package:namida/core/dimensions.dart'; import 'package:namida/core/enums.dart'; import 'package:namida/core/extensions.dart'; @@ -41,6 +40,7 @@ import 'package:namida/youtube/pages/yt_channel_subpage.dart'; import 'package:namida/youtube/seek_ready_widget.dart'; import 'package:namida/youtube/widgets/yt_action_button.dart'; import 'package:namida/youtube/widgets/yt_comment_card.dart'; +import 'package:namida/youtube/widgets/yt_description_widget.dart'; import 'package:namida/youtube/widgets/yt_playlist_card.dart'; import 'package:namida/youtube/widgets/yt_queue_chip.dart'; import 'package:namida/youtube/widgets/yt_shimmer.dart'; @@ -49,7 +49,6 @@ import 'package:namida/youtube/widgets/yt_thumbnail.dart'; import 'package:namida/youtube/widgets/yt_video_card.dart'; import 'package:namida/youtube/yt_miniplayer_comments_subpage.dart'; import 'package:namida/youtube/yt_utils.dart'; -import 'package:youtipie/youtipie.dart'; const _space2ForThumbnail = 90.0; const _extraPaddingForYTMiniplayer = 12.0; @@ -263,39 +262,11 @@ class YoutubeMiniPlayerState extends State { final videoViewCount = videoInfo?.viewsCount; final description = videoInfo?.description; - final descriptionWidget = description == null || description == '' + final descriptionWidget = description == null ? null - : SelectionArea( - child: Html( - data: description, - style: { - '*': Style.fromTextStyle( - mainTextTheme.displayMedium!.copyWith( - fontSize: 14.0, - ), - ), - 'a': Style.fromTextStyle( - mainTextTheme.displayMedium!.copyWith( - color: mainTheme.colorScheme.primary.withAlpha(210), - fontSize: 13.5, - ), - ) - }, - onLinkTap: (url, attributes, element) async { - if (url != null) { - final partsDur = url.split("$currentId&t="); - if (partsDur.length > 1) { - try { - await Player.inst.seek(Duration(seconds: int.parse(partsDur.last))); - } catch (e) { - snackyy(title: lang.ERROR, message: e.toString(), isError: true, top: false); - } - } else { - await NamidaLinkUtils.openLink(url); - } - } - }, - ), + : YoutubeDescriptionWidget( + videoId: currentId, + content: description, ); YoutubeController.inst.downloadedFilesMap; // for refreshing. @@ -938,11 +909,10 @@ class YoutubeMiniPlayerState extends State { itemCount: 10, shrinkWrap: true, itemBuilder: (context, index) { - const comment = null; return const YTCommentCard( - key: Key("${comment == null}"), margin: EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), - comment: comment, + comment: null, + videoId: null, ); }, ), @@ -961,6 +931,7 @@ class YoutubeMiniPlayerState extends State { key: Key("${comment == null}_${comment?.commentId}"), margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), comment: comment, + videoId: currentId, ); }, ), diff --git a/lib/youtube/yt_miniplayer_comments_subpage.dart b/lib/youtube/yt_miniplayer_comments_subpage.dart index 775239d9..74ef7468 100644 --- a/lib/youtube/yt_miniplayer_comments_subpage.dart +++ b/lib/youtube/yt_miniplayer_comments_subpage.dart @@ -108,11 +108,10 @@ class _YTMiniplayerCommentsSubpageState extends State=3.4.0 <4.0.0"