From ff4632d7242c96cbd266fb65ea7e0b1f8933f6d1 Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Tue, 20 Aug 2024 01:17:35 +0300 Subject: [PATCH] feat: channel about page ref: #227 --- lib/ui/widgets/custom_widgets.dart | 13 +- .../yt_channel_info_controller.dart | 4 +- lib/youtube/pages/yt_channel_subpage.dart | 164 ++++++---- .../pages/yt_channel_subpage_about.dart | 285 ++++++++++++++++++ lib/youtube/pages/yt_channel_subpage_tab.dart | 14 +- pubspec.yaml | 2 +- 6 files changed, 411 insertions(+), 71 deletions(-) create mode 100644 lib/youtube/pages/yt_channel_subpage_about.dart diff --git a/lib/ui/widgets/custom_widgets.dart b/lib/ui/widgets/custom_widgets.dart index 134089e3..bf1aa4f8 100644 --- a/lib/ui/widgets/custom_widgets.dart +++ b/lib/ui/widgets/custom_widgets.dart @@ -2313,24 +2313,27 @@ class _FadeDismissibleState extends State with SingleTickerProv class NamidaSelectableAutoLinkText extends StatelessWidget { final String text; - const NamidaSelectableAutoLinkText({super.key, required this.text}); + final double fontScale; + const NamidaSelectableAutoLinkText({super.key, required this.text, this.fontScale = 1.0}); @override Widget build(BuildContext context) { return SelectableAutoLinkText( text, - style: context.textTheme.displayMedium?.copyWith(fontSize: 13.5), + style: context.textTheme.displayMedium?.copyWith( + fontSize: 13.5 * fontScale, + ), linkStyle: context.textTheme.displayMedium?.copyWith( color: context.theme.colorScheme.primary.withAlpha(210), - fontSize: 13.5, + fontSize: 13.5 * fontScale, ), highlightedLinkStyle: TextStyle( color: context.theme.colorScheme.primary.withAlpha(220), backgroundColor: context.theme.colorScheme.onSurface.withAlpha(40), - fontSize: 13.5, + fontSize: 13.5 * fontScale, ), scrollPhysics: const NeverScrollableScrollPhysics(), - onTap: (url) async => await NamidaLinkUtils.openLink(url), + onTap: (url) async => await NamidaLinkUtils.openLinkPreferNamida(url), ); } } diff --git a/lib/youtube/controller/info_controllers/yt_channel_info_controller.dart b/lib/youtube/controller/info_controllers/yt_channel_info_controller.dart index 33eb67a2..4270dd67 100644 --- a/lib/youtube/controller/info_controllers/yt_channel_info_controller.dart +++ b/lib/youtube/controller/info_controllers/yt_channel_info_controller.dart @@ -13,8 +13,8 @@ class _ChannelInfoController { return res.read(); } - Future fetchChannelAbout({required YoutiPieChannelPageResult channel}) async { - final res = await YoutiPie.channel.fetchChannelAbout(channel: channel); + Future fetchChannelAbout({required YoutiPieChannelPageResult channel, ExecuteDetails? details}) async { + final res = await YoutiPie.channel.fetchChannelAbout(channel: channel, details: details); return res; } diff --git a/lib/youtube/pages/yt_channel_subpage.dart b/lib/youtube/pages/yt_channel_subpage.dart index 226f1e9b..6a073627 100644 --- a/lib/youtube/pages/yt_channel_subpage.dart +++ b/lib/youtube/pages/yt_channel_subpage.dart @@ -2,13 +2,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:jiffy/jiffy.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; +import 'package:youtipie/class/channels/channel_about_link.dart'; import 'package:youtipie/class/channels/channel_home_section.dart'; import 'package:youtipie/class/channels/channel_info.dart'; import 'package:youtipie/class/channels/channel_items_sort.dart'; +import 'package:youtipie/class/channels/channel_page_about.dart'; import 'package:youtipie/class/channels/channel_page_result.dart'; import 'package:youtipie/class/channels/channel_tab.dart'; import 'package:youtipie/class/channels/channel_tab_result.dart'; @@ -30,12 +33,14 @@ import 'package:namida/controller/connectivity.dart'; import 'package:namida/controller/edit_delete_controller.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/thumbnail_manager.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'; import 'package:namida/core/icon_fonts/broken_icons.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/core/utils.dart'; +import 'package:namida/packages/three_arched_circle.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings/extra_settings.dart'; import 'package:namida/youtube/class/youtube_id.dart'; @@ -51,6 +56,7 @@ import 'package:namida/youtube/widgets/yt_thumbnail.dart'; import 'package:namida/youtube/widgets/yt_video_card.dart'; import 'package:namida/youtube/widgets/yt_videos_actions_bar.dart'; +part 'yt_channel_subpage_about.dart'; part 'yt_channel_subpage_tab.dart'; part 'yt_channel_subpage_videos_tab.dart'; @@ -84,33 +90,43 @@ class _YTChannelSubpageState extends State with TickerProvider final _tabsGlobalKeys = {}; final _tabLastFetched = {}; + final _aboutPageKey = GlobalKey<_YTChannelSubpageAboutState>(); + DateTime? _aboutPageLastFetched; + late final _scrollAnimation = AnimationController(vsync: this, value: 1.0); late final _itemsScrollController = ScrollController(); late final _scrollControllersOffsets = {}; int _tabIndex = 0; - void _setTabIndex(YoutiPieChannelPageResult res) { + /// returns tab to be selected possible index. + int? _setTabsData(YoutiPieChannelPageResult res) { + _tabsGlobalKeys.clear(); + final length = res.tabs.length; - int? videosTabIndex; + int? initiallySelected; + int? videoTabIndex; for (int i = 0; i < length; i++) { final tab = res.tabs[i]; - if (tab.initiallySelected) _tabIndex = i; + if (tab.initiallySelected) initiallySelected = i; - if (videosTabIndex == null && tab.isVideosTab()) { - videosTabIndex = i; + if (videoTabIndex == null && tab.isVideosTab()) { + videoTabIndex = i; _tabsGlobalKeys[i] = GlobalKey<_YTChannelVideosTabState>(); } else { _tabsGlobalKeys[i] = GlobalKey<_YTChannelSubpageTabState>(); } } - if (videosTabIndex != null) _tabIndex = videosTabIndex; + + return initiallySelected ?? videoTabIndex; } bool _animatedFully = false; final _scrollThreshold = 100; double _latestAnimation = 1.0; void _scrollAnimationListener() { + if (_isAboutTab()) return; + final scroll = _itemsScrollController.positions.lastOrNull; if (scroll != null) { final isDownwards = scroll.userScrollDirection == ScrollDirection.reverse; @@ -134,6 +150,10 @@ class _YTChannelSubpageState extends State with TickerProvider } } + bool _isAboutTab() { + return _tabIndex == _tabsGlobalKeys.length - 1 + 1; // last tab + } + @override void initState() { super.initState(); @@ -142,8 +162,11 @@ class _YTChannelSubpageState extends State with TickerProvider if (channelInfoCache != null) { _channelInfoSubButton.value = channelInfoCache; _channelInfo = channelInfoCache; - _setTabIndex(channelInfoCache); + final tabToBeSelected = _setTabsData(channelInfoCache); + if (tabToBeSelected != null) _tabIndex = tabToBeSelected; _fetchCurrentTab(channelInfoCache); + } else { + _tabsGlobalKeys[0] = GlobalKey<_YTChannelVideosTabState>(); } // -- always get new info. @@ -151,8 +174,13 @@ class _YTChannelSubpageState extends State with TickerProvider (value) { if (value != null) { _channelInfoSubButton.value = value; - _setTabIndex(value); - if (mounted) setState(() => _channelInfo = value); + final tabToBeSelected = _setTabsData(value); + if (mounted) { + setState(() { + if (_tabIndex == 0 && tabToBeSelected != null) _tabIndex = tabToBeSelected; // only set if tab wasnt changed + _channelInfo = value; + }); + } onRefresh(() => _fetchCurrentTab(value, forceRequest: true), forceProceed: true); } }, @@ -173,17 +201,23 @@ class _YTChannelSubpageState extends State with TickerProvider Future _fetchCurrentTab(YoutiPieChannelPageResult channelInfo, {bool? forceRequest}) async { if (_tabsGlobalKeys.isEmpty) return; - final currentKeyState = _tabsGlobalKeys[_tabIndex]?.currentState; + final currentKeyState = _isAboutTab() ? _aboutPageKey : _tabsGlobalKeys[_tabIndex]?.currentState; forceRequest ??= _shouldForceRequestTab(_tabIndex); if (currentKeyState is _YTChannelVideosTabState) { await currentKeyState.fetchChannelStreams(channelInfo, forceRequest: forceRequest); } else if (currentKeyState is _YTChannelSubpageTabState) { await currentKeyState.fetchTabAndUpdate(forceRequest: forceRequest); + } else if (currentKeyState is _YTChannelSubpageAboutState) { + await currentKeyState.fetchAboutAndUpdate(forceRequest: forceRequest); } } bool _shouldForceRequestTab(int tabIndex) { - final diff = _tabLastFetched[tabIndex]?.difference(DateTime.now()); + return _didEnoughTimePass(_tabLastFetched[tabIndex]); + } + + bool _didEnoughTimePass(DateTime? datetime) { + final diff = datetime?.difference(DateTime.now()); return diff == null ? true : diff.abs() > const Duration(seconds: 180); } @@ -376,13 +410,7 @@ class _YTChannelSubpageState extends State with TickerProvider ), const SizedBox(height: 4.0), Text( - subsCountText ?? - (subsCount == null - ? '? ${lang.SUBSCRIBERS}' - : [ - subsCount.formatDecimalShort(), - subsCount < 2 ? lang.SUBSCRIBER : lang.SUBSCRIBERS, - ].join(' ')), + subsCountText ?? (subsCount == null ? '? ${lang.SUBSCRIBERS}' : subsCount.displaySubscribersKeywordShort), style: context.textTheme.displayMedium?.copyWith( fontSize: 12.0, ), @@ -418,49 +446,73 @@ class _YTChannelSubpageState extends State with TickerProvider children: [ header, const SizedBox(height: 4.0), - channelInfo?.tabs == null - ? Expanded( - child: YTChannelVideosTab( + Expanded( + child: NamidaTabView( + key: Key("${_tabIndex}_${_tabsGlobalKeys.length}"), + isScrollable: true, + compact: true, + tabs: [ + if (channelInfo != null) ...channelInfo.tabs.map((e) => e.title) else lang.VIDEOS, + lang.ABOUT, + ], + initialIndex: _tabIndex, + onIndexChanged: (index) { + try { + _scrollControllersOffsets[_tabIndex] ??= _itemsScrollController.offset; + } catch (_) {} + + _tabIndex = index; + if (channelInfo != null) _fetchCurrentTab(channelInfo); + + if (_isAboutTab()) { + if (_scrollAnimation.value < 1.0) { + _scrollAnimation.animateTo( + 1.0, + duration: const Duration(milliseconds: 300), + curve: Curves.fastEaseInToSlowEaseOut, + ); + } + } + }, + children: [ + if (channelInfo != null) + ...channelInfo.tabs.mapIndexed((e, i) { + if (e.isVideosTab()) { + return YTChannelVideosTab( + key: _tabsGlobalKeys[i], + scrollController: _itemsScrollController, + channelInfo: _channelInfo, + localChannel: ch, + ); + } + return YTChannelSubpageTab( + key: _tabsGlobalKeys[i], + scrollController: _itemsScrollController, + channelId: channelID, + tab: e, + tabFetcher: (fetch) => onRefresh(fetch, forceProceed: true), + onSuccessFetch: () => _tabLastFetched[i] = DateTime.now(), + shouldForceRequest: () => _shouldForceRequestTab(i), + ); + }) + else + YTChannelVideosTab( scrollController: _itemsScrollController, channelInfo: _channelInfo, localChannel: ch, ), + YTChannelSubpageAbout( + key: _aboutPageKey, + scrollController: _itemsScrollController, + channelId: channelID, + channelInfo: () => channelInfo, + tabFetcher: (fetch) => onRefresh(fetch, forceProceed: true), + onSuccessFetch: () => _aboutPageLastFetched = DateTime.now(), + shouldForceRequest: () => _didEnoughTimePass(_aboutPageLastFetched), ) - : Expanded( - child: NamidaTabView( - isScrollable: true, - compact: true, - tabs: channelInfo!.tabs.map((e) => e.title).toList(), - initialIndex: _tabIndex, - onIndexChanged: (index) { - try { - _scrollControllersOffsets[_tabIndex] ??= _itemsScrollController.offset; - } catch (_) {} - - _tabIndex = index; - _fetchCurrentTab(channelInfo); - }, - children: channelInfo.tabs.mapIndexed((e, i) { - if (e.isVideosTab()) { - return YTChannelVideosTab( - key: _tabsGlobalKeys[i], - scrollController: _itemsScrollController, - channelInfo: _channelInfo, - localChannel: ch, - ); - } - return YTChannelSubpageTab( - key: _tabsGlobalKeys[i], - scrollController: _itemsScrollController, - channelId: channelID, - tab: e, - tabFetcher: (fetch) => onRefresh(fetch, forceProceed: true), - onSuccessFetch: () => _tabLastFetched[i] = DateTime.now(), - shouldForceRequest: () => _shouldForceRequestTab(i), - ); - }).toList(), - ), - ), + ], + ), + ), ], ), pullToRefreshWidget, diff --git a/lib/youtube/pages/yt_channel_subpage_about.dart b/lib/youtube/pages/yt_channel_subpage_about.dart new file mode 100644 index 00000000..e28a7f78 --- /dev/null +++ b/lib/youtube/pages/yt_channel_subpage_about.dart @@ -0,0 +1,285 @@ +part of 'yt_channel_subpage.dart'; + +class YTChannelSubpageAbout extends StatefulWidget { + final ScrollController scrollController; + final String channelId; + final YoutiPieChannelPageResult? Function() channelInfo; + final Future Function(Future Function({YoutiPieChannelItemsSort? sort, bool forceRequest}) fetch) tabFetcher; + final bool Function() shouldForceRequest; + final void Function() onSuccessFetch; + + const YTChannelSubpageAbout({ + super.key, + required this.scrollController, + required this.channelId, + required this.channelInfo, + required this.tabFetcher, + required this.onSuccessFetch, + required this.shouldForceRequest, + }); + + @override + State createState() => _YTChannelSubpageAboutState(); +} + +class _YTChannelSubpageAboutState extends State { + ChannelPageAbout? _aboutResult; + bool _isLoadingInitial = false; + + Future fetchAboutAndUpdate({YoutiPieChannelItemsSort? sort, bool? forceRequest}) async { + forceRequest ??= widget.shouldForceRequest(); + + if (forceRequest == false && _aboutResult != null) return null; // prevent calling widget.onSuccessFetch + + final channelInfo = widget.channelInfo(); + if (channelInfo == null) return null; + + final aboutResult = await YoutubeInfoController.channel.fetchChannelAbout( + channel: channelInfo, + details: forceRequest ? ExecuteDetails.forceRequest() : null, + ); + + if (aboutResult != null) widget.onSuccessFetch(); + + refreshState(() { + _aboutResult = aboutResult; + _isLoadingInitial = false; + }); + + return aboutResult; + } + + @override + void initState() { + final aboutResultCache = YoutubeInfoController.channel.fetchChannelAboutSync(widget.channelId); + if (aboutResultCache != null) { + _aboutResult = aboutResultCache; + } else { + _isLoadingInitial = true; + } + + if (widget.shouldForceRequest()) widget.tabFetcher(fetchAboutAndUpdate); + super.initState(); + } + + String _getWorkingUrl(ChannelAboutLink aboutLink) { + String url = aboutLink.link ?? aboutLink.linkText; + if (!url.startsWith('https://')) url = 'https://$url'; + return url; + } + + void _copyUrlToClipboard(String url) { + Clipboard.setData(ClipboardData(text: url)); + snackyy( + title: lang.COPIED_TO_CLIPBOARD, + message: url, + leftBarIndicatorColor: context.theme.colorScheme.primary, + top: false, + ); + } + + @override + Widget build(BuildContext context) { + final aboutResult = _aboutResult; + const dividerContainer = NamidaContainerDivider( + margin: EdgeInsets.symmetric(horizontal: 32.0, vertical: 12.0), + ); + return NamidaScrollbar( + controller: widget.scrollController, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: _isLoadingInitial + ? ThreeArchedCircle( + color: context.theme.colorScheme.secondaryContainer, + size: 64.0, + ) + : aboutResult == null + ? Center( + child: Text( + lang.ERROR, + style: context.textTheme.displayLarge, + ), + ) + : ListView( + controller: widget.scrollController, + children: [ + const SizedBox(height: 24.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: NamidaSelectableAutoLinkText( + text: aboutResult.description ?? '', + fontScale: 1.08, + ), + ), + dividerContainer, + ...aboutResult.aboutLinks.map( + (e) { + final iconUrl = e.icons.pick()?.url; + return NamidaInkWell( + onTap: () { + final url = _getWorkingUrl(e); + NamidaLinkUtils.openLinkPreferNamida(url); + }, + onLongPress: () { + final url = _getWorkingUrl(e); + _copyUrlToClipboard(url); + }, + margin: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.symmetric(vertical: 10.0), + bgColor: context.theme.colorScheme.secondaryContainer.withOpacity(0.01), + decoration: BoxDecoration( + border: Border.all( + width: 1.5, + color: context.theme.colorScheme.secondary.withOpacity(0.2), + ), + ), + borderRadius: 10.0, + child: Row( + children: [ + const SizedBox(width: 12.0), + YoutubeThumbnail( + key: ValueKey(iconUrl), + width: 24.0, + height: 24.0, + isCircle: true, + isImportantInCache: false, + type: ThumbnailType.other, + customUrl: iconUrl, + boxShadow: const [ + BoxShadow( + color: Colors.white, + blurRadius: 0, + spreadRadius: 1.5, + ) + ], + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.title, + style: context.textTheme.displayMedium?.copyWith( + fontSize: 15.0, + color: Color.alphaBlend( + context.theme.colorScheme.onSecondaryContainer.withOpacity(0.5), + context.theme.colorScheme.onSurface.withOpacity(0.5), + ), + ), + ), + Text( + e.linkText, + style: context.textTheme.displaySmall?.copyWith( + fontSize: 11.5, + // color: context.theme.colorScheme.onSecondaryContainer.withOpacity(0.7), + ), + ), + ], + ), + ), + const SizedBox(width: 12.0), + ], + ), + ); + }, + ), + dividerContainer, + NamidaInkWell( + bgColor: context.theme.cardColor.withOpacity(0.5), + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), + child: Column( + children: [ + _SmolInfo( + title: aboutResult.canonicalChannelUrl?.splitLast('/'), + icon: Broken.link, + onTap: () { + final url = aboutResult.canonicalChannelUrl; + if (url == null) return; + _copyUrlToClipboard(url); + }, + ), + _SmolInfo( + title: aboutResult.subscriberCount?.displaySubscribersKeywordShort ?? aboutResult.subscriberCountText, // short cuz its not acc + icon: Broken.profile_2user, + ), + _SmolInfo( + title: aboutResult.videoCount?.displayVideoKeyword ?? aboutResult.videoCountText, + icon: Broken.video, + ), + _SmolInfo( + title: aboutResult.viewCount?.displayViewsKeyword ?? aboutResult.viewCountText, + icon: Broken.activity, + ), + _SmolInfo( + title: aboutResult.joinedText, + icon: Broken.clock, + ), + _SmolInfo( + title: aboutResult.country, + icon: Broken.global, + ), + ], + ), + ), + kBottomPaddingWidget, + ], + ), + ), + ); + } +} + +class _SmolInfo extends StatelessWidget { + final String? title; + final IconData? icon; + final VoidCallback? onTap; + + const _SmolInfo({ + required this.title, + required this.icon, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + if (title == null) return const SizedBox(); + Widget child = Row( + children: [ + const SizedBox(width: 12.0), + Icon( + icon, + size: 20.0, + color: context.theme.colorScheme.onSurface.withOpacity(0.7), + ), + const SizedBox(width: 12.0), + Expanded( + child: Text( + title!, + style: context.textTheme.displayMedium?.copyWith( + fontSize: 15.0, + color: Color.alphaBlend( + context.theme.colorScheme.onSecondaryContainer.withOpacity(0.3), + context.theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + ), + const SizedBox(width: 12.0), + ], + ); + if (onTap != null) { + child = TapDetector( + onTap: onTap, + child: child, + ); + } + child = Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: child, + ); + + return child; + } +} diff --git a/lib/youtube/pages/yt_channel_subpage_tab.dart b/lib/youtube/pages/yt_channel_subpage_tab.dart index 511fe471..edefcb41 100644 --- a/lib/youtube/pages/yt_channel_subpage_tab.dart +++ b/lib/youtube/pages/yt_channel_subpage_tab.dart @@ -42,13 +42,13 @@ class _YTChannelSubpageTabState extends State { ); if (tabResult != null) widget.onSuccessFetch(); - if (mounted) { - setState(() { - _tabResult = tabResult; - _currentSort = tabResult?.customSort ?? tabResult?.itemsSort.firstWhereEff((e) => e.initiallySelected); - _isLoadingInitial = false; - }); - } + + refreshState(() { + _tabResult = tabResult; + _currentSort = tabResult?.customSort ?? tabResult?.itemsSort.firstWhereEff((e) => e.initiallySelected); + _isLoadingInitial = false; + }); + return tabResult; } diff --git a/pubspec.yaml b/pubspec.yaml index 85d8a7ee..d28b9c1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: namida description: A Beautiful and Feature-rich Music Player, With YouTube & Video Support Built in Flutter publish_to: "none" -version: 3.9.56-beta+240819222 +version: 3.9.6-beta+240819222 environment: sdk: ">=3.4.0 <4.0.0"