From 34a10fe234d45bc64138c3520deb8a9d5cc2852d Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Sun, 14 Jul 2024 03:04:24 +0300 Subject: [PATCH] feat(yt): channel subscribe & notifications this comes with across-pages safety, hitting the button in a place temoprarely disables other active buttons so damn cool oh ye and a sneaky lil fix ref: #227 --- lib/class/track.dart | 2 +- lib/controller/waveform_controller.dart | 2 +- lib/core/namida_converter_ext.dart | 9 + lib/core/translations/keys.dart | 1 + lib/ui/dialogs/general_popup_dialog.dart | 2 +- .../controller/youtube_current_info.dart | 27 +- lib/youtube/pages/yt_channel_subpage.dart | 13 +- lib/youtube/widgets/yt_comment_card.dart | 6 +- lib/youtube/widgets/yt_subscribe_buttons.dart | 299 +++++++++++++++++- lib/youtube/youtube_miniplayer.dart | 16 +- pubspec.yaml | 2 +- 11 files changed, 355 insertions(+), 24 deletions(-) diff --git a/lib/class/track.dart b/lib/class/track.dart index 079c48a0..ed8f3d66 100644 --- a/lib/class/track.dart +++ b/lib/class/track.dart @@ -472,7 +472,7 @@ extension TrackUtils on Track { set duration(int value) { final trx = Indexer.inst.allTracksMappedByPath.value[this]; if (trx != null) { - Indexer.inst.allTracksMappedByPath.value[this] = trx.copyWith(duration: value); + Indexer.inst.allTracksMappedByPath[this] = trx.copyWith(duration: value); Indexer.inst.allTracksMappedByPath.refresh(); } } diff --git a/lib/controller/waveform_controller.dart b/lib/controller/waveform_controller.dart index ce3591ae..2c4cc26e 100644 --- a/lib/controller/waveform_controller.dart +++ b/lib/controller/waveform_controller.dart @@ -48,7 +48,7 @@ class WaveformController { Future.delayed(const Duration(milliseconds: 800)), ]); - if (stillPlaying(path)) { + if (waveformData.isNotEmpty && stillPlaying(path)) { // ----- Updating [_currentWaveform] const maxWaveformCount = 2000; final numberOfScales = duration.inMilliseconds ~/ 50; diff --git a/lib/core/namida_converter_ext.dart b/lib/core/namida_converter_ext.dart index adcb162d..2b271737 100644 --- a/lib/core/namida_converter_ext.dart +++ b/lib/core/namida_converter_ext.dart @@ -687,6 +687,10 @@ extension CommentsSortTypeUtils on CommentsSortType { String toText() => _NamidaConverters.inst.getTitle(this); } +extension ChannelNotificationsUtils on ChannelNotifications { + String toText() => _NamidaConverters.inst.getTitle(this); +} + extension RouteUtils on NamidaRoute { List tracksListInside() { final iter = tracksInside(); @@ -1305,6 +1309,11 @@ class _NamidaConverters { CommentsSortType.top: lang.TOP, CommentsSortType.newest: lang.NEWEST, }, + ChannelNotifications: { + ChannelNotifications.all: lang.ALL, + ChannelNotifications.personalized: lang.PERSONALIZED, + ChannelNotifications.none: lang.NONE, + }, }; // ==================================================== diff --git a/lib/core/translations/keys.dart b/lib/core/translations/keys.dart index 7df1d60b..85d45b4e 100644 --- a/lib/core/translations/keys.dart +++ b/lib/core/translations/keys.dart @@ -461,6 +461,7 @@ abstract class LanguageKeys { String get PERCENTAGE => _getKey('PERCENTAGE'); String get PERFORMANCE_MODE => _getKey('PERFORMANCE_MODE'); String get PERFORMANCE_NOTE => _getKey('PERFORMANCE_NOTE'); + String get PERSONALIZED => _getKey('PERSONALIZED'); String get PICK_COLORS_FROM_DEVICE_WALLPAPER => _getKey('PICK_COLORS_FROM_DEVICE_WALLPAPER'); String get PICK_FROM_STORAGE => _getKey('PICK_FROM_STORAGE'); String get PINNED => _getKey('PINNED'); diff --git a/lib/ui/dialogs/general_popup_dialog.dart b/lib/ui/dialogs/general_popup_dialog.dart index 1a48bc37..7cea4dff 100644 --- a/lib/ui/dialogs/general_popup_dialog.dart +++ b/lib/ui/dialogs/general_popup_dialog.dart @@ -1026,7 +1026,7 @@ Future showGeneralPopupDialog( }, ), - isSingle && tracks.first == Player.inst.currentItem.value + isSingle && tracks.first == Player.inst.currentTrack?.track ? NamidaOpacity( opacity: Player.inst.sleepTimerConfig.value.sleepAfterItems == 1 ? 0.6 : 1.0, child: IgnorePointer( diff --git a/lib/youtube/controller/youtube_current_info.dart b/lib/youtube/controller/youtube_current_info.dart index d8e679d1..f41502de 100644 --- a/lib/youtube/controller/youtube_current_info.dart +++ b/lib/youtube/controller/youtube_current_info.dart @@ -7,6 +7,7 @@ class _YoutubeCurrentInfoController { bool get _canShowComments => settings.youtubeStyleMiniplayer.value; RxBaseCore get currentVideoPage => _currentVideoPage; + RxBaseCore get currentChannelPage => _currentChannelPage; RxBaseCore get currentComments => _currentComments; RxBaseCore get isLoadingVideoPage => _isLoadingVideoPage; RxBaseCore get isLoadingInitialComments => _isLoadingInitialComments; @@ -20,6 +21,7 @@ class _YoutubeCurrentInfoController { final currentCachedQualities = [].obs; final _currentVideoPage = Rxn(); + final _currentChannelPage = Rxn(); final _currentRelatedVideos = Rxn(); final _currentComments = Rxn(); final currentYTStreams = Rxn(); @@ -39,6 +41,7 @@ class _YoutubeCurrentInfoController { void resetAll() { currentCachedQualities.clear(); _currentVideoPage.value = null; + _currentChannelPage.value = null; _currentRelatedVideos.value = null; _currentComments.value = null; currentYTStreams.value = null; @@ -52,6 +55,11 @@ class _YoutubeCurrentInfoController { final vidcache = YoutiPie.cacheBuilder.forVideoPage(videoId: videoId); final vidPageCached = vidcache.read(); _currentVideoPage.value = vidPageCached; + + final chId = vidPageCached?.channelInfo?.id ?? YoutubeInfoController.utils.getVideoChannelID(videoId); + final chPage = chId == null ? null : YoutiPie.cacheBuilder.forChannel(channelId: chId).read(); + _currentChannelPage.value = chPage; + final relatedcache = YoutiPie.cacheBuilder.forRelatedVideos(videoId: videoId); _currentRelatedVideos.value = relatedcache.read() ?? vidPageCached?.relatedVideosResult; return vidPageCached != null; @@ -80,6 +88,7 @@ class _YoutubeCurrentInfoController { if (requestPage) { if (onVideoPageReset != null) onVideoPageReset!(); // jumps miniplayer to top _currentVideoPage.value = null; + _currentChannelPage.value = null; } if (requestComments) { _currentComments.value = null; @@ -92,8 +101,23 @@ class _YoutubeCurrentInfoController { final page = await YoutubeInfoController.video.fetchVideoPage(videoId, details: ExecuteDetails.forceRequest()); _isLoadingVideoPage.value = false; + if (page != null) { + final chId = page.channelInfo?.id ?? YoutubeInfoController.utils.getVideoChannelID(videoId); + if (chId != null) { + YoutubeInfoController.channel.fetchChannelInfo(channelId: page.channelInfo?.id, details: ExecuteDetails.forceRequest()).then( + (chPage) { + if (_canSafelyModifyMetadata(videoId)) { + _currentChannelPage.value = chPage; + } + }, + ); + } + } + if (_canSafelyModifyMetadata(videoId)) { - if (requestPage) _currentVideoPage.value = page; // page is still requested cuz comments need it + if (requestPage) { + _currentVideoPage.value = page; // page is still requested cuz comments need it + } if (requestComments) { final commentsContinuation = page?.commentResult.continuation; if (commentsContinuation != null && _canShowComments) { @@ -106,6 +130,7 @@ class _YoutubeCurrentInfoController { if (_canSafelyModifyMetadata(videoId)) { _isLoadingInitialComments.value = false; _currentVideoPage.refresh(); + _currentChannelPage.refresh(); _currentComments.value = comm; _isCurrentCommentsFromCache.value = false; _initialCommentsContinuation = comm?.continuation; diff --git a/lib/youtube/pages/yt_channel_subpage.dart b/lib/youtube/pages/yt_channel_subpage.dart index 3824bc0c..102d430a 100644 --- a/lib/youtube/pages/yt_channel_subpage.dart +++ b/lib/youtube/pages/yt_channel_subpage.dart @@ -57,7 +57,7 @@ class _YTChannelSubpageState extends YoutubeChannelController subscribed: false, ); - YoutiPieChannelPageResult? _channelInfo; + final _channelInfo = Rxn(); // rx is accessed only in subscribe button. keep using setState(). YoutiPieFetchAllRes? _currentFetchAllRes; @override @@ -68,7 +68,7 @@ class _YTChannelSubpageState extends YoutubeChannelController final channelInfoCache = YoutubeInfoController.channel.fetchChannelInfoSync(ch.channelID); if (channelInfoCache != null) { - _channelInfo = channelInfoCache; + _channelInfo.value = channelInfoCache; fetchChannelStreams(channelInfoCache); } @@ -76,7 +76,7 @@ class _YTChannelSubpageState extends YoutubeChannelController YoutubeInfoController.channel.fetchChannelInfo(channelId: ch.channelID, details: ExecuteDetails.forceRequest()).then( (value) { if (value != null) { - setState(() => _channelInfo = value); + setState(() => _channelInfo.value = value); onRefresh(() => fetchChannelStreams(value, forceRequest: true), forceProceed: true); } }, @@ -213,7 +213,7 @@ class _YTChannelSubpageState extends YoutubeChannelController @override Widget build(BuildContext context) { - final channelInfo = _channelInfo; + final channelInfo = _channelInfo.value; const thumbnailHeight = Dimensions.youtubeThumbnailHeight; const thumbnailWidth = Dimensions.youtubeThumbnailWidth; const thumbnailItemExtent = thumbnailHeight + 8.0 * 2; @@ -344,7 +344,10 @@ class _YTChannelSubpageState extends YoutubeChannelController ), ), const SizedBox(width: 4.0), - YTSubscribeButton(channelID: channelID), + YTSubscribeButton( + channelID: channelID, + mainChannelInfo: _channelInfo, + ), const SizedBox(width: 12.0), ], ), diff --git a/lib/youtube/widgets/yt_comment_card.dart b/lib/youtube/widgets/yt_comment_card.dart index 515143e6..fa85f119 100644 --- a/lib/youtube/widgets/yt_comment_card.dart +++ b/lib/youtube/widgets/yt_comment_card.dart @@ -67,10 +67,10 @@ class _YTCommentCardState extends State { onEnd(); if (res == true) { _currentLikeStatus.value = _currentLikeStatus.value = action.toExpectedStatus(); - return true; - } else { - return false; + return !isLiked; } + + return isLiked; } void _onRepliesTap({required CommentInfoItem comment, required int? repliesCount}) { diff --git a/lib/youtube/widgets/yt_subscribe_buttons.dart b/lib/youtube/widgets/yt_subscribe_buttons.dart index 8e28c495..499016ee 100644 --- a/lib/youtube/widgets/yt_subscribe_buttons.dart +++ b/lib/youtube/widgets/yt_subscribe_buttons.dart @@ -1,14 +1,309 @@ import 'package:flutter/material.dart'; +import 'package:youtipie/class/channels/channel_page_result.dart'; +import 'package:youtipie/core/enum.dart'; +import 'package:youtipie/youtipie.dart'; +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/core/extensions.dart'; import 'package:namida/core/icon_fonts/broken_icons.dart'; +import 'package:namida/core/namida_converter_ext.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/core/utils.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; +import 'package:namida/ui/widgets/settings/extra_settings.dart'; import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; -class YTSubscribeButton extends StatelessWidget { +class _YTSubscribeButtonManager { + const _YTSubscribeButtonManager(); + + static final _activeChannelInfos = >>{}; + static final _activeModifications = {}.obs; + + static void add(String? channelId, RxBaseCore channelPageRx) { + if (channelId == null) return; + _activeChannelInfos.addForce(channelId, channelPageRx); + } + + static void remove(String? channelId, RxBaseCore channelPageRx) { + if (channelId == null) return; + _activeChannelInfos[channelId]?.remove(channelPageRx); + if (_activeChannelInfos[channelId]?.isEmpty == true) _activeChannelInfos.remove(channelId); + } + + static void onModifyStart(String? channelId) { + if (channelId == null) return; + _activeModifications[channelId] = true; + } + + static void onModifyDone(String? channelId) { + if (channelId == null) return; + _activeModifications[channelId] = false; + } + + static void afterModifySuccess(String? channelId, RxBaseCore channelPageRx) { + if (channelId == null) return; + _activeChannelInfos[channelId]?.loop( + (item) { + item.value?.rebuild( + newSubscribed: channelPageRx.value?.subscribed, + newNotifications: channelPageRx.value?.notifications, + newNotificationParameters: channelPageRx.value?.notificationParameters, + ); + item.refresh(); + }, + ); + } +} + +class YTSubscribeButton extends StatefulWidget { + final String? channelID; + final RxBaseCore mainChannelInfo; + + const YTSubscribeButton({ + super.key, + required this.channelID, + required this.mainChannelInfo, + }); + + @override + State createState() => _YTSubscribeButtonState(); +} + +class _YTSubscribeButtonState extends State { + late bool? _currentSubscribed; + late ChannelNotifications? _currentNotificationsStatus; + + void _onPageChanged() { + if (mounted) { + final channelInfo = widget.mainChannelInfo.value; + setState(() { + _currentSubscribed = channelInfo?.subscribed; + _currentNotificationsStatus = channelInfo?.notifications; + }); + } + } + + Future _onChangeSubscribeStatus(bool isSubscribed, void Function() onStart, void Function() onEnd) async { + final channelInfo = widget.mainChannelInfo.value; + if (channelInfo == null) return isSubscribed; + + onStart(); + _YTSubscribeButtonManager.onModifyStart(widget.channelID); + final res = await YoutiPie.channelAction.changeSubscribeStatus( + mainChannelPage: widget.mainChannelInfo.value, + channelEngagement: channelInfo.channelEngagement, + subscribe: !isSubscribed, + ); + _YTSubscribeButtonManager.onModifyDone(widget.channelID); + onEnd(); + if (res != null) { + _YTSubscribeButtonManager.afterModifySuccess(widget.channelID, widget.mainChannelInfo); + refreshState(() { + _currentSubscribed = res.isNowSubbed; + _currentNotificationsStatus = res.newNotificationStatus; + }); + return !isSubscribed; + } + + return isSubscribed; + } + + void _onNotificationsTap() async { + final tileActiveNoti = _currentNotificationsStatus.obs; + final isSaving = false.obs; + + await NamidaNavigator.inst.navigateDialog( + onDisposing: () { + tileActiveNoti.close(); + isSaving.close(); + }, + dialog: CustomBlurryDialog( + title: lang.CONFIGURE, + normalTitleStyle: true, + actions: [ + const CancelButton(), + ObxO( + rx: tileActiveNoti, + builder: (activeNoti) => ObxO( + rx: isSaving, + builder: (saving) => NamidaButton( + enabled: activeNoti != null && !saving, + text: lang.SAVE.toUpperCase(), + textWidget: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (saving) const LoadingIndicator(), + if (saving) const SizedBox(width: 4.0), + NamidaButtonText(lang.SAVE.toUpperCase()), + ], + ), + onPressed: () async { + final notiToActivate = tileActiveNoti.value; + if (notiToActivate == null) return; + + isSaving.value = true; + _YTSubscribeButtonManager.onModifyStart(widget.channelID); + final res = await YoutiPie.channelAction.changeChannelNotificationStatus( + mainChannelPage: widget.mainChannelInfo.value, + notifications: notiToActivate, + ); + _YTSubscribeButtonManager.onModifyDone(widget.channelID); + isSaving.value = false; + if (res == true) { + _YTSubscribeButtonManager.afterModifySuccess(widget.channelID, widget.mainChannelInfo); + refreshState(() => _currentNotificationsStatus = notiToActivate); + NamidaNavigator.inst.closeDialog(); + } + }, + ), + ), + ), + ], + child: ObxO( + rx: tileActiveNoti, + builder: (activeNoti) => Column( + children: [ + ...ChannelNotifications.values.map( + (e) { + return Padding( + padding: const EdgeInsets.all(3.0), + child: ListTileWithCheckMark( + leading: _notificationsToIcon(e, 24.0), + title: e.toText(), + active: e == activeNoti, + onTap: () => tileActiveNoti.value = e, + ), + ); + }, + ) + ], + ), + ), + ), + ); + } + + @override + void initState() { + _YTSubscribeButtonManager.add(widget.channelID, widget.mainChannelInfo); + _onPageChanged(); // fill initial values + widget.mainChannelInfo.addListener(_onPageChanged); + super.initState(); + } + + @override + void dispose() { + _YTSubscribeButtonManager.remove(widget.channelID, widget.mainChannelInfo); + widget.mainChannelInfo.removeListener(_onPageChanged); + super.dispose(); + } + + Future _confirmUnsubscribe() async { + bool confirmed = false; + await NamidaNavigator.inst.navigateDialog( + dialog: CustomBlurryDialog( + isWarning: true, + normalTitleStyle: true, + bodyText: lang.CONFIRM, + actions: [ + const CancelButton(), + NamidaButton( + text: lang.REMOVE.toUpperCase(), + onPressed: () async { + NamidaNavigator.inst.closeDialog(); + confirmed = true; + }, + ), + ], + ), + ); + return confirmed; + } + + Widget? _notificationsToIcon(ChannelNotifications? noti, double iconSize) { + return switch (noti) { + ChannelNotifications.all => Icon( + Broken.notification_bing, + size: iconSize, + ), + ChannelNotifications.personalized => Icon( + Broken.notification_1, + size: iconSize, + ), + ChannelNotifications.none => StackedIcon( + baseIcon: Broken.notification_1, + secondaryIcon: Broken.slash, + iconSize: iconSize, + secondaryIconSize: 11.0, + disableColor: true, + ), + null => null, + }; + } + + @override + Widget build(BuildContext context) { + final subscribed = _currentSubscribed == true; + const iconSize = 20.0; + final notificationIcon = _notificationsToIcon(_currentNotificationsStatus, iconSize); + + return ObxO( + rx: _YTSubscribeButtonManager._activeModifications, + builder: (activeModifications) => AnimatedEnabled( + enabled: activeModifications[widget.channelID] != true && _currentSubscribed != null, + durationMS: 300, + child: Row( + children: [ + if (subscribed && notificationIcon != null) + NamidaLoadingSwitcher( + size: iconSize, + builder: (startLoading, stopLoading, isLoading) => NamidaIconButton( + horizontalPadding: 4.0, + onPressed: () { + final info = widget.mainChannelInfo.value; + if (info == null) return; + _onNotificationsTap(); + }, + icon: null, + child: notificationIcon, + ), + ), + NamidaLoadingSwitcher( + size: 24.0, + builder: (startLoading, stopLoading, isLoading) => TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: Color.alphaBlend(Colors.grey.withOpacity(subscribed ? 0.6 : 0.0), context.theme.colorScheme.primary), + ), + child: NamidaButtonText( + subscribed ? lang.SUBSCRIBED : lang.SUBSCRIBE, + ), + onPressed: () async { + final info = widget.mainChannelInfo.value; + if (info == null) return; + if (subscribed) { + final confirmed = await _confirmUnsubscribe(); + if (!confirmed) return; + } + _onChangeSubscribeStatus( + subscribed, + startLoading, + stopLoading, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class YTSubscribeButtonLocal extends StatelessWidget { final String? channelID; - const YTSubscribeButton({super.key, required this.channelID}); + const YTSubscribeButtonLocal({super.key, required this.channelID}); @override Widget build(BuildContext context) { diff --git a/lib/youtube/youtube_miniplayer.dart b/lib/youtube/youtube_miniplayer.dart index d7de8146..2a30e002 100644 --- a/lib/youtube/youtube_miniplayer.dart +++ b/lib/youtube/youtube_miniplayer.dart @@ -125,12 +125,13 @@ class YoutubeMiniPlayerState extends State { action: action, ); onEnd(); + if (res == true) { _currentVideoLikeStatus.value = _currentVideoLikeStatus.value = action.toExpectedStatus(); - return true; - } else { - return false; + return !isLiked; } + + return isLiked; } void _onPageChanged() { @@ -755,7 +756,7 @@ class YoutubeMiniPlayerState extends State { }, child: Row( children: [ - const SizedBox(width: 18.0), + const SizedBox(width: 16.0), NamidaDummyContainer( width: 42.0, height: 42.0, @@ -838,12 +839,9 @@ class YoutubeMiniPlayerState extends State { const SizedBox(width: 12.0), YTSubscribeButton( channelID: channelID, - listenable: YoutubeInfoController.current.currentVideoPage, - retrieveInfo: () => YoutubeInfoController.current.currentVideoPage.value?.channelInfo, - mainPage: () => YoutubeInfoController.current.currentVideoPage.value, - mainChannelInfo: null, + mainChannelInfo: YoutubeInfoController.current.currentChannelPage, ), - const SizedBox(width: 20.0), + const SizedBox(width: 12.0), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 77f2920c..cb6ec066 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.2.3-beta+240713186 +version: 3.2.5-beta+240713238 environment: sdk: ">=3.4.0 <4.0.0"