From 098a60497426d662bd8d02f429334f4634c5cae0 Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Fri, 5 Jul 2024 23:54:16 +0300 Subject: [PATCH] feat(yt): youtube login support comes with subscription and fixed feed ref: #227 --- lib/core/constants.dart | 9 + lib/core/dimensions.dart | 2 + lib/core/enums.dart | 3 + lib/core/translations/keys.dart | 34 ++ lib/main.dart | 3 + lib/ui/widgets/settings/youtube_settings.dart | 13 + .../youtube_account_controller.dart | 326 ++++++++++++++++++ .../controller/youtube_controller.dart | 4 - .../controller/youtube_current_info.dart | 10 +- .../controller/youtube_info_controller.dart | 1 - lib/youtube/pages/user/membership_card.dart | 73 ++++ .../user/youtube_account_manage_page.dart | 240 +++++++++++++ .../youtube_manage_subscription_page.dart | 277 +++++++++++++++ lib/youtube/pages/youtube_feed_page.dart | 217 ++++++++++++ lib/youtube/pages/youtube_home_view.dart | 4 +- lib/youtube/pages/youtube_page.dart | 115 ------ pubspec.yaml | 13 +- 17 files changed, 1212 insertions(+), 132 deletions(-) create mode 100644 lib/youtube/controller/youtube_account_controller.dart create mode 100644 lib/youtube/pages/user/membership_card.dart create mode 100644 lib/youtube/pages/user/youtube_account_manage_page.dart create mode 100644 lib/youtube/pages/user/youtube_manage_subscription_page.dart create mode 100644 lib/youtube/pages/youtube_feed_page.dart delete mode 100644 lib/youtube/pages/youtube_page.dart diff --git a/lib/core/constants.dart b/lib/core/constants.dart index d306fd1b..66621274 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_udid/flutter_udid.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -16,6 +17,9 @@ import 'package:namida/core/extensions.dart'; class NamidaDeviceInfo { static int sdkVersion = 21; + static String? get deviceId => _deviceId; + static String? _deviceId; + static final androidInfoCompleter = Completer(); static final packageInfoCompleter = Completer(); @@ -41,6 +45,11 @@ class NamidaDeviceInfo { } } + static Future fetchDeviceId() async { + if (_deviceId != null) return; + _deviceId = await FlutterUdid.udid; + } + static Future fetchPackageInfo() async { if (_fetchedPackageInfo) return; _fetchedPackageInfo = true; diff --git a/lib/core/dimensions.dart b/lib/core/dimensions.dart index 35ec3238..3c67dda6 100644 --- a/lib/core/dimensions.dart +++ b/lib/core/dimensions.dart @@ -30,6 +30,8 @@ class Dimensions { route == RouteType.SETTINGS_subpage || // bcz no search route == RouteType.YOUTUBE_PLAYLIST_DOWNLOAD_SUBPAGE || // bcz has fab route == RouteType.SUBPAGE_INDEXER_UPDATE_MISSING_TRACKS || // bcz has fab + route == RouteType.YOUTUBE_USER_MANAGE_ACCOUNT_SUBPAGE || // bcz has middle button + route == RouteType.YOUTUBE_USER_MANAGE_SUBSCRIPTION_SUBPAGE || // bcz bcz.. ((fab == FABType.shuffle || fab == FABType.play) && currentRoute?.hasTracksInside() != true) || (settings.selectedLibraryTab.valueR == LibraryTab.tracks && LibraryTab.tracks.isBarVisible.valueR == false); return shouldHide; diff --git a/lib/core/enums.dart b/lib/core/enums.dart index da45109f..c732ea50 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -222,6 +222,9 @@ enum RouteType { YOUTUBE_MOST_PLAYED_SUBPAGE, YOUTUBE_CHANNEL_SUBPAGE, + YOUTUBE_USER_MANAGE_ACCOUNT_SUBPAGE, + YOUTUBE_USER_MANAGE_SUBSCRIPTION_SUBPAGE, + /// others UNKNOWN, } diff --git a/lib/core/translations/keys.dart b/lib/core/translations/keys.dart index 8a9521c5..4fafb281 100644 --- a/lib/core/translations/keys.dart +++ b/lib/core/translations/keys.dart @@ -8,6 +8,7 @@ abstract class LanguageKeys { String get ABOUT => _getKey('ABOUT'); String get ACTIVE => _getKey('ACTIVE'); + String get ADD_ACCOUNT => _getKey('ADD_ACCOUNT'); String get ADD_ALL => _getKey('ADD_ALL'); String get ADD_ALL_AND_REMOVE_OLD_ONES => _getKey('ADD_ALL_AND_REMOVE_OLD_ONES'); String get ADD_AS_A_NEW_PLAYLIST => _getKey('ADD_AS_A_NEW_PLAYLIST'); @@ -77,10 +78,12 @@ abstract class LanguageKeys { String get CHANGELOG => _getKey('CHANGELOG'); String get CHANNEL => _getKey('CHANNEL'); String get CHANNELS => _getKey('CHANNELS'); + String get CHECK => _getKey('CHECK'); String get CHECK_FOR_MORE => _getKey('CHECK_FOR_MORE'); String get CHECK_LIST => _getKey('CHECK_LIST'); String get CHOOSE_WHAT_TO_CLEAR => _getKey('CHOOSE_WHAT_TO_CLEAR'); String get CHOOSE => _getKey('CHOOSE'); + String get CLAIM => _getKey('CLAIM'); String get CLEAR_IMAGE_CACHE_WARNING => _getKey('CLEAR_IMAGE_CACHE_WARNING'); String get CLEAR_IMAGE_CACHE => _getKey('CLEAR_IMAGE_CACHE'); String get CLEAR_TRACK_ITEM_MULTIPLE => _getKey('CLEAR_TRACK_ITEM_MULTIPLE'); @@ -142,6 +145,7 @@ abstract class LanguageKeys { String get DELETE_FILE_CACHE_SUBTITLE => _getKey('DELETE_FILE_CACHE_SUBTITLE'); String get DELETE_TEMP_FILES => _getKey('DELETE_TEMP_FILES'); String get DESCRIPTION => _getKey('DESCRIPTION'); + String get DID_YOU_MEAN => _getKey('DID_YOU_MEAN'); String get DIM_INTENSITY => _getKey('DIM_INTENSITY'); String get DIM_MINIPLAYER_AFTER_SECONDS => _getKey('DIM_MINIPLAYER_AFTER_SECONDS'); String get DIRECTORY_DOESNT_EXIST => _getKey('DIRECTORY_DOESNT_EXIST'); @@ -184,6 +188,7 @@ abstract class LanguageKeys { String get EDIT => _getKey('EDIT'); String get EDIT_ARTWORK => _getKey('EDIT_ARTWORK'); String get EDIT_TAGS => _getKey('EDIT_TAGS'); + String get EMAIL => _getKey('EMAIL'); String get EMPTY_NON_MEANINGFUL_TAG_FIELDS => _getKey('EMPTY_NON_MEANINGFUL_TAG_FIELDS'); String get EMPTY_VALUE => _getKey('EMPTY_VALUE'); String get ENABLE_BLUR_EFFECT => _getKey('ENABLE_BLUR_EFFECT'); @@ -231,6 +236,7 @@ abstract class LanguageKeys { String get FAILED => _getKey('FAILED'); String get FAVOURITES => _getKey('FAVOURITES'); String get FETCHING => _getKey('FETCHING'); + String get FETCHING_OF_ALL_VIDEOS => _getKey('FETCHING_OF_ALL_VIDEOS'); String get FILENAME_SHOULDNT_START_WITH => _getKey('FILENAME_SHOULDNT_START_WITH'); String get FILE_NAME_WO_EXT => _getKey('FILE_NAME_WO_EXT'); String get FILE_NAME => _getKey('FILE_NAME'); @@ -336,6 +342,8 @@ abstract class LanguageKeys { String get LYRICS_SOURCE => _getKey('LYRICS_SOURCE'); String get M3U_PLAYLIST => _getKey('M3U_PLAYLIST'); String get MAKE_YOUR_FIRST_LISTEN => _getKey('MAKE_YOUR_FIRST_LISTEN'); + String get MANAGE => _getKey('MANAGE'); + String get MANAGE_YOUR_ACCOUNTS => _getKey('MANAGE_YOUR_ACCOUNTS'); String get MANUAL_BACKUP_SUBTITLE => _getKey('MANUAL_BACKUP_SUBTITLE'); String get MANUAL_BACKUP => _getKey('MANUAL_BACKUP'); String get MATCHING_TYPE => _getKey('MATCHING_TYPE'); @@ -345,6 +353,16 @@ abstract class LanguageKeys { String get MAX_AUDIO_CACHE_SIZE => _getKey('MAX_AUDIO_CACHE_SIZE'); String get MAX_IMAGE_CACHE_SIZE => _getKey('MAX_IMAGE_CACHE_SIZE'); String get MAX_VIDEO_CACHE_SIZE => _getKey('MAX_VIDEO_CACHE_SIZE'); + String get MEMBERSHIP_CODE => _getKey('MEMBERSHIP_CODE'); + String get MEMBERSHIP_CODE_SENT_TO_EMAIL => _getKey('MEMBERSHIP_CODE_SENT_TO_EMAIL'); + String get MEMBERSHIP_DIDNT_CHANGE => _getKey('MEMBERSHIP_DIDNT_CHANGE'); + String get MEMBERSHIP_ENJOY_NEW => _getKey('MEMBERSHIP_ENJOY_NEW'); + String get MEMBERSHIP_FREE_COUPON => _getKey('MEMBERSHIP_FREE_COUPON'); + String get MEMBERSHIP_MANAGE => _getKey('MEMBERSHIP_MANAGE'); + String get MEMBERSHIP_NO_SUBSCRIPTIONS_FOUND_FOR_USER => _getKey('MEMBERSHIP_NO_SUBSCRIPTIONS_FOUND_FOR_USER'); + String get MEMBERSHIP_SIGN_IN_TO_PATREON_ACCOUNT => _getKey('MEMBERSHIP_SIGN_IN_TO_PATREON_ACCOUNT'); + String get MEMBERSHIP_UNKNOWN => _getKey('MEMBERSHIP_UNKNOWN'); + String get MEMBERSHIP_YOU_NEED_MEMBERSHIP_OF_TO_ADD_MULTIPLE_ACCOUNTS => _getKey('MEMBERSHIP_YOU_NEED_MEMBERSHIP_OF_TO_ADD_MULTIPLE_ACCOUNTS'); String get METADATA_CACHE => _getKey('METADATA_CACHE'); String get METADATA_EDIT_FAILED => _getKey('METADATA_EDIT_FAILED'); String get METADATA_READ_FAILED => _getKey('METADATA_READ_FAILED'); @@ -373,6 +391,7 @@ abstract class LanguageKeys { String get NAME_CONTAINS_BAD_CHARACTER => _getKey('NAME_CONTAINS_BAD_CHARACTER'); String get NAME => _getKey('NAME'); String get NEVER => _getKey('NEVER'); + String get NEWEST => _getKey('NEWEST'); String get NEW_DIRECTORY => _getKey('NEW_DIRECTORY'); String get NEW_TRACKS_ADD => _getKey('NEW_TRACKS_ADD'); String get NEW_TRACKS_MOODS_SUBTITLE => _getKey('NEW_TRACKS_MOODS_SUBTITLE'); @@ -394,6 +413,7 @@ abstract class LanguageKeys { String get NO_EXCLUDED_FOLDERS => _getKey('NO_EXCLUDED_FOLDERS'); String get NO_FOLDER_CHOSEN => _getKey('NO_FOLDER_CHOSEN'); String get NO_MOODS_AVAILABLE => _getKey('NO_MOODS_AVAILABLE'); + String get NO_NETWORK_AVAILABLE_TO_FETCH_DATA => _getKey('NO_NETWORK_AVAILABLE_TO_FETCH_DATA'); String get NO_TRACKS_FOUND_BETWEEN_DATES => _getKey('NO_TRACKS_FOUND_BETWEEN_DATES'); String get NO_TRACKS_FOUND_IN_DIRECTORY => _getKey('NO_TRACKS_FOUND_IN_DIRECTORY'); String get NO_TRACKS_FOUND => _getKey('NO_TRACKS_FOUND'); @@ -420,6 +440,8 @@ abstract class LanguageKeys { String get OPEN_MINIPLAYER => _getKey('OPEN_MINIPLAYER'); String get OPEN_QUEUE => _getKey('OPEN_QUEUE'); String get OPEN_YOUTUBE_LINK => _getKey('OPEN_YOUTUBE_LINK'); + String get OPERATION_REQUIRES_ACCOUNT => _getKey('OPERATION_REQUIRES_ACCOUNT'); + String get OPERATION_REQUIRES_MEMBERSHIP => _getKey('OPERATION_REQUIRES_MEMBERSHIP'); String get OR => _getKey('OR'); String get OTHERS => _getKey('OTHERS'); String get OUTPUT => _getKey('OUTPUT'); @@ -554,6 +576,16 @@ abstract class LanguageKeys { String get SHUFFLE => _getKey('SHUFFLE'); String get SHUFFLE_ALL => _getKey('SHUFFLE_ALL'); String get SHUFFLE_NEXT => _getKey('SHUFFLE_NEXT'); + String get SIGNING_IN_ALLOWS_BASIC_USAGE => _getKey('SIGNING_IN_ALLOWS_BASIC_USAGE'); + String get SIGNING_IN_ALLOWS_BASIC_USAGE_SUBTITLE => _getKey('SIGNING_IN_ALLOWS_BASIC_USAGE_SUBTITLE'); + String get SIGN_IN => _getKey('SIGN_IN'); + String get SIGN_IN_CANCELED => _getKey('SIGN_IN_CANCELED'); + String get SIGN_IN_FAILED => _getKey('SIGN_IN_FAILED'); + String get SIGN_IN_TO_YOUR_ACCOUNT => _getKey('SIGN_IN_TO_YOUR_ACCOUNT'); + String get SIGN_IN_YOU_DONT_HAVE_ACCOUNT => _getKey('SIGN_IN_YOU_DONT_HAVE_ACCOUNT'); + String get SIGN_IN_YOU_NEED_ACCOUNT_TO_VIEW_PAGE => _getKey('SIGN_IN_YOU_NEED_ACCOUNT_TO_VIEW_PAGE'); + String get SIGN_OUT => _getKey('SIGN_OUT'); + String get SIGN_OUT_FROM_NAME => _getKey('SIGN_OUT_FROM_NAME'); String get SIZE => _getKey('SIZE'); String get SKIP_SILENCE => _getKey('SKIP_SILENCE'); String get SKIP => _getKey('SKIP'); @@ -593,6 +625,7 @@ abstract class LanguageKeys { String get THEME_SETTINGS => _getKey('THEME_SETTINGS'); String get THUMBNAILS => _getKey('THUMBNAILS'); String get TITLE => _getKey('TITLE'); + String get TOP => _getKey('TOP'); String get TOP_COMMENTS => _getKey('TOP_COMMENTS'); String get TOP_COMMENTS_SUBTITLE => _getKey('TOP_COMMENTS_SUBTITLE'); String get TOP_RECENTS => _getKey('TOP_RECENTS'); @@ -667,6 +700,7 @@ abstract class LanguageKeys { String get WEEK => _getKey('WEEK'); String get YEAR => _getKey('YEAR'); String get YES => _getKey('YES'); + String get YOUR_CURRENT_MEMBERSHIP_IS => _getKey('YOUR_CURRENT_MEMBERSHIP_IS'); String get YOUTUBE_MUSIC => _getKey('YOUTUBE_MUSIC'); String get YOUTUBE => _getKey('YOUTUBE'); String get YOUTUBE_SETTINGS_SUBTITLE => _getKey('YOUTUBE_SETTINGS_SUBTITLE'); diff --git a/lib/main.dart b/lib/main.dart index 788196f8..333354b0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,6 +47,7 @@ import 'package:namida/core/utils.dart'; import 'package:namida/main_page_wrapper.dart'; import 'package:namida/packages/scroll_physics_modified.dart'; import 'package:namida/ui/widgets/video_widget.dart'; +import 'package:namida/youtube/controller/youtube_account_controller.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; import 'package:namida/youtube/controller/youtube_info_controller.dart'; @@ -160,7 +161,9 @@ void mainInitialization() async { const StorageCacheManager().trimExtraFiles(); + NamidaDeviceInfo.fetchDeviceId(); YoutubeInfoController.initialize(); + YoutubeAccountController.initialize(); QueueController.inst.prepareAllQueuesFile(); diff --git a/lib/ui/widgets/settings/youtube_settings.dart b/lib/ui/widgets/settings/youtube_settings.dart index c8b16dc1..06807d1d 100644 --- a/lib/ui/widgets/settings/youtube_settings.dart +++ b/lib/ui/widgets/settings/youtube_settings.dart @@ -13,8 +13,10 @@ import 'package:namida/core/utils.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings_card.dart'; import 'package:namida/youtube/controller/yt_miniplayer_ui_controller.dart'; +import 'package:namida/youtube/pages/user/youtube_account_manage_page.dart'; enum _YoutubeSettingKeys { + manageYourAccounts, youtubeStyleMiniplayer, rememberAudioOnly, topComments, @@ -35,6 +37,7 @@ class YoutubeSettings extends SettingSubpageProvider { @override Map> get lookupMap => { + _YoutubeSettingKeys.manageYourAccounts: [lang.MANAGE_YOUR_ACCOUNTS], _YoutubeSettingKeys.youtubeStyleMiniplayer: [lang.YOUTUBE_STYLE_MINIPLAYER], _YoutubeSettingKeys.rememberAudioOnly: [lang.REMEMBER_AUDIO_ONLY_MODE], _YoutubeSettingKeys.topComments: [lang.TOP_COMMENTS, lang.TOP_COMMENTS_SUBTITLE], @@ -55,6 +58,16 @@ class YoutubeSettings extends SettingSubpageProvider { icon: Broken.video, child: Column( children: [ + getItemWrapper( + key: _YoutubeSettingKeys.manageYourAccounts, + child: CustomListTile( + bgColor: getBgColor(_YoutubeSettingKeys.manageYourAccounts), + icon: Broken.user_edit, + title: lang.MANAGE_YOUR_ACCOUNTS, + trailing: const Icon(Broken.arrow_right_3), + onTap: () => NamidaNavigator.inst.navigateTo(const YoutubeAccountManagePage()), + ), + ), getItemWrapper( key: _YoutubeSettingKeys.youtubeStyleMiniplayer, child: Obx( diff --git a/lib/youtube/controller/youtube_account_controller.dart b/lib/youtube/controller/youtube_account_controller.dart new file mode 100644 index 00000000..cb8e73d2 --- /dev/null +++ b/lib/youtube/controller/youtube_account_controller.dart @@ -0,0 +1,326 @@ +// ignore_for_file: constant_identifier_names + +import 'package:namico_login_manager/namico_login_manager.dart'; +import 'package:namico_subscription_manager/class/supabase_sub.dart'; +import 'package:namico_subscription_manager/class/support_tier.dart'; +import 'package:namico_subscription_manager/core/enum.dart'; +import 'package:namico_subscription_manager/namico_subscription_manager.dart'; +import 'package:youtipie/class/youtipie_feed/channel_info_item.dart'; +import 'package:youtipie/core/enum.dart'; +import 'package:youtipie/managers/acount_manager.dart'; +import 'package:youtipie/youtipie.dart'; + +import 'package:namida/controller/connectivity.dart'; +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/core/constants.dart'; +import 'package:namida/core/translations/language.dart'; +import 'package:namida/core/utils.dart'; +import 'package:namida/youtube/pages/user/youtube_account_manage_page.dart'; + +class YoutubeAccountController { + const YoutubeAccountController._(); + + static final current = YoutiPie.cookies; + static final membership = _CurrentMembership._(); + + static RxBaseCore get signInProgress => _signInProgress; + static final _signInProgress = Rxn(); + + /// functions to be called after stable connection is obtained. + static final _pendingRequests = Function()>{}; + + static const _operationNeedsAccount = { + YoutiPieOperation.fetchNotifications: true, + YoutiPieOperation.fetchNotificationsNext: true, + YoutiPieOperation.fetchHistory: true, + YoutiPieOperation.fetchHistoryNext: true, + YoutiPieOperation.addVideoToHistory: true, + YoutiPieOperation.createPlaylist: true, + YoutiPieOperation.editPlaylist: true, + YoutiPieOperation.deletePlaylist: true, + YoutiPieOperation.fetchUserPlaylists: true, + YoutiPieOperation.fetchUserPlaylistsNext: true, + YoutiPieOperation.addHostedPlaylistToLibrary: true, + YoutiPieOperation.removeHostedPlaylistFromLibrary: true, + YoutiPieOperation.changeVideoLikeStatus: true, + YoutiPieOperation.changeCommentLikeStatus: true, + YoutiPieOperation.changeReplyLikeStatus: true, + YoutiPieOperation.changeChannelSubscriptionStatus: true, + YoutiPieOperation.changeChannelNotificationStatus: true, + YoutiPieOperation.createComment: true, + YoutiPieOperation.editComment: true, + YoutiPieOperation.deleteComment: true, + YoutiPieOperation.createReply: true, + YoutiPieOperation.editReply: true, + YoutiPieOperation.deleteReply: true, + }; + + /// By default, all account operations need membership, these are exceptions. + static const _operationNeedsMembership = { + YoutiPieOperation.addVideoToHistory: false, + YoutiPieOperation.fetchUserPlaylists: false, + YoutiPieOperation.fetchUserPlaylistsNext: false, + }; + + static void initialize() { + current.canAddMultiAccounts = false; + + NamicoSubscriptionManager.initialize(dataDirectory: AppDirs.YOUTIPIE_DATA); + + NamicoSubscriptionManager.onError = (message, e, st) { + _showError(message, exception: e); + logger.error(message, e: e, st: st); + }; + + YoutiPie.canExecuteOperation = (operation) { + if (_operationNeedsAccount[operation] == true) { + if (current.activeAccountChannel.value == null) { + // -- no account + _showError(lang.OPERATION_REQUIRES_ACCOUNT.replaceFirst('_NAME_', '`${operation.name}`')); + return false; + } else { + final needsMembership = _operationNeedsMembership[operation] == true; + if (needsMembership) { + final ms = membership.userMembershipTypeGlobal.value; + if (ms == null || ms.index < MembershipType.cutie.index) { + // -- has account but no membership + _showError( + '${lang.OPERATION_REQUIRES_MEMBERSHIP.replaceFirst('_OPERATION_', '`${operation.name}`').replaceFirst('_NAME_', '`${MembershipType.cutie.name}`')}. ${lang.YOUR_CURRENT_MEMBERSHIP_IS.replaceFirst('_NAME_', "`${ms?.name}`")}', // no membership to begin with. + manageSubscriptionButton: true, + ); + return false; + } + } + } + } + return true; + }; + + final patreonSupportTier = NamicoSubscriptionManager.patreon.getUserSupportTierInCacheValid(); + if (patreonSupportTier != null) { + final ms = patreonSupportTier.toMembershipType(); + membership.userPatreonTier.value = patreonSupportTier; + membership.userMembershipTypePatreon.value = patreonSupportTier.toMembershipType(); + membership._updateGlobal(ms); + } else { + _pendingRequests['patreon'] = () async => await membership.checkPatreon(showError: false); + } + + final supasub = NamicoSubscriptionManager.supabase.getUserSubInCacheValid(); + if (supasub != null) { + final ms = supasub.toMembershipType(); + membership.userSupabaseSub.value = supasub; + membership.userMembershipTypeSupabase.value = supasub.toMembershipType(); + membership._updateGlobal(ms); + } else { + final info = NamicoSubscriptionManager.supabase.getUserSubInCache(); + if (info != null) { + final uuid = info.uuid; + final email = info.email; + if (uuid != null && email != null) { + _pendingRequests['supabase'] = () async => await membership.checkSupabase(uuid, email); + } + } + } + _executePendingRequests(); + } + + static Future _executePendingRequests() async { + final hasConnection = ConnectivityController.inst.hasConnection; + if (hasConnection) { + _executePendingRequestsImmediate(); + } else { + await Future.delayed(const Duration(seconds: 20)); // we aint adding callbacks, we then will check with each request if there is pending requests + _executePendingRequestsImmediate(); + } + } + + static Future _executePendingRequestsImmediate() async { + final copy = Map Function()>.from(_pendingRequests); + for (final e in copy.entries) { + try { + final fn = e.value; + await fn(); + _pendingRequests.remove(e.key); + } catch (_) {} + } + } + + static bool _checkCanSignIn() { + final userMembershipType = membership.userMembershipTypeGlobal.value ?? MembershipType.unknown; + + if (userMembershipType == MembershipType.owner) { + if (current.canAddMultiAccounts != true) { + _showInfo('‧₊˚❀༉‧₊˚ welcome boss ‧₊˚❀༉‧₊˚'); + current.canAddMultiAccounts = true; + } + return true; + } + + bool canSignIn = false; + final accounts = current.signedInAccounts.value; + if (accounts.isNotEmpty) { + if (userMembershipType == MembershipType.pookie || userMembershipType == MembershipType.patootie) { + canSignIn = true; + current.canAddMultiAccounts = true; + } else { + _showError( + '${lang.MEMBERSHIP_YOU_NEED_MEMBERSHIP_OF_TO_ADD_MULTIPLE_ACCOUNTS.replaceFirst('_NAME1_', '`${MembershipType.pookie.name}`').replaceFirst('_NAME2_', '`${MembershipType.patootie.name}`')}. ${lang.YOUR_CURRENT_MEMBERSHIP_IS.replaceFirst('_NAME_', "`${userMembershipType.name}`")}', + manageSubscriptionButton: true, + ); + } + } else { + canSignIn = true; + current.canAddMultiAccounts = false; + + // -- this was if we didnt allow sign in before membership + // if (userMembershipType == MembershipType.cutie) { + // canSignIn = true; + // current.canAddMultiAccounts = false; + // } else { + // _showError( + // "${lang.YOUR_CURRENT_MEMBERSHIP_IS.replaceFirst('_NAME_', "`${userMembershipType.name}`")}", + // manageSubscriptionButton: true, + // ); + // } + } + return canSignIn; + } + + static Future _youtubeSignInButton({ + required LoginPageConfiguration pageConfig, + required bool forceSignIn, + required void Function(YoutiLoginProgress progress) onProgress, + }) async { + if (_checkCanSignIn()) { + final signedInChannel = await YoutiAccountManager.signIn( + pageConfig: pageConfig, + onProgress: onProgress, + forceSignIn: forceSignIn, + ); + return signedInChannel; + } else { + return null; + } + } + + static void _showError(String msg, {Object? exception, bool manageSubscriptionButton = false}) { + String title = lang.ERROR; + if (exception != null) title += ': $exception'; + + snackyy( + message: msg, + title: title, + isError: true, + displaySeconds: 3, + button: manageSubscriptionButton + ? ( + lang.MANAGE, + () => NamidaNavigator.inst.navigateTo(const YoutubeManageSubscriptionPage()), + ) + : null, + ); + } + + static void _showInfo(String msg, {String? title}) { + snackyy(message: msg, title: title ?? '', displaySeconds: 3); + } + + static Future signIn({required LoginPageConfiguration pageConfig, required bool forceSignIn}) async { + void onProgress(YoutiLoginProgress p) { + _signInProgress.value = p; + if (p == YoutiLoginProgress.canceled) { + _showInfo(lang.SIGN_IN_CANCELED); + } else if (p == YoutiLoginProgress.failed) { + _showError(lang.SIGN_IN_FAILED); + } + } + + final res = await _youtubeSignInButton(pageConfig: pageConfig, forceSignIn: forceSignIn, onProgress: onProgress); + _signInProgress.value = null; + return res; + } + + static void signOut({required ChannelInfoItem userChannel}) { + YoutiPie.cookies.signOut(userChannel); + } + + static void setAccountActive({required ChannelInfoItem userChannel}) { + YoutiPie.cookies.setAccount(userChannel); + } + + static void setAccountAnonymous() { + YoutiPie.cookies.setAnonymous(); + } +} + +class _CurrentMembership { + _CurrentMembership._(); + + final userSupabaseSub = Rxn(); + final userPatreonTier = Rxn(); + + final userMembershipTypeGlobal = Rxn(); + final userMembershipTypeSupabase = Rxn(); + final userMembershipTypePatreon = Rxn(); + + void _updateGlobal(MembershipType ms) { + final current = userMembershipTypeGlobal.value; + if (current == null || ms.index > current.index) { + userMembershipTypeGlobal.value = ms; + } + } + + Future claimPatreon({required LoginPageConfiguration pageConfig, required SignInDecision signIn}) async { + final tier = await NamicoSubscriptionManager.patreon.getUserSupportTier( + pageConfig: pageConfig, + signIn: signIn, + ); + if (tier == null) { + YoutubeAccountController._showError(lang.MEMBERSHIP_NO_SUBSCRIPTIONS_FOUND_FOR_USER); + return; + } + userPatreonTier.value = tier; + final ms = NamicoSubscriptionManager.usdToMembershipType(tier.ammountUSD); + userMembershipTypePatreon.value = ms; + _updateGlobal(ms); + } + + Future checkPatreon({bool showError = true}) async { + final tier = await NamicoSubscriptionManager.patreon.getUserSupportTierWithoutLogin(); + if (tier == null) { + if (showError) YoutubeAccountController._showError(lang.MEMBERSHIP_NO_SUBSCRIPTIONS_FOUND_FOR_USER); + return; + } + userPatreonTier.value = tier; + final ms = NamicoSubscriptionManager.usdToMembershipType(tier.ammountUSD); + userMembershipTypePatreon.value = ms; + _updateGlobal(ms); + } + + Future checkSupabase(String code, String email) async { + final deviceId = NamidaDeviceInfo.deviceId; + final sub = await NamicoSubscriptionManager.supabase.fetchUserValid( + uuid: code, + email: email, + deviceId: deviceId, + ); + userSupabaseSub.value = sub; + final ms = NamicoSubscriptionManager.usdToMembershipType(sub.usd); + userMembershipTypeSupabase.value = ms; + _updateGlobal(ms); + } + + Future claimSupabase(String code, String email) async { + final deviceId = NamidaDeviceInfo.deviceId; + final sub = await NamicoSubscriptionManager.supabase.claimSubscription( + uuid: code, + email: email, + deviceId: deviceId, + ); + userSupabaseSub.value = sub; + final ms = NamicoSubscriptionManager.usdToMembershipType(sub.usd); + userMembershipTypeSupabase.value = ms; + _updateGlobal(ms); + } +} diff --git a/lib/youtube/controller/youtube_controller.dart b/lib/youtube/controller/youtube_controller.dart index cb00b9c7..cd8a7f8e 100644 --- a/lib/youtube/controller/youtube_controller.dart +++ b/lib/youtube/controller/youtube_controller.dart @@ -5,7 +5,6 @@ import 'dart:isolate'; import 'package:youtipie/class/streams/audio_stream.dart'; import 'package:youtipie/class/streams/video_stream.dart'; import 'package:youtipie/class/streams/video_streams_result.dart'; -import 'package:youtipie/class/youtipie_feed/yt_feed_base.dart'; import 'package:youtipie/youtipie.dart'; import 'package:namida/base/ports_provider.dart'; @@ -31,9 +30,6 @@ class YoutubeController { static final YoutubeController _instance = YoutubeController._internal(); YoutubeController._internal(); - final homepageFeed = [].obs; - - // final commentToParsedHtml = {}; /// {id: {}} final downloadsVideoProgressMap = >{}.obs; diff --git a/lib/youtube/controller/youtube_current_info.dart b/lib/youtube/controller/youtube_current_info.dart index a6b611b7..ac3b5b12 100644 --- a/lib/youtube/controller/youtube_current_info.dart +++ b/lib/youtube/controller/youtube_current_info.dart @@ -11,7 +11,6 @@ class _YoutubeCurrentInfoController { RxBaseCore get isLoadingVideoPage => _isLoadingVideoPage; RxBaseCore get isLoadingInitialComments => _isLoadingInitialComments; RxBaseCore get isLoadingMoreComments => _isLoadingMoreComments; - RxBaseCore get currentFeed => _currentFeed; /// Used to keep track of current comments sources, mainly to /// prevent fetching next comments when cached version is loaded. @@ -29,8 +28,6 @@ class _YoutubeCurrentInfoController { final _isLoadingMoreComments = false.obs; final _isCurrentCommentsFromCache = Rxn(); - final _currentFeed = Rxn(); - String? _initialCommentsContinuation; /// Checks if the requested id is still playing, since most functions are async and will often @@ -51,11 +48,6 @@ class _YoutubeCurrentInfoController { _isCurrentCommentsFromCache.value = null; } - Future prepareFeed() async { - final val = await YoutiPie.feed.fetchFeed(); - if (val != null) _currentFeed.value = val; - } - bool updateVideoPageSync(String videoId) { final vidcache = YoutiPie.cacheBuilder.forVideoPage(videoId: videoId); final vidPageCached = vidcache.read(); @@ -77,7 +69,7 @@ class _YoutubeCurrentInfoController { if (!ConnectivityController.inst.hasConnection) { snackyy( title: lang.ERROR, - message: lang.NO_NETWORK_AVAILABLE_TO_FETCH_VIDEO_PAGE, + message: lang.NO_NETWORK_AVAILABLE_TO_FETCH_DATA, isError: true, top: false, ); diff --git a/lib/youtube/controller/youtube_info_controller.dart b/lib/youtube/controller/youtube_info_controller.dart index 7913eee6..a85e45c6 100644 --- a/lib/youtube/controller/youtube_info_controller.dart +++ b/lib/youtube/controller/youtube_info_controller.dart @@ -21,7 +21,6 @@ import 'package:youtipie/class/channels/tabs/channel_tab_videos_result.dart'; import 'package:youtipie/class/execute_details.dart'; import 'package:youtipie/class/related_videos_request_params.dart'; import 'package:youtipie/class/result_wrapper/comment_result.dart'; -import 'package:youtipie/class/result_wrapper/feed_result.dart'; import 'package:youtipie/class/result_wrapper/related_videos_result.dart'; import 'package:youtipie/class/result_wrapper/search_result.dart'; import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; diff --git a/lib/youtube/pages/user/membership_card.dart b/lib/youtube/pages/user/membership_card.dart new file mode 100644 index 00000000..c9e3729d --- /dev/null +++ b/lib/youtube/pages/user/membership_card.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:namico_subscription_manager/core/enum.dart'; + +import 'package:namida/core/extensions.dart'; +import 'package:namida/core/icon_fonts/broken_icons.dart'; +import 'package:namida/core/utils.dart'; +import 'package:namida/youtube/controller/youtube_account_controller.dart'; + +class MembershipCard extends StatelessWidget { + final bool displayName; + const MembershipCard({super.key, required this.displayName}); + + @override + Widget build(BuildContext context) { + final containerColor = context.theme.colorScheme.secondaryContainer; + final brL = 10.0.multipliedRadius; + final brM = 8.0.multipliedRadius; + return DecoratedBox( + decoration: BoxDecoration( + color: containerColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(brL), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + decoration: BoxDecoration( + color: containerColor.withOpacity(0.5), + border: Border.all( + color: containerColor, + ), + borderRadius: BorderRadius.circular(brM), + ), + child: ObxO( + rx: YoutubeAccountController.membership.userSupabaseSub, + builder: (userSupabaseSub) => ObxO( + rx: YoutubeAccountController.membership.userMembershipTypeGlobal, + builder: (userMembershipType) { + userMembershipType ??= MembershipType.unknown; + String text = userMembershipType.name.capitalizeFirst(); + if (displayName) { + final username = userSupabaseSub?.name; + if (username != null && username.isNotEmpty) text += ' - $username'; + } + + Widget child = Text( + text, + style: context.textTheme.displayLarge, + ); + + if (userMembershipType == MembershipType.owner) { + child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Broken.crown, + size: 20.0, + ), + const SizedBox(width: 8.0), + child, + ], + ); + } + + return child; + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/youtube/pages/user/youtube_account_manage_page.dart b/lib/youtube/pages/user/youtube_account_manage_page.dart new file mode 100644 index 00000000..23021631 --- /dev/null +++ b/lib/youtube/pages/user/youtube_account_manage_page.dart @@ -0,0 +1,240 @@ +// ignore_for_file: constant_identifier_names + +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:namico_login_manager/namico_login_manager.dart'; +import 'package:namico_subscription_manager/core/enum.dart'; +import 'package:youtipie/class/youtipie_feed/channel_info_item.dart'; +import 'package:youtipie/youtipie.dart'; + +import 'package:namida/class/route.dart'; +import 'package:namida/controller/navigator_controller.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/ui/dialogs/edit_tags_dialog.dart'; +import 'package:namida/ui/widgets/custom_widgets.dart'; +import 'package:namida/ui/widgets/settings/extra_settings.dart'; +import 'package:namida/youtube/controller/youtube_account_controller.dart'; +import 'package:namida/youtube/pages/user/membership_card.dart'; +import 'package:namida/youtube/widgets/yt_thumbnail.dart'; + +part 'youtube_manage_subscription_page.dart'; + +class YoutubeAccountManagePage extends StatelessWidget with NamidaRouteWidget { + @override + RouteType get route => RouteType.YOUTUBE_USER_MANAGE_SUBSCRIPTION_SUBPAGE; + + const YoutubeAccountManagePage({super.key}); + + void _onSignInTap(BuildContext context, {required bool forceSignIn}) { + final header = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + lang.SIGN_IN_TO_YOUR_ACCOUNT, + style: context.textTheme.displayMedium, + ), + ObxO( + rx: YoutubeAccountController.signInProgress, + builder: (loginProgress) => loginProgress == null + ? const SizedBox() + : Text( + loginProgress.name, + style: context.textTheme.displaySmall, + ), + ), + ], + ); + YoutubeAccountController.signIn( + pageConfig: LoginPageConfiguration( + header: header, + popPage: (_) => NamidaNavigator.inst.popRoot(), + pushPage: (page, opaque) { + NamidaNavigator.inst.navigateToRoot(page, opaque: opaque); + }, + ), + forceSignIn: forceSignIn, + ); + } + + void _onRemoveChannel(ChannelInfoItem channel, bool active) { + String bodyText; + void Function() singOutFn; + if (active) { + bodyText = '${lang.SIGN_OUT_FROM_NAME.replaceFirst('_NAME_', channel.title.addDQuotation())}?'; + singOutFn = YoutubeAccountController.setAccountAnonymous; + } else { + bodyText = '${lang.REMOVE}: "${channel.title}"?'; + singOutFn = () => YoutubeAccountController.signOut(userChannel: channel); + } + NamidaNavigator.inst.navigateDialog( + dialog: CustomBlurryDialog( + normalTitleStyle: true, + isWarning: true, + bodyText: bodyText, + actions: [ + const CancelButton(), + NamidaButton( + onPressed: () { + singOutFn(); + NamidaNavigator.inst.closeDialog(); + }, + text: (active ? lang.SIGN_OUT : lang.REMOVE).toUpperCase(), + ) + ], + ), + ); + } + + void _onSetAccount(ChannelInfoItem channel) { + YoutubeAccountController.setAccountActive(userChannel: channel); + } + + @override + Widget build(BuildContext context) { + final accountColorActive = context.theme.colorScheme.secondaryContainer.withOpacity(0.8); + final accountColorNonActive = context.theme.cardColor.withOpacity(0.5); + return BackgroundWrapper( + child: ObxO( + rx: YoutubeAccountController.current.signedInAccounts, + builder: (signedInAccountsSet) { + final signedInAccounts = signedInAccountsSet.toList(); + return ObxO( + rx: YoutubeAccountController.current.activeAccountChannel, + builder: (currentChannel) => Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24.0), + ObxO( + rx: YoutubeAccountController.membership.userMembershipTypeGlobal, + builder: (userMembershipType) { + final hasMembership = userMembershipType != null && userMembershipType.index >= MembershipType.cutie.index; + return CustomListTile( + borderR: 12.0, + onTap: () => NamidaNavigator.inst.navigateTo(const YoutubeManageSubscriptionPage()), + title: hasMembership ? lang.MEMBERSHIP_MANAGE : "${lang.SIGNING_IN_ALLOWS_BASIC_USAGE}.\n${lang.SIGNING_IN_ALLOWS_BASIC_USAGE_SUBTITLE}", + icon: Broken.money_3, + bgColor: Color.alphaBlend( + context.theme.cardTheme.color?.withOpacity(0.3) ?? Colors.transparent, + context.theme.colorScheme.secondaryContainer, + ).withOpacity(0.5), + trailingRaw: const MembershipCard(displayName: false), + ); + }, + ), + const NamidaContainerDivider( + margin: EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + ), + if (signedInAccounts.isNotEmpty) + Expanded( + child: Material( + type: MaterialType.transparency, // cuz it overflow with bg + child: ListView.separated( + separatorBuilder: (context, index) => const SizedBox(height: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 12.0).add( + EdgeInsets.only(bottom: Dimensions.inst.globalBottomPaddingTotalR + 48.0), // 'Add account' button + ), + itemCount: signedInAccounts.length, + itemBuilder: (context, index) { + final acc = signedInAccounts[index]; + final active = currentChannel?.id == acc.id; + return CustomListTile( + title: acc.title, + subtitle: acc.handler, + bgColor: active ? accountColorActive : accountColorNonActive, + borderR: 14.0, + visualDensity: VisualDensity.compact, + onTap: () => _onSetAccount(acc), + leading: YoutubeThumbnail( + type: ThumbnailType.channel, + key: Key(acc.id), + width: 64.0, + forceSquared: false, + isImportantInCache: true, + customUrl: acc.thumbnails.pick()?.url, + isCircle: true, + ), + trailing: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (active) + const NamidaCheckMark( + size: 12.0, + active: true, + ), + IconButton( + tooltip: active ? lang.SIGN_OUT : lang.REMOVE, + onPressed: () => _onRemoveChannel(acc, active), + icon: active ? const Icon(Broken.logout) : const Icon(Broken.trash), + ), + ], + ), + ); + }, + ), + ), + ) + else + Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + lang.SIGN_IN_YOU_DONT_HAVE_ACCOUNT, + style: context.textTheme.displayLarge, + ), + ) + ], + ), + ), + Positioned( + bottom: 0, + right: 0, + left: 0, + child: Padding( + padding: EdgeInsets.only( + bottom: Dimensions.inst.globalBottomPaddingTotalR, + ), + child: Align( + child: ObxO( + rx: YoutubeAccountController.signInProgress, + builder: (loginProgress) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: loginProgress != null + ? [ + NamidaInkWellButton( + enabled: false, + text: loginProgress.name.toUpperCase(), + icon: null, + sizeMultiplier: 1.0, + ), + ] + : [ + NamidaInkWellButton( + onTap: () => _onSignInTap(context, forceSignIn: true), + text: lang.ADD_ACCOUNT, + icon: Broken.user_add, + sizeMultiplier: 1.2, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/youtube/pages/user/youtube_manage_subscription_page.dart b/lib/youtube/pages/user/youtube_manage_subscription_page.dart new file mode 100644 index 00000000..e3c6e931 --- /dev/null +++ b/lib/youtube/pages/user/youtube_manage_subscription_page.dart @@ -0,0 +1,277 @@ +part of 'youtube_account_manage_page.dart'; + +class YoutubeManageSubscriptionPage extends StatefulWidget with NamidaRouteWidget { + @override + RouteType get route => RouteType.YOUTUBE_USER_MANAGE_SUBSCRIPTION_SUBPAGE; + + const YoutubeManageSubscriptionPage({super.key}); + + @override + State createState() => _YoutubeManageSubscriptionPageState(); +} + +class _YoutubeManageSubscriptionPageState extends State { + late final _codeController = TextEditingController(); + late final _emailController = TextEditingController(); + late final _formKey = GlobalKey(); + + late final _isChecking = false.obs; + late final _isClaiming = false.obs; + + @override + void dispose() { + _codeController.dispose(); + _emailController.dispose(); + _isChecking.close(); + _isClaiming.close(); + super.dispose(); + } + + void _showError(String msg, {Object? exception}) { + snackyy(message: exception.toString(), isError: true, displaySeconds: 3); + } + + Future _onFreeCouponSubmit(Future Function(String code, String email) fn) async { + final code = _codeController.text; + final email = _emailController.text; + final validated = _formKey.currentState?.validate(); + if (validated ?? (code.isNotEmpty && email.isNotEmpty)) { + final old = YoutubeAccountController.membership.userMembershipTypeGlobal.value; + try { + await fn(code, email); + + final newMS = YoutubeAccountController.membership.userMembershipTypeGlobal.value; + + if (newMS == null) { + if (old != null) snackyy(message: lang.MEMBERSHIP_UNKNOWN, isError: true, top: false); + } else if (old == newMS) { + final name = YoutubeAccountController.membership.userSupabaseSub.value?.name; + String trailing = ''; + if (name != null && name.isNotEmpty) trailing += '$name '; + snackyy(message: '${lang.MEMBERSHIP_DIDNT_CHANGE}, `${newMS.name}` $trailing', top: false); + } else { + final name = YoutubeAccountController.membership.userSupabaseSub.value?.name; + String trailing = ''; + if (name != null && name.isNotEmpty) trailing += '$name '; + if (newMS.index <= MembershipType.none.index) { + trailing = ':('; + } else if (newMS == MembershipType.owner) { + trailing = 'o7'; + } else { + trailing = ':D'; + } + snackyy( + message: '${lang.MEMBERSHIP_ENJOY_NEW}, `${newMS.name}` $trailing', + borderColor: Colors.green.withOpacity(0.8), + top: false, + ); + } + } catch (e) { + _showError('', exception: e); + } + } + } + + void _onPatreonLoginTap(BuildContext context) { + final header = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + lang.MEMBERSHIP_SIGN_IN_TO_PATREON_ACCOUNT, + style: context.textTheme.displayMedium, + ), + ], + ); + final pageConfig = LoginPageConfiguration( + header: header, + popPage: (_) => NamidaNavigator.inst.popRoot(), + pushPage: (page, opaque) { + NamidaNavigator.inst.navigateToRoot(page, opaque: opaque); + }, + ); + YoutubeAccountController.membership.claimPatreon( + pageConfig: pageConfig, + signIn: SignInDecision.enabled, + ); + } + + @override + Widget build(BuildContext context) { + return BackgroundWrapper( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: ObxO( + rx: YoutubeAccountController.membership.userMembershipTypeGlobal, + builder: (membershipType) => ListView( + children: [ + const SizedBox(height: 64.0), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), + decoration: BoxDecoration( + color: context.theme.cardColor, + borderRadius: BorderRadius.circular(18.0.multipliedRadius), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12.0), + const MembershipCard(displayName: true), + const SizedBox(height: 12.0), + ObxO( + rx: YoutubeAccountController.membership.userPatreonTier, + builder: (userPatreonTier) => NamidaExpansionTile( + initiallyExpanded: true, + icon: Broken.wallet_2, + titleText: 'Patreon', + subtitle: ObxO( + rx: YoutubeAccountController.membership.userMembershipTypePatreon, + builder: (userMembershipTypePatreon) => Text( + userMembershipTypePatreon?.name ?? '?', + style: context.textTheme.displaySmall, + ), + ), + children: [ + CustomListTile( + icon: Broken.login_1, + title: lang.SIGN_IN, + onTap: () => _onPatreonLoginTap(context), + ), + ], + ), + ), + const SizedBox(height: 12.0), + Form( + key: _formKey, + child: NamidaExpansionTile( + initiallyExpanded: true, + icon: Broken.ticket_star, + titleText: lang.MEMBERSHIP_FREE_COUPON, + trailing: ObxO( + rx: _isChecking, + builder: (isChecking) => ObxO( + rx: _isClaiming, + builder: (isClaiming) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + isChecking || isClaiming ? const LoadingIndicator() : const SizedBox(), + IconButton( + onPressed: () {}, + icon: const Icon( + Broken.arrow_down_2, + size: 20.0, + ), + ), + ], + ), + ), + ), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ObxO( + rx: YoutubeAccountController.membership.userMembershipTypeSupabase, + builder: (userMembershipTypeSupabase) => Text( + userMembershipTypeSupabase?.name ?? '?', + style: context.textTheme.displaySmall, + ), + ), + ObxO( + rx: YoutubeAccountController.membership.userSupabaseSub, + builder: (userSupabaseSub) { + if (userSupabaseSub == null) return const SizedBox(); + final availableTill = userSupabaseSub.availableTill; + String endTimeLeftText; + if (availableTill == null) { + endTimeLeftText = '?'; + } else { + endTimeLeftText = Jiffy.parseFromDateTime(availableTill).fromNow(withPrefixAndSuffix: false); + } + return Text( + " - $endTimeLeftText", + style: context.textTheme.displaySmall, + ); + }, + ), + ], + ), + children: [ + const SizedBox(height: 12.0), + CustomTagTextField( + controller: _codeController, + hintText: lang.MEMBERSHIP_CODE, + labelText: lang.MEMBERSHIP_CODE_SENT_TO_EMAIL, + validatorMode: AutovalidateMode.onUserInteraction, + validator: (value) { + if (value == null || value.isEmpty == true) return lang.EMPTY_VALUE; + return null; + }, + ), + const SizedBox(height: 12.0), + CustomTagTextField( + controller: _emailController, + hintText: lang.EMAIL, + labelText: lang.EMAIL, + validatorMode: AutovalidateMode.onUserInteraction, + validator: (value) { + if (value == null || value.isEmpty == true) return lang.EMPTY_VALUE; + return null; + }, + ), + const SizedBox(height: 12.0), + ObxO( + rx: _isChecking, + builder: (isChecking) => ObxO( + rx: _isClaiming, + builder: (isClaiming) => AnimatedEnabled( + enabled: !isChecking && !isClaiming, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const SizedBox(width: 8.0), + NamidaButton( + icon: Broken.cloud_change, + iconSize: 20.0, + onPressed: () async { + _isChecking.value = true; + await _onFreeCouponSubmit(YoutubeAccountController.membership.checkSupabase); + _isChecking.value = false; + }, + text: lang.CHECK, + ), + const SizedBox(width: 8.0), + NamidaButton( + icon: Broken.ticket_expired, + iconSize: 20.0, + onPressed: () async { + _isClaiming.value = true; + await _onFreeCouponSubmit(YoutubeAccountController.membership.claimSupabase); + _isClaiming.value = false; + }, + text: lang.CLAIM, + ), + const SizedBox(width: 8.0), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + Builder( + builder: (context) { + return SizedBox( + height: Dimensions.inst.globalBottomPaddingTotalR + context.viewInsets.bottom, + ); + }, + ) + ], + )), + ), + ); + } +} diff --git a/lib/youtube/pages/youtube_feed_page.dart b/lib/youtube/pages/youtube_feed_page.dart new file mode 100644 index 00000000..e9ccf2b3 --- /dev/null +++ b/lib/youtube/pages/youtube_feed_page.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:youtipie/class/execute_details.dart'; +import 'package:youtipie/class/result_wrapper/feed_result.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/base/pull_to_refresh.dart'; +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/core/dimensions.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/ui/widgets/custom_widgets.dart'; +import 'package:namida/ui/widgets/settings/extra_settings.dart'; +import 'package:namida/youtube/controller/youtube_account_controller.dart'; +import 'package:namida/youtube/pages/user/youtube_account_manage_page.dart'; +import 'package:namida/youtube/widgets/yt_playlist_card.dart'; +import 'package:namida/youtube/widgets/yt_video_card.dart'; + +class YoutubeHomeFeedPage extends StatefulWidget { + const YoutubeHomeFeedPage({super.key}); + + @override + State createState() => _YoutubePageState(); +} + +class _YoutubePageState extends State with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + final _controller = ScrollController(); + final _isLoadingCurrentFeed = Rxn(); + final _isLoadingNext = false.obs; + final _currentFeed = Rxn(); + + @override + void initState() { + super.initState(); + _fetchFeed(); + } + + @override + void dispose() { + _controller.dispose(); + _isLoadingCurrentFeed.close(); + _currentFeed.close(); + super.dispose(); + } + + Future _fetchFeed() async { + _isLoadingCurrentFeed.value = true; + final val = await YoutiPie.feed.fetchFeed(details: ExecuteDetails.forceRequest()); + _isLoadingCurrentFeed.value = false; + if (val != null) _currentFeed.value = val; + } + + Future _fetchFeedNext() async { + _isLoadingNext.value = true; + final feed = _currentFeed; + final fetched = await feed.value?.fetchNext(); + if (fetched == true) feed.refresh(); + _isLoadingNext.value = false; + } + + @override + Widget build(BuildContext context) { + super.build(context); + + const thumbnailHeight = Dimensions.youtubeThumbnailHeight; + const thumbnailWidth = Dimensions.youtubeThumbnailWidth; + const thumbnailItemExtent = thumbnailHeight + 8.0 * 2; + + final header = Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Text( + lang.HOME, + style: context.textTheme.displayLarge?.copyWith(fontSize: 38.0), + ), + ); + + final pagePadding = EdgeInsets.only(top: 24.0, bottom: Dimensions.inst.globalBottomPaddingTotalR); + + return BackgroundWrapper( + child: PullToRefresh( + controller: _controller, + onRefresh: _fetchFeed, + child: ObxO( + rx: YoutubeAccountController.current.activeAccountChannel, + builder: (activeAccountChannel) => activeAccountChannel == null + ? Padding( + padding: pagePadding, + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: header, + ), + const SizedBox(height: 38.0), + Text( + lang.SIGN_IN_YOU_NEED_ACCOUNT_TO_VIEW_PAGE, + style: context.textTheme.displayLarge, + ), + const SizedBox(height: 12.0), + NamidaInkWellButton( + sizeMultiplier: 1.1, + icon: Broken.user_edit, + text: lang.MANAGE_YOUR_ACCOUNTS, + onTap: () { + NamidaNavigator.inst.navigateTo(const YoutubeAccountManagePage()); + }, + ), + ], + ), + ) + : ObxO( + rx: _isLoadingCurrentFeed, + builder: (isLoadingCurrentFeed) => ObxO( + rx: _currentFeed, + builder: (homepageFeed) { + return LazyLoadListView( + onReachingEnd: _fetchFeedNext, + scrollController: _controller, + listview: (controller) => CustomScrollView( + controller: controller, + slivers: [ + SliverPadding(padding: EdgeInsets.only(top: pagePadding.top)), + SliverToBoxAdapter( + child: header, + ), + isLoadingCurrentFeed == null + ? const SliverToBoxAdapter() + : isLoadingCurrentFeed == true + ? SliverToBoxAdapter( + child: ShimmerWrapper( + transparent: false, + shimmerEnabled: true, + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemCount: 15, + shrinkWrap: true, + itemBuilder: (context, index) { + return const YoutubeVideoCardDummy( + shimmerEnabled: true, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + ); + }, + ), + ), + ) + : homepageFeed == null + ? const SliverToBoxAdapter() + : SliverFixedExtentList.builder( + itemCount: homepageFeed.items.length, + itemExtent: thumbnailItemExtent, + itemBuilder: (context, i) { + final item = homepageFeed.items[i]; + return switch (item.runtimeType) { + const (StreamInfoItem) => YoutubeVideoCard( + key: Key((item as StreamInfoItem).id), + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + isImageImportantInCache: false, + video: item, + playlistID: null, + ), + const (StreamInfoItemShort) => YoutubeShortVideoCard( + key: Key("${(item as StreamInfoItemShort?)?.id}"), + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + short: item as StreamInfoItemShort, + playlistID: null, + ), + const (PlaylistInfoItem) => YoutubePlaylistCard( + key: Key((item as PlaylistInfoItem).id), + playlist: item, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + subtitle: item.subtitle, + playOnTap: true, + ), + _ => const YoutubeVideoCardDummy( + shimmerEnabled: true, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + ), + }; + }, + ), + SliverToBoxAdapter( + child: ObxO( + rx: _isLoadingNext, + builder: (isLoadingNext) => isLoadingNext + ? const Padding( + padding: EdgeInsets.all(12.0), + child: Center( + child: LoadingIndicator(), + ), + ) + : const SizedBox(), + ), + ), + SliverPadding(padding: EdgeInsets.only(top: pagePadding.bottom)), + ], + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/youtube/pages/youtube_home_view.dart b/lib/youtube/pages/youtube_home_view.dart index f7eb53cc..c276e618 100644 --- a/lib/youtube/pages/youtube_home_view.dart +++ b/lib/youtube/pages/youtube_home_view.dart @@ -5,7 +5,7 @@ import 'package:namida/controller/settings_controller.dart'; import 'package:namida/core/enums.dart'; import 'package:namida/core/namida_converter_ext.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; -import 'package:namida/youtube/pages/youtube_page.dart'; +import 'package:namida/youtube/pages/youtube_feed_page.dart'; import 'package:namida/youtube/pages/yt_channels_page.dart'; import 'package:namida/youtube/pages/yt_downloads_page.dart'; import 'package:namida/youtube/youtube_playlists_view.dart'; @@ -27,7 +27,7 @@ class YouTubeHomeView extends StatelessWidget with NamidaRouteWidget { settings.save(ytInitialHomePage: YTHomePages.values[index]); }, children: const [ - YoutubePage(), + YoutubeHomeFeedPage(), YoutubeChannelsPage(), YoutubePlaylistsView(), YTDownloadsPage(), diff --git a/lib/youtube/pages/youtube_page.dart b/lib/youtube/pages/youtube_page.dart deleted file mode 100644 index df5bf0c4..00000000 --- a/lib/youtube/pages/youtube_page.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:namida/core/dimensions.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/youtube/controller/youtube_controller.dart'; -import 'package:namida/youtube/controller/youtube_info_controller.dart'; -import 'package:namida/youtube/widgets/yt_playlist_card.dart'; -import 'package:namida/youtube/widgets/yt_video_card.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/yt_feed_base.dart'; - -class YoutubePage extends StatefulWidget { - const YoutubePage({super.key}); - - @override - State createState() => _YoutubePageState(); -} - -class _YoutubePageState extends State with AutomaticKeepAliveClientMixin { - @override - bool get wantKeepAlive => true; - - @override - void initState() { - super.initState(); - YoutubeInfoController.current.prepareFeed(); - } - - @override - Widget build(BuildContext context) { - super.build(context); - - const thumbnailHeight = Dimensions.youtubeThumbnailHeight; - const thumbnailWidth = Dimensions.youtubeThumbnailWidth; - const thumbnailItemExtent = thumbnailHeight + 8.0 * 2; - return BackgroundWrapper( - child: ObxO( - rx: YoutubeController.inst.homepageFeed, - builder: (homepageFeed) { - final feed = homepageFeed.isEmpty ? List.filled(10, null) : homepageFeed; - - if (feed.isNotEmpty && feed.first == null) { - return ShimmerWrapper( - transparent: false, - shimmerEnabled: true, - child: ListView.builder( - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - itemCount: feed.length, - shrinkWrap: true, - itemBuilder: (context, index) { - return const YoutubeVideoCardDummy( - shimmerEnabled: true, - thumbnailWidth: thumbnailWidth, - thumbnailHeight: thumbnailHeight, - ); - }, - ), - ); - } - return NamidaListView( - // padding: const EdgeInsets.only(top: 32.0, bottom: kBottomPadding), - header: Padding( - padding: const EdgeInsets.all(24.0), - child: Text( - lang.HOME, - style: context.textTheme.displayLarge?.copyWith(fontSize: 38.0), - ), - ), - itemBuilder: (context, i) { - final item = feed[i]; - - return switch (item.runtimeType) { - const (StreamInfoItem) => YoutubeVideoCard( - key: Key((item as StreamInfoItem).id), - thumbnailWidth: thumbnailWidth, - thumbnailHeight: thumbnailHeight, - isImageImportantInCache: false, - video: item, - playlistID: null, - ), - const (StreamInfoItemShort) => YoutubeShortVideoCard( - key: Key("${(item as StreamInfoItemShort?)?.id}"), - thumbnailWidth: thumbnailWidth, - thumbnailHeight: thumbnailHeight, - short: item as StreamInfoItemShort, - playlistID: null, - ), - const (PlaylistInfoItem) => YoutubePlaylistCard( - key: Key((item as PlaylistInfoItem).id), - playlist: item, - thumbnailWidth: thumbnailWidth, - thumbnailHeight: thumbnailHeight, - subtitle: item.subtitle, - playOnTap: true, - ), - _ => const YoutubeVideoCardDummy( - shimmerEnabled: true, - thumbnailWidth: thumbnailWidth, - thumbnailHeight: thumbnailHeight, - ), - }; - }, - itemCount: feed.length, - itemExtent: thumbnailItemExtent, - ); - }, - ), - ); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 049a5eab..c21dd55a 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: 2.9.7-beta+240705185 +version: 3.0.0-beta+240705208 environment: sdk: ">=3.4.0 <4.0.0" @@ -13,6 +13,10 @@ dependency_overrides: url: https://github.com/MSOB7YY/just_audio path: just_audio_platform_interface/ ref: video + on_audio_query_platform_interface: + git: + url: https://github.com/MSOB7YY/on_audio_query + path: packages/on_audio_query_platform_interface dependencies: flutter: @@ -63,6 +67,7 @@ dependencies: lrc: ^1.0.2 vibration: ^1.8.4 flutter_displaymode: ^0.6.0 + flutter_udid: ^3.0.0 # ---- Audio Indexing & Playback ---- just_audio: @@ -88,6 +93,12 @@ dependencies: youtipie: git: url: https://github.com/namidaco/youtipie + namico_login_manager: + git: + url: https://github.com/namidaco/namico_login_manager + namico_subscription_manager: + git: + url: https://github.com/namidaco/namico_subscription_manager # ---- Image Utilities ---- palette_generator: ^0.3.3+2